diff --git a/src/components/dms/ActionsWrapper.tsx b/src/components/dms/ActionsWrapper.tsx index 89c4a06520..76ef0ae34e 100644 --- a/src/components/dms/ActionsWrapper.tsx +++ b/src/components/dms/ActionsWrapper.tsx @@ -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 ( - + {trigger => // will always be true, since this file is platform split trigger.IS_NATIVE && ( diff --git a/src/components/dms/ActionsWrapper.web.tsx b/src/components/dms/ActionsWrapper.web.tsx index 0d3df37637..99ba3351f5 100644 --- a/src/components/dms/ActionsWrapper.web.tsx +++ b/src/components/dms/ActionsWrapper.web.tsx @@ -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({ ) }} - + {({props, state, IS_NATIVE, control}) => { // always false, file is platform split if (IS_NATIVE) return null diff --git a/src/components/dms/MessageContextMenu.tsx b/src/components/dms/MessageContextMenu.tsx index 8e6c470d59..90d1062e67 100644 --- a/src/components/dms/MessageContextMenu.tsx +++ b/src/components/dms/MessageContextMenu.tsx @@ -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 = ({ )} + { @@ -199,7 +202,7 @@ export let MessageContextMenu = ({ control={blockOrDeleteControl} currentScreen="conversation" params={{ - convoId: convo.convo.view.id, + convoId, message, }} /> diff --git a/src/components/dms/MessageItem.tsx b/src/components/dms/MessageItem.tsx index a4cf84e986..ae7cebce0a 100644 --- a/src/components/dms/MessageItem.tsx +++ b/src/components/dms/MessageItem.tsx @@ -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) && ( 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 && ( @@ -219,6 +221,7 @@ function InnerReady({ isActive, isDisabled, hasMessages, + readyToShow, }: { hasScrolled: boolean setHasScrolled: React.Dispatch> @@ -226,6 +229,7 @@ function InnerReady({ isActive: boolean isDisabled: boolean hasMessages: boolean + readyToShow: boolean }) { const navigation = useNavigation() const {top: topInset} = useSafeAreaInsets() @@ -317,12 +321,14 @@ function InnerReady({ ) : ( header )} - {isActive && ( + {isActive && convo && ( )} diff --git a/src/screens/Messages/ConversationSettings/index.tsx b/src/screens/Messages/ConversationSettings/index.tsx index c65f2caff9..86e61fbbe8 100644 --- a/src/screens/Messages/ConversationSettings/index.tsx +++ b/src/screens/Messages/ConversationSettings/index.tsx @@ -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) { - + ) } -function SettingsInner() { +function SettingsInner({convoId}: {convoId: string}) { const {t: l} = useLingui() - const convoState = useConvo() const navigation = useNavigation() 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 ( convoState.error.retry()} + onRetry={() => refetch()} sideBorders={false} /> ) } - if (!convoState.convo || !moderationOpts) { + if (!convo || !moderationOpts) { return ( @@ -125,7 +131,7 @@ function SettingsInner() { ) } - if (convoState.convo.kind !== 'group') { + if (convo.kind !== 'group') { return ( - ) + return } function keyExtractor(item: Item) { @@ -170,11 +170,9 @@ function isGroupMember( function GroupSettings({ convo, moderationOpts, - isReady, }: { convo: Extract moderationOpts: ModerationOpts - isReady: boolean }) { const [isPTRing, setIsPTRing] = useState(false) @@ -254,7 +252,7 @@ function GroupSettings({ /> ) case 'ADD_MEMBERS_LINK': - return + return case 'CHAT_MEMBER': return ( } renderItem={renderItem} @@ -311,12 +308,10 @@ function SettingsHeader({ convo, isOwner, moderationOpts, - isReady, }: { convo: Extract isOwner: boolean moderationOpts: ModerationOpts - isReady: boolean }) { const t = useTheme() const {i18n, t: l} = useLingui() @@ -476,7 +471,7 @@ function SettingsHeader({ ]}> {isOwner ? ( { - 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}) { > 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 ( + - {convoState.convo?.kind === 'group' && - convoState.hasAllHistory ? ( - + {convo.kind === 'group' && convoState.hasAllHistory ? ( + ) : 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={ {ax.features.enabled(ax.features.DmsNewMessageComposerEnable) ? ( @@ -676,7 +689,7 @@ function ConversationFooter({ ) case 'request': - return + return case 'standard': return children } diff --git a/src/state/messages/convo/agent.ts b/src/state/messages/convo/agent.ts index e4a92ad8d6..059e2ced5a 100644 --- a/src/state/messages/convo/agent.ts +++ b/src/state/messages/convo/agent.ts @@ -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) { - 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, - ) { - 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': diff --git a/src/state/messages/convo/index.tsx b/src/state/messages/convo/index.tsx index 14a297e6b9..2d6ab462e2 100644 --- a/src/state/messages/convo/index.tsx +++ b/src/state/messages/convo/index.tsx @@ -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(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( + const {currentAccount} = useSession() + + const getRecipientDids = useCallback(() => { + const convo = queryClient.getQueryData( 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(() => { + if (!isConvoActive(service)) return service + const originalSend = service.sendMessage + return { + ...service, + sendMessage: message => { + queryClient.setQueryData( + getConvoKey(convoId), + old => { + if (!old || old.status !== 'request') return old + return {...old, status: 'accepted'} + }, + ) + originalSend(message) + }, + } + }, [service, queryClient, convoId]) - return {children} + return ( + + {children} + + ) } diff --git a/src/state/messages/convo/types.ts b/src/state/messages/convo/types.ts index 363c8291b5..5ad06b8852 100644 --- a/src/state/messages/convo/types.ts +++ b/src/state/messages/convo/types.ts @@ -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 -type MarkConvoAccepted = () => void type AddReaction = (messageId: string, reaction: string) => Promise type RemoveReaction = (messageId: string, reaction: string) => Promise 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 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 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 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 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' + } diff --git a/src/state/messages/convo/util.ts b/src/state/messages/convo/util.ts index 5301d10bbb..a364f3a558 100644 --- a/src/state/messages/convo/util.ts +++ b/src/state/messages/convo/util.ts @@ -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 ) } diff --git a/src/state/queries/messages/accept-conversation.ts b/src/state/queries/messages/accept-conversation.ts index 0c06055b55..7ca053b667 100644 --- a/src/state/queries/messages/accept-conversation.ts +++ b/src/state/queries/messages/accept-conversation.ts @@ -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( + 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?: {