Skip to content

Commit

Permalink
perf(web): fixed unread message count events
Browse files Browse the repository at this point in the history
  • Loading branch information
nikkothari22 committed Mar 22, 2024
1 parent fa702d5 commit 663c8cc
Show file tree
Hide file tree
Showing 15 changed files with 364 additions and 92 deletions.
16 changes: 13 additions & 3 deletions raven-app/src/components/feature/channels/ChannelList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { useContext, useMemo, useState } from "react"
import { ChannelListContext, ChannelListContextType, ChannelListItem, UnreadCountData } from "../../../utils/channel/ChannelListProvider"
import { ChannelIcon } from "@/utils/layout/channelIcon"
import { Flex, Text } from "@radix-ui/themes"
import { clsx } from "clsx"
import { useLocation, useParams } from "react-router-dom"

export const ChannelList = ({ unread_count }: { unread_count?: UnreadCountData }) => {

Expand Down Expand Up @@ -45,12 +45,22 @@ const ChannelItem = ({ channel, unreadCount }: { channel: ChannelListItem, unrea

const unreadCountForChannel = useMemo(() => unreadCount.find((unread) => unread.name == channel.name)?.unread_count, [channel.name, unreadCount])

const { channelID } = useParams()

const { state } = useLocation()

/**
* Show the unread count if it exists and either the channel is not the current channel,
* or if it is the current channel, the user is viewing a base message
*/
const showUnread = unreadCountForChannel && (channelID !== channel.name || state?.baseMessage)

return (
<SidebarItem to={channel.name} className={'py-1.5'}>
<ChannelIcon type={channel.type} size='18' />
<Flex justify='between' align={'center'} width='100%'>
<Text size='2' className="text-ellipsis line-clamp-1" as='span' weight={unreadCountForChannel ? 'bold' : 'regular'}>{channel.channel_name}</Text>
{unreadCountForChannel ? <SidebarBadge>{unreadCountForChannel}</SidebarBadge> : null}
<Text size='2' className="text-ellipsis line-clamp-1" as='span' weight={showUnread ? 'bold' : 'regular'}>{channel.channel_name}</Text>
{showUnread ? <SidebarBadge>{unreadCountForChannel}</SidebarBadge> : null}
</Flex>
</SidebarItem>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,6 @@ export const LinkPreview = memo(({ isScrolling }: { isScrolling?: boolean }) =>

// const href = editor?.getAttributes('link').href

// console.log(editor?.state)

const { data, isLoading } = useFrappeGetCall<{ message: LinkPreviewDetails[] }>('raven.api.preview_links.get_preview_link', {
urls: JSON.stringify([href])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ const UserMentionRenderer = ({ node }: NodeViewRendererProps) => {

const ChannelMentionRenderer = ({ node }: NodeViewRendererProps) => {

// console.log(node)

return (
<NodeViewWrapper as={'span'}>
<Link asChild>
Expand Down
55 changes: 43 additions & 12 deletions raven-app/src/components/feature/chat/ChatStream/useChatStream.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useFrappeDocumentEventListener, useFrappeEventListener, useFrappeGetCall, useFrappePostCall } from 'frappe-react-sdk'
import { MutableRefObject, useEffect, useLayoutEffect, useMemo, useState } from 'react'
import { MutableRefObject, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useBeforeUnload, useLocation, useNavigate, useParams } from 'react-router-dom'
import { Message } from '../../../../../../types/Messaging/Message'
import { convertFrappeTimestampToUserTimezone } from '@/utils/dateConversions/utils'
Expand Down Expand Up @@ -37,6 +37,7 @@ const useChatStream = (scrollRef: MutableRefObject<HTMLDivElement | null>) => {
useBeforeUnload(() => {
window.history.replaceState({}, '')
})

useEffect(() => {
let timer: NodeJS.Timeout | null = null;
// Clear the highlighted message after 4 seconds
Expand All @@ -55,7 +56,15 @@ const useChatStream = (scrollRef: MutableRefObject<HTMLDivElement | null>) => {
const { call: fetchOlderMessages, loading: loadingOlderMessages } = useFrappePostCall('raven.api.chat_stream.get_older_messages')
const { call: fetchNewerMessages, loading: loadingNewerMessages } = useFrappePostCall('raven.api.chat_stream.get_newer_messages')

/** State variable used to track if the latest messages have been fetched and to scroll to the bottom of the chat stream */
const [done, setDone] = useState(false)

/**
* Ref that is updated when no new messages are available
* Used to track visit when the user leaves the channel
*/
const latestMessagesLoaded = useRef(false)

const { data, isLoading, error, mutate } = useFrappeGetCall<GetMessagesResponse>('raven.api.chat_stream.get_messages', {
'channel_id': channelID,
'base_message': state?.baseMessage ? state.baseMessage : undefined
Expand All @@ -65,6 +74,7 @@ const useChatStream = (scrollRef: MutableRefObject<HTMLDivElement | null>) => {
if (!highlightedMessage) {
if (!data.message.has_new_messages) {
setDone(true)
latestMessagesLoaded.current = true
}
} else {
setTimeout(() => {
Expand All @@ -74,8 +84,8 @@ const useChatStream = (scrollRef: MutableRefObject<HTMLDivElement | null>) => {
}
})

/** When loading is complete, scroll down to the bottom
*
/**
* When loading is complete, scroll down to the bottom
* Need to scroll down twice because the scrollHeight is not updated immediately after the first scroll
*/
useLayoutEffect(() => {
Expand All @@ -100,6 +110,23 @@ const useChatStream = (scrollRef: MutableRefObject<HTMLDivElement | null>) => {
}
}, [done, channelID])


/** If the user has already loaded all the latest messages and exits the channel, we update the timestamp of last visit */

const { call: trackVisit } = useFrappePostCall('raven.api.raven_channel_member.track_visit')
/**
* Track visit when unmounting if new messages were loaded.
* We are using a ref since the hook is not re-executed when the data is updated
*/
useEffect(() => {
/** Call */
return () => {
if (latestMessagesLoaded.current) {
trackVisit({ channel_id: channelID })
}
}
}, [channelID])

/**
* Instead of maintaining two arrays for messages and previous messages, we can maintain a single array
* This is maintained by the useSWR hook (useFrappeGetCall) here.
Expand Down Expand Up @@ -129,7 +156,7 @@ const useChatStream = (scrollRef: MutableRefObject<HTMLDivElement | null>) => {
if (event.channel_id === channelID) {

mutate((d) => {
if (d) {
if (d && d.message.has_new_messages === false) {
// Update the array of messages - append the new message in it and then sort it by date
const existingMessages = d.message.messages ?? []
const newMessages = [...existingMessages, event.message_details]
Expand All @@ -144,22 +171,22 @@ const useChatStream = (scrollRef: MutableRefObject<HTMLDivElement | null>) => {
has_new_messages: d.message.has_new_messages ?? false
}
})
} else {
return d
}

}, {
revalidate: false,
}).then(() => {
// If the user is focused on the page, then we also need to
if (scrollRef.current) {
// We only scroll to the bottom if the user is close to the bottom
scrollRef.current?.scrollTo(0, scrollRef.current?.scrollHeight)
// TODO: Else we show a notification that there are new messages
if (scrollRef.current.scrollTop !== 0) {

if (data?.message.has_new_messages === false) {
// If the user is focused on the page, then we also need to
if (scrollRef.current) {
// We only scroll to the bottom if the user is close to the bottom
scrollRef.current?.scrollTo(0, scrollRef.current?.scrollHeight)
// TODO: Else we show a notification that there are new messages
}
}
})

}
})

Expand Down Expand Up @@ -367,6 +394,10 @@ const useChatStream = (scrollRef: MutableRefObject<HTMLDivElement | null>) => {
return d
}, {
revalidate: false,
}).then((res) => {
if (res?.message.has_new_messages === false) {
latestMessagesLoaded.current = true
}
})
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useFrappePostCall } from "frappe-react-sdk"
import { useContext, useMemo, useState } from "react"
import { useNavigate } from "react-router-dom"
import { useNavigate, useParams } from "react-router-dom"
import { SidebarGroup, SidebarGroupItem, SidebarGroupLabel, SidebarGroupList, SidebarIcon, SidebarButtonItem } from "../../layout/Sidebar"
import { SidebarBadge, SidebarItem, SidebarViewMoreButton } from "../../layout/Sidebar/SidebarComp"
import { UserContext } from "../../../utils/auth/UserProvider"
Expand Down Expand Up @@ -62,15 +62,19 @@ const DirectMessageItem = ({ channel, unreadCount }: { channel: DMChannelListIte
const userData = useGetUser(channel.peer_user_id)
const isActive = useIsUserActive(channel.peer_user_id)

const { channelID } = useParams()

const showUnread = unreadCountForChannel && channelID !== channel.name

return <SidebarItem to={channel.name} className={'py-0.5'}>
<SidebarIcon>
<UserAvatar src={userData?.user_image} alt={userData?.full_name} isActive={isActive} size='1' />
</SidebarIcon>
<Flex justify='between' width='100%'>
<Text size='2' className="text-ellipsis line-clamp-1" weight={unreadCountForChannel ? 'bold' : 'regular'}>
<Text size='2' className="text-ellipsis line-clamp-1" weight={showUnread ? 'bold' : 'regular'}>
{channel.peer_user_id !== currentUser ? userData?.full_name ?? channel.peer_user_id : `${userData?.full_name} (You)`}
</Text>
{unreadCountForChannel ? <SidebarBadge>{unreadCountForChannel}</SidebarBadge> : null}
{showUnread ? <SidebarBadge>{unreadCountForChannel}</SidebarBadge> : null}
</Flex>
</SidebarItem>
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ export const ErrorBanner = ({ error, overrideHeading, children }: ErrorBannerPro
// exc: With entire traceback - useful for reporting maybe
// httpStatus and httpStatusText - not needed
// _server_messages: Array of messages - useful for showing to user
// console.log(JSON.parse(error?._server_messages!))

const messages = useMemo(() => {
if (!error) return []
Expand Down
12 changes: 2 additions & 10 deletions raven-app/src/components/layout/Sidebar/SidebarBody.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,13 @@
import { ChannelList } from '../../feature/channels/ChannelList'
import { DirectMessageList } from '../../feature/direct-messages/DirectMessageList'
import { SidebarItem } from './SidebarComp'
import { useFrappeEventListener, useFrappeGetCall } from 'frappe-react-sdk'
import { UnreadCountData } from '../../../utils/channel/ChannelListProvider'
import { AccessibleIcon, Box, Flex, ScrollArea, Text } from '@radix-ui/themes'
import { BiBookmark } from 'react-icons/bi'
import useUnreadMessageCount from '@/hooks/useUnreadMessageCount'

export const SidebarBody = () => {

const { data: unread_count, mutate: update_count } = useFrappeGetCall<{ message: UnreadCountData }>("raven.api.raven_message.get_unread_count_for_channels",
undefined,
'unread_channel_count', {
// revalidateOnFocus: false,
})
useFrappeEventListener('raven:unread_channel_count_updated', () => {
update_count()
})
const unread_count = useUnreadMessageCount()

return (
<ScrollArea type="hover" scrollbars="vertical" className='h-[calc(100vh-7rem)]'>
Expand Down
116 changes: 116 additions & 0 deletions raven-app/src/hooks/useUnreadMessageCount.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { UserContext } from "@/utils/auth/UserProvider"
import { UnreadCountData } from "@/utils/channel/ChannelListProvider"
import { useFrappeGetCall, FrappeContext, FrappeConfig, useFrappeEventListener } from "frappe-react-sdk"
import { useContext } from "react"
import { useParams, useLocation } from "react-router-dom"

/**
*
* Hook to manage unread message count
For every channel member, we store a last_visit timestamp in the Raven Channel Member doctype. To get the number of unread messages, we can simply look at the no. of messages created after this timestamp for any given channel.
The last_visit for a member of a channel is updated when:
1. Latest messages are fetched (usually when a channel is opened)- this is in the get_messages API under chat_stream
2. The user fetches newer messages (let's say they were viewing a saved message (older) and scrolled down until they reached the bottom of the chat stream). This is in the get_newer_messages API under chat_stream.
3. When the user sends a new message (handled by controller under Raven Message)
4. When the user exits a channel after all the latest messages had been loaded (called from the frontend)
When Raven loads, we fetch the unread message counts for all channels. Post that, updates to these counts are made when:
1. If a user opens a channel directly (no base message) - we locally update the unread message count to 0 - no API call
2. If a realtime event is published for unread message count change and the sender is not the user itself - we only fetch the unread count for the particular channel (instead of all channels like we used to).
The realtime event for unread message count changed is published when:
1. A new message is sent
2. A message is deleted
3. The user scrolls to the bottom of the chat stream from a base message.
* @returns unread_count - The unread message count for the current user
*/
const useUnreadMessageCount = () => {

const { currentUser } = useContext(UserContext)
const { data: unread_count, mutate: updateCount } = useFrappeGetCall<{ message: UnreadCountData }>("raven.api.raven_message.get_unread_count_for_channels",
undefined,
'unread_channel_count', {
revalidateOnFocus: false,
revalidateIfStale: false,
revalidateOnReconnect: true,
})

const { call } = useContext(FrappeContext) as FrappeConfig

const fetchUnreadCountForChannel = async (channelID: string) => {

updateCount(d => {
if (d) {
// If the channel ID is present in the unread count, then fetch and update the unread count for the channel
if (d.message.channels.find(c => c.name === channelID)) {
return call.get('raven.api.raven_message.get_unread_count_for_channel', {
channel_id: channelID
}).then((data: { message: number }) => {
const newChannels = d.message.channels.map(c => {
if (c.name === channelID)
return {
...c,
unread_count: data.message
}
return c
})

const total_unread_count_in_channels = newChannels.reduce((acc: number, c) => {
if (!c.user_id) {
return acc + c.unread_count
} else {
return acc
}
}, 0)

const total_unread_count_in_dms = newChannels.reduce((acc: number, c) => {
if (c.user_id) {
return acc + c.unread_count
} else {
return acc
}
}, 0)

return {
message: {
total_unread_count_in_channels,
total_unread_count_in_dms,
channels: newChannels
}
}
}
)
} else {
return d
}
} else {
return d
}
}, {
revalidate: false
})
}

const { channelID } = useParams()
const { state } = useLocation()

useFrappeEventListener('raven:unread_channel_count_updated', (event) => {
// If the event is published by the current user, then don't update the unread count
if (event.sent_by !== currentUser) {
// If the user is already on the channel and is at the bottom of the chat (no base message), then don't update the unread count
if (channelID === event.channel_id && !state?.baseMessage) {
} else {
//TODO: perf: Can try to just increment the count by one instead of fetching the count again
// https://github.com/The-Commit-Company/Raven/pull/745#issuecomment-2014313429
fetchUnreadCountForChannel(event.channel_id)
}
}
})

return unread_count
}

export default useUnreadMessageCount
Loading

0 comments on commit 663c8cc

Please sign in to comment.