Use new listConvoRequests endpoint (#10755)

Co-authored-by: Samuel Newman <mozzius@protonmail.com>
This commit is contained in:
DS Boyce
2026-06-08 04:36:34 -07:00
committed by GitHub
parent c02f2a1eda
commit e60e31733a
12 changed files with 628 additions and 180 deletions
-10
View File
@@ -1773,16 +1773,6 @@
"count": 7
}
},
"src/state/queries/messages/accept-conversation.ts": {
"@typescript-eslint/no-floating-promises": {
"count": 2
}
},
"src/state/queries/messages/update-all-read.ts": {
"@typescript-eslint/no-floating-promises": {
"count": 3
}
},
"src/state/queries/my-lists.ts": {
"@typescript-eslint/no-floating-promises": {
"count": 2
+3 -3
View File
@@ -36,7 +36,7 @@ export function AvatarBubbles({
moderationOpts,
}: {
animate?: boolean
profiles: bsky.profile.AnyProfileView[]
profiles: (bsky.profile.AnyProfileView | undefined)[]
/**
* By default, when there are more than 2 profiles, the current user is
* filtered out (so you don't see yourself among your own group's members).
@@ -50,12 +50,12 @@ export function AvatarBubbles({
const {currentAccount} = useSession()
const profiles =
!self && allProfiles.length > 2
? allProfiles.filter(p => p?.did != null && p.did !== currentAccount?.did)
? allProfiles.filter(p => !p || p.did !== currentAccount?.did)
: allProfiles
const moderations = useMemo(() => {
if (!moderationOpts) return []
return profiles.map(p => {
return moderateProfile(p, moderationOpts)
return p && moderateProfile(p, moderationOpts)
})
}, [profiles, moderationOpts])
+40 -23
View File
@@ -1,8 +1,9 @@
import {useCallback, useMemo, useState} from 'react'
import {View} from 'react-native'
import {
type ChatBskyConvoDefs,
type ChatBskyConvoListConvos,
ChatBskyConvoDefs,
type ChatBskyConvoListConvoRequests,
ChatBskyGroupDefs,
} from '@atproto/api'
import {Trans, useLingui} from '@lingui/react/macro'
import {useFocusEffect, useNavigation} from '@react-navigation/native'
@@ -23,7 +24,7 @@ import {logger} from '#/logger'
import {MESSAGE_SCREEN_POLL_INTERVAL} from '#/state/messages/convo/const'
import {useMessagesEventBus} from '#/state/messages/events'
import {useLeftConvos} from '#/state/queries/messages/leave-conversation'
import {useListConvosQuery} from '#/state/queries/messages/list-conversations'
import {useListConvoRequests} from '#/state/queries/messages/list-conversation-requests'
import {useUpdateAllRead} from '#/state/queries/messages/update-all-read'
import {EmptyState} from '#/view/com/util/EmptyState'
import {List} from '#/view/com/util/List'
@@ -43,11 +44,16 @@ import {ListFooter} from '#/components/Lists'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import {IS_NATIVE} from '#/env'
import {RequestListItem} from './components/RequestListItem'
import {IncomingRequestListItem} from './components/IncomingRequestListItem'
import {OutgoingRequestListItem} from './components/OutgoingRequestListItem'
import {useIsWithinSplitView} from './components/splitView/context'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'MessagesInbox'>
type RequestItem =
| {type: 'incoming'; view: ChatBskyConvoDefs.ConvoView}
| {type: 'outgoing'; view: ChatBskyGroupDefs.JoinRequestConvoView}
export function MessagesInboxScreen(props: Props) {
const {t: l} = useLingui()
const aaCopy = useAgeAssuranceCopy()
@@ -61,29 +67,36 @@ export function MessagesInboxScreen(props: Props) {
}
export function MessagesInboxScreenInner({}: Props) {
const listConvosQuery = useListConvosQuery({status: 'request'})
const listConvosQuery = useListConvoRequests()
const {data} = listConvosQuery
const leftConvos = useLeftConvos()
const conversations = useMemo(() => {
if (data?.pages) {
const convos = data.pages
.flatMap(page => page.convos)
// filter out convos that are actively being left
.filter(convo => !leftConvos.includes(convo.id))
return convos
const conversations = useMemo<RequestItem[]>(() => {
if (!data?.pages) return []
const items: RequestItem[] = []
for (const page of data.pages) {
for (const item of page.requests) {
if (ChatBskyConvoDefs.isConvoView(item)) {
// filter out convos that are actively being left
if (leftConvos.includes(item.id)) continue
items.push({type: 'incoming', view: item})
} else if (ChatBskyGroupDefs.isJoinRequestConvoView(item)) {
items.push({type: 'outgoing', view: item})
}
}
}
return []
return items
}, [data, leftConvos])
const hasUnreadConvos = useMemo(() => {
return conversations.some(
conversation =>
conversation.members.every(
item =>
item.type === 'incoming' &&
item.view.members.every(
member => member.handle !== 'missing.invalid',
) && conversation.unreadCount > 0,
) &&
item.view.unreadCount > 0,
)
}, [conversations])
@@ -111,10 +124,10 @@ function RequestList({
conversations,
}: {
listConvosQuery: UseInfiniteQueryResult<
InfiniteData<ChatBskyConvoListConvos.OutputSchema>,
InfiniteData<ChatBskyConvoListConvoRequests.OutputSchema>,
Error
>
conversations: ChatBskyConvoDefs.ConvoView[]
conversations: RequestItem[]
}) {
const {t: l} = useLingui()
const t = useTheme()
@@ -274,17 +287,21 @@ function RequestList({
windowSize={11}
desktopFixedHeight
sideBorders={false}
contentContainerStyle={[web(a.py_sm)]}
/>
</>
)
}
function keyExtractor(item: ChatBskyConvoDefs.ConvoView) {
return item.id
function keyExtractor(item: RequestItem) {
return item.type === 'incoming' ? item.view.id : item.view.convoId
}
function renderItem({item}: {item: ChatBskyConvoDefs.ConvoView}) {
return <RequestListItem convo={item} />
function renderItem({item}: {item: RequestItem}) {
if (item.type === 'incoming') {
return <IncomingRequestListItem convo={item.view} />
}
return <OutgoingRequestListItem convo={item.view} />
}
function MarkAsReadHeaderButton() {
@@ -11,7 +11,7 @@ import {Text} from '#/components/Typography'
import {ChatListItem, ChatListItemPortal} from './ChatListItem'
import {AcceptChatButton, DeleteChatButton, RejectMenu} from './RequestButtons'
export function RequestListItem({
export function IncomingRequestListItem({
convo: convoView,
}: {
convo: ChatBskyConvoDefs.ConvoView
@@ -0,0 +1,128 @@
import {View} from 'react-native'
import {
type ChatBskyGroupDefs,
ChatBskyGroupWithdrawJoinRequest,
} from '@atproto/api'
import {Trans, useLingui} from '@lingui/react/macro'
import {isNetworkError} from '#/lib/strings/errors'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useWithdrawJoinGroupChatRequest} from '#/state/queries/messages/withdraw-join-group-chat'
import {TimeElapsed} from '#/view/com/util/TimeElapsed'
import {atoms as a, useTheme, web} from '#/alf'
import {AvatarBubbles} from '#/components/AvatarBubbles'
import {createStaticClick, Link} from '#/components/Link'
import * as Prompt from '#/components/Prompt'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
export function OutgoingRequestListItem({
convo: convoView,
}: {
convo: ChatBskyGroupDefs.JoinRequestConvoView
}) {
const t = useTheme()
const {t: l} = useLingui()
const prompt = Prompt.usePromptControl()
const moderationOpts = useModerationOpts()
const {mutate: withdrawRequest, isPending: isWithdrawPending} =
useWithdrawJoinGroupChatRequest({
onSuccess: () => {
Toast.show(l`Join request rescinded.`)
},
onError: error => {
let errorMessage = l`Failed to rescind your request. Please try again.`
if (isNetworkError(error)) {
errorMessage = l`There was a problem with your internet connection, please try again`
} else if (
error instanceof
ChatBskyGroupWithdrawJoinRequest.InvalidJoinRequestError
) {
errorMessage = l`Invalid rescind request.`
}
Toast.show(errorMessage)
},
})
return (
<>
<Link
label={l`Rescind request to join group chat`}
{...createStaticClick(() => {
prompt.open()
})}>
{({hovered, pressed, focused}) => (
<View
style={[
a.flex_row,
a.align_center,
a.flex_1,
a.px_lg,
a.py_md,
a.gap_md,
(hovered || pressed || focused) && t.atoms.bg_contrast_25,
]}>
<AvatarBubbles
profiles={[
convoView?.owner ?? undefined,
...Array(
Math.min(3, Math.max(0, convoView.memberCount - 1)),
).fill(undefined),
]}
size={48}
moderationOpts={moderationOpts}
/>
<View style={[a.flex_1]}>
<View style={[a.w_full, a.flex_row, a.align_center, a.pb_2xs]}>
<View style={[a.flex_shrink]}>
<Text
emoji
numberOfLines={1}
style={[a.text_md, a.font_semi_bold]}>
{convoView.name}
</Text>
</View>
<View style={[a.pl_xs]}>
<TimeElapsed timestamp={convoView.requestedAt}>
{({timeElapsed}) => (
<Text
style={[
a.text_sm,
t.atoms.text_contrast_medium,
web({whiteSpace: 'preserve nowrap'}),
]}>
{timeElapsed}
</Text>
)}
</TimeElapsed>
</View>
</View>
<Text
numberOfLines={1}
style={[a.text_sm, t.atoms.text_contrast_high]}>
<Trans comment="Displayed when the user has requested to join a group chat.">
You requested to join
</Trans>
</Text>
</View>
</View>
)}
</Link>
<Prompt.Basic
control={prompt}
title={l`Rescind request`}
description={l`Are you sure you want to rescind your request to join ${convoView.name}?`}
confirmButtonCta={l`Rescind request`}
onConfirm={() => {
prompt.close(() => {
if (isWithdrawPending) return
withdrawRequest({convoId: convoView.convoId})
})
}}
/>
</>
)
}
+2
View File
@@ -11,6 +11,7 @@ import {findAllProfilesInQueryData as findAllProfilesInContactMatchesQueryData}
import {findAllProfilesInQueryData as findAllProfilesInKnownFollowersQueryData} from '#/state/queries/known-followers'
import {findAllProfilesInQueryData as findAllProfilesInListMembersQueryData} from '#/state/queries/list-members'
import {findAllProfilesInQueryData as findAllProfilesInGetConvoQueryData} from '#/state/queries/messages/conversation'
import {findAllProfilesInQueryData as findAllProfilesInListConvoRequestsQueryData} from '#/state/queries/messages/list-conversation-requests'
import {findAllProfilesInQueryData as findAllProfilesInListConvosQueryData} from '#/state/queries/messages/list-conversations'
import {findAllProfilesInQueryData as findAllProfilesInMessagesQueryData} from '#/state/queries/messages/list-convo-members'
import {findAllProfilesInQueryData as findAllProfilesInMyBlockedAccountsQueryData} from '#/state/queries/my-blocked-accounts'
@@ -259,6 +260,7 @@ function* findProfilesInCache(
yield* findAllProfilesInSuggestedFollowsQueryData(queryClient, did)
yield* findAllProfilesInActorSearchQueryData(queryClient, did)
yield* findAllProfilesInListConvosQueryData(queryClient, did)
yield* findAllProfilesInListConvoRequestsQueryData(queryClient, did)
yield* findAllProfilesInFeedsQueryData(queryClient, did)
yield* findAllProfilesInPostThreadV2QueryData(queryClient, did)
yield* findAllProfilesInKnownFollowersQueryData(queryClient, did)
@@ -7,6 +7,11 @@ import {useMutation, useQueryClient} from '@tanstack/react-query'
import {DM_SERVICE_HEADERS} from '#/lib/constants'
import {logger} from '#/logger'
import {useAgent} from '#/state/session'
import {
type ConvoRequestListQueryData,
optimisticDelete as optimisticDeleteRequest,
RQKEY_ROOT as REQUESTS_RQKEY_ROOT,
} from './list-conversation-requests'
import {
RQKEY as CONVO_LIST_KEY,
RQKEY_ROOT as CONVO_LIST_ROOT_KEY,
@@ -96,11 +101,20 @@ export function useAcceptConversation(
}
},
)
const prevRequestsQueries =
queryClient.getQueriesData<ConvoRequestListQueryData>({
queryKey: [REQUESTS_RQKEY_ROOT],
})
queryClient.setQueriesData<ConvoRequestListQueryData>(
{queryKey: [REQUESTS_RQKEY_ROOT]},
old => optimisticDeleteRequest(convoId, old),
)
onMutate?.()
return {prevAcceptedPages, prevInboxPages}
return {prevAcceptedPages, prevInboxPages, prevRequestsQueries}
},
onSuccess: data => {
queryClient.invalidateQueries({queryKey: [CONVO_LIST_KEY]})
void queryClient.invalidateQueries({queryKey: [CONVO_LIST_KEY]})
void queryClient.invalidateQueries({queryKey: [REQUESTS_RQKEY_ROOT]})
onSuccess?.(data)
},
onError: (error, _, context) => {
@@ -131,7 +145,13 @@ export function useAcceptConversation(
}
},
)
queryClient.invalidateQueries({queryKey: [CONVO_LIST_ROOT_KEY]})
if (context?.prevRequestsQueries) {
for (const [queryKey, prevData] of context.prevRequestsQueries) {
queryClient.setQueryData(queryKey, prevData)
}
}
void queryClient.invalidateQueries({queryKey: [CONVO_LIST_ROOT_KEY]})
void queryClient.invalidateQueries({queryKey: [REQUESTS_RQKEY_ROOT]})
onError?.(error)
},
})
@@ -0,0 +1,165 @@
import {
ChatBskyConvoDefs,
type ChatBskyConvoListConvoRequests,
ChatBskyGroupDefs,
} from '@atproto/api'
import {
type InfiniteData,
type QueryClient,
useInfiniteQuery,
} from '@tanstack/react-query'
import {DM_SERVICE_HEADERS} from '#/lib/constants'
import {useAgent} from '#/state/session'
const DEFAULT_LIMIT = 10
export const RQKEY_ROOT = 'convo-request-list'
export const RQKEY = (limit: number = DEFAULT_LIMIT) => [RQKEY_ROOT, limit]
type RQPageParam = string | undefined
export function useListConvoRequests({
enabled = true,
limit = DEFAULT_LIMIT,
}: {
enabled?: boolean
limit?: number
} = {}) {
const agent = useAgent()
return useInfiniteQuery({
enabled,
queryKey: RQKEY(limit),
queryFn: async ({pageParam}) => {
const {data} = await agent.chat.bsky.convo.listConvoRequests(
{limit, cursor: pageParam},
{headers: DM_SERVICE_HEADERS},
)
return data
},
initialPageParam: undefined as RQPageParam,
getNextPageParam: lastPage => lastPage.cursor,
})
}
export type ConvoRequestListQueryData = {
pageParams: Array<string | undefined>
pages: Array<ChatBskyConvoListConvoRequests.OutputSchema>
}
export type ConvoRequestItem =
ChatBskyConvoListConvoRequests.OutputSchema['requests'][number]
export function optimisticUpdate(
chatId: string,
old: ConvoRequestListQueryData | undefined,
updateFn: (convo: ChatBskyConvoDefs.ConvoView) => ChatBskyConvoDefs.ConvoView,
): ConvoRequestListQueryData | undefined {
if (!old) return old
return {
...old,
pages: old.pages.map(page => ({
...page,
requests: page.requests.map((item): ConvoRequestItem => {
if (ChatBskyConvoDefs.isConvoView(item) && item.id === chatId) {
return {
...updateFn(item),
$type: 'chat.bsky.convo.defs#convoView',
}
}
return item
}),
})),
}
}
export function optimisticDelete(
chatId: string,
old: ConvoRequestListQueryData | undefined,
) {
if (!old) return old
return {
...old,
pages: old.pages.map(page => ({
...page,
requests: page.requests.filter(
item => !ChatBskyConvoDefs.isConvoView(item) || item.id !== chatId,
),
})),
}
}
export function markAllRead(
old: ConvoRequestListQueryData | undefined,
): ConvoRequestListQueryData | undefined {
if (!old) return old
return {
...old,
pages: old.pages.map(page => ({
...page,
requests: page.requests.map((item): ConvoRequestItem => {
if (ChatBskyConvoDefs.isConvoView(item)) {
return {
...item,
$type: 'chat.bsky.convo.defs#convoView',
unreadCount: 0,
}
}
return item
}),
})),
}
}
export function optimisticDeleteJoinRequest(
convoId: string,
old: ConvoRequestListQueryData | undefined,
) {
if (!old) return old
return {
...old,
pages: old.pages.map(page => ({
...page,
requests: page.requests.filter(
item =>
!ChatBskyGroupDefs.isJoinRequestConvoView(item) ||
item.convoId !== convoId,
),
})),
}
}
export function* findAllProfilesInQueryData(
queryClient: QueryClient,
did: string,
) {
const queryDatas = queryClient.getQueriesData<
InfiniteData<ChatBskyConvoListConvoRequests.OutputSchema>
>({
queryKey: [RQKEY_ROOT],
})
for (const [_queryKey, queryData] of queryDatas) {
if (!queryData?.pages) continue
for (const page of queryData.pages) {
for (const item of page.requests) {
if (ChatBskyConvoDefs.isConvoView(item)) {
for (const member of item.members) {
if (member.did === did) {
yield member
}
}
} else if (ChatBskyGroupDefs.isJoinRequestConvoView(item)) {
if (item.owner.did === did) {
yield item.owner
}
}
}
}
}
}
+215 -133
View File
@@ -23,6 +23,13 @@ import {parseConvoView} from '#/components/dms/util'
import * as bsky from '#/types/bsky'
import {RQKEY as CONVO_KEY} from './conversation'
import {useLeftConvos} from './leave-conversation'
import {
type ConvoRequestListQueryData,
optimisticDelete as optimisticDeleteRequest,
optimisticDeleteJoinRequest,
optimisticUpdate as optimisticUpdateRequest,
RQKEY_ROOT as REQUESTS_RQKEY_ROOT,
} from './list-conversation-requests'
import {listConvoMembersQueryKey} from './list-convo-members'
const DEFAULT_LIMIT = 10
@@ -130,6 +137,7 @@ export function ListConvosProviderInner({
const refetchAndInvalidate = () => {
void refetch()
void queryClient.invalidateQueries({queryKey: [RQKEY_ROOT]})
void queryClient.invalidateQueries({queryKey: [REQUESTS_RQKEY_ROOT]})
}
return throttle(refetchAndInvalidate, 500, {
leading: true,
@@ -157,6 +165,22 @@ export function ListConvosProviderInner({
)
}
function updateConvoInAllLists(
convoId: string,
fn: (
convo: ChatBskyConvoDefs.ConvoView,
) => ChatBskyConvoDefs.ConvoView,
) {
queryClient.setQueriesData<ConvoListQueryData>(
{queryKey: [RQKEY_ROOT]},
old => optimisticUpdate(convoId, old, fn),
)
queryClient.setQueriesData<ConvoRequestListQueryData>(
{queryKey: [REQUESTS_RQKEY_ROOT]},
old => optimisticUpdateRequest(convoId, old, fn),
)
}
function mutateConvoView(
convoId: string,
fn: (
@@ -167,9 +191,17 @@ export function ListConvosProviderInner({
CONVO_KEY(convoId),
old => (old ? fn(old) : old),
)
updateConvoInAllLists(convoId, fn)
}
function deleteConvoFromAllLists(convoId: string) {
queryClient.setQueriesData<ConvoListQueryData>(
{queryKey: [RQKEY_ROOT]},
old => optimisticUpdate(convoId, old, fn),
old => optimisticDelete(convoId, old),
)
queryClient.setQueriesData<ConvoRequestListQueryData>(
{queryKey: [REQUESTS_RQKEY_ROOT]},
old => optimisticDeleteRequest(convoId, old),
)
}
@@ -220,35 +252,26 @@ export function ListConvosProviderInner({
if (ChatBskyConvoDefs.isLogBeginConvo(log)) {
debouncedRefetch()
} else if (ChatBskyConvoDefs.isLogLeaveConvo(log)) {
queryClient.setQueriesData(
{queryKey: [RQKEY_ROOT]},
(old?: ConvoListQueryData) => optimisticDelete(log.convoId, old),
)
deleteConvoFromAllLists(log.convoId)
} else if (ChatBskyConvoDefs.isLogDeleteMessage(log)) {
queryClient.setQueriesData(
{queryKey: [RQKEY_ROOT]},
(old?: ConvoListQueryData) =>
optimisticUpdate(log.convoId, old, convo => {
if (
(ChatBskyConvoDefs.isDeletedMessageView(log.message) ||
ChatBskyConvoDefs.isMessageView(log.message)) &&
(ChatBskyConvoDefs.isDeletedMessageView(
convo.lastMessage,
) ||
ChatBskyConvoDefs.isMessageView(convo.lastMessage))
) {
return log.message.id === convo.lastMessage.id
? {
...convo,
rev: log.rev,
lastMessage: log.message,
}
: convo
} else {
return convo
}
}),
)
updateConvoInAllLists(log.convoId, convo => {
if (
(ChatBskyConvoDefs.isDeletedMessageView(log.message) ||
ChatBskyConvoDefs.isMessageView(log.message)) &&
(ChatBskyConvoDefs.isDeletedMessageView(convo.lastMessage) ||
ChatBskyConvoDefs.isMessageView(convo.lastMessage))
) {
return log.message.id === convo.lastMessage.id
? {
...convo,
rev: log.rev,
lastMessage: log.message,
}
: convo
} else {
return convo
}
})
} else if (ChatBskyConvoDefs.isLogCreateMessage(log)) {
// Store in a new var to avoid TS errors due to closures.
const logRef: ChatBskyConvoDefs.LogCreateMessage = log
@@ -335,27 +358,24 @@ export function ListConvosProviderInner({
)
} else if (updatedConvo.status === 'request') {
queryClient.setQueriesData({queryKey: RQKEY('request')}, updateFn)
// also move-to-top in the new requests cache
queryClient.setQueriesData<ConvoRequestListQueryData>(
{queryKey: [REQUESTS_RQKEY_ROOT]},
old => moveConvoToTopInRequests(updatedConvo, old),
)
}
} else if (ChatBskyConvoDefs.isLogReadMessage(log)) {
queryClient.setQueriesData(
{queryKey: [RQKEY_ROOT]},
(old?: ConvoListQueryData) =>
optimisticUpdate(log.convoId, old, convo => ({
...convo,
unreadCount: 0,
rev: log.rev,
})),
)
updateConvoInAllLists(log.convoId, convo => ({
...convo,
unreadCount: 0,
rev: log.rev,
}))
} else if (ChatBskyConvoDefs.isLogReadConvo(log)) {
queryClient.setQueriesData(
{queryKey: [RQKEY_ROOT]},
(old?: ConvoListQueryData) =>
optimisticUpdate(log.convoId, old, convo => ({
...convo,
unreadCount: 0,
rev: log.rev,
})),
)
updateConvoInAllLists(log.convoId, convo => ({
...convo,
unreadCount: 0,
rev: log.rev,
}))
} else if (ChatBskyConvoDefs.isLogAcceptConvo(log)) {
const requests = queryClient.getQueryData<ConvoListQueryData>(
RQKEY('request'),
@@ -373,6 +393,11 @@ export function ListConvosProviderInner({
RQKEY('request'),
(old?: ConvoListQueryData) => optimisticDelete(log.convoId, old),
)
// also remove from the new requests cache
queryClient.setQueriesData<ConvoRequestListQueryData>(
{queryKey: [REQUESTS_RQKEY_ROOT]},
old => optimisticDeleteRequest(log.convoId, old),
)
queryClient.setQueriesData(
{queryKey: RQKEY('accepted')},
(old?: ConvoListQueryData) => {
@@ -398,55 +423,50 @@ export function ListConvosProviderInner({
},
)
} else if (ChatBskyConvoDefs.isLogMuteConvo(log)) {
queryClient.setQueriesData(
{queryKey: [RQKEY_ROOT]},
(old?: ConvoListQueryData) =>
optimisticUpdate(log.convoId, old, convo => ({
...convo,
muted: true,
rev: log.rev,
})),
)
updateConvoInAllLists(log.convoId, convo => ({
...convo,
muted: true,
rev: log.rev,
}))
} else if (ChatBskyConvoDefs.isLogUnmuteConvo(log)) {
queryClient.setQueriesData(
{queryKey: [RQKEY_ROOT]},
(old?: ConvoListQueryData) =>
optimisticUpdate(log.convoId, old, convo => ({
...convo,
muted: false,
rev: log.rev,
})),
)
updateConvoInAllLists(log.convoId, convo => ({
...convo,
muted: false,
rev: log.rev,
}))
} else if (ChatBskyConvoDefs.isLogLockConvo(log)) {
mutateConvoView(log.convoId, convo =>
ChatBskyConvoDefs.isGroupConvo(convo.kind)
? {
...convo,
kind: {...convo.kind, lockStatus: 'locked'},
rev: log.rev,
}
: {...convo, rev: log.rev},
)
mutateConvoView(log.convoId, convo => {
if (ChatBskyConvoDefs.isGroupConvo(convo.kind)) {
return {
...convo,
kind: {...convo.kind, lockStatus: 'locked'},
rev: log.rev,
}
}
return {...convo, rev: log.rev}
})
} else if (ChatBskyConvoDefs.isLogUnlockConvo(log)) {
mutateConvoView(log.convoId, convo =>
ChatBskyConvoDefs.isGroupConvo(convo.kind)
? {
...convo,
kind: {...convo.kind, lockStatus: 'unlocked'},
rev: log.rev,
}
: {...convo, rev: log.rev},
)
mutateConvoView(log.convoId, convo => {
if (ChatBskyConvoDefs.isGroupConvo(convo.kind)) {
return {
...convo,
kind: {...convo.kind, lockStatus: 'unlocked'},
rev: log.rev,
}
}
return {...convo, rev: log.rev}
})
} else if (ChatBskyConvoDefs.isLogLockConvoPermanently(log)) {
mutateConvoView(log.convoId, convo =>
ChatBskyConvoDefs.isGroupConvo(convo.kind)
? {
...convo,
kind: {...convo.kind, lockStatus: 'locked-permanently'},
rev: log.rev,
}
: {...convo, rev: log.rev},
)
mutateConvoView(log.convoId, convo => {
if (ChatBskyConvoDefs.isGroupConvo(convo.kind)) {
return {
...convo,
kind: {...convo.kind, lockStatus: 'locked-permanently'},
rev: log.rev,
}
}
return {...convo, rev: log.rev}
})
} else if (
ChatBskyConvoDefs.isLogCreateJoinLink(log) ||
ChatBskyConvoDefs.isLogEditJoinLink(log) ||
@@ -455,37 +475,68 @@ export function ListConvosProviderInner({
) {
// Join link data not included in the log event, trigger refetch to get it
debouncedRefetch()
} else if (ChatBskyConvoDefs.isLogEditGroup(log)) {
// Updated group details (name etc.) aren't included in the log
// event, so refetch to pick them up.
debouncedRefetch()
} else if (
ChatBskyConvoDefs.isLogApproveJoinRequest(log) ||
ChatBskyConvoDefs.isLogRejectJoinRequest(log)
) {
queryClient.setQueriesData(
{queryKey: [RQKEY_ROOT]},
(old?: ConvoListQueryData) =>
updateGroupConvoJoinRequestCount(log, old, -1),
// Route through mutateConvoView (not updateConvoInAllLists) so the
// single-convo cache updates too, keeping the in-convo requests
// banner in sync.
mutateConvoView(log.convoId, convo =>
applyJoinRequestCountDelta(convo, log.rev, -1),
)
} else if (ChatBskyConvoDefs.isLogIncomingJoinRequest(log)) {
queryClient.setQueriesData(
{queryKey: [RQKEY_ROOT]},
(old?: ConvoListQueryData) =>
updateGroupConvoJoinRequestCount(log, old, 1),
// Route through mutateConvoView (not updateConvoInAllLists) so the
// single-convo cache updates too, letting the in-convo requests
// banner appear live.
mutateConvoView(log.convoId, convo =>
applyJoinRequestCountDelta(convo, log.rev, 1),
)
} else if (ChatBskyConvoDefs.isLogReadJoinRequests(log)) {
// The owner marked join requests as read (possibly on another
// device). Zero the unread count but keep the total, mirroring the
// useMarkJoinRequestsRead mutation.
mutateConvoView(log.convoId, convo => {
if (!ChatBskyConvoDefs.isGroupConvo(convo.kind)) {
return {...convo, rev: log.rev}
}
return {
...convo,
kind: {...convo.kind, unreadJoinRequestCount: 0},
rev: log.rev,
}
})
} else if (ChatBskyConvoDefs.isLogOutgoingJoinRequest(log)) {
// Viewer isn't in the chat yet, no need to do anything
} else if (ChatBskyConvoDefs.isLogAddReaction(log)) {
queryClient.setQueriesData(
{queryKey: [RQKEY_ROOT]},
(old?: ConvoListQueryData) =>
optimisticUpdate(log.convoId, old, convo => ({
...convo,
lastReaction: {
$type: 'chat.bsky.convo.defs#messageAndReactionView',
reaction: log.reaction,
message: log.message,
},
rev: log.rev,
})),
// Viewer isn't in the chat yet, but the inbox surfaces outgoing
// requests, so refetch to pick up the new entry.
debouncedRefetch()
} else if (ChatBskyConvoDefs.isLogWithdrawIncomingJoinRequest(log)) {
// A requester rescinded their request to a group the viewer owns.
// Mirror of isLogIncomingJoinRequest: decrement the counts.
mutateConvoView(log.convoId, convo =>
applyJoinRequestCountDelta(convo, log.rev, -1),
)
} else if (ChatBskyConvoDefs.isLogWithdrawOutgoingJoinRequest(log)) {
// The viewer rescinded their own outgoing join request (possibly on
// another device). Remove it from the requests inbox cache.
queryClient.setQueriesData<ConvoRequestListQueryData>(
{queryKey: [REQUESTS_RQKEY_ROOT]},
old => optimisticDeleteJoinRequest(log.convoId, old),
)
} else if (ChatBskyConvoDefs.isLogAddReaction(log)) {
updateConvoInAllLists(log.convoId, convo => ({
...convo,
lastReaction: {
$type: 'chat.bsky.convo.defs#messageAndReactionView',
reaction: log.reaction,
message: log.message,
},
rev: log.rev,
}))
} else if (ChatBskyConvoDefs.isLogAddMember(log)) {
const data = log.message.data
if (
@@ -739,27 +790,58 @@ function optimisticUpdate(
}
}
function updateGroupConvoJoinRequestCount(
log: {convoId: string; rev: string},
old: ConvoListQueryData | undefined,
function applyJoinRequestCountDelta(
convo: ChatBskyConvoDefs.ConvoView,
rev: string,
delta: 1 | -1,
) {
return optimisticUpdate(log.convoId, old, convo => {
// Join requests are only meaningful for group convos.
if (!ChatBskyConvoDefs.isGroupConvo(convo.kind)) {
return {...convo, rev: log.rev}
): ChatBskyConvoDefs.ConvoView {
// Join requests are only meaningful for group convos.
if (!ChatBskyConvoDefs.isGroupConvo(convo.kind)) {
return {...convo, rev}
}
// Bump the total and unread counts together. Both are clamped at 0 and
// collapse to undefined when empty, matching the server's shape.
const bump = (current: number | undefined) => {
const next = Math.max(0, (current ?? 0) + delta)
return next === 0 ? undefined : next
}
return {
...convo,
kind: {
...convo.kind,
joinRequestCount: bump(convo.kind.joinRequestCount),
unreadJoinRequestCount: bump(convo.kind.unreadJoinRequestCount),
},
rev,
}
}
function moveConvoToTopInRequests(
updatedConvo: ChatBskyConvoDefs.ConvoView,
old: ConvoRequestListQueryData | undefined,
): ConvoRequestListQueryData | undefined {
if (!old) return old
const typedConvo: ConvoRequestListQueryData['pages'][number]['requests'][number] =
{
$type: 'chat.bsky.convo.defs#convoView',
...updatedConvo,
}
const current = convo.kind.joinRequestCount ?? 0
const next = Math.max(0, current + delta)
return {
...convo,
kind: {
...convo.kind,
joinRequestCount: next === 0 ? undefined : next,
},
rev: log.rev,
}
})
return {
...old,
pages: old.pages.map((page, i) => {
const filtered = page.requests.filter(
item =>
!ChatBskyConvoDefs.isConvoView(item) || item.id !== updatedConvo.id,
)
if (i === 0) {
return {
...page,
requests: [typedConvo, ...filtered],
}
}
return {...page, requests: filtered}
}),
}
}
function removeMemberFromConvoView(
@@ -1,9 +1,10 @@
import {type ChatBskyGroupRequestJoin} from '@atproto/api'
import {useMutation} from '@tanstack/react-query'
import {useMutation, useQueryClient} from '@tanstack/react-query'
import {DM_SERVICE_HEADERS} from '#/lib/constants'
import {logger} from '#/logger'
import {useAgent, useSession} from '#/state/session'
import {RQKEY_ROOT as REQUESTS_RQKEY_ROOT} from './list-conversation-requests'
export function useRequestJoinGroupChat({
onSuccess,
@@ -13,6 +14,7 @@ export function useRequestJoinGroupChat({
onError?: (error: Error) => void
} = {}) {
const agent = useAgent()
const queryClient = useQueryClient()
const {hasSession} = useSession()
return useMutation({
@@ -27,6 +29,7 @@ export function useRequestJoinGroupChat({
return res.data
},
onSuccess: data => {
void queryClient.invalidateQueries({queryKey: [REQUESTS_RQKEY_ROOT]})
onSuccess?.(data)
},
onError: error => {
+35 -4
View File
@@ -4,6 +4,11 @@ import {useMutation, useQueryClient} from '@tanstack/react-query'
import {DM_SERVICE_HEADERS} from '#/lib/constants'
import {logger} from '#/logger'
import {useAgent} from '#/state/session'
import {
type ConvoRequestListQueryData,
markAllRead as markAllRequestsRead,
RQKEY_ROOT as REQUESTS_RQKEY_ROOT,
} from './list-conversation-requests'
import {RQKEY as CONVO_LIST_KEY} from './list-conversations'
export function useUpdateAllRead(
@@ -32,6 +37,9 @@ export function useUpdateAllRead(
},
onMutate: () => {
let prevPages: ChatBskyConvoListConvos.OutputSchema[] = []
let prevRequestsQueries: Array<
[readonly unknown[], ConvoRequestListQueryData | undefined]
> = []
queryClient.setQueryData(
CONVO_LIST_KEY(status),
(old?: {
@@ -75,11 +83,24 @@ export function useUpdateAllRead(
}
},
)
if (status === 'request') {
prevRequestsQueries =
queryClient.getQueriesData<ConvoRequestListQueryData>({
queryKey: [REQUESTS_RQKEY_ROOT],
})
queryClient.setQueriesData<ConvoRequestListQueryData>(
{queryKey: [REQUESTS_RQKEY_ROOT]},
markAllRequestsRead,
)
}
onMutate?.()
return {prevPages}
return {prevPages, prevRequestsQueries}
},
onSuccess: () => {
queryClient.invalidateQueries({queryKey: CONVO_LIST_KEY(status)})
void queryClient.invalidateQueries({queryKey: CONVO_LIST_KEY(status)})
if (status === 'request') {
void queryClient.invalidateQueries({queryKey: [REQUESTS_RQKEY_ROOT]})
}
onSuccess?.()
},
onError: (error, _, context) => {
@@ -97,8 +118,18 @@ export function useUpdateAllRead(
}
},
)
queryClient.invalidateQueries({queryKey: CONVO_LIST_KEY(status)})
queryClient.invalidateQueries({queryKey: CONVO_LIST_KEY('all', 'unread')})
if (status === 'request' && context?.prevRequestsQueries) {
for (const [queryKey, prevData] of context.prevRequestsQueries) {
queryClient.setQueryData(queryKey, prevData)
}
}
void queryClient.invalidateQueries({queryKey: CONVO_LIST_KEY(status)})
void queryClient.invalidateQueries({
queryKey: CONVO_LIST_KEY('all', 'unread'),
})
if (status === 'request') {
void queryClient.invalidateQueries({queryKey: [REQUESTS_RQKEY_ROOT]})
}
onError?.(error)
},
})
@@ -1,9 +1,14 @@
import {type ChatBskyGroupWithdrawJoinRequest} from '@atproto/api'
import {useMutation} from '@tanstack/react-query'
import {useMutation, useQueryClient} from '@tanstack/react-query'
import {DM_SERVICE_HEADERS} from '#/lib/constants'
import {logger} from '#/logger'
import {useAgent, useSession} from '#/state/session'
import {
type ConvoRequestListQueryData,
optimisticDeleteJoinRequest,
RQKEY_ROOT as REQUESTS_RQKEY_ROOT,
} from './list-conversation-requests'
export function useWithdrawJoinGroupChatRequest({
onSuccess,
@@ -13,6 +18,7 @@ export function useWithdrawJoinGroupChatRequest({
onError?: (error: Error) => void
} = {}) {
const agent = useAgent()
const queryClient = useQueryClient()
const {hasSession} = useSession()
return useMutation({
@@ -27,7 +33,11 @@ export function useWithdrawJoinGroupChatRequest({
)
return res.data
},
onSuccess: data => {
onSuccess: (data, {convoId}) => {
queryClient.setQueriesData<ConvoRequestListQueryData>(
{queryKey: [REQUESTS_RQKEY_ROOT]},
old => optimisticDeleteJoinRequest(convoId, old),
)
onSuccess?.(data)
},
onError: error => {