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({
message,
convoId,
isFromSelf,
senderProfile,
children,
}: {
message: ChatBskyConvoDefs.MessageView
convoId: string
hasReactions?: boolean
isFromSelf: boolean
senderProfile?: bsky.profile.AnyProfileView
@@ -21,7 +23,10 @@ export function ActionsWrapper({
const {t: l} = useLingui()
return (
<MessageContextMenu message={message} senderProfile={senderProfile}>
<MessageContextMenu
message={message}
convoId={convoId}
senderProfile={senderProfile}>
{trigger =>
// will always be true, since this file is platform split
trigger.IS_NATIVE && (
+6 -1
View File
@@ -16,12 +16,14 @@ import {hasReachedReactionLimit} from './util'
export function ActionsWrapper({
message,
convoId,
hasReactions,
isFromSelf,
senderProfile,
children,
}: {
message: ChatBskyConvoDefs.MessageView
convoId: string
hasReactions?: boolean
isFromSelf: boolean
senderProfile?: bsky.profile.AnyProfileView
@@ -115,7 +117,10 @@ export function ActionsWrapper({
)
}}
</EmojiReactionPicker>
<MessageContextMenu message={message} senderProfile={senderProfile}>
<MessageContextMenu
message={message}
convoId={convoId}
senderProfile={senderProfile}>
{({props, state, IS_NATIVE, control}) => {
// always false, file is platform split
if (IS_NATIVE) return null
+5 -2
View File
@@ -31,10 +31,12 @@ import {hasReachedReactionLimit} from './util'
export let MessageContextMenu = ({
message,
convoId,
senderProfile,
children,
}: {
message: ChatBskyConvoDefs.MessageView
convoId: string
senderProfile?: bsky.profile.AnyProfileView
children: TriggerProps['children']
}): React.ReactNode => {
@@ -181,11 +183,12 @@ export let MessageContextMenu = ({
)}
</ContextMenu.Outer>
</ContextMenu.Root>
<ReportDialog
control={reportControl}
subject={{
view: 'message',
convoId: convo.convo.view.id,
convoId,
message,
}}
onAfterSubmit={() => {
@@ -199,7 +202,7 @@ export let MessageContextMenu = ({
control={blockOrDeleteControl}
currentScreen="conversation"
params={{
convoId: convo.convo.view.id,
convoId,
message,
}}
/>
+3
View File
@@ -85,12 +85,14 @@ function isWithinClusterBoundary({
let MessageItem = ({
item,
convoId,
isGroupChat = false,
prevMessage,
nextMessage,
relatedProfiles,
}: {
item: ConvoItem & {type: 'message' | 'pending-message'}
convoId: string
isGroupChat?: boolean
prevMessage:
| ChatBskyConvoDefs.MessageView
@@ -431,6 +433,7 @@ let MessageItem = ({
hasReactions={hasReactions}
isFromSelf={isFromSelf}
message={message}
convoId={convoId}
senderProfile={profile}>
{AppBskyEmbedRecord.isView(message.embed) && (
<MessageItemEmbed
+13 -7
View File
@@ -114,6 +114,10 @@ function Inner({convoId}: {convoId: string}) {
? parseConvoView(convoData, currentAccount?.did)
: 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,
// 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.
@@ -187,20 +191,18 @@ function Inner({convoId}: {convoId: string}) {
hasScrolled={hasScrolled}
setHasScrolled={setHasScrolled}
isActive={isConvoActive(convoState)}
isDisabled={convoState.status === ConvoStatus.Disabled}
isDisabled={isDisabled}
hasMessages={isConvoActive(convoState) && convoState.items.length > 0}
readyToShow={readyToShow}
/>
{!readyToShow && (
<View
style={[
a.absolute,
a.z_10,
a.w_full,
a.h_full,
a.inset_0,
{zIndex: -10},
a.justify_center,
a.align_center,
// t.atoms.bg,
{backgroundColor: 'rgba(255,0,0,0.2)'},
]}>
<View style={[{marginBottom: 75}]}>
<Loader size="xl" />
@@ -219,6 +221,7 @@ function InnerReady({
isActive,
isDisabled,
hasMessages,
readyToShow,
}: {
hasScrolled: boolean
setHasScrolled: React.Dispatch<React.SetStateAction<boolean>>
@@ -226,6 +229,7 @@ function InnerReady({
isActive: boolean
isDisabled: boolean
hasMessages: boolean
readyToShow: boolean
}) {
const navigation = useNavigation<NavigationProp>()
const {top: topInset} = useSafeAreaInsets()
@@ -317,12 +321,14 @@ function InnerReady({
) : (
header
)}
{isActive && (
{isActive && convo && (
<MessagesList
convo={convo}
hasScrolled={hasScrolled}
setHasScrolled={setHasScrolled}
hasAcceptOverride={!!params.accept}
transparentHeaderHeight={IS_LIQUID_GLASS ? headerHeight : 0}
hideMessages={!readyToShow}
footer={footer}
/>
)}
@@ -16,9 +16,9 @@ import {
type NavigationProp,
} from '#/lib/routes/types'
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 {ConvoProvider} from '#/state/messages/convo'
import {useConvoQuery} from '#/state/queries/messages/conversation'
import {useEditGroupChatName} from '#/state/queries/messages/edit-group-chat-name'
import {useLeaveConvo} from '#/state/queries/messages/leave-conversation'
import {useListConvoMembersQuery} from '#/state/queries/messages/list-convo-members'
@@ -34,6 +34,7 @@ import * as Dialog from '#/components/Dialog'
import {
type ConvoWithDetails,
type GroupConvoMember,
parseConvoView,
} from '#/components/dms/util'
import {Error} from '#/components/Error'
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.Outer>
<ConvoProvider key={convoId} convoId={convoId}>
<SettingsInner />
<SettingsInner convoId={convoId} />
</ConvoProvider>
</Layout.Screen>
)
}
function SettingsInner() {
function SettingsInner({convoId}: {convoId: string}) {
const {t: l} = useLingui()
const convoState = useConvo()
const navigation = useNavigation<NavigationProp>()
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 (
<Error
title={l`Something went wrong`}
message={l`We couldnt load this conversations settings`}
onRetry={() => convoState.error.retry()}
onRetry={() => refetch()}
sideBorders={false}
/>
)
}
if (!convoState.convo || !moderationOpts) {
if (!convo || !moderationOpts) {
return (
<View style={[a.flex_1, a.align_center, a.justify_center]}>
<Loader size="xl" />
@@ -125,7 +131,7 @@ function SettingsInner() {
)
}
if (convoState.convo.kind !== 'group') {
if (convo.kind !== 'group') {
return (
<Error
title={l`Wrong kind of conversation`}
@@ -141,13 +147,7 @@ function SettingsInner() {
)
}
return (
<GroupSettings
convo={convoState.convo}
moderationOpts={moderationOpts}
isReady={isConvoActive(convoState)}
/>
)
return <GroupSettings convo={convo} moderationOpts={moderationOpts} />
}
function keyExtractor(item: Item) {
@@ -170,11 +170,9 @@ function isGroupMember(
function GroupSettings({
convo,
moderationOpts,
isReady,
}: {
convo: Extract<ConvoWithDetails, {kind: 'group'}>
moderationOpts: ModerationOpts
isReady: boolean
}) {
const [isPTRing, setIsPTRing] = useState(false)
@@ -254,7 +252,7 @@ function GroupSettings({
/>
)
case 'ADD_MEMBERS_LINK':
return <AddMembersLink convo={convo} disabled={!isReady} />
return <AddMembersLink convo={convo} />
case 'CHAT_MEMBER':
return (
<Member
@@ -295,7 +293,6 @@ function GroupSettings({
convo={convo}
isOwner={isOwner}
moderationOpts={moderationOpts}
isReady={isReady}
/>
}
renderItem={renderItem}
@@ -311,12 +308,10 @@ function SettingsHeader({
convo,
isOwner,
moderationOpts,
isReady,
}: {
convo: Extract<ConvoWithDetails, {kind: 'group'}>
isOwner: boolean
moderationOpts: ModerationOpts
isReady: boolean
}) {
const t = useTheme()
const {i18n, t: l} = useLingui()
@@ -476,7 +471,7 @@ function SettingsHeader({
]}>
<SettingsButton
color={convo.view.muted ? 'negative_subtle' : 'secondary'}
disabled={!isReady || isMuting}
disabled={isMuting}
icon={convo.view.muted ? BellOffIcon : BellIcon}
label={
convo.view.muted
@@ -488,7 +483,7 @@ function SettingsHeader({
/>
{isOwner ? (
<SettingsButton
disabled={!isReady || isEditingName}
disabled={isEditingName}
icon={EditIcon}
label={l`Edit this group chats name`}
text={l`Edit name`}
@@ -497,7 +492,7 @@ function SettingsHeader({
) : null}
{isJoinLinkEnabled ? (
<SettingsButton
disabled={!isReady || lockStatus !== 'unlocked'}
disabled={lockStatus !== 'unlocked'}
icon={ChainLinkIcon}
label={
isOwner
@@ -511,7 +506,7 @@ function SettingsHeader({
{canLockGroupChat ? (
<SettingsButton
color={lockStatus === 'locked' ? 'negative_subtle' : 'secondary'}
disabled={!isReady || isLocking}
disabled={isLocking}
icon={LockIcon}
label={
lockStatus === 'locked'
@@ -526,7 +521,6 @@ function SettingsHeader({
) : null}
{!isOwner && isReportLinkEnabled && (
<SettingsButton
disabled={!isReady}
icon={FlagIcon}
label={l`Report this group chat`}
text={l`Report`}
@@ -535,7 +529,7 @@ function SettingsHeader({
)}
{!isOwner && (
<SettingsButton
disabled={!isReady || isLeaving}
disabled={isLeaving}
icon={ArrowBoxLeftIcon}
label={l`Leave this group chat`}
text={l`Leave`}
@@ -1,4 +1,4 @@
import {useCallback, useMemo} from 'react'
import {useMemo} from 'react'
import {View} from 'react-native'
import {LinearGradient} from 'expo-linear-gradient'
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 {sanitizeHandle} from '#/lib/strings/handles'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {type ActiveConvoStates} from '#/state/messages/convo'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useTheme, web} from '#/alf'
import {LeaveConvoPrompt} from '#/components/dms/LeaveConvoPrompt'
import {type ConvoWithDetails} from '#/components/dms/util'
import {KnownFollowers} from '#/components/KnownFollowers'
import {usePromptControl} from '#/components/Prompt'
import {Text} from '#/components/Typography'
import type * as bsky from '#/types/bsky'
import {AcceptChatButton, DeleteChatButton, RejectMenu} from './RequestButtons'
export function ChatStatusInfo({convoState}: {convoState: ActiveConvoStates}) {
export function ChatStatusInfo({convo}: {convo: ConvoWithDetails}) {
const t = useTheme()
const {t: l} = useLingui()
const moderationOpts = useModerationOpts()
const leaveConvoControl = usePromptControl()
const onAcceptChat = useCallback(() => {
convoState.markConvoAccepted()
}, [convoState])
// 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
const otherUser = convoState.convo.primaryMember
const otherUser = convo.primaryMember
const lastMessage = ChatBskyConvoDefs.isMessageView(
convoState.convo.view.lastMessage,
)
? convoState.convo.view.lastMessage
const lastMessage = ChatBskyConvoDefs.isMessageView(convo.view.lastMessage)
? convo.view.lastMessage
: null
if (!moderationOpts) {
@@ -65,7 +59,7 @@ export function ChatStatusInfo({convoState}: {convoState: ActiveConvoStates}) {
<RejectMenu
label={lastMessage ? l`Block or report` : l`Block`}
icon={true}
convo={convoState.convo.view}
convo={convo.view}
profile={otherUser}
color="negative_subtle"
size="large"
@@ -80,7 +74,7 @@ export function ChatStatusInfo({convoState}: {convoState: ActiveConvoStates}) {
context: 'Button',
})}
icon={true}
convo={convoState.convo.view}
convo={convo.view}
color="secondary"
size="large"
currentScreen="conversation"
@@ -88,7 +82,7 @@ export function ChatStatusInfo({convoState}: {convoState: ActiveConvoStates}) {
onPress={leaveConvoControl.open}
/>
<LeaveConvoPrompt
convoId={convoState.convo.view.id}
convoId={convo.view.id}
control={leaveConvoControl}
currentScreen="conversation"
hasMessages={false}
@@ -97,8 +91,7 @@ export function ChatStatusInfo({convoState}: {convoState: ActiveConvoStates}) {
<View style={[a.w_full, a.flex_row]}>
<AcceptChatButton
icon={true}
onAcceptConvo={onAcceptChat}
convo={convoState.convo.view}
convo={convo.view}
color="primary"
size="large"
currentScreen="conversation"
@@ -57,6 +57,7 @@ import {MessageItem} from '#/components/dms/MessageItem'
import {NewMessagesPill} from '#/components/dms/NewMessagesPill'
import {SystemMessageGroup} from '#/components/dms/SystemMessageGroup'
import {SystemMessageItem} from '#/components/dms/SystemMessageItem'
import {type ConvoWithDetails} from '#/components/dms/util'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
@@ -112,20 +113,25 @@ function onScrollToIndexFailed() {
}
export function MessagesList({
convo,
hasScrolled,
setHasScrolled,
footer,
hasAcceptOverride,
transparentHeaderHeight,
hideMessages,
}: {
convo: ConvoWithDetails
hasScrolled: boolean
setHasScrolled: React.Dispatch<React.SetStateAction<boolean>>
footer?: React.ReactNode
hasAcceptOverride?: boolean
transparentHeaderHeight?: number
hideMessages?: boolean
}) {
const ax = useAnalytics()
const convoState = useConvoActive()
const isGroupChat = convo.kind === 'group'
const agent = useAgent()
const getPost = useGetPost()
const {embedUri, setEmbed} = useMessageEmbed()
@@ -419,7 +425,8 @@ export function MessagesList({
return (
<MessageItem
item={item}
isGroupChat={convoState.convo.kind === 'group'}
convoId={convo.view.id}
isGroupChat={isGroupChat}
prevMessage={getNeighborMessage(renderItems, index - 1)}
nextMessage={getNeighborMessage(renderItems, index + 1)}
relatedProfiles={convoState.relatedProfiles}
@@ -480,7 +487,7 @@ export function MessagesList({
)
return (
<InviteLinkDialogProvider convo={convoState.convo}>
<InviteLinkDialogProvider convo={convo}>
<KeyboardGestureArea
interpolator="ios"
// HACKFIX: https://github.com/kirillzyusko/react-native-keyboard-controller/issues/1419
@@ -513,22 +520,24 @@ export function MessagesList({
ListHeaderComponent={
<>
<MaybeLoader isLoading={convoState.isFetchingHistory} />
{convoState.convo?.kind === 'group' &&
convoState.hasAllHistory ? (
<MessagesListInfoPanel convo={convoState.convo} />
{convo.kind === 'group' && convoState.hasAllHistory ? (
<MessagesListInfoPanel convo={convo} />
) : null}
</>
}
// native only (prop is not supported on web)
renderScrollComponent={renderScrollComponent}
contentContainerStyle={{
paddingBottom: platform({
// ios is slightly larger as the input has no top padding
ios: tokens.space.lg,
android: tokens.space.md,
web: 0, // web uses ListFooterComponent instead for scroll reasons
}),
}}
contentContainerStyle={[
hideMessages && {opacity: 0},
{
paddingBottom: platform({
// ios is slightly larger as the input has no top padding
ios: tokens.space.lg,
android: tokens.space.md,
web: 0, // web uses ListFooterComponent instead for scroll reasons
}),
},
]}
ListFooterComponent={
<View
style={web({height: tokens.space.md + inputHeightJS})}
@@ -558,6 +567,7 @@ export function MessagesList({
{footer ?? (
<ConversationFooter
convoState={convoState}
convo={convo}
hasAcceptOverride={hasAcceptOverride}>
{ax.features.enabled(ax.features.DmsNewMessageComposerEnable) ? (
<MessageComposer
@@ -633,6 +643,7 @@ type FooterState = 'loading' | 'new-chat' | 'request' | 'standard'
function getFooterState(
convoState: ActiveConvoStates,
convo: ConvoWithDetails,
hasAcceptOverride?: boolean,
): FooterState {
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'
}
@@ -652,10 +663,12 @@ function getFooterState(
function ConversationFooter({
convoState,
convo,
hasAcceptOverride,
children,
}: {
convoState: ConvoState
convo: ConvoWithDetails
hasAcceptOverride?: boolean
children?: React.ReactNode // message input
}) {
@@ -663,11 +676,11 @@ function ConversationFooter({
return null
}
const footerState = getFooterState(convoState, hasAcceptOverride)
const footerState = getFooterState(convoState, convo, hasAcceptOverride)
switch (footerState) {
case 'loading':
return null
return children
case 'new-chat':
return (
<>
@@ -676,7 +689,7 @@ function ConversationFooter({
</>
)
case 'request':
return <ChatStatusInfo convoState={convoState} />
return <ChatStatusInfo convo={convo} />
case 'standard':
return children
}
+20 -340
View File
@@ -4,7 +4,6 @@ import {
ChatBskyConvoDefs,
type ChatBskyConvoGetLog,
type ChatBskyConvoSendMessage,
type ChatBskyGroupDefs,
} from '@atproto/api'
import {XRPCError} from '@atproto/api'
import {EventEmitter} from 'eventemitter3'
@@ -26,7 +25,6 @@ import {
type ConvoDispatch,
ConvoDispatchEvent,
type ConvoError,
ConvoErrorCode,
type ConvoEvent,
type ConvoItem,
ConvoItemError,
@@ -37,11 +35,6 @@ import {
import {type MessagesEventBus} from '#/state/messages/events/agent'
import {type MessagesEventBusError} from '#/state/messages/events/types'
import {logger} from '#/state/messages/logger'
import {
type ConvoWithDetails,
type GroupConvoMember,
parseConvoView,
} from '#/components/dms/util'
import {IS_NATIVE} from '#/env'
export function isConvoItemMessage(
@@ -81,6 +74,7 @@ export class Convo {
private agent: AtpAgent
private events: MessagesEventBus
private senderUserDid: string
private getRecipientDids: () => string[]
private status: ConvoStatus = ConvoStatus.Uninitialized
private error: ConvoError | undefined
@@ -115,9 +109,6 @@ export class Convo {
private emitter = new EventEmitter<{event: [ConvoEvent]}>()
convoId: string
convo: ConvoWithDetails | undefined
sender: ChatBskyActorDefs.ProfileViewBasic | undefined
recipients: ChatBskyActorDefs.ProfileViewBasic[] | undefined
snapshot: ConvoState | undefined
constructor(params: ConvoParams) {
@@ -126,12 +117,8 @@ export class Convo {
this.agent = params.agent
this.events = params.events
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.getSnapshot = this.getSnapshot.bind(this)
this.sendMessage = this.sendMessage.bind(this)
@@ -140,13 +127,9 @@ export class Convo {
this.ingestFirehose = this.ingestFirehose.bind(this)
this.onFirehoseConnect = this.onFirehoseConnect.bind(this)
this.onFirehoseError = this.onFirehoseError.bind(this)
this.markConvoAccepted = this.markConvoAccepted.bind(this)
this.addReaction = this.addReaction.bind(this)
this.removeReaction = this.removeReaction.bind(this)
this.updateGroupName = this.updateGroupName.bind(this)
this.updateGroupMembers = this.updateGroupMembers.bind(this)
this.updateJoinLink = this.updateJoinLink.bind(this)
this.updateLockStatus = this.updateLockStatus.bind(this)
this.updateRelatedProfiles = this.updateRelatedProfiles.bind(this)
}
private commit() {
@@ -184,7 +167,6 @@ export class Convo {
deleteMessage: this.deleteMessage,
sendMessage: this.sendMessage,
fetchMessageHistory: this.fetchMessageHistory,
markConvoAccepted: this.markConvoAccepted,
addReaction: this.addReaction,
removeReaction: this.removeReaction,
}
@@ -193,7 +175,6 @@ export class Convo {
deleteMessage: undefined,
sendMessage: undefined,
fetchMessageHistory: undefined,
markConvoAccepted: undefined,
addReaction: undefined,
removeReaction: undefined,
}
@@ -203,7 +184,6 @@ export class Convo {
return {
status: ConvoStatus.Initializing,
items: [],
convo: this.convo,
error: undefined,
...shared,
...emptyMethods,
@@ -213,7 +193,6 @@ export class Convo {
return {
status: this.status,
items: this.getItems(),
convo: this.convo!,
relatedProfiles: this.relatedProfiles,
error: undefined,
...shared,
@@ -224,7 +203,6 @@ export class Convo {
return {
status: this.status,
items: this.getItems(),
convo: this.convo!,
relatedProfiles: this.relatedProfiles,
error: undefined,
...shared,
@@ -235,7 +213,6 @@ export class Convo {
return {
status: this.status,
items: this.getItems(),
convo: this.convo!,
relatedProfiles: this.relatedProfiles,
error: undefined,
...shared,
@@ -246,7 +223,6 @@ export class Convo {
return {
status: this.status,
items: this.getItems(),
convo: this.convo!,
relatedProfiles: this.relatedProfiles,
error: undefined,
...shared,
@@ -257,7 +233,6 @@ export class Convo {
return {
status: ConvoStatus.Error,
items: [],
convo: undefined,
error: this.error!,
isFetchingHistory: false,
hasAllHistory: false,
@@ -268,7 +243,6 @@ export class Convo {
return {
status: ConvoStatus.Uninitialized,
items: [],
convo: this.convo,
error: undefined,
isFetchingHistory: false,
// Explicit null check since the value is initially undefined.
@@ -322,20 +296,12 @@ export class Convo {
this.withdrawRequestedPollInterval()
break
}
case ConvoDispatchEvent.Disable: {
this.status = ConvoStatus.Disabled
void this.fetchMessageHistory() // finish init
this.cleanupFirehoseConnection?.()
this.withdrawRequestedPollInterval()
break
}
}
break
}
case ConvoStatus.Ready: {
switch (action.event) {
case ConvoDispatchEvent.Resume: {
void this.refreshConvo()
this.requestPollInterval(ACTIVE_POLL_INTERVAL)
break
}
@@ -357,12 +323,6 @@ export class Convo {
this.withdrawRequestedPollInterval()
break
}
case ConvoDispatchEvent.Disable: {
this.status = ConvoStatus.Disabled
this.cleanupFirehoseConnection?.()
this.withdrawRequestedPollInterval()
break
}
}
break
}
@@ -372,14 +332,8 @@ export class Convo {
if (this.wasChatInactive()) {
this.reset()
} else {
if (this.convo) {
this.status = ConvoStatus.Ready
void this.refreshConvo()
this.maybeRecoverFromNetworkError()
} else {
this.status = ConvoStatus.Initializing
void this.setup()
}
this.status = ConvoStatus.Ready
this.maybeRecoverFromNetworkError()
this.requestPollInterval(ACTIVE_POLL_INTERVAL)
}
break
@@ -397,12 +351,6 @@ export class Convo {
this.withdrawRequestedPollInterval()
break
}
case ConvoDispatchEvent.Disable: {
this.status = ConvoStatus.Disabled
this.cleanupFirehoseConnection?.()
this.withdrawRequestedPollInterval()
break
}
}
break
}
@@ -421,10 +369,6 @@ export class Convo {
this.error = action.payload
break
}
case ConvoDispatchEvent.Disable: {
this.status = ConvoStatus.Disabled
break
}
}
break
}
@@ -447,17 +391,9 @@ export class Convo {
this.error = action.payload
break
}
case ConvoDispatchEvent.Disable: {
this.status = ConvoStatus.Disabled
break
}
}
break
}
case ConvoStatus.Disabled: {
// can't do anything
break
}
default:
break
}
@@ -475,7 +411,6 @@ export class Convo {
}
private reset() {
this.convo = undefined
this.snapshot = undefined
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,
* but this allows us to render the convo header immediately.
* Merge a batch of related profiles into `this.relatedProfiles`. Called
* both by internal ingestion (message-history + firehose responses) and
* externally by `ConvoProvider` when `useListConvoMembersQuery` data
* updates.
*/
private setupPlaceholderData(
data: NonNullable<ConvoParams['placeholderData']>,
) {
this.setConvo(data.convo)
updateRelatedProfiles(profiles: ChatBskyActorDefs.ProfileViewBasic[]) {
if (profiles.length === 0) return
for (const profile of profiles) {
this.relatedProfiles.set(profile.did, profile)
}
this.commit()
}
private async setup() {
const setupStart = Date.now()
logger.debug('setup start', {id: this.id, convoId: this.convoId})
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()
}
private setup() {
logger.debug('setup', {id: this.id, convoId: this.convoId})
this.dispatch({event: ConvoDispatchEvent.Ready})
}
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
async fetchMessageHistory() {
logger.debug('fetchMessageHistory called', {
@@ -1017,11 +782,6 @@ export class Convo {
id: tempId,
message,
})
if (this.convo?.view.status === 'request') {
this.updateConvo({
status: 'accepted',
})
}
this.commit()
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() {
logger.debug(
`processing messages (${this.pendingMessages.size} remaining)`,
@@ -1170,14 +853,11 @@ export class Convo {
case 'block between recipient and sender':
this.emitter.emit('event', {
type: 'invalidate-block-state',
accountDids: [
this.senderUserDid,
...this.recipients!.map(r => r.did),
],
accountDids: [this.senderUserDid, ...this.getRecipientDids()],
})
break
case 'Account is disabled':
this.dispatch({event: ConvoDispatchEvent.Disable})
this.emitter.emit('event', {type: 'account-disabled'})
break
case 'Convo not found':
case 'Account does not exist':
+74 -67
View File
@@ -3,10 +3,11 @@ import {
useCallback,
useContext,
useEffect,
useMemo,
useState,
useSyncExternalStore,
} from 'react'
import {ChatBskyConvoDefs} from '@atproto/api'
import {type ChatBskyConvoDefs} from '@atproto/api'
import {useFocusEffect} from '@react-navigation/native'
import {useQueryClient} from '@tanstack/react-query'
@@ -16,7 +17,6 @@ import {
type ConvoParams,
type ConvoState,
type ConvoStateBackgrounded,
type ConvoStateDisabled,
type ConvoStateReady,
type ConvoStateSuspended,
} from '#/state/messages/convo/types'
@@ -27,21 +27,12 @@ import {
useMarkAsReadMutation,
} from '#/state/queries/messages/conversation'
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 {useAgent} from '#/state/session'
import {type GroupConvoMember} from '#/components/dms/util'
import {useAgent, useSession} from '#/state/session'
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)
ChatContext.displayName = 'ChatContext'
@@ -63,7 +54,6 @@ export function useConvoActive() {
| ConvoStateReady
| ConvoStateBackgrounded
| ConvoStateSuspended
| ConvoStateDisabled
if (!ctx) {
throw new Error('useConvo must be used within a ConvoProvider')
}
@@ -82,17 +72,27 @@ export function ConvoProvider({
const queryClient = useQueryClient()
const agent = useAgent()
const events = useMessagesEventBus()
const [convo] = useState(() => {
const placeholder = queryClient.getQueryData<ChatBskyConvoDefs.ConvoView>(
const {currentAccount} = useSession()
const getRecipientDids = useCallback(() => {
const convo = queryClient.getQueryData<ChatBskyConvoDefs.ConvoView>(
getConvoKey(convoId),
)
return new Convo({
convoId,
agent,
events,
placeholderData: placeholder ? {convo: placeholder} : undefined,
})
})
if (!convo) return []
return convo.members
.filter(m => m.did !== currentAccount?.did)
.map(m => m.did)
}, [queryClient, convoId, currentAccount?.did])
const [convo] = useState(
() =>
new Convo({
convoId,
agent,
events,
getRecipientDids,
}),
)
const service = useSyncExternalStore(convo.subscribe, convo.getSnapshot)
const {mutate: markAsRead} = useMarkAsReadMutation()
@@ -103,15 +103,30 @@ export function ConvoProvider({
if (isActive) {
convo.resume()
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 () => {
convo.background()
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(() => {
return convo.on(event => {
switch (event.type) {
@@ -124,52 +139,44 @@ export function ConvoProvider({
void queryClient.invalidateQueries({
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(() => {
const [root, id] = getConvoKey(convoId)
return queryClient.getQueryCache().subscribe(event => {
const queryKey = event.query.queryKey as string[]
if (queryKey[0] === root && queryKey[1] === id) {
const data = event.query.state.data as
| ChatBskyConvoDefs.ConvoView
| undefined
if (data && convo.convo && data.muted !== convo.convo.view.muted) {
convo.updateMuted(data.muted)
}
if (
data &&
ChatBskyConvoDefs.isGroupConvo(data.kind) &&
convo.convo?.kind === 'group'
) {
if (data.kind.name !== convo.convo.details.name) {
convo.updateGroupName(data.kind.name)
}
if (data.kind.joinLink !== convo.convo.details.joinLink) {
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])
// Auto-accept: when the user sends in a request-status convo, optimistically
// flip the cached status to 'accepted' so UI updates immediately. The server
// accepts on first send.
const wrappedService = useMemo<ConvoState>(() => {
if (!isConvoActive(service)) return service
const originalSend = service.sendMessage
return {
...service,
sendMessage: message => {
queryClient.setQueryData<ChatBskyConvoDefs.ConvoView>(
getConvoKey(convoId),
old => {
if (!old || old.status !== 'request') return old
return {...old, status: 'accepted'}
},
)
originalSend(message)
},
}
}, [service, queryClient, convoId])
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'
import {type MessagesEventBus} from '#/state/messages/events/agent'
import {type ConvoWithDetails} from '#/components/dms/util'
export type ConvoParams = {
convoId: string
agent: BskyAgent
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 {
@@ -108,42 +111,36 @@ type SendMessage = (
message: ChatBskyConvoSendMessage.InputSchema['message'],
) => void
type FetchMessageHistory = () => Promise<void>
type MarkConvoAccepted = () => void
type AddReaction = (messageId: string, reaction: string) => Promise<void>
type RemoveReaction = (messageId: string, reaction: string) => Promise<void>
export type ConvoStateUninitialized = {
status: ConvoStatus.Uninitialized
items: []
convo: ConvoWithDetails | undefined
error: undefined
isFetchingHistory: false
hasAllHistory: boolean
deleteMessage: undefined
sendMessage: undefined
fetchMessageHistory: undefined
markConvoAccepted: undefined
addReaction: undefined
removeReaction: undefined
}
export type ConvoStateInitializing = {
status: ConvoStatus.Initializing
items: []
convo: ConvoWithDetails | undefined
error: undefined
isFetchingHistory: boolean
hasAllHistory: boolean
deleteMessage: undefined
sendMessage: undefined
fetchMessageHistory: undefined
markConvoAccepted: undefined
addReaction: undefined
removeReaction: undefined
}
export type ConvoStateReady = {
status: ConvoStatus.Ready
items: ConvoItem[]
convo: ConvoWithDetails
relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic>
error: undefined
isFetchingHistory: boolean
@@ -151,14 +148,12 @@ export type ConvoStateReady = {
deleteMessage: DeleteMessage
sendMessage: SendMessage
fetchMessageHistory: FetchMessageHistory
markConvoAccepted: MarkConvoAccepted
addReaction: AddReaction
removeReaction: RemoveReaction
}
export type ConvoStateBackgrounded = {
status: ConvoStatus.Backgrounded
items: ConvoItem[]
convo: ConvoWithDetails
relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic>
error: undefined
isFetchingHistory: boolean
@@ -166,14 +161,12 @@ export type ConvoStateBackgrounded = {
deleteMessage: DeleteMessage
sendMessage: SendMessage
fetchMessageHistory: FetchMessageHistory
markConvoAccepted: MarkConvoAccepted
addReaction: AddReaction
removeReaction: RemoveReaction
}
export type ConvoStateSuspended = {
status: ConvoStatus.Suspended
items: ConvoItem[]
convo: ConvoWithDetails
relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic>
error: undefined
isFetchingHistory: boolean
@@ -181,28 +174,24 @@ export type ConvoStateSuspended = {
deleteMessage: DeleteMessage
sendMessage: SendMessage
fetchMessageHistory: FetchMessageHistory
markConvoAccepted: MarkConvoAccepted
addReaction: AddReaction
removeReaction: RemoveReaction
}
export type ConvoStateError = {
status: ConvoStatus.Error
items: []
convo: undefined
error: ConvoError
isFetchingHistory: false
hasAllHistory: false
deleteMessage: undefined
sendMessage: undefined
fetchMessageHistory: undefined
markConvoAccepted: undefined
addReaction: undefined
removeReaction: undefined
}
export type ConvoStateDisabled = {
status: ConvoStatus.Disabled
items: ConvoItem[]
convo: ConvoWithDetails
relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic>
error: undefined
isFetchingHistory: boolean
@@ -210,7 +199,6 @@ export type ConvoStateDisabled = {
deleteMessage: DeleteMessage
sendMessage: SendMessage
fetchMessageHistory: FetchMessageHistory
markConvoAccepted: MarkConvoAccepted
addReaction: AddReaction
removeReaction: RemoveReaction
}
@@ -223,7 +211,11 @@ export type ConvoState =
| ConvoStateError
| ConvoStateDisabled
export type ConvoEvent = {
type: 'invalidate-block-state'
accountDids: string[]
}
export type ConvoEvent =
| {
type: 'invalidate-block-state'
accountDids: string[]
}
| {
type: 'account-disabled'
}
+1 -4
View File
@@ -1,7 +1,6 @@
import {
type ConvoState,
type ConvoStateBackgrounded,
type ConvoStateDisabled,
type ConvoStateReady,
type ConvoStateSuspended,
ConvoStatus,
@@ -15,7 +14,6 @@ export type ActiveConvoStates =
| ConvoStateReady
| ConvoStateBackgrounded
| ConvoStateSuspended
| ConvoStateDisabled
/**
* 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 (
convo.status === ConvoStatus.Ready ||
convo.status === ConvoStatus.Backgrounded ||
convo.status === ConvoStatus.Suspended ||
convo.status === ConvoStatus.Disabled
convo.status === ConvoStatus.Suspended
)
}
@@ -1,5 +1,6 @@
import {
type ChatBskyConvoAcceptConvo,
type ChatBskyConvoDefs,
type ChatBskyConvoListConvos,
} from '@atproto/api'
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 {logger} from '#/logger'
import {useAgent} from '#/state/session'
import {RQKEY as CONVO_KEY} from './conversation'
import {
RQKEY as CONVO_LIST_KEY,
RQKEY_ROOT as CONVO_LIST_ROOT_KEY,
@@ -39,9 +41,18 @@ export function useAcceptConversation(
onMutate: () => {
let prevAcceptedPages: ChatBskyConvoListConvos.OutputSchema[] = []
let prevInboxPages: ChatBskyConvoListConvos.OutputSchema[] = []
let prevConvo: ChatBskyConvoDefs.ConvoView | undefined
let convoBeingAccepted:
| ChatBskyConvoListConvos.OutputSchema['convos'][number]
| undefined
queryClient.setQueryData<ChatBskyConvoDefs.ConvoView>(
CONVO_KEY(convoId),
old => {
if (!old) return old
prevConvo = old
return {...old, status: 'accepted'}
},
)
queryClient.setQueryData(
CONVO_LIST_KEY('request'),
(old?: {
@@ -97,7 +108,7 @@ export function useAcceptConversation(
},
)
onMutate?.()
return {prevAcceptedPages, prevInboxPages}
return {prevAcceptedPages, prevInboxPages, prevConvo}
},
onSuccess: data => {
queryClient.invalidateQueries({queryKey: [CONVO_LIST_KEY]})
@@ -105,6 +116,9 @@ export function useAcceptConversation(
},
onError: (error, _, context) => {
logger.error(error)
if (context?.prevConvo) {
queryClient.setQueryData(CONVO_KEY(convoId), context.prevConvo)
}
queryClient.setQueryData(
CONVO_LIST_KEY('accepted'),
(old?: {