[🐴] Unread messages badge (#3901)

* add badge

* move stringify logic to hook

* add mutation hooks

* optimistic mark convo as read

* don't count muted chats

* Integrate new context

* Integrate mark unread mutation

* Remove unused edit

---------

Co-authored-by: Eric Bailey <git@esb.lol>
zio/stable
Samuel Newman 2024-05-08 03:23:09 +01:00 committed by GitHub
parent 0c41b3188a
commit 4fe5a869c3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 162 additions and 8 deletions

View File

@ -6,6 +6,7 @@ import {useFocusEffect, useIsFocused} from '@react-navigation/native'
import {Convo, ConvoParams, ConvoState} from '#/state/messages/convo' import {Convo, ConvoParams, ConvoState} from '#/state/messages/convo'
import {CurrentConvoIdProvider} from '#/state/messages/current-convo-id' import {CurrentConvoIdProvider} from '#/state/messages/current-convo-id'
import {MessagesEventBusProvider} from '#/state/messages/events' import {MessagesEventBusProvider} from '#/state/messages/events'
import {useMarkAsReadMutation} from '#/state/queries/messages/conversation'
import {useAgent} from '#/state/session' import {useAgent} from '#/state/session'
import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage' import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage'
@ -37,15 +38,18 @@ export function ChatProvider({
}), }),
) )
const service = useSyncExternalStore(convo.subscribe, convo.getSnapshot) const service = useSyncExternalStore(convo.subscribe, convo.getSnapshot)
const {mutate: markAsRead} = useMarkAsReadMutation()
useFocusEffect( useFocusEffect(
React.useCallback(() => { React.useCallback(() => {
convo.resume() convo.resume()
markAsRead({convoId})
return () => { return () => {
convo.background() convo.background()
markAsRead({convoId})
} }
}, [convo]), }, [convo, convoId, markAsRead]),
) )
React.useEffect(() => { React.useEffect(() => {
@ -56,6 +60,8 @@ export function ChatProvider({
} else { } else {
convo.background() convo.background()
} }
markAsRead({convoId})
} }
} }
@ -64,7 +70,7 @@ export function ChatProvider({
return () => { return () => {
sub.remove() sub.remove()
} }
}, [convo, isScreenFocused]) }, [convoId, convo, isScreenFocused, markAsRead])
return <ChatContext.Provider value={service}>{children}</ChatContext.Provider> return <ChatContext.Provider value={service}>{children}</ChatContext.Provider>
} }

View File

@ -1,6 +1,7 @@
import {BskyAgent} from '@atproto-labs/api' import {BskyAgent} from '@atproto-labs/api'
import {useQuery} from '@tanstack/react-query' import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {RQKEY as ListConvosQueryKey} from '#/state/queries/messages/list-converations'
import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage' import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage'
import {useHeaders} from './temp-headers' import {useHeaders} from './temp-headers'
@ -23,3 +24,36 @@ export function useConvoQuery(convoId: string) {
}, },
}) })
} }
export function useMarkAsReadMutation() {
const headers = useHeaders()
const {serviceUrl} = useDmServiceUrlStorage()
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({
convoId,
messageId,
}: {
convoId: string
messageId?: string
}) => {
const agent = new BskyAgent({service: serviceUrl})
await agent.api.chat.bsky.convo.updateRead(
{
convoId,
messageId,
},
{
encoding: 'application/json',
headers,
},
)
},
onSuccess() {
queryClient.invalidateQueries({
queryKey: ListConvosQueryKey,
})
},
})
}

View File

@ -1,6 +1,12 @@
import {BskyAgent} from '@atproto-labs/api' import {useCallback, useMemo} from 'react'
import {useInfiniteQuery} from '@tanstack/react-query' import {
BskyAgent,
ChatBskyConvoDefs,
ChatBskyConvoListConvos,
} from '@atproto-labs/api'
import {useInfiniteQuery, useQueryClient} from '@tanstack/react-query'
import {useCurrentConvoId} from '#/state/messages/current-convo-id'
import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage' import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage'
import {useHeaders} from './temp-headers' import {useHeaders} from './temp-headers'
@ -27,3 +33,100 @@ export function useListConvos({refetchInterval}: {refetchInterval: number}) {
refetchInterval, refetchInterval,
}) })
} }
export function useUnreadMessageCount() {
const {currentConvoId} = useCurrentConvoId()
const convos = useListConvos({
refetchInterval: 30_000,
})
const count =
convos.data?.pages
.flatMap(page => page.convos)
.filter(convo => convo.id !== currentConvoId)
.reduce((acc, convo) => {
return acc + (!convo.muted && convo.unreadCount > 0 ? 1 : 0)
}, 0) ?? 0
return useMemo(() => {
return {
count,
numUnread: count > 0 ? (count > 30 ? '30+' : String(count)) : undefined,
}
}, [count])
}
type ConvoListQueryData = {
pageParams: Array<string | undefined>
pages: Array<ChatBskyConvoListConvos.OutputSchema>
}
export function useOnDeleteMessage() {
const queryClient = useQueryClient()
return useCallback(
(chatId: string, messageId: string) => {
queryClient.setQueryData(RQKEY, (old: ConvoListQueryData) => {
return optimisticUpdate(chatId, old, convo =>
messageId === convo.lastMessage?.id
? {
...convo,
lastMessage: {
$type: 'chat.bsky.convo.defs#deletedMessageView',
id: messageId,
rev: '',
},
}
: convo,
)
})
},
[queryClient],
)
}
export function useOnNewMessage() {
const queryClient = useQueryClient()
return useCallback(
(chatId: string, message: ChatBskyConvoDefs.MessageView) => {
queryClient.setQueryData(RQKEY, (old: ConvoListQueryData) => {
return optimisticUpdate(chatId, old, convo => ({
...convo,
lastMessage: message,
unreadCount: convo.unreadCount + 1,
}))
})
queryClient.invalidateQueries({queryKey: RQKEY})
},
[queryClient],
)
}
export function useOnCreateConvo() {
const queryClient = useQueryClient()
return useCallback(() => {
queryClient.invalidateQueries({queryKey: RQKEY})
}, [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,
),
})),
}
}

