Files
bsky-social-app/src/state/queries/messages/list-conversations.tsx
T
2025-01-08 11:37:19 -06:00

355 lines
9.4 KiB
TypeScript

import React, {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
} from 'react'
import {
ChatBskyConvoDefs,
ChatBskyConvoListConvos,
moderateProfile,
} from '@atproto/api'
import {
InfiniteData,
QueryClient,
useInfiniteQuery,
useQueryClient,
} from '@tanstack/react-query'
import throttle from 'lodash.throttle'
import {useCurrentConvoId} from '#/state/messages/current-convo-id'
import {useMessagesEventBus} from '#/state/messages/events'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {DM_SERVICE_HEADERS} from '#/state/queries/messages/const'
import {useAgent, useSession} from '#/state/session'
export const RQKEY = ['convo-list']
type RQPageParam = string | undefined
export function useListConvosQuery({
enabled,
}: {
enabled?: boolean
} = {}) {
const agent = useAgent()
return useInfiniteQuery({
enabled,
queryKey: RQKEY,
queryFn: async ({pageParam}) => {
const {data} = await agent.api.chat.bsky.convo.listConvos(
{cursor: pageParam, limit: 20},
{headers: DM_SERVICE_HEADERS},
)
return data
},
initialPageParam: undefined as RQPageParam,
getNextPageParam: lastPage => lastPage.cursor,
})
}
const ListConvosContext = createContext<ChatBskyConvoDefs.ConvoView[] | null>(
null,
)
export function useListConvos() {
const ctx = useContext(ListConvosContext)
if (!ctx) {
throw new Error('useListConvos must be used within a ListConvosProvider')
}
return ctx
}
export function ListConvosProvider({children}: {children: React.ReactNode}) {
const {hasSession} = useSession()
if (!hasSession) {
return (
<ListConvosContext.Provider value={[]}>
{children}
</ListConvosContext.Provider>
)
}
return <ListConvosProviderInner>{children}</ListConvosProviderInner>
}
export function ListConvosProviderInner({
children,
}: {
children: React.ReactNode
}) {
const {refetch, data} = useListConvosQuery()
const messagesBus = useMessagesEventBus()
const queryClient = useQueryClient()
const {currentConvoId} = useCurrentConvoId()
const {currentAccount} = useSession()
const debouncedRefetch = useMemo(
() =>
throttle(refetch, 500, {
leading: true,
trailing: true,
}),
[refetch],
)
useEffect(() => {
const unsub = messagesBus.on(
events => {
if (events.type !== 'logs') return
events.logs.forEach(log => {
if (ChatBskyConvoDefs.isLogBeginConvo(log)) {
debouncedRefetch()
} else if (ChatBskyConvoDefs.isLogLeaveConvo(log)) {
queryClient.setQueryData(RQKEY, (old: ConvoListQueryData) =>
optimisticDelete(log.convoId, old),
)
} else if (ChatBskyConvoDefs.isLogDeleteMessage(log)) {
queryClient.setQueryData(RQKEY, (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
}
}),
)
} else if (ChatBskyConvoDefs.isLogCreateMessage(log)) {
queryClient.setQueryData(RQKEY, (old: ConvoListQueryData) => {
if (!old) return old
function updateConvo(convo: ChatBskyConvoDefs.ConvoView) {
if (!ChatBskyConvoDefs.isLogCreateMessage(log)) return convo
let unreadCount = convo.unreadCount
if (convo.id !== currentConvoId) {
if (
ChatBskyConvoDefs.isMessageView(log.message) ||
ChatBskyConvoDefs.isDeletedMessageView(log.message)
) {
if (log.message.sender.did !== currentAccount?.did) {
unreadCount++
}
}
} else {
unreadCount = 0
}
return {
...convo,
rev: log.rev,
lastMessage: log.message,
unreadCount,
}
}
function filterConvoFromPage(
convo: ChatBskyConvoDefs.ConvoView[],
) {
return convo.filter(
c =>
c.id !==
(log as ChatBskyConvoDefs.LogCreateMessage).convoId,
)
}
const existingConvo = getConvoFromQueryData(log.convoId, old)
if (existingConvo) {
return {
...old,
pages: old.pages.map((page, i) => {
if (i === 0) {
return {
...page,
convos: [
updateConvo(existingConvo),
...filterConvoFromPage(page.convos),
],
}
}
return {
...page,
convos: filterConvoFromPage(page.convos),
}
}),
}
} else {
/**
* We received a message from an conversation old enough that
* it doesn't exist in the query cache, meaning we need to
* refetch and bump the old convo to the top.
*/
debouncedRefetch()
}
})
}
})
},
{
// get events for all chats
convoId: undefined,
},
)
return () => unsub()
}, [
messagesBus,
currentConvoId,
refetch,
queryClient,
currentAccount?.did,
debouncedRefetch,
])
const ctx = useMemo(() => {
return data?.pages.flatMap(page => page.convos) ?? []
}, [data])
return (
<ListConvosContext.Provider value={ctx}>
{children}
</ListConvosContext.Provider>
)
}
export function useUnreadMessageCount() {
const {currentConvoId} = useCurrentConvoId()
const {currentAccount} = useSession()
const convos = useListConvos()
const moderationOpts = useModerationOpts()
const count = useMemo(() => {
return (
convos
.filter(convo => convo.id !== currentConvoId)
.reduce((acc, convo) => {
const otherMember = convo.members.find(
member => member.did !== currentAccount?.did,
)
if (!otherMember || !moderationOpts) return acc
const moderation = moderateProfile(otherMember, moderationOpts)
const shouldIgnore =
convo.muted ||
moderation.blocked ||
otherMember.did === 'missing.invalid'
const unreadCount = !shouldIgnore && convo.unreadCount > 0 ? 1 : 0
return acc + unreadCount
}, 0) ?? 0
)
}, [convos, currentAccount?.did, currentConvoId, moderationOpts])
return useMemo(() => {
return {
count,
numUnread: count > 0 ? (count > 10 ? '10+' : String(count)) : undefined,
}
}, [count])
}
export type ConvoListQueryData = {
pageParams: Array<string | undefined>
pages: Array<ChatBskyConvoListConvos.OutputSchema>
}
export function useOnMarkAsRead() {
const queryClient = useQueryClient()
return useCallback(
(chatId: string) => {
queryClient.setQueryData(RQKEY, (old: ConvoListQueryData) => {
return optimisticUpdate(chatId, old, convo => ({
...convo,
unreadCount: 0,
}))
})
},
[queryClient],
)
}
function optimisticUpdate(
chatId: string,
old: ConvoListQueryData,
updateFn: (convo: ChatBskyConvoDefs.ConvoView) => ChatBskyConvoDefs.ConvoView,
) {
if (!old) return old
return {
...old,
pages: old.pages.map(page => ({
...page,
convos: page.convos.map(convo =>
chatId === convo.id ? updateFn(convo) : convo,
),
})),
}
}
function optimisticDelete(chatId: string, old: ConvoListQueryData) {
if (!old) return old
return {
...old,
pages: old.pages.map(page => ({
...page,
convos: page.convos.filter(convo => chatId !== convo.id),
})),
}
}
export function getConvoFromQueryData(chatId: string, old: ConvoListQueryData) {
for (const page of old.pages) {
for (const convo of page.convos) {
if (convo.id === chatId) {
return convo
}
}
}
return null
}
export function* findAllProfilesInQueryData(
queryClient: QueryClient,
did: string,
) {
const queryDatas = queryClient.getQueriesData<
InfiniteData<ChatBskyConvoListConvos.OutputSchema>
>({
queryKey: RQKEY,
})
for (const [_queryKey, queryData] of queryDatas) {
if (!queryData?.pages) {
continue
}
for (const page of queryData.pages) {
for (const convo of page.convos) {
for (const member of convo.members) {
if (member.did === did) {
yield member
}
}
}
}
}
}