893 lines
28 KiB
TypeScript
893 lines
28 KiB
TypeScript
import {createContext, useCallback, useContext, useEffect, useMemo} from 'react'
|
|
import {
|
|
type ChatBskyActorDefs,
|
|
ChatBskyConvoDefs,
|
|
type ChatBskyConvoListConvos,
|
|
moderateProfile,
|
|
type ModerationOpts,
|
|
} from '@atproto/api'
|
|
import {
|
|
type InfiniteData,
|
|
type QueryClient,
|
|
useInfiniteQuery,
|
|
useQueryClient,
|
|
} from '@tanstack/react-query'
|
|
import throttle from 'lodash.throttle'
|
|
|
|
import {DM_SERVICE_HEADERS} from '#/lib/constants'
|
|
import {useCurrentConvoId} from '#/state/messages/current-convo-id'
|
|
import {useMessagesEventBus} from '#/state/messages/events'
|
|
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
|
import {useAgent, useSession} from '#/state/session'
|
|
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 {listConvoMembersQueryKey} from './list-convo-members'
|
|
|
|
const DEFAULT_LIMIT = 10
|
|
export const UNREAD_LIMIT = 20
|
|
|
|
export const RQKEY_ROOT = 'convo-list'
|
|
export const RQKEY = (
|
|
status: 'accepted' | 'request' | 'all',
|
|
readState: 'all' | 'unread' = 'all',
|
|
kind: 'all' | 'group' | 'direct' = 'all',
|
|
lockStatus:
|
|
| 'unlocked'
|
|
| 'locked'
|
|
| 'locked-permanently'
|
|
| undefined = undefined,
|
|
limit?: number,
|
|
) => [RQKEY_ROOT, status, readState, kind, lockStatus, limit]
|
|
type RQPageParam = string | undefined
|
|
|
|
export function useListConvosQuery({
|
|
enabled,
|
|
status,
|
|
readState = 'all',
|
|
kind = 'all',
|
|
limit = DEFAULT_LIMIT,
|
|
lockStatus,
|
|
}: {
|
|
enabled?: boolean
|
|
status?: 'request' | 'accepted'
|
|
readState?: 'all' | 'unread'
|
|
kind?: 'all' | 'group' | 'direct'
|
|
limit?: number
|
|
lockStatus?: 'unlocked' | 'locked' | 'locked-permanently'
|
|
} = {}) {
|
|
const agent = useAgent()
|
|
|
|
return useInfiniteQuery({
|
|
enabled,
|
|
queryKey: RQKEY(status ?? 'all', readState, kind, lockStatus, limit),
|
|
queryFn: async ({pageParam}) => {
|
|
const {data} = await agent.chat.bsky.convo.listConvos(
|
|
{
|
|
limit,
|
|
cursor: pageParam,
|
|
readState: readState === 'unread' ? 'unread' : undefined,
|
|
kind: kind === 'all' ? undefined : kind,
|
|
lockStatus,
|
|
status,
|
|
},
|
|
{headers: DM_SERVICE_HEADERS},
|
|
)
|
|
return data
|
|
},
|
|
initialPageParam: undefined as RQPageParam,
|
|
getNextPageParam: lastPage => lastPage.cursor,
|
|
})
|
|
}
|
|
|
|
const ListConvosContext = createContext<{
|
|
accepted: ChatBskyConvoDefs.ConvoView[]
|
|
request: ChatBskyConvoDefs.ConvoView[]
|
|
} | null>(null)
|
|
ListConvosContext.displayName = 'ListConvosContext'
|
|
|
|
export function useListConvos() {
|
|
const ctx = useContext(ListConvosContext)
|
|
if (!ctx) {
|
|
throw new Error('useListConvos must be used within a ListConvosProvider')
|
|
}
|
|
return ctx
|
|
}
|
|
|
|
const empty = {accepted: [], request: []}
|
|
export function ListConvosProvider({children}: {children: React.ReactNode}) {
|
|
const {hasSession} = useSession()
|
|
|
|
if (!hasSession) {
|
|
return (
|
|
<ListConvosContext.Provider value={empty}>
|
|
{children}
|
|
</ListConvosContext.Provider>
|
|
)
|
|
}
|
|
|
|
return <ListConvosProviderInner>{children}</ListConvosProviderInner>
|
|
}
|
|
|
|
export function ListConvosProviderInner({
|
|
children,
|
|
}: {
|
|
children: React.ReactNode
|
|
}) {
|
|
const {refetch, data} = useListConvosQuery({
|
|
readState: 'unread',
|
|
limit: UNREAD_LIMIT,
|
|
lockStatus: 'unlocked',
|
|
})
|
|
const messagesBus = useMessagesEventBus()
|
|
const queryClient = useQueryClient()
|
|
const {currentConvoId} = useCurrentConvoId()
|
|
const {currentAccount} = useSession()
|
|
const leftConvos = useLeftConvos()
|
|
|
|
const debouncedRefetch = useMemo(() => {
|
|
const refetchAndInvalidate = () => {
|
|
void refetch()
|
|
void queryClient.invalidateQueries({queryKey: [RQKEY_ROOT]})
|
|
}
|
|
return throttle(refetchAndInvalidate, 500, {
|
|
leading: true,
|
|
trailing: true,
|
|
})
|
|
}, [refetch, queryClient])
|
|
|
|
useEffect(() => {
|
|
const unsub = messagesBus.on(
|
|
events => {
|
|
if (events.type !== 'logs') return
|
|
|
|
function mutateMembers(
|
|
convoId: string,
|
|
fn: (
|
|
members: ChatBskyActorDefs.ProfileViewBasic[],
|
|
) => ChatBskyActorDefs.ProfileViewBasic[],
|
|
) {
|
|
queryClient.setQueryData<ChatBskyActorDefs.ProfileViewBasic[]>(
|
|
listConvoMembersQueryKey(convoId),
|
|
old => {
|
|
if (!old) return // query doesn't exist yet, skip
|
|
return fn(old)
|
|
},
|
|
)
|
|
}
|
|
|
|
function mutateConvoView(
|
|
convoId: string,
|
|
fn: (
|
|
convo: ChatBskyConvoDefs.ConvoView,
|
|
) => ChatBskyConvoDefs.ConvoView,
|
|
) {
|
|
queryClient.setQueryData<ChatBskyConvoDefs.ConvoView>(
|
|
CONVO_KEY(convoId),
|
|
old => (old ? fn(old) : old),
|
|
)
|
|
queryClient.setQueriesData<ConvoListQueryData>(
|
|
{queryKey: [RQKEY_ROOT]},
|
|
old => optimisticUpdate(convoId, old, fn),
|
|
)
|
|
}
|
|
|
|
function handleMemberAdded(
|
|
convoId: string,
|
|
did: string,
|
|
relatedProfiles: ChatBskyActorDefs.ProfileViewBasic[],
|
|
rev: string,
|
|
) {
|
|
const newMember = relatedProfiles.find(r => r.did === did)
|
|
if (!newMember) return
|
|
// If the optimistic add already added them, skip the
|
|
// memberCount bump to avoid double-counting.
|
|
const alreadyKnownMember =
|
|
queryClient
|
|
.getQueryData<
|
|
ChatBskyActorDefs.ProfileViewBasic[]
|
|
>(listConvoMembersQueryKey(convoId))
|
|
?.some(m => m.did === did) ?? false
|
|
mutateMembers(convoId, list =>
|
|
list.some(m => m.did === did) ? list : list.concat(newMember),
|
|
)
|
|
mutateConvoView(convoId, convo =>
|
|
addMemberToConvoView(convo, newMember, rev, alreadyKnownMember),
|
|
)
|
|
}
|
|
|
|
function handleMemberRemoved(
|
|
convoId: string,
|
|
did: string,
|
|
rev: string,
|
|
) {
|
|
// If the optimistic remove already dropped them from the full
|
|
// list, skip the memberCount decrement to avoid double-counting.
|
|
const alreadyRemovedMember =
|
|
queryClient
|
|
.getQueryData<
|
|
ChatBskyActorDefs.ProfileViewBasic[]
|
|
>(listConvoMembersQueryKey(convoId))
|
|
?.some(m => m.did === did) === false
|
|
mutateMembers(convoId, list => list.filter(m => m.did !== did))
|
|
mutateConvoView(convoId, convo =>
|
|
removeMemberFromConvoView(convo, did, rev, alreadyRemovedMember),
|
|
)
|
|
}
|
|
|
|
for (const log of events.logs) {
|
|
if (ChatBskyConvoDefs.isLogBeginConvo(log)) {
|
|
debouncedRefetch()
|
|
} else if (ChatBskyConvoDefs.isLogLeaveConvo(log)) {
|
|
queryClient.setQueriesData(
|
|
{queryKey: [RQKEY_ROOT]},
|
|
(old?: ConvoListQueryData) => optimisticDelete(log.convoId, old),
|
|
)
|
|
} 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
|
|
}
|
|
}),
|
|
)
|
|
} else if (ChatBskyConvoDefs.isLogCreateMessage(log)) {
|
|
// Store in a new var to avoid TS errors due to closures.
|
|
const logRef: ChatBskyConvoDefs.LogCreateMessage = log
|
|
|
|
// Get all matching queries
|
|
const queries = queryClient.getQueriesData<ConvoListQueryData>({
|
|
queryKey: [RQKEY_ROOT],
|
|
})
|
|
|
|
// Check if convo exists in any query
|
|
let foundConvo: ChatBskyConvoDefs.ConvoView | null = null
|
|
for (const [_key, query] of queries) {
|
|
if (!query) continue
|
|
const convo = getConvoFromQueryData(logRef.convoId, query)
|
|
if (convo) {
|
|
foundConvo = convo
|
|
break
|
|
}
|
|
}
|
|
|
|
if (!foundConvo) {
|
|
// Convo not found, trigger refetch
|
|
debouncedRefetch()
|
|
return
|
|
}
|
|
|
|
// Update the convo
|
|
const updatedConvo = {
|
|
...foundConvo,
|
|
rev: logRef.rev,
|
|
lastMessage: logRef.message,
|
|
unreadCount:
|
|
foundConvo.id !== currentConvoId
|
|
? (ChatBskyConvoDefs.isMessageView(logRef.message) ||
|
|
ChatBskyConvoDefs.isDeletedMessageView(logRef.message)) &&
|
|
logRef.message.sender.did !== currentAccount?.did
|
|
? foundConvo.unreadCount + 1
|
|
: foundConvo.unreadCount
|
|
: 0,
|
|
}
|
|
|
|
function filterConvoFromPage(convo: ChatBskyConvoDefs.ConvoView[]) {
|
|
return convo.filter(c => c.id !== logRef.convoId)
|
|
}
|
|
|
|
// Update all matching queries
|
|
function updateFn(old?: ConvoListQueryData) {
|
|
if (!old) return old
|
|
return {
|
|
...old,
|
|
pages: old.pages.map((page, i) => {
|
|
if (i === 0) {
|
|
return {
|
|
...page,
|
|
convos: [
|
|
updatedConvo,
|
|
...filterConvoFromPage(page.convos),
|
|
],
|
|
}
|
|
}
|
|
return {
|
|
...page,
|
|
convos: filterConvoFromPage(page.convos),
|
|
}
|
|
}),
|
|
}
|
|
}
|
|
// always update the unread one
|
|
queryClient.setQueriesData(
|
|
{queryKey: RQKEY('all', 'unread')},
|
|
(old?: ConvoListQueryData) =>
|
|
old
|
|
? updateFn(old)
|
|
: ({
|
|
pageParams: [undefined],
|
|
pages: [{convos: [updatedConvo], cursor: undefined}],
|
|
} satisfies ConvoListQueryData),
|
|
)
|
|
// update the other ones based on status of the incoming message
|
|
if (updatedConvo.status === 'accepted') {
|
|
queryClient.setQueriesData(
|
|
{queryKey: RQKEY('accepted')},
|
|
updateFn,
|
|
)
|
|
} else if (updatedConvo.status === 'request') {
|
|
queryClient.setQueriesData({queryKey: RQKEY('request')}, updateFn)
|
|
}
|
|
} else if (ChatBskyConvoDefs.isLogReadMessage(log)) {
|
|
queryClient.setQueriesData(
|
|
{queryKey: [RQKEY_ROOT]},
|
|
(old?: ConvoListQueryData) =>
|
|
optimisticUpdate(log.convoId, old, 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,
|
|
})),
|
|
)
|
|
} else if (ChatBskyConvoDefs.isLogAcceptConvo(log)) {
|
|
const requests = queryClient.getQueryData<ConvoListQueryData>(
|
|
RQKEY('request'),
|
|
)
|
|
if (!requests) {
|
|
debouncedRefetch()
|
|
return
|
|
}
|
|
const acceptedConvo = getConvoFromQueryData(log.convoId, requests)
|
|
if (!acceptedConvo) {
|
|
debouncedRefetch()
|
|
return
|
|
}
|
|
queryClient.setQueryData(
|
|
RQKEY('request'),
|
|
(old?: ConvoListQueryData) => optimisticDelete(log.convoId, old),
|
|
)
|
|
queryClient.setQueriesData(
|
|
{queryKey: RQKEY('accepted')},
|
|
(old?: ConvoListQueryData) => {
|
|
if (!old) {
|
|
debouncedRefetch()
|
|
return old
|
|
}
|
|
return {
|
|
...old,
|
|
pages: old.pages.map((page, i) => {
|
|
if (i === 0) {
|
|
return {
|
|
...page,
|
|
convos: [
|
|
{...acceptedConvo, status: 'accepted'},
|
|
...page.convos,
|
|
],
|
|
}
|
|
}
|
|
return page
|
|
}),
|
|
}
|
|
},
|
|
)
|
|
} else if (ChatBskyConvoDefs.isLogMuteConvo(log)) {
|
|
queryClient.setQueriesData(
|
|
{queryKey: [RQKEY_ROOT]},
|
|
(old?: ConvoListQueryData) =>
|
|
optimisticUpdate(log.convoId, old, 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,
|
|
})),
|
|
)
|
|
} else if (ChatBskyConvoDefs.isLogLockConvo(log)) {
|
|
queryClient.setQueriesData(
|
|
{queryKey: [RQKEY_ROOT]},
|
|
(old?: ConvoListQueryData) =>
|
|
optimisticUpdate(log.convoId, old, 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)) {
|
|
queryClient.setQueriesData(
|
|
{queryKey: [RQKEY_ROOT]},
|
|
(old?: ConvoListQueryData) =>
|
|
optimisticUpdate(log.convoId, old, 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)) {
|
|
queryClient.setQueriesData(
|
|
{queryKey: [RQKEY_ROOT]},
|
|
(old?: ConvoListQueryData) =>
|
|
optimisticUpdate(log.convoId, old, 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) ||
|
|
ChatBskyConvoDefs.isLogEnableJoinLink(log) ||
|
|
ChatBskyConvoDefs.isLogDisableJoinLink(log)
|
|
) {
|
|
// Join link data not included in the log event, trigger refetch to get it
|
|
debouncedRefetch()
|
|
} else if (
|
|
ChatBskyConvoDefs.isLogApproveJoinRequest(log) ||
|
|
ChatBskyConvoDefs.isLogRejectJoinRequest(log)
|
|
) {
|
|
queryClient.setQueriesData(
|
|
{queryKey: [RQKEY_ROOT]},
|
|
(old?: ConvoListQueryData) =>
|
|
updateGroupConvoJoinRequestCount(log, old, -1),
|
|
)
|
|
} else if (ChatBskyConvoDefs.isLogIncomingJoinRequest(log)) {
|
|
queryClient.setQueriesData(
|
|
{queryKey: [RQKEY_ROOT]},
|
|
(old?: ConvoListQueryData) =>
|
|
updateGroupConvoJoinRequestCount(log, old, 1),
|
|
)
|
|
} 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,
|
|
})),
|
|
)
|
|
} else if (ChatBskyConvoDefs.isLogAddMember(log)) {
|
|
const data = log.message.data
|
|
if (
|
|
bsky.dangerousIsType<ChatBskyConvoDefs.SystemMessageDataAddMember>(
|
|
data,
|
|
ChatBskyConvoDefs.isSystemMessageDataAddMember,
|
|
)
|
|
) {
|
|
handleMemberAdded(
|
|
log.convoId,
|
|
data.member.did,
|
|
log.relatedProfiles,
|
|
log.rev,
|
|
)
|
|
}
|
|
// Refetch so the server can refresh the curated members list.
|
|
void queryClient.invalidateQueries({
|
|
queryKey: CONVO_KEY(log.convoId),
|
|
})
|
|
debouncedRefetch()
|
|
} else if (ChatBskyConvoDefs.isLogRemoveMember(log)) {
|
|
const data = log.message.data
|
|
if (
|
|
bsky.dangerousIsType<ChatBskyConvoDefs.SystemMessageDataRemoveMember>(
|
|
data,
|
|
ChatBskyConvoDefs.isSystemMessageDataRemoveMember,
|
|
)
|
|
) {
|
|
handleMemberRemoved(log.convoId, data.member.did, log.rev)
|
|
}
|
|
// Refetch so the server can refill the curated members list.
|
|
void queryClient.invalidateQueries({
|
|
queryKey: CONVO_KEY(log.convoId),
|
|
})
|
|
debouncedRefetch()
|
|
} else if (ChatBskyConvoDefs.isLogMemberJoin(log)) {
|
|
const data = log.message.data
|
|
if (
|
|
bsky.dangerousIsType<ChatBskyConvoDefs.SystemMessageDataMemberJoin>(
|
|
data,
|
|
ChatBskyConvoDefs.isSystemMessageDataMemberJoin,
|
|
)
|
|
) {
|
|
handleMemberAdded(
|
|
log.convoId,
|
|
data.member.did,
|
|
log.relatedProfiles,
|
|
log.rev,
|
|
)
|
|
}
|
|
void queryClient.invalidateQueries({
|
|
queryKey: CONVO_KEY(log.convoId),
|
|
})
|
|
debouncedRefetch()
|
|
} else if (ChatBskyConvoDefs.isLogMemberLeave(log)) {
|
|
const data = log.message.data
|
|
if (
|
|
bsky.dangerousIsType<ChatBskyConvoDefs.SystemMessageDataMemberLeave>(
|
|
data,
|
|
ChatBskyConvoDefs.isSystemMessageDataMemberLeave,
|
|
)
|
|
) {
|
|
handleMemberRemoved(log.convoId, data.member.did, log.rev)
|
|
}
|
|
void queryClient.invalidateQueries({
|
|
queryKey: CONVO_KEY(log.convoId),
|
|
})
|
|
debouncedRefetch()
|
|
} else if (ChatBskyConvoDefs.isLogRemoveReaction(log)) {
|
|
queryClient.setQueriesData(
|
|
{queryKey: [RQKEY_ROOT]},
|
|
(old?: ConvoListQueryData) =>
|
|
optimisticUpdate(log.convoId, old, convo => {
|
|
if (
|
|
// if the convo is the same
|
|
log.convoId === convo.id &&
|
|
ChatBskyConvoDefs.isMessageAndReactionView(
|
|
convo.lastReaction,
|
|
) &&
|
|
ChatBskyConvoDefs.isMessageView(log.message) &&
|
|
// ...and the message is the same
|
|
convo.lastReaction.message.id === log.message.id &&
|
|
// ...and the reaction is the same
|
|
convo.lastReaction.reaction.sender.did ===
|
|
log.reaction.sender.did &&
|
|
convo.lastReaction.reaction.value === log.reaction.value
|
|
) {
|
|
return {
|
|
...convo,
|
|
// ...remove the reaction. hopefully they didn't react twice in a row!
|
|
lastReaction: undefined,
|
|
rev: log.rev,
|
|
}
|
|
} else {
|
|
return convo
|
|
}
|
|
}),
|
|
)
|
|
}
|
|
}
|
|
},
|
|
{
|
|
// get events for all chats
|
|
convoId: undefined,
|
|
},
|
|
)
|
|
|
|
return () => unsub()
|
|
}, [
|
|
messagesBus,
|
|
currentConvoId,
|
|
queryClient,
|
|
currentAccount?.did,
|
|
debouncedRefetch,
|
|
])
|
|
|
|
const ctx = useMemo(() => {
|
|
const convos =
|
|
data?.pages
|
|
.flatMap(page => page.convos)
|
|
.filter(convo => !leftConvos.includes(convo.id)) ?? []
|
|
return {
|
|
accepted: convos.filter(conv => conv.status === 'accepted'),
|
|
request: convos.filter(conv => conv.status === 'request'),
|
|
}
|
|
}, [data, leftConvos])
|
|
|
|
return (
|
|
<ListConvosContext.Provider value={ctx}>
|
|
{children}
|
|
</ListConvosContext.Provider>
|
|
)
|
|
}
|
|
|
|
export function useUnreadMessageCount() {
|
|
const {currentConvoId} = useCurrentConvoId()
|
|
const {currentAccount} = useSession()
|
|
const {accepted, request} = useListConvos()
|
|
const moderationOpts = useModerationOpts()
|
|
|
|
return useMemo<{
|
|
count: number
|
|
numUnread?: string
|
|
hasNew: boolean
|
|
}>(() => {
|
|
const acceptedCount = calculateCount(
|
|
accepted,
|
|
currentAccount?.did,
|
|
currentConvoId,
|
|
moderationOpts,
|
|
)
|
|
const requestCount = calculateCount(
|
|
request,
|
|
currentAccount?.did,
|
|
currentConvoId,
|
|
moderationOpts,
|
|
)
|
|
if (acceptedCount > 0) {
|
|
const total = acceptedCount + Math.min(requestCount, 1)
|
|
return {
|
|
count: total,
|
|
numUnread: total > 10 ? '10+' : String(total),
|
|
// only needed when numUnread is undefined
|
|
hasNew: false,
|
|
}
|
|
} else if (requestCount > 0) {
|
|
return {
|
|
count: 1,
|
|
numUnread: undefined,
|
|
hasNew: true,
|
|
}
|
|
} else {
|
|
return {
|
|
count: 0,
|
|
numUnread: undefined,
|
|
hasNew: false,
|
|
}
|
|
}
|
|
}, [accepted, request, currentAccount?.did, currentConvoId, moderationOpts])
|
|
}
|
|
|
|
function calculateCount(
|
|
convos: ChatBskyConvoDefs.ConvoView[],
|
|
currentAccountDid: string | undefined,
|
|
currentConvoId: string | undefined,
|
|
moderationOpts: ModerationOpts | undefined,
|
|
) {
|
|
return (
|
|
convos
|
|
.filter(convo => convo.id !== currentConvoId)
|
|
.reduce((acc, convoView) => {
|
|
const convo = parseConvoView(convoView, currentAccountDid)
|
|
|
|
if (!convo || !moderationOpts) return acc
|
|
|
|
const shouldIgnore =
|
|
convo.view.muted ||
|
|
!convo.primaryMember ||
|
|
moderateProfile(convo.primaryMember, moderationOpts).blocked ||
|
|
convo.primaryMember.handle === 'missing.invalid' ||
|
|
(convo.kind === 'group' && convo.details.lockStatus !== 'unlocked')
|
|
const unreadCount = !shouldIgnore && convo.view.unreadCount > 0 ? 1 : 0
|
|
|
|
return acc + unreadCount
|
|
}, 0) ?? 0
|
|
)
|
|
}
|
|
|
|
export type ConvoListQueryData = {
|
|
pageParams: Array<string | undefined>
|
|
pages: Array<ChatBskyConvoListConvos.OutputSchema>
|
|
}
|
|
|
|
export function useOnMarkAsRead() {
|
|
const queryClient = useQueryClient()
|
|
|
|
return useCallback(
|
|
(chatId: string) => {
|
|
queryClient.setQueriesData(
|
|
{queryKey: [RQKEY_ROOT]},
|
|
(old?: ConvoListQueryData) => {
|
|
if (!old) return old
|
|
return optimisticUpdate(chatId, old, convo => ({
|
|
...convo,
|
|
unreadCount: 0,
|
|
}))
|
|
},
|
|
)
|
|
},
|
|
[queryClient],
|
|
)
|
|
}
|
|
|
|
function optimisticUpdate(
|
|
chatId: string,
|
|
old?: ConvoListQueryData,
|
|
updateFn?: (
|
|
convo: ChatBskyConvoDefs.ConvoView,
|
|
) => ChatBskyConvoDefs.ConvoView,
|
|
) {
|
|
if (!old || !updateFn) return old
|
|
|
|
return {
|
|
...old,
|
|
pages: old.pages.map(page => ({
|
|
...page,
|
|
convos: page.convos.map(convo =>
|
|
chatId === convo.id ? updateFn(convo) : convo,
|
|
),
|
|
})),
|
|
}
|
|
}
|
|
|
|
function updateGroupConvoJoinRequestCount(
|
|
log: {convoId: string; rev: string},
|
|
old: ConvoListQueryData | undefined,
|
|
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}
|
|
}
|
|
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,
|
|
}
|
|
})
|
|
}
|
|
|
|
function removeMemberFromConvoView(
|
|
convo: ChatBskyConvoDefs.ConvoView,
|
|
did: string,
|
|
rev: string,
|
|
alreadyRemovedMember: boolean,
|
|
): ChatBskyConvoDefs.ConvoView {
|
|
// Member add/remove/join/leave events are only meaningful for group convos.
|
|
if (!ChatBskyConvoDefs.isGroupConvo(convo.kind)) return convo
|
|
const nextMembers = convo.members.filter(m => m.did !== did)
|
|
return {
|
|
...convo,
|
|
rev,
|
|
members: nextMembers,
|
|
kind: {
|
|
...convo.kind,
|
|
memberCount: alreadyRemovedMember
|
|
? convo.kind.memberCount
|
|
: Math.max(0, convo.kind.memberCount - 1),
|
|
},
|
|
}
|
|
}
|
|
|
|
function addMemberToConvoView(
|
|
convo: ChatBskyConvoDefs.ConvoView,
|
|
member: ChatBskyActorDefs.ProfileViewBasic,
|
|
rev: string,
|
|
alreadyKnownMember: boolean,
|
|
): ChatBskyConvoDefs.ConvoView {
|
|
// Member add/remove/join/leave events are only meaningful for group convos.
|
|
if (!ChatBskyConvoDefs.isGroupConvo(convo.kind)) return convo
|
|
const alreadyInCuratedList = convo.members.some(m => m.did === member.did)
|
|
const nextMembers = alreadyInCuratedList
|
|
? convo.members
|
|
: convo.members.concat(member)
|
|
return {
|
|
...convo,
|
|
rev,
|
|
members: nextMembers,
|
|
kind: {
|
|
...convo.kind,
|
|
memberCount: alreadyKnownMember
|
|
? convo.kind.memberCount
|
|
: convo.kind.memberCount + 1,
|
|
},
|
|
}
|
|
}
|
|
|
|
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_ROOT],
|
|
})
|
|
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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|