View File

@ -27,6 +27,7 @@ import {getTabState, TabState} from '#/lib/routes/helpers'
import {useGate} from '#/lib/statsig/statsig' import {useGate} from '#/lib/statsig/statsig'
import {s} from '#/lib/styles' import {s} from '#/lib/styles'
import {emitSoftReset} from '#/state/events' import {emitSoftReset} from '#/state/events'
import {useUnreadMessageCount} from '#/state/queries/messages/list-converations'
import {useUnreadNotifications} from '#/state/queries/notifications/unread' import {useUnreadNotifications} from '#/state/queries/notifications/unread'
import {useProfileQuery} from '#/state/queries/profile' import {useProfileQuery} from '#/state/queries/profile'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
@ -68,6 +69,7 @@ export function BottomBar({navigation}: BottomTabBarProps) {
isAtMessages, isAtMessages,
} = useNavigationTabState() } = useNavigationTabState()
const numUnreadNotifications = useUnreadNotifications() const numUnreadNotifications = useUnreadNotifications()
const numUnreadMessages = useUnreadMessageCount()
const {footerMinimalShellTransform} = useMinimalShellMode() const {footerMinimalShellTransform} = useMinimalShellMode()
const {data: profile} = useProfileQuery({did: currentAccount?.did}) const {data: profile} = useProfileQuery({did: currentAccount?.did})
const {requestSwitchToAccount} = useLoggedOutViewControls() const {requestSwitchToAccount} = useLoggedOutViewControls()
@ -257,9 +259,15 @@ export function BottomBar({navigation}: BottomTabBarProps) {
) )
} }
onPress={onPressMessages} onPress={onPressMessages}
notificationCount={numUnreadMessages.numUnread}
accessible={true}
accessibilityRole="tab" accessibilityRole="tab"
accessibilityLabel={_(msg`Messages`)} accessibilityLabel={_(msg`Messages`)}
accessibilityHint="" accessibilityHint={
numUnreadMessages.count > 0
? `${numUnreadMessages.numUnread} unread`
: ''
}
/> />
)} )}
<Btn <Btn

View File

@ -16,6 +16,7 @@ import {useGate} from '#/lib/statsig/statsig'
import {isInvalidHandle} from '#/lib/strings/handles' import {isInvalidHandle} from '#/lib/strings/handles'
import {emitSoftReset} from '#/state/events' import {emitSoftReset} from '#/state/events'
import {useFetchHandle} from '#/state/queries/handle' import {useFetchHandle} from '#/state/queries/handle'
import {useUnreadMessageCount} from '#/state/queries/messages/list-converations'
import {useUnreadNotifications} from '#/state/queries/notifications/unread' import {useUnreadNotifications} from '#/state/queries/notifications/unread'
import {useProfileQuery} from '#/state/queries/profile' import {useProfileQuery} from '#/state/queries/profile'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
@ -274,7 +275,8 @@ export function DesktopLeftNav() {
const pal = usePalette('default') const pal = usePalette('default')
const {_} = useLingui() const {_} = useLingui()
const {isDesktop, isTablet} = useWebMediaQueries() const {isDesktop, isTablet} = useWebMediaQueries()
const numUnread = useUnreadNotifications() const numUnreadNotifications = useUnreadNotifications()
const numUnreadMessages = useUnreadMessageCount()
const gate = useGate() const gate = useGate()
if (!hasSession && !isDesktop) { if (!hasSession && !isDesktop) {
@ -333,7 +335,7 @@ export function DesktopLeftNav() {
/> />
<NavItem <NavItem
href="/notifications" href="/notifications"
count={numUnread} count={numUnreadNotifications}
icon={ icon={
<BellIcon <BellIcon
strokeWidth={2} strokeWidth={2}
@ -353,6 +355,7 @@ export function DesktopLeftNav() {
{gate('dms') && ( {gate('dms') && (
<NavItem <NavItem
href="/messages" href="/messages"
count={numUnreadMessages.numUnread}
icon={<Envelope style={pal.text} width={isDesktop ? 26 : 30} />} icon={<Envelope style={pal.text} width={isDesktop ? 26 : 30} />}
iconFilled={ iconFilled={
<EnvelopeFilled style={pal.text} width={isDesktop ? 26 : 30} /> <EnvelopeFilled style={pal.text} width={isDesktop ? 26 : 30} />