refactor data fetching

This commit is contained in:
Samuel Newman
2026-04-28 17:17:57 +01:00
parent 5b481954fe
commit 738c57878d
13 changed files with 220 additions and 508 deletions
+6 -1
View File
@@ -8,11 +8,13 @@ import type * as bsky from '#/types/bsky'
export function ActionsWrapper({ export function ActionsWrapper({
message, message,
convoId,
isFromSelf, isFromSelf,
senderProfile, senderProfile,
children, children,
}: { }: {
message: ChatBskyConvoDefs.MessageView message: ChatBskyConvoDefs.MessageView
convoId: string
hasReactions?: boolean hasReactions?: boolean
isFromSelf: boolean isFromSelf: boolean
senderProfile?: bsky.profile.AnyProfileView senderProfile?: bsky.profile.AnyProfileView
@@ -21,7 +23,10 @@ export function ActionsWrapper({
const {t: l} = useLingui() const {t: l} = useLingui()
return ( return (
<MessageContextMenu message={message} senderProfile={senderProfile}> <MessageContextMenu
message={message}
convoId={convoId}
senderProfile={senderProfile}>
{trigger => {trigger =>
// will always be true, since this file is platform split // will always be true, since this file is platform split
trigger.IS_NATIVE && ( trigger.IS_NATIVE && (
+6 -1
View File
@@ -16,12 +16,14 @@ import {hasReachedReactionLimit} from './util'
export function ActionsWrapper({ export function ActionsWrapper({
message, message,
convoId,
hasReactions, hasReactions,
isFromSelf, isFromSelf,
senderProfile, senderProfile,
children, children,
}: { }: {
message: ChatBskyConvoDefs.MessageView message: ChatBskyConvoDefs.MessageView
convoId: string
hasReactions?: boolean hasReactions?: boolean
isFromSelf: boolean isFromSelf: boolean
senderProfile?: bsky.profile.AnyProfileView senderProfile?: bsky.profile.AnyProfileView
@@ -115,7 +117,10 @@ export function ActionsWrapper({
) )
}} }}
</EmojiReactionPicker> </EmojiReactionPicker>
<MessageContextMenu message={message} senderProfile={senderProfile}> <MessageContextMenu
message={message}
convoId={convoId}
senderProfile={senderProfile}>
{({props, state, IS_NATIVE, control}) => { {({props, state, IS_NATIVE, control}) => {
// always false, file is platform split // always false, file is platform split
if (IS_NATIVE) return null if (IS_NATIVE) return null
+5 -2
View File
@@ -31,10 +31,12 @@ import {hasReachedReactionLimit} from './util'
export let MessageContextMenu = ({ export let MessageContextMenu = ({
message, message,
convoId,
senderProfile, senderProfile,
children, children,
}: { }: {
message: ChatBskyConvoDefs.MessageView message: ChatBskyConvoDefs.MessageView
convoId: string
senderProfile?: bsky.profile.AnyProfileView senderProfile?: bsky.profile.AnyProfileView
children: TriggerProps['children'] children: TriggerProps['children']
}): React.ReactNode => { }): React.ReactNode => {
@@ -181,11 +183,12 @@ export let MessageContextMenu = ({
)} )}
</ContextMenu.Outer> </ContextMenu.Outer>
</ContextMenu.Root> </ContextMenu.Root>
<ReportDialog <ReportDialog
control={reportControl} control={reportControl}
subject={{ subject={{
view: 'message', view: 'message',
convoId: convo.convo.view.id, convoId,
message, message,
}} }}
onAfterSubmit={() => { onAfterSubmit={() => {
@@ -199,7 +202,7 @@ export let MessageContextMenu = ({
control={blockOrDeleteControl} control={blockOrDeleteControl}
currentScreen="conversation" currentScreen="conversation"
params={{ params={{
convoId: convo.convo.view.id, convoId,
message, message,
}} }}
/> />
+3
View File
@@ -85,12 +85,14 @@ function isWithinClusterBoundary({
let MessageItem = ({ let MessageItem = ({
item, item,
convoId,
isGroupChat = false, isGroupChat = false,
prevMessage, prevMessage,
nextMessage, nextMessage,
relatedProfiles, relatedProfiles,
}: { }: {
item: ConvoItem & {type: 'message' | 'pending-message'} item: ConvoItem & {type: 'message' | 'pending-message'}
convoId: string
isGroupChat?: boolean isGroupChat?: boolean
prevMessage: prevMessage:
| ChatBskyConvoDefs.MessageView | ChatBskyConvoDefs.MessageView
@@ -431,6 +433,7 @@ let MessageItem = ({
hasReactions={hasReactions} hasReactions={hasReactions}
isFromSelf={isFromSelf} isFromSelf={isFromSelf}
message={message} message={message}
convoId={convoId}
senderProfile={profile}> senderProfile={profile}>
{AppBskyEmbedRecord.isView(message.embed) && ( {AppBskyEmbedRecord.isView(message.embed) && (
<MessageItemEmbed <MessageItemEmbed
+13 -7
View File
@@ -114,6 +114,10 @@ function Inner({convoId}: {convoId: string}) {
? parseConvoView(convoData, currentAccount?.did) ? parseConvoView(convoData, currentAccount?.did)
: null : null
const isDisabled = Boolean(
convoData?.members.find(m => m.did === currentAccount?.did)?.chatDisabled,
)
// Because we want to give the list a chance to asynchronously scroll to the end before it is visible to the user, // Because we want to give the list a chance to asynchronously scroll to the end before it is visible to the user,
// we use `hasScrolled` to determine when to render. With that said however, there is a chance that the chat will be // we use `hasScrolled` to determine when to render. With that said however, there is a chance that the chat will be
// empty. So, we also check for that possible state as well and render once we can. // empty. So, we also check for that possible state as well and render once we can.
@@ -187,20 +191,18 @@ function Inner({convoId}: {convoId: string}) {
hasScrolled={hasScrolled} hasScrolled={hasScrolled}
setHasScrolled={setHasScrolled} setHasScrolled={setHasScrolled}
isActive={isConvoActive(convoState)} isActive={isConvoActive(convoState)}
isDisabled={convoState.status === ConvoStatus.Disabled} isDisabled={isDisabled}
hasMessages={isConvoActive(convoState) && convoState.items.length > 0} hasMessages={isConvoActive(convoState) && convoState.items.length > 0}
readyToShow={readyToShow}
/> />
{!readyToShow && ( {!readyToShow && (
<View <View
style={[ style={[
a.absolute, a.absolute,
a.z_10, a.inset_0,
a.w_full, {zIndex: -10},
a.h_full,
a.justify_center, a.justify_center,
a.align_center, a.align_center,
// t.atoms.bg,
{backgroundColor: 'rgba(255,0,0,0.2)'},
]}> ]}>
<View style={[{marginBottom: 75}]}> <View style={[{marginBottom: 75}]}>
<Loader size="xl" /> <Loader size="xl" />
@@ -219,6 +221,7 @@ function InnerReady({
isActive, isActive,
isDisabled, isDisabled,
hasMessages, hasMessages,
readyToShow,
}: { }: {
hasScrolled: boolean hasScrolled: boolean
setHasScrolled: React.Dispatch<React.SetStateAction<boolean>> setHasScrolled: React.Dispatch<React.SetStateAction<boolean>>
@@ -226,6 +229,7 @@ function InnerReady({
isActive: boolean isActive: boolean
isDisabled: boolean isDisabled: boolean
hasMessages: boolean hasMessages: boolean
readyToShow: boolean
}) { }) {
const navigation = useNavigation<NavigationProp>() const navigation = useNavigation<NavigationProp>()
const {top: topInset} = useSafeAreaInsets() const {top: topInset} = useSafeAreaInsets()
@@ -317,12 +321,14 @@ function InnerReady({
) : ( ) : (
header header
)} )}
{isActive && ( {isActive && convo && (
<MessagesList <MessagesList
convo={convo}
hasScrolled={hasScrolled} hasScrolled={hasScrolled}
setHasScrolled={setHasScrolled} setHasScrolled={setHasScrolled}
hasAcceptOverride={!!params.accept} hasAcceptOverride={!!params.accept}
transparentHeaderHeight={IS_LIQUID_GLASS ? headerHeight : 0} transparentHeaderHeight={IS_LIQUID_GLASS ? headerHeight : 0}
hideMessages={!readyToShow}
footer={footer} footer={footer}
/> />
)} )}
@@ -16,9 +16,9 @@ import {
type NavigationProp, type NavigationProp,
} from '#/lib/routes/types' } from '#/lib/routes/types'
import {logger} from '#/logger' import {logger} from '#/logger'
import {ConvoProvider, isConvoActive, useConvo} from '#/state/messages/convo'
import {ConvoStatus} from '#/state/messages/convo/types'
import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {ConvoProvider} from '#/state/messages/convo'
import {useConvoQuery} from '#/state/queries/messages/conversation'
import {useEditGroupChatName} from '#/state/queries/messages/edit-group-chat-name' import {useEditGroupChatName} from '#/state/queries/messages/edit-group-chat-name'
import {useLeaveConvo} from '#/state/queries/messages/leave-conversation' import {useLeaveConvo} from '#/state/queries/messages/leave-conversation'
import {useListConvoMembersQuery} from '#/state/queries/messages/list-convo-members' import {useListConvoMembersQuery} from '#/state/queries/messages/list-convo-members'
@@ -34,6 +34,7 @@ import * as Dialog from '#/components/Dialog'
import { import {
type ConvoWithDetails, type ConvoWithDetails,
type GroupConvoMember, type GroupConvoMember,
parseConvoView,
} from '#/components/dms/util' } from '#/components/dms/util'
import {Error} from '#/components/Error' import {Error} from '#/components/Error'
import {ArrowBoxLeft_Stroke2_Corner0_Rounded as ArrowBoxLeftIcon} from '#/components/icons/ArrowBoxLeft' import {ArrowBoxLeft_Stroke2_Corner0_Rounded as ArrowBoxLeftIcon} from '#/components/icons/ArrowBoxLeft'
@@ -94,30 +95,35 @@ export function MessagesConversationSettingsScreen({route}: Props) {
<Layout.Header.Slot /> <Layout.Header.Slot />
</Layout.Header.Outer> </Layout.Header.Outer>
<ConvoProvider key={convoId} convoId={convoId}> <ConvoProvider key={convoId} convoId={convoId}>
<SettingsInner /> <SettingsInner convoId={convoId} />
</ConvoProvider> </ConvoProvider>
</Layout.Screen> </Layout.Screen>
) )
} }
function SettingsInner() { function SettingsInner({convoId}: {convoId: string}) {
const {t: l} = useLingui() const {t: l} = useLingui()
const convoState = useConvo()
const navigation = useNavigation<NavigationProp>() const navigation = useNavigation<NavigationProp>()
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
const {currentAccount} = useSession()
const {data: convoData, error, refetch} = useConvoQuery({convoId})
if (convoState.status === ConvoStatus.Error) { const convo = convoData
? parseConvoView(convoData, currentAccount?.did)
: null
if (error) {
return ( return (
<Error <Error
title={l`Something went wrong`} title={l`Something went wrong`}
message={l`We couldnt load this conversations settings`} message={l`We couldnt load this conversations settings`}
onRetry={() => convoState.error.retry()} onRetry={() => refetch()}
sideBorders={false} sideBorders={false}
/> />
) )
} }
if (!convoState.convo || !moderationOpts) { if (!convo || !moderationOpts) {
return ( return (
<View style={[a.flex_1, a.align_center, a.justify_center]}> <View style={[a.flex_1, a.align_center, a.justify_center]}>
<Loader size="xl" /> <Loader size="xl" />
@@ -125,7 +131,7 @@ function SettingsInner() {
) )
} }
if (convoState.convo.kind !== 'group') { if (convo.kind !== 'group') {
return ( return (
<Error <Error
title={l`Wrong kind of conversation`} title={l`Wrong kind of conversation`}
@@ -141,13 +147,7 @@ function SettingsInner() {
) )
} }
return ( return <GroupSettings convo={convo} moderationOpts={moderationOpts} />
<GroupSettings
convo={convoState.convo}
moderationOpts={moderationOpts}
isReady={isConvoActive(convoState)}
/>
)
} }
function keyExtractor(item: Item) { function keyExtractor(item: Item) {
@@ -170,11 +170,9 @@ function isGroupMember(
function GroupSettings({ function GroupSettings({
convo, convo,
moderationOpts, moderationOpts,
isReady,
}: { }: {
convo: Extract<ConvoWithDetails, {kind: 'group'}> convo: Extract<ConvoWithDetails, {kind: 'group'}>
moderationOpts: ModerationOpts moderationOpts: ModerationOpts
isReady: boolean
}) { }) {
const [isPTRing, setIsPTRing] = useState(false) const [isPTRing, setIsPTRing] = useState(false)
@@ -254,7 +252,7 @@ function GroupSettings({
/> />
) )
case 'ADD_MEMBERS_LINK': case 'ADD_MEMBERS_LINK':
return <AddMembersLink convo={convo} disabled={!isReady} /> return <AddMembersLink convo={convo} />
case 'CHAT_MEMBER': case 'CHAT_MEMBER':
return ( return (
<Member <Member
@@ -295,7 +293,6 @@ function GroupSettings({
convo={convo} convo={convo}
isOwner={isOwner} isOwner={isOwner}
moderationOpts={moderationOpts} moderationOpts={moderationOpts}
isReady={isReady}
/> />
} }
renderItem={renderItem} renderItem={renderItem}
@@ -311,12 +308,10 @@ function SettingsHeader({
convo, convo,
isOwner, isOwner,
moderationOpts, moderationOpts,
isReady,
}: { }: {
convo: Extract<ConvoWithDetails, {kind: 'group'}> convo: Extract<ConvoWithDetails, {kind: 'group'}>
isOwner: boolean isOwner: boolean
moderationOpts: ModerationOpts moderationOpts: ModerationOpts
isReady: boolean
}) { }) {
const t = useTheme() const t = useTheme()
const {i18n, t: l} = useLingui() const {i18n, t: l} = useLingui()
@@ -476,7 +471,7 @@ function SettingsHeader({
]}> ]}>
<SettingsButton <SettingsButton
color={convo.view.muted ? 'negative_subtle' : 'secondary'} color={convo.view.muted ? 'negative_subtle' : 'secondary'}
disabled={!isReady || isMuting} disabled={isMuting}
icon={convo.view.muted ? BellOffIcon : BellIcon} icon={convo.view.muted ? BellOffIcon : BellIcon}
label={ label={
convo.view.muted convo.view.muted
@@ -488,7 +483,7 @@ function SettingsHeader({
/> />
{isOwner ? ( {isOwner ? (
<SettingsButton <SettingsButton
disabled={!isReady || isEditingName} disabled={isEditingName}
icon={EditIcon} icon={EditIcon}
label={l`Edit this group chats name`} label={l`Edit this group chats name`}
text={l`Edit name`} text={l`Edit name`}
@@ -497,7 +492,7 @@ function SettingsHeader({
) : null} ) : null}
{isJoinLinkEnabled ? ( {isJoinLinkEnabled ? (
<SettingsButton <SettingsButton
disabled={!isReady || lockStatus !== 'unlocked'} disabled={lockStatus !== 'unlocked'}
icon={ChainLinkIcon} icon={ChainLinkIcon}
label={ label={
isOwner isOwner
@@ -511,7 +506,7 @@ function SettingsHeader({
{canLockGroupChat ? ( {canLockGroupChat ? (
<SettingsButton <SettingsButton
color={lockStatus === 'locked' ? 'negative_subtle' : 'secondary'} color={lockStatus === 'locked' ? 'negative_subtle' : 'secondary'}
disabled={!isReady || isLocking} disabled={isLocking}
icon={LockIcon} icon={LockIcon}
label={ label={
lockStatus === 'locked' lockStatus === 'locked'
@@ -526,7 +521,6 @@ function SettingsHeader({
) : null} ) : null}
{!isOwner && isReportLinkEnabled && ( {!isOwner && isReportLinkEnabled && (
<SettingsButton <SettingsButton
disabled={!isReady}
icon={FlagIcon} icon={FlagIcon}
label={l`Report this group chat`} label={l`Report this group chat`}
text={l`Report`} text={l`Report`}
@@ -535,7 +529,7 @@ function SettingsHeader({
)} )}
{!isOwner && ( {!isOwner && (
<SettingsButton <SettingsButton
disabled={!isReady || isLeaving} disabled={isLeaving}
icon={ArrowBoxLeftIcon} icon={ArrowBoxLeftIcon}
label={l`Leave this group chat`} label={l`Leave this group chat`}
text={l`Leave`} text={l`Leave`}
@@ -1,4 +1,4 @@
import {useCallback, useMemo} from 'react' import {useMemo} from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {LinearGradient} from 'expo-linear-gradient' import {LinearGradient} from 'expo-linear-gradient'
import {ChatBskyConvoDefs, moderateProfile} from '@atproto/api' import {ChatBskyConvoDefs, moderateProfile} from '@atproto/api'
@@ -7,35 +7,29 @@ import {Trans, useLingui} from '@lingui/react/macro'
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name' import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
import {sanitizeHandle} from '#/lib/strings/handles' import {sanitizeHandle} from '#/lib/strings/handles'
import {useProfileShadow} from '#/state/cache/profile-shadow' import {useProfileShadow} from '#/state/cache/profile-shadow'
import {type ActiveConvoStates} from '#/state/messages/convo'
import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useModerationOpts} from '#/state/preferences/moderation-opts'
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, web} from '#/alf'
import {LeaveConvoPrompt} from '#/components/dms/LeaveConvoPrompt' import {LeaveConvoPrompt} from '#/components/dms/LeaveConvoPrompt'
import {type ConvoWithDetails} from '#/components/dms/util'
import {KnownFollowers} from '#/components/KnownFollowers' import {KnownFollowers} from '#/components/KnownFollowers'
import {usePromptControl} from '#/components/Prompt' import {usePromptControl} from '#/components/Prompt'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import type * as bsky from '#/types/bsky' import type * as bsky from '#/types/bsky'
import {AcceptChatButton, DeleteChatButton, RejectMenu} from './RequestButtons' import {AcceptChatButton, DeleteChatButton, RejectMenu} from './RequestButtons'
export function ChatStatusInfo({convoState}: {convoState: ActiveConvoStates}) { export function ChatStatusInfo({convo}: {convo: ConvoWithDetails}) {
const t = useTheme() const t = useTheme()
const {t: l} = useLingui() const {t: l} = useLingui()
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
const leaveConvoControl = usePromptControl() const leaveConvoControl = usePromptControl()
const onAcceptChat = useCallback(() => {
convoState.markConvoAccepted()
}, [convoState])
// either the other person, or the chat owner // either the other person, or the chat owner
// if we ever allow someone other than the owner to invite people, this will need to change // if we ever allow someone other than the owner to invite people, this will need to change
const otherUser = convoState.convo.primaryMember const otherUser = convo.primaryMember
const lastMessage = ChatBskyConvoDefs.isMessageView( const lastMessage = ChatBskyConvoDefs.isMessageView(convo.view.lastMessage)
convoState.convo.view.lastMessage, ? convo.view.lastMessage
)
? convoState.convo.view.lastMessage
: null : null
if (!moderationOpts) { if (!moderationOpts) {
@@ -65,7 +59,7 @@ export function ChatStatusInfo({convoState}: {convoState: ActiveConvoStates}) {
<RejectMenu <RejectMenu
label={lastMessage ? l`Block or report` : l`Block`} label={lastMessage ? l`Block or report` : l`Block`}
icon={true} icon={true}
convo={convoState.convo.view} convo={convo.view}
profile={otherUser} profile={otherUser}
color="negative_subtle" color="negative_subtle"
size="large" size="large"
@@ -80,7 +74,7 @@ export function ChatStatusInfo({convoState}: {convoState: ActiveConvoStates}) {
context: 'Button', context: 'Button',
})} })}
icon={true} icon={true}
convo={convoState.convo.view} convo={convo.view}
color="secondary" color="secondary"
size="large" size="large"
currentScreen="conversation" currentScreen="conversation"
@@ -88,7 +82,7 @@ export function ChatStatusInfo({convoState}: {convoState: ActiveConvoStates}) {
onPress={leaveConvoControl.open} onPress={leaveConvoControl.open}
/> />
<LeaveConvoPrompt <LeaveConvoPrompt
convoId={convoState.convo.view.id} convoId={convo.view.id}
control={leaveConvoControl} control={leaveConvoControl}
currentScreen="conversation" currentScreen="conversation"
hasMessages={false} hasMessages={false}
@@ -97,8 +91,7 @@ export function ChatStatusInfo({convoState}: {convoState: ActiveConvoStates}) {
<View style={[a.w_full, a.flex_row]}> <View style={[a.w_full, a.flex_row]}>
<AcceptChatButton <AcceptChatButton
icon={true} icon={true}
onAcceptConvo={onAcceptChat} convo={convo.view}
convo={convoState.convo.view}
color="primary" color="primary"
size="large" size="large"
currentScreen="conversation" currentScreen="conversation"
@@ -57,6 +57,7 @@ import {MessageItem} from '#/components/dms/MessageItem'
import {NewMessagesPill} from '#/components/dms/NewMessagesPill' import {NewMessagesPill} from '#/components/dms/NewMessagesPill'
import {SystemMessageGroup} from '#/components/dms/SystemMessageGroup' import {SystemMessageGroup} from '#/components/dms/SystemMessageGroup'
import {SystemMessageItem} from '#/components/dms/SystemMessageItem' import {SystemMessageItem} from '#/components/dms/SystemMessageItem'
import {type ConvoWithDetails} from '#/components/dms/util'
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics' import {useAnalytics} from '#/analytics'
@@ -112,20 +113,25 @@ function onScrollToIndexFailed() {
} }
export function MessagesList({ export function MessagesList({
convo,
hasScrolled, hasScrolled,
setHasScrolled, setHasScrolled,
footer, footer,
hasAcceptOverride, hasAcceptOverride,
transparentHeaderHeight, transparentHeaderHeight,
hideMessages,
}: { }: {
convo: ConvoWithDetails
hasScrolled: boolean hasScrolled: boolean
setHasScrolled: React.Dispatch<React.SetStateAction<boolean>> setHasScrolled: React.Dispatch<React.SetStateAction<boolean>>
footer?: React.ReactNode footer?: React.ReactNode
hasAcceptOverride?: boolean hasAcceptOverride?: boolean
transparentHeaderHeight?: number transparentHeaderHeight?: number
hideMessages?: boolean
}) { }) {
const ax = useAnalytics() const ax = useAnalytics()
const convoState = useConvoActive() const convoState = useConvoActive()
const isGroupChat = convo.kind === 'group'
const agent = useAgent() const agent = useAgent()
const getPost = useGetPost() const getPost = useGetPost()
const {embedUri, setEmbed} = useMessageEmbed() const {embedUri, setEmbed} = useMessageEmbed()
@@ -419,7 +425,8 @@ export function MessagesList({
return ( return (
<MessageItem <MessageItem
item={item} item={item}
isGroupChat={convoState.convo.kind === 'group'} convoId={convo.view.id}
isGroupChat={isGroupChat}
prevMessage={getNeighborMessage(renderItems, index - 1)} prevMessage={getNeighborMessage(renderItems, index - 1)}
nextMessage={getNeighborMessage(renderItems, index + 1)} nextMessage={getNeighborMessage(renderItems, index + 1)}
relatedProfiles={convoState.relatedProfiles} relatedProfiles={convoState.relatedProfiles}
@@ -480,7 +487,7 @@ export function MessagesList({
) )
return ( return (
<InviteLinkDialogProvider convo={convoState.convo}> <InviteLinkDialogProvider convo={convo}>
<KeyboardGestureArea <KeyboardGestureArea
interpolator="ios" interpolator="ios"
// HACKFIX: https://github.com/kirillzyusko/react-native-keyboard-controller/issues/1419 // HACKFIX: https://github.com/kirillzyusko/react-native-keyboard-controller/issues/1419
@@ -513,22 +520,24 @@ export function MessagesList({
ListHeaderComponent={ ListHeaderComponent={
<> <>
<MaybeLoader isLoading={convoState.isFetchingHistory} /> <MaybeLoader isLoading={convoState.isFetchingHistory} />
{convoState.convo?.kind === 'group' && {convo.kind === 'group' && convoState.hasAllHistory ? (
convoState.hasAllHistory ? ( <MessagesListInfoPanel convo={convo} />
<MessagesListInfoPanel convo={convoState.convo} />
) : null} ) : null}
</> </>
} }
// native only (prop is not supported on web) // native only (prop is not supported on web)
renderScrollComponent={renderScrollComponent} renderScrollComponent={renderScrollComponent}
contentContainerStyle={{ contentContainerStyle={[
paddingBottom: platform({ hideMessages && {opacity: 0},
// ios is slightly larger as the input has no top padding {
ios: tokens.space.lg, paddingBottom: platform({
android: tokens.space.md, // ios is slightly larger as the input has no top padding
web: 0, // web uses ListFooterComponent instead for scroll reasons ios: tokens.space.lg,
}), android: tokens.space.md,
}} web: 0, // web uses ListFooterComponent instead for scroll reasons
}),
},
]}
ListFooterComponent={ ListFooterComponent={
<View <View
style={web({height: tokens.space.md + inputHeightJS})} style={web({height: tokens.space.md + inputHeightJS})}
@@ -558,6 +567,7 @@ export function MessagesList({
{footer ?? ( {footer ?? (
<ConversationFooter <ConversationFooter
convoState={convoState} convoState={convoState}
convo={convo}
hasAcceptOverride={hasAcceptOverride}> hasAcceptOverride={hasAcceptOverride}>
{ax.features.enabled(ax.features.DmsNewMessageComposerEnable) ? ( {ax.features.enabled(ax.features.DmsNewMessageComposerEnable) ? (
<MessageComposer <MessageComposer
@@ -633,6 +643,7 @@ type FooterState = 'loading' | 'new-chat' | 'request' | 'standard'
function getFooterState( function getFooterState(
convoState: ActiveConvoStates, convoState: ActiveConvoStates,
convo: ConvoWithDetails,
hasAcceptOverride?: boolean, hasAcceptOverride?: boolean,
): FooterState { ): FooterState {
if (convoState.items.length === 0) { if (convoState.items.length === 0) {
@@ -643,7 +654,7 @@ function getFooterState(
} }
} }
if (convoState.convo.view.status === 'request' && !hasAcceptOverride) { if (convo.view.status === 'request' && !hasAcceptOverride) {
return 'request' return 'request'
} }
@@ -652,10 +663,12 @@ function getFooterState(
function ConversationFooter({ function ConversationFooter({
convoState, convoState,
convo,
hasAcceptOverride, hasAcceptOverride,
children, children,
}: { }: {
convoState: ConvoState convoState: ConvoState
convo: ConvoWithDetails
hasAcceptOverride?: boolean hasAcceptOverride?: boolean
children?: React.ReactNode // message input children?: React.ReactNode // message input
}) { }) {
@@ -663,11 +676,11 @@ function ConversationFooter({
return null return null
} }
const footerState = getFooterState(convoState, hasAcceptOverride) const footerState = getFooterState(convoState, convo, hasAcceptOverride)
switch (footerState) { switch (footerState) {
case 'loading': case 'loading':
return null return children
case 'new-chat': case 'new-chat':
return ( return (
<> <>
@@ -676,7 +689,7 @@ function ConversationFooter({
</> </>
) )
case 'request': case 'request':
return <ChatStatusInfo convoState={convoState} /> return <ChatStatusInfo convo={convo} />
case 'standard': case 'standard':
return children return children
} }
+20 -340
View File
@@ -4,7 +4,6 @@ import {
ChatBskyConvoDefs, ChatBskyConvoDefs,
type ChatBskyConvoGetLog, type ChatBskyConvoGetLog,
type ChatBskyConvoSendMessage, type ChatBskyConvoSendMessage,
type ChatBskyGroupDefs,
} from '@atproto/api' } from '@atproto/api'
import {XRPCError} from '@atproto/api' import {XRPCError} from '@atproto/api'
import {EventEmitter} from 'eventemitter3' import {EventEmitter} from 'eventemitter3'
@@ -26,7 +25,6 @@ import {
type ConvoDispatch, type ConvoDispatch,
ConvoDispatchEvent, ConvoDispatchEvent,
type ConvoError, type ConvoError,
ConvoErrorCode,
type ConvoEvent, type ConvoEvent,
type ConvoItem, type ConvoItem,
ConvoItemError, ConvoItemError,
@@ -37,11 +35,6 @@ 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 {logger} from '#/state/messages/logger' import {logger} from '#/state/messages/logger'
import {
type ConvoWithDetails,
type GroupConvoMember,
parseConvoView,
} from '#/components/dms/util'
import {IS_NATIVE} from '#/env' import {IS_NATIVE} from '#/env'
export function isConvoItemMessage( export function isConvoItemMessage(
@@ -81,6 +74,7 @@ export class Convo {
private agent: AtpAgent private agent: AtpAgent
private events: MessagesEventBus private events: MessagesEventBus
private senderUserDid: string private senderUserDid: string
private getRecipientDids: () => string[]
private status: ConvoStatus = ConvoStatus.Uninitialized private status: ConvoStatus = ConvoStatus.Uninitialized
private error: ConvoError | undefined private error: ConvoError | undefined
@@ -115,9 +109,6 @@ export class Convo {
private emitter = new EventEmitter<{event: [ConvoEvent]}>() private emitter = new EventEmitter<{event: [ConvoEvent]}>()
convoId: string convoId: string
convo: ConvoWithDetails | undefined
sender: ChatBskyActorDefs.ProfileViewBasic | undefined
recipients: ChatBskyActorDefs.ProfileViewBasic[] | undefined
snapshot: ConvoState | undefined snapshot: ConvoState | undefined
constructor(params: ConvoParams) { constructor(params: ConvoParams) {
@@ -126,12 +117,8 @@ export class Convo {
this.agent = params.agent this.agent = params.agent
this.events = params.events this.events = params.events
this.senderUserDid = params.agent.assertDid this.senderUserDid = params.agent.assertDid
this.getRecipientDids = params.getRecipientDids
if (params.placeholderData) {
this.setupPlaceholderData(params.placeholderData)
}
this.setConvo = this.setConvo.bind(this)
this.subscribe = this.subscribe.bind(this) this.subscribe = this.subscribe.bind(this)
this.getSnapshot = this.getSnapshot.bind(this) this.getSnapshot = this.getSnapshot.bind(this)
this.sendMessage = this.sendMessage.bind(this) this.sendMessage = this.sendMessage.bind(this)
@@ -140,13 +127,9 @@ export class Convo {
this.ingestFirehose = this.ingestFirehose.bind(this) this.ingestFirehose = this.ingestFirehose.bind(this)
this.onFirehoseConnect = this.onFirehoseConnect.bind(this) this.onFirehoseConnect = this.onFirehoseConnect.bind(this)
this.onFirehoseError = this.onFirehoseError.bind(this) this.onFirehoseError = this.onFirehoseError.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.updateGroupName = this.updateGroupName.bind(this) this.updateRelatedProfiles = this.updateRelatedProfiles.bind(this)
this.updateGroupMembers = this.updateGroupMembers.bind(this)
this.updateJoinLink = this.updateJoinLink.bind(this)
this.updateLockStatus = this.updateLockStatus.bind(this)
} }
private commit() { private commit() {
@@ -184,7 +167,6 @@ export class Convo {
deleteMessage: this.deleteMessage, deleteMessage: this.deleteMessage,
sendMessage: this.sendMessage, sendMessage: this.sendMessage,
fetchMessageHistory: this.fetchMessageHistory, fetchMessageHistory: this.fetchMessageHistory,
markConvoAccepted: this.markConvoAccepted,
addReaction: this.addReaction, addReaction: this.addReaction,
removeReaction: this.removeReaction, removeReaction: this.removeReaction,
} }
@@ -193,7 +175,6 @@ export class Convo {
deleteMessage: undefined, deleteMessage: undefined,
sendMessage: undefined, sendMessage: undefined,
fetchMessageHistory: undefined, fetchMessageHistory: undefined,
markConvoAccepted: undefined,
addReaction: undefined, addReaction: undefined,
removeReaction: undefined, removeReaction: undefined,
} }
@@ -203,7 +184,6 @@ export class Convo {
return { return {
status: ConvoStatus.Initializing, status: ConvoStatus.Initializing,
items: [], items: [],
convo: this.convo,
error: undefined, error: undefined,
...shared, ...shared,
...emptyMethods, ...emptyMethods,
@@ -213,7 +193,6 @@ export class Convo {
return { return {
status: this.status, status: this.status,
items: this.getItems(), items: this.getItems(),
convo: this.convo!,
relatedProfiles: this.relatedProfiles, relatedProfiles: this.relatedProfiles,
error: undefined, error: undefined,
...shared, ...shared,
@@ -224,7 +203,6 @@ export class Convo {
return { return {
status: this.status, status: this.status,
items: this.getItems(), items: this.getItems(),
convo: this.convo!,
relatedProfiles: this.relatedProfiles, relatedProfiles: this.relatedProfiles,
error: undefined, error: undefined,
...shared, ...shared,
@@ -235,7 +213,6 @@ export class Convo {
return { return {
status: this.status, status: this.status,
items: this.getItems(), items: this.getItems(),
convo: this.convo!,
relatedProfiles: this.relatedProfiles, relatedProfiles: this.relatedProfiles,
error: undefined, error: undefined,
...shared, ...shared,
@@ -246,7 +223,6 @@ export class Convo {
return { return {
status: this.status, status: this.status,
items: this.getItems(), items: this.getItems(),
convo: this.convo!,
relatedProfiles: this.relatedProfiles, relatedProfiles: this.relatedProfiles,
error: undefined, error: undefined,
...shared, ...shared,
@@ -257,7 +233,6 @@ export class Convo {
return { return {
status: ConvoStatus.Error, status: ConvoStatus.Error,
items: [], items: [],
convo: undefined,
error: this.error!, error: this.error!,
isFetchingHistory: false, isFetchingHistory: false,
hasAllHistory: false, hasAllHistory: false,
@@ -268,7 +243,6 @@ export class Convo {
return { return {
status: ConvoStatus.Uninitialized, status: ConvoStatus.Uninitialized,
items: [], items: [],
convo: this.convo,
error: undefined, error: undefined,
isFetchingHistory: false, isFetchingHistory: false,
// Explicit null check since the value is initially undefined. // Explicit null check since the value is initially undefined.
@@ -322,20 +296,12 @@ export class Convo {
this.withdrawRequestedPollInterval() this.withdrawRequestedPollInterval()
break break
} }
case ConvoDispatchEvent.Disable: {
this.status = ConvoStatus.Disabled
void this.fetchMessageHistory() // finish init
this.cleanupFirehoseConnection?.()
this.withdrawRequestedPollInterval()
break
}
} }
break break
} }
case ConvoStatus.Ready: { case ConvoStatus.Ready: {
switch (action.event) { switch (action.event) {
case ConvoDispatchEvent.Resume: { case ConvoDispatchEvent.Resume: {
void this.refreshConvo()
this.requestPollInterval(ACTIVE_POLL_INTERVAL) this.requestPollInterval(ACTIVE_POLL_INTERVAL)
break break
} }
@@ -357,12 +323,6 @@ export class Convo {
this.withdrawRequestedPollInterval() this.withdrawRequestedPollInterval()
break break
} }
case ConvoDispatchEvent.Disable: {
this.status = ConvoStatus.Disabled
this.cleanupFirehoseConnection?.()
this.withdrawRequestedPollInterval()
break
}
} }
break break
} }
@@ -372,14 +332,8 @@ export class Convo {
if (this.wasChatInactive()) { if (this.wasChatInactive()) {
this.reset() this.reset()
} else { } else {
if (this.convo) { this.status = ConvoStatus.Ready
this.status = ConvoStatus.Ready this.maybeRecoverFromNetworkError()
void this.refreshConvo()
this.maybeRecoverFromNetworkError()
} else {
this.status = ConvoStatus.Initializing
void this.setup()
}
this.requestPollInterval(ACTIVE_POLL_INTERVAL) this.requestPollInterval(ACTIVE_POLL_INTERVAL)
} }
break break
@@ -397,12 +351,6 @@ export class Convo {
this.withdrawRequestedPollInterval() this.withdrawRequestedPollInterval()
break break
} }
case ConvoDispatchEvent.Disable: {
this.status = ConvoStatus.Disabled
this.cleanupFirehoseConnection?.()
this.withdrawRequestedPollInterval()
break
}
} }
break break
} }
@@ -421,10 +369,6 @@ export class Convo {
this.error = action.payload this.error = action.payload
break break
} }
case ConvoDispatchEvent.Disable: {
this.status = ConvoStatus.Disabled
break
}
} }
break break
} }
@@ -447,17 +391,9 @@ export class Convo {
this.error = action.payload this.error = action.payload
break break
} }
case ConvoDispatchEvent.Disable: {
this.status = ConvoStatus.Disabled
break
}
} }
break break
} }
case ConvoStatus.Disabled: {
// can't do anything
break
}
default: default:
break break
} }
@@ -475,7 +411,6 @@ export class Convo {
} }
private reset() { private reset() {
this.convo = undefined
this.snapshot = undefined this.snapshot = undefined
this.status = ConvoStatus.Uninitialized this.status = ConvoStatus.Uninitialized
@@ -512,97 +447,23 @@ export class Convo {
} }
} }
private setConvo(convo: ChatBskyConvoDefs.ConvoView) {
this.convo = parseConvoView(convo, this.senderUserDid) ?? this.convo
if (this.convo) {
for (const member of this.convo.members) {
this.relatedProfiles.set(member.did, member)
}
}
}
private updateConvo(convo: Partial<ChatBskyConvoDefs.ConvoView>) {
if (this.convo) {
this.convo =
parseConvoView({...this.convo.view, ...convo}, this.senderUserDid) ??
this.convo
for (const member of this.convo.members) {
this.relatedProfiles.set(member.did, member)
}
}
}
/** /**
* Initialises the convo with placeholder data, if provided. We still refetch it before rendering the convo, * Merge a batch of related profiles into `this.relatedProfiles`. Called
* but this allows us to render the convo header immediately. * both by internal ingestion (message-history + firehose responses) and
* externally by `ConvoProvider` when `useListConvoMembersQuery` data
* updates.
*/ */
private setupPlaceholderData( updateRelatedProfiles(profiles: ChatBskyActorDefs.ProfileViewBasic[]) {
data: NonNullable<ConvoParams['placeholderData']>, if (profiles.length === 0) return
) { for (const profile of profiles) {
this.setConvo(data.convo) this.relatedProfiles.set(profile.did, profile)
}
this.commit()
} }
private async setup() { private setup() {
const setupStart = Date.now() logger.debug('setup', {id: this.id, convoId: this.convoId})
logger.debug('setup start', {id: this.id, convoId: this.convoId}) this.dispatch({event: ConvoDispatchEvent.Ready})
try {
const {convo} = await this.fetchConvo()
this.setConvo(convo)
/*
* Some validation prior to `Ready` status
*/
if (!this.convo) {
throw new Error('could not find convo')
}
const self = this.convo.members.find(m => m.did === this.senderUserDid)
if (!self) {
throw new Error('could not find self in convo')
}
const userIsDisabled = Boolean(self.chatDisabled)
logger.debug('setup complete', {
id: this.id,
convoId: this.convoId,
setupMs: Date.now() - setupStart,
userIsDisabled,
})
if (userIsDisabled) {
this.dispatch({event: ConvoDispatchEvent.Disable})
} else {
this.dispatch({event: ConvoDispatchEvent.Ready})
}
} catch (err) {
const e = err as Error
logger.debug('setup failed', {
id: this.id,
convoId: this.convoId,
setupMs: Date.now() - setupStart,
message: e.message,
})
if (!isNetworkError(e) && !isErrorMaybeAppPasswordPermissions(e)) {
logger.error('setup failed', {
safeMessage: e.message,
})
}
this.dispatch({
event: ConvoDispatchEvent.Error,
payload: {
exception: e,
code: ConvoErrorCode.InitFailed,
retry: () => {
this.reset()
},
},
})
this.commit()
}
} }
init() { init() {
@@ -644,102 +505,6 @@ export class Convo {
} }
} }
private pendingFetchConvo:
| Promise<{convo: ChatBskyConvoDefs.ConvoView}>
| undefined
async fetchConvo() {
if (this.pendingFetchConvo) return this.pendingFetchConvo
// non-blocking
void this.fetchMemberList()
const fetchStart = Date.now()
logger.debug('fetchConvo start', {id: this.id, convoId: this.convoId})
this.pendingFetchConvo = (async () => {
try {
const response = await networkRetry(2, () => {
return this.agent.chat.bsky.convo.getConvo(
{convoId: this.convoId},
{headers: DM_SERVICE_HEADERS},
)
})
const convo = response.data.convo
logger.debug('fetchConvo done', {
id: this.id,
convoId: this.convoId,
fetchMs: Date.now() - fetchStart,
})
return {
convo,
}
} finally {
this.pendingFetchConvo = undefined
}
})()
return this.pendingFetchConvo
}
async refreshConvo() {
try {
void this.fetchMemberList()
const {convo} = await this.fetchConvo()
// throw new Error('UNCOMMENT TO TEST REFRESH FAILURE')
this.setConvo(convo)
} catch (err) {
const e = err as Error
if (!isNetworkError(e) && !isErrorMaybeAppPasswordPermissions(e)) {
logger.error(`failed to refresh convo`, {
safeMessage: e.message,
})
}
}
}
// purely for populating `this.relatedProfiles` - we do not pipe it
// into the ConvoWithDetails. If you want to drive UI based on the member list,
// use `useListConvoMembersQuery`
// we shouldn't also block loading off of this - the UI should be resilient
async fetchMemberList() {
const start = Date.now()
let cursor: string | undefined
let pages = 0
let total = 0
logger.debug('fetchMemberList start', {
id: this.id,
convoId: this.convoId,
})
do {
const result = await networkRetry(2, () => {
return this.agent.chat.bsky.convo.getConvoMembers(
{
convoId: this.convoId,
limit: 50,
cursor,
},
{headers: DM_SERVICE_HEADERS},
)
})
cursor = result.data.cursor
pages++
total += result.data.members.length
for (const member of result.data.members) {
this.relatedProfiles.set(member.did, member)
}
} while (cursor)
logger.debug('fetchMemberList done', {
id: this.id,
convoId: this.convoId,
pages,
total,
fetchMs: Date.now() - start,
})
}
private fetchMessageHistoryError: {retry: () => void} | undefined private fetchMessageHistoryError: {retry: () => void} | undefined
async fetchMessageHistory() { async fetchMessageHistory() {
logger.debug('fetchMessageHistory called', { logger.debug('fetchMessageHistory called', {
@@ -1017,11 +782,6 @@ export class Convo {
id: tempId, id: tempId,
message, message,
}) })
if (this.convo?.view.status === 'request') {
this.updateConvo({
status: 'accepted',
})
}
this.commit() this.commit()
if (!this.isProcessingPendingMessages && !this.pendingMessageFailure) { if (!this.isProcessingPendingMessages && !this.pendingMessageFailure) {
@@ -1029,83 +789,6 @@ export class Convo {
} }
} }
markConvoAccepted() {
this.updateConvo({
status: 'accepted',
})
this.commit()
}
updateMuted(muted: boolean) {
this.updateConvo({
muted,
})
this.commit()
}
updateGroupName(name: string) {
if (this.convo?.kind !== 'group') {
throw new Error('updateGroupName can only be called on group convo')
}
this.updateConvo({
kind: {
...this.convo.details,
name,
},
})
this.commit()
}
updateGroupMembers(members: GroupConvoMember[], memberCount: number) {
if (this.convo?.kind !== 'group') {
throw new Error('updateGroupMembers can only be called on group convo')
}
this.updateConvo({
members,
kind: {
...this.convo.details,
memberCount,
},
})
this.commit()
}
updateJoinLink(joinLink: ChatBskyGroupDefs.JoinLinkView | undefined) {
if (this.convo?.kind !== 'group') {
throw new Error('updateJoinLink can only be called on group convo')
}
this.updateConvo({
kind: {
...this.convo.details,
joinLink,
},
})
this.commit()
}
updateLockStatus(lockStatus: ChatBskyConvoDefs.ConvoLockStatus) {
if (this.convo?.kind !== 'group') {
throw new Error('updateLockStatus can only be called on group convo')
}
this.updateConvo({
kind: {
...this.convo.details,
lockStatus,
},
})
this.commit()
}
async processPendingMessages() { async processPendingMessages() {
logger.debug( logger.debug(
`processing messages (${this.pendingMessages.size} remaining)`, `processing messages (${this.pendingMessages.size} remaining)`,
@@ -1170,14 +853,11 @@ export class Convo {
case 'block between recipient and sender': case 'block between recipient and sender':
this.emitter.emit('event', { this.emitter.emit('event', {
type: 'invalidate-block-state', type: 'invalidate-block-state',
accountDids: [ accountDids: [this.senderUserDid, ...this.getRecipientDids()],
this.senderUserDid,
...this.recipients!.map(r => r.did),
],
}) })
break break
case 'Account is disabled': case 'Account is disabled':
this.dispatch({event: ConvoDispatchEvent.Disable}) this.emitter.emit('event', {type: 'account-disabled'})
break break
case 'Convo not found': case 'Convo not found':
case 'Account does not exist': case 'Account does not exist':
+74 -67
View File
@@ -3,10 +3,11 @@ import {
useCallback, useCallback,
useContext, useContext,
useEffect, useEffect,
useMemo,
useState, useState,
useSyncExternalStore, useSyncExternalStore,
} from 'react' } from 'react'
import {ChatBskyConvoDefs} from '@atproto/api' import {type ChatBskyConvoDefs} from '@atproto/api'
import {useFocusEffect} from '@react-navigation/native' import {useFocusEffect} from '@react-navigation/native'
import {useQueryClient} from '@tanstack/react-query' import {useQueryClient} from '@tanstack/react-query'
@@ -16,7 +17,6 @@ import {
type ConvoParams, type ConvoParams,
type ConvoState, type ConvoState,
type ConvoStateBackgrounded, type ConvoStateBackgrounded,
type ConvoStateDisabled,
type ConvoStateReady, type ConvoStateReady,
type ConvoStateSuspended, type ConvoStateSuspended,
} from '#/state/messages/convo/types' } from '#/state/messages/convo/types'
@@ -27,21 +27,12 @@ import {
useMarkAsReadMutation, useMarkAsReadMutation,
} from '#/state/queries/messages/conversation' } from '#/state/queries/messages/conversation'
import {RQKEY_ROOT as ListConvosQueryKeyRoot} from '#/state/queries/messages/list-conversations' import {RQKEY_ROOT as ListConvosQueryKeyRoot} from '#/state/queries/messages/list-conversations'
import {useListConvoMembersQuery} from '#/state/queries/messages/list-convo-members'
import {RQKEY as createProfileQueryKey} from '#/state/queries/profile' import {RQKEY as createProfileQueryKey} from '#/state/queries/profile'
import {useAgent} from '#/state/session' import {useAgent, useSession} from '#/state/session'
import {type GroupConvoMember} from '#/components/dms/util'
export * from '#/state/messages/convo/util' export * from '#/state/messages/convo/util'
function membersChanged(
a: ChatBskyConvoDefs.ConvoView['members'],
b: ChatBskyConvoDefs.ConvoView['members'],
) {
if (a.length !== b.length) return true
const aDids = new Set(a.map(m => m.did))
return b.some(m => !aDids.has(m.did))
}
const ChatContext = createContext<ConvoState | null>(null) const ChatContext = createContext<ConvoState | null>(null)
ChatContext.displayName = 'ChatContext' ChatContext.displayName = 'ChatContext'
@@ -63,7 +54,6 @@ export function useConvoActive() {
| ConvoStateReady | ConvoStateReady
| ConvoStateBackgrounded | ConvoStateBackgrounded
| ConvoStateSuspended | ConvoStateSuspended
| ConvoStateDisabled
if (!ctx) { if (!ctx) {
throw new Error('useConvo must be used within a ConvoProvider') throw new Error('useConvo must be used within a ConvoProvider')
} }
@@ -82,17 +72,27 @@ export function ConvoProvider({
const queryClient = useQueryClient() const queryClient = useQueryClient()
const agent = useAgent() const agent = useAgent()
const events = useMessagesEventBus() const events = useMessagesEventBus()
const [convo] = useState(() => { const {currentAccount} = useSession()
const placeholder = queryClient.getQueryData<ChatBskyConvoDefs.ConvoView>(
const getRecipientDids = useCallback(() => {
const convo = queryClient.getQueryData<ChatBskyConvoDefs.ConvoView>(
getConvoKey(convoId), getConvoKey(convoId),
) )
return new Convo({ if (!convo) return []
convoId, return convo.members
agent, .filter(m => m.did !== currentAccount?.did)
events, .map(m => m.did)
placeholderData: placeholder ? {convo: placeholder} : undefined, }, [queryClient, convoId, currentAccount?.did])
})
}) const [convo] = useState(
() =>
new Convo({
convoId,
agent,
events,
getRecipientDids,
}),
)
const service = useSyncExternalStore(convo.subscribe, convo.getSnapshot) const service = useSyncExternalStore(convo.subscribe, convo.getSnapshot)
const {mutate: markAsRead} = useMarkAsReadMutation() const {mutate: markAsRead} = useMarkAsReadMutation()
@@ -103,15 +103,30 @@ export function ConvoProvider({
if (isActive) { if (isActive) {
convo.resume() convo.resume()
markAsRead({convoId}) markAsRead({convoId})
// agent no longer owns the convo — invalidate the RQ cache so the
// header, member list, and status stay fresh after returning to
// the screen.
void queryClient.invalidateQueries({queryKey: getConvoKey(convoId)})
return () => { return () => {
convo.background() convo.background()
markAsRead({convoId}) markAsRead({convoId})
} }
} }
}, [isActive, convo, convoId, markAsRead]), }, [isActive, convo, convoId, markAsRead, queryClient]),
) )
// Push member-list data into the agent's `relatedProfiles` Map so that
// messages render sender names even when the sender isn't returned in the
// per-message `relatedProfiles` payload. The query hook is already
// firehose-aware for add/remove-member events.
const {data: memberList} = useListConvoMembersQuery({convoId})
useEffect(() => {
if (memberList) {
convo.updateRelatedProfiles(memberList)
}
}, [memberList, convo])
useEffect(() => { useEffect(() => {
return convo.on(event => { return convo.on(event => {
switch (event.type) { switch (event.type) {
@@ -124,52 +139,44 @@ export function ConvoProvider({
void queryClient.invalidateQueries({ void queryClient.invalidateQueries({
queryKey: [ListConvosQueryKeyRoot], queryKey: [ListConvosQueryKeyRoot],
}) })
break
}
case 'account-disabled': {
// Re-fetch the convo so the UI can surface the disabled state
// via `chatDisabled` on the self member.
void queryClient.invalidateQueries({
queryKey: getConvoKey(convoId),
})
break
} }
} }
}) })
}, [convo, queryClient]) }, [convo, queryClient, convoId])
useEffect(() => { // Auto-accept: when the user sends in a request-status convo, optimistically
const [root, id] = getConvoKey(convoId) // flip the cached status to 'accepted' so UI updates immediately. The server
return queryClient.getQueryCache().subscribe(event => { // accepts on first send.
const queryKey = event.query.queryKey as string[] const wrappedService = useMemo<ConvoState>(() => {
if (queryKey[0] === root && queryKey[1] === id) { if (!isConvoActive(service)) return service
const data = event.query.state.data as const originalSend = service.sendMessage
| ChatBskyConvoDefs.ConvoView return {
| undefined ...service,
if (data && convo.convo && data.muted !== convo.convo.view.muted) { sendMessage: message => {
convo.updateMuted(data.muted) queryClient.setQueryData<ChatBskyConvoDefs.ConvoView>(
} getConvoKey(convoId),
if ( old => {
data && if (!old || old.status !== 'request') return old
ChatBskyConvoDefs.isGroupConvo(data.kind) && return {...old, status: 'accepted'}
convo.convo?.kind === 'group' },
) { )
if (data.kind.name !== convo.convo.details.name) { originalSend(message)
convo.updateGroupName(data.kind.name) },
} }
if (data.kind.joinLink !== convo.convo.details.joinLink) { }, [service, queryClient, convoId])
convo.updateJoinLink(data.kind.joinLink)
}
if (data.kind.lockStatus !== convo.convo.details.lockStatus) {
convo.updateLockStatus(data.kind.lockStatus)
}
}
if (
data &&
ChatBskyConvoDefs.isGroupConvo(data.kind) &&
convo.convo?.kind === 'group' &&
(membersChanged(data.members, convo.convo.members) ||
data.kind.memberCount !== convo.convo.details.memberCount)
) {
convo.updateGroupMembers(
data.members as GroupConvoMember[],
data.kind.memberCount,
)
}
}
})
}, [convo, convoId, queryClient])
return <ChatContext.Provider value={service}>{children}</ChatContext.Provider> return (
<ChatContext.Provider value={wrappedService}>
{children}
</ChatContext.Provider>
)
} }
+15 -23
View File
@@ -6,15 +6,18 @@ import {
} from '@atproto/api' } from '@atproto/api'
import {type MessagesEventBus} from '#/state/messages/events/agent' import {type MessagesEventBus} from '#/state/messages/events/agent'
import {type ConvoWithDetails} from '#/components/dms/util'
export type ConvoParams = { export type ConvoParams = {
convoId: string convoId: string
agent: BskyAgent agent: BskyAgent
events: MessagesEventBus events: MessagesEventBus
placeholderData?: { /**
convo: ChatBskyConvoDefs.ConvoView * Returns the DIDs of the current user's conversation partners (every
} * member other than self). Called lazily when the agent needs to invalidate
* block state after a send failure. Source of truth is the `useConvoQuery`
* cache see `ConvoProvider`.
*/
getRecipientDids: () => string[]
} }
export enum ConvoStatus { export enum ConvoStatus {
@@ -108,42 +111,36 @@ type SendMessage = (
message: ChatBskyConvoSendMessage.InputSchema['message'], message: ChatBskyConvoSendMessage.InputSchema['message'],
) => void ) => void
type FetchMessageHistory = () => Promise<void> type FetchMessageHistory = () => Promise<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>
export type ConvoStateUninitialized = { export type ConvoStateUninitialized = {
status: ConvoStatus.Uninitialized status: ConvoStatus.Uninitialized
items: [] items: []
convo: ConvoWithDetails | undefined
error: undefined error: undefined
isFetchingHistory: false isFetchingHistory: false
hasAllHistory: boolean hasAllHistory: boolean
deleteMessage: undefined deleteMessage: undefined
sendMessage: undefined sendMessage: undefined
fetchMessageHistory: undefined fetchMessageHistory: undefined
markConvoAccepted: undefined
addReaction: undefined addReaction: undefined
removeReaction: undefined removeReaction: undefined
} }
export type ConvoStateInitializing = { export type ConvoStateInitializing = {
status: ConvoStatus.Initializing status: ConvoStatus.Initializing
items: [] items: []
convo: ConvoWithDetails | undefined
error: undefined error: undefined
isFetchingHistory: boolean isFetchingHistory: boolean
hasAllHistory: boolean hasAllHistory: boolean
deleteMessage: undefined deleteMessage: undefined
sendMessage: undefined sendMessage: undefined
fetchMessageHistory: undefined fetchMessageHistory: undefined
markConvoAccepted: undefined
addReaction: undefined addReaction: undefined
removeReaction: undefined removeReaction: undefined
} }
export type ConvoStateReady = { export type ConvoStateReady = {
status: ConvoStatus.Ready status: ConvoStatus.Ready
items: ConvoItem[] items: ConvoItem[]
convo: ConvoWithDetails
relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic> relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic>
error: undefined error: undefined
isFetchingHistory: boolean isFetchingHistory: boolean
@@ -151,14 +148,12 @@ export type ConvoStateReady = {
deleteMessage: DeleteMessage deleteMessage: DeleteMessage
sendMessage: SendMessage sendMessage: SendMessage
fetchMessageHistory: FetchMessageHistory fetchMessageHistory: FetchMessageHistory
markConvoAccepted: MarkConvoAccepted
addReaction: AddReaction addReaction: AddReaction
removeReaction: RemoveReaction removeReaction: RemoveReaction
} }
export type ConvoStateBackgrounded = { export type ConvoStateBackgrounded = {
status: ConvoStatus.Backgrounded status: ConvoStatus.Backgrounded
items: ConvoItem[] items: ConvoItem[]
convo: ConvoWithDetails
relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic> relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic>
error: undefined error: undefined
isFetchingHistory: boolean isFetchingHistory: boolean
@@ -166,14 +161,12 @@ export type ConvoStateBackgrounded = {
deleteMessage: DeleteMessage deleteMessage: DeleteMessage
sendMessage: SendMessage sendMessage: SendMessage
fetchMessageHistory: FetchMessageHistory fetchMessageHistory: FetchMessageHistory
markConvoAccepted: MarkConvoAccepted
addReaction: AddReaction addReaction: AddReaction
removeReaction: RemoveReaction removeReaction: RemoveReaction
} }
export type ConvoStateSuspended = { export type ConvoStateSuspended = {
status: ConvoStatus.Suspended status: ConvoStatus.Suspended
items: ConvoItem[] items: ConvoItem[]
convo: ConvoWithDetails
relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic> relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic>
error: undefined error: undefined
isFetchingHistory: boolean isFetchingHistory: boolean
@@ -181,28 +174,24 @@ export type ConvoStateSuspended = {
deleteMessage: DeleteMessage deleteMessage: DeleteMessage
sendMessage: SendMessage sendMessage: SendMessage
fetchMessageHistory: FetchMessageHistory fetchMessageHistory: FetchMessageHistory
markConvoAccepted: MarkConvoAccepted
addReaction: AddReaction addReaction: AddReaction
removeReaction: RemoveReaction removeReaction: RemoveReaction
} }
export type ConvoStateError = { export type ConvoStateError = {
status: ConvoStatus.Error status: ConvoStatus.Error
items: [] items: []
convo: undefined
error: ConvoError error: ConvoError
isFetchingHistory: false isFetchingHistory: false
hasAllHistory: false hasAllHistory: false
deleteMessage: undefined deleteMessage: undefined
sendMessage: undefined sendMessage: undefined
fetchMessageHistory: undefined fetchMessageHistory: undefined
markConvoAccepted: undefined
addReaction: undefined addReaction: undefined
removeReaction: undefined removeReaction: undefined
} }
export type ConvoStateDisabled = { export type ConvoStateDisabled = {
status: ConvoStatus.Disabled status: ConvoStatus.Disabled
items: ConvoItem[] items: ConvoItem[]
convo: ConvoWithDetails
relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic> relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic>
error: undefined error: undefined
isFetchingHistory: boolean isFetchingHistory: boolean
@@ -210,7 +199,6 @@ export type ConvoStateDisabled = {
deleteMessage: DeleteMessage deleteMessage: DeleteMessage
sendMessage: SendMessage sendMessage: SendMessage
fetchMessageHistory: FetchMessageHistory fetchMessageHistory: FetchMessageHistory
markConvoAccepted: MarkConvoAccepted
addReaction: AddReaction addReaction: AddReaction
removeReaction: RemoveReaction removeReaction: RemoveReaction
} }
@@ -223,7 +211,11 @@ export type ConvoState =
| ConvoStateError | ConvoStateError
| ConvoStateDisabled | ConvoStateDisabled
export type ConvoEvent = { export type ConvoEvent =
type: 'invalidate-block-state' | {
accountDids: string[] type: 'invalidate-block-state'
} accountDids: string[]
}
| {
type: 'account-disabled'
}
+1 -4
View File
@@ -1,7 +1,6 @@
import { import {
type ConvoState, type ConvoState,
type ConvoStateBackgrounded, type ConvoStateBackgrounded,
type ConvoStateDisabled,
type ConvoStateReady, type ConvoStateReady,
type ConvoStateSuspended, type ConvoStateSuspended,
ConvoStatus, ConvoStatus,
@@ -15,7 +14,6 @@ export type ActiveConvoStates =
| ConvoStateReady | ConvoStateReady
| ConvoStateBackgrounded | ConvoStateBackgrounded
| ConvoStateSuspended | ConvoStateSuspended
| ConvoStateDisabled
/** /**
* Checks if a `Convo` has a `status` that is "active", meaning the chat is * Checks if a `Convo` has a `status` that is "active", meaning the chat is
@@ -26,7 +24,6 @@ export function isConvoActive(convo: ConvoState): convo is ActiveConvoStates {
return ( return (
convo.status === ConvoStatus.Ready || convo.status === ConvoStatus.Ready ||
convo.status === ConvoStatus.Backgrounded || convo.status === ConvoStatus.Backgrounded ||
convo.status === ConvoStatus.Suspended || convo.status === ConvoStatus.Suspended
convo.status === ConvoStatus.Disabled
) )
} }
@@ -1,5 +1,6 @@
import { import {
type ChatBskyConvoAcceptConvo, type ChatBskyConvoAcceptConvo,
type ChatBskyConvoDefs,
type ChatBskyConvoListConvos, type ChatBskyConvoListConvos,
} from '@atproto/api' } from '@atproto/api'
import {useMutation, useQueryClient} from '@tanstack/react-query' import {useMutation, useQueryClient} from '@tanstack/react-query'
@@ -7,6 +8,7 @@ import {useMutation, useQueryClient} from '@tanstack/react-query'
import {DM_SERVICE_HEADERS} from '#/lib/constants' import {DM_SERVICE_HEADERS} from '#/lib/constants'
import {logger} from '#/logger' import {logger} from '#/logger'
import {useAgent} from '#/state/session' import {useAgent} from '#/state/session'
import {RQKEY as CONVO_KEY} from './conversation'
import { import {
RQKEY as CONVO_LIST_KEY, RQKEY as CONVO_LIST_KEY,
RQKEY_ROOT as CONVO_LIST_ROOT_KEY, RQKEY_ROOT as CONVO_LIST_ROOT_KEY,
@@ -39,9 +41,18 @@ export function useAcceptConversation(
onMutate: () => { onMutate: () => {
let prevAcceptedPages: ChatBskyConvoListConvos.OutputSchema[] = [] let prevAcceptedPages: ChatBskyConvoListConvos.OutputSchema[] = []
let prevInboxPages: ChatBskyConvoListConvos.OutputSchema[] = [] let prevInboxPages: ChatBskyConvoListConvos.OutputSchema[] = []
let prevConvo: ChatBskyConvoDefs.ConvoView | undefined
let convoBeingAccepted: let convoBeingAccepted:
| ChatBskyConvoListConvos.OutputSchema['convos'][number] | ChatBskyConvoListConvos.OutputSchema['convos'][number]
| undefined | undefined
queryClient.setQueryData<ChatBskyConvoDefs.ConvoView>(
CONVO_KEY(convoId),
old => {
if (!old) return old
prevConvo = old
return {...old, status: 'accepted'}
},
)
queryClient.setQueryData( queryClient.setQueryData(
CONVO_LIST_KEY('request'), CONVO_LIST_KEY('request'),
(old?: { (old?: {
@@ -97,7 +108,7 @@ export function useAcceptConversation(
}, },
) )
onMutate?.() onMutate?.()
return {prevAcceptedPages, prevInboxPages} return {prevAcceptedPages, prevInboxPages, prevConvo}
}, },
onSuccess: data => { onSuccess: data => {
queryClient.invalidateQueries({queryKey: [CONVO_LIST_KEY]}) queryClient.invalidateQueries({queryKey: [CONVO_LIST_KEY]})
@@ -105,6 +116,9 @@ export function useAcceptConversation(
}, },
onError: (error, _, context) => { onError: (error, _, context) => {
logger.error(error) logger.error(error)
if (context?.prevConvo) {
queryClient.setQueryData(CONVO_KEY(convoId), context.prevConvo)
}
queryClient.setQueryData( queryClient.setQueryData(
CONVO_LIST_KEY('accepted'), CONVO_LIST_KEY('accepted'),
(old?: { (old?: {