diff --git a/server/src/modules/groups/groups.controller.ts b/server/src/modules/groups/groups.controller.ts index 5a37cb2..4876573 100644 --- a/server/src/modules/groups/groups.controller.ts +++ b/server/src/modules/groups/groups.controller.ts @@ -20,12 +20,13 @@ import { isNull, like, lt, + ne, notExists, notInArray, or, sql, } from 'drizzle-orm' -import { unionAll } from 'drizzle-orm/pg-core' +import { union } from 'drizzle-orm/pg-core' import { RequestHandler } from 'express' import { membersTable } from '../members/members.schema' import { addMembers } from '../members/members.service' @@ -146,12 +147,23 @@ export const listGroups: RequestHandler = async (req, res, next) => { } } -export const listUserGroups: RequestHandler = async (req, res, next) => { +export const listUserChats: RequestHandler = async (req, res, next) => { try { + const userGroups = db + .$with('user_groups') + .as( + db + .select(getTableColumns(groupsTable)) + .from(groupsTable) + .innerJoin(membersTable, eq(membersTable.groupId, groupsTable.id)) + .where(eq(membersTable.userId, req.user!.id)), + ) + const groupMessagesWithRowNumber = db .$with('group_messages_with_row_number') .as( db + .with(userGroups) .select({ ...getTableColumns(messagesTable), rowNumber: rowNumber().over({ @@ -161,14 +173,14 @@ export const listUserGroups: RequestHandler = async (req, res, next) => { }), }) .from(messagesTable) + .innerJoin(userGroups, eq(userGroups.id, messagesTable.groupId)) .where(isNotNull(messagesTable.groupId)), ) - const groupsWithLastMessage = db.$with('groups_with_last_message').as( + const userGroupsWithLastMessage = db.$with('groups_with_last_message').as( db - .with(groupMessagesWithRowNumber) .select({ - chatName: groupsTable.name, + chatName: userGroups.name, groupId: groupMessagesWithRowNumber.groupId, partnerId: groupMessagesWithRowNumber.receiverId, lastMessage: { @@ -177,19 +189,17 @@ export const listUserGroups: RequestHandler = async (req, res, next) => { }, lastActivity: coalesce( groupMessagesWithRowNumber.createdAt, - groupsTable.createdAt, + userGroups.createdAt, ).as('last_activity'), }) - .from(groupsTable) + .from(userGroups) .leftJoin( groupMessagesWithRowNumber, and( - eq(groupsTable.id, groupMessagesWithRowNumber.groupId), + eq(userGroups.id, groupMessagesWithRowNumber.groupId), eq(groupMessagesWithRowNumber.rowNumber, 1), ), - ) - .innerJoin(membersTable, eq(membersTable.groupId, groupsTable.id)) - .where(eq(membersTable.userId, req.user!.id)), + ), ) const directMessagesWithPartner = db @@ -231,7 +241,6 @@ export const listUserGroups: RequestHandler = async (req, res, next) => { .$with('direct_messages_with_last_activity') .as( db - .with(directMessagesWithPartner) .select({ chatName: usersTable.username, groupId: directMessagesWithPartner.groupId, @@ -251,15 +260,21 @@ export const listUserGroups: RequestHandler = async (req, res, next) => { ) const unreadCounts = db.$with('unread_counts').as( - unionAll( + union( db .select({ - groupId: sql`${groupsTable.id}`.as('unread_group_id'), - receiverId: nullAs('unread_receiver_id'), + groupId: sql`${userGroups.id}`.as('unread_group_id'), + partnerId: nullAs('unread_partner_id'), unreadCount: count(messagesTable.id).as('unread_count'), }) - .from(groupsTable) - .leftJoin(messagesTable, eq(groupsTable.id, messagesTable.groupId)) + .from(userGroups) + .leftJoin( + messagesTable, + and( + eq(messagesTable.groupId, userGroups.id), + ne(messagesTable.senderId, req.user!.id), + ), + ) .leftJoin( messageRecipientsTable, and( @@ -268,13 +283,11 @@ export const listUserGroups: RequestHandler = async (req, res, next) => { ), ) .where(isNull(messageRecipientsTable.messageId)) - .groupBy(groupsTable.id), + .groupBy(userGroups.id), db .select({ groupId: nullAs('unread_group_id'), - receiverId: sql`${messagesTable.receiverId}`.as( - 'unread_receiver_id', - ), + partnerId: sql`${messagesTable.senderId}`.as('unread_partner_id'), unreadCount: count(messagesTable.id).as('unread_count'), }) .from(messagesTable) @@ -295,24 +308,29 @@ export const listUserGroups: RequestHandler = async (req, res, next) => { ), ), ) - .groupBy(messagesTable.receiverId), + .groupBy(messagesTable.senderId), ), ) const combinedChats = db .$with('combined_chats') .as( - unionAll( - db.with(groupsWithLastMessage).select().from(groupsWithLastMessage), - db - .with(directMessagesWithLastActivity) - .select() - .from(directMessagesWithLastActivity), + union( + db.select().from(userGroupsWithLastMessage), + db.select().from(directMessagesWithLastActivity), ), ) const qb = db - .with(combinedChats, unreadCounts) + .with( + userGroups, + groupMessagesWithRowNumber, + userGroupsWithLastMessage, + directMessagesWithPartner, + directMessagesWithLastActivity, + combinedChats, + unreadCounts, + ) .select({ groupId: combinedChats.groupId, partnerId: combinedChats.partnerId, @@ -326,9 +344,9 @@ export const listUserGroups: RequestHandler = async (req, res, next) => { .from(combinedChats) .leftJoin( unreadCounts, - and( + or( eq(combinedChats.groupId, unreadCounts.groupId), - eq(combinedChats.partnerId, unreadCounts.receiverId), + eq(combinedChats.partnerId, unreadCounts.partnerId), ), ) .$dynamic() @@ -337,9 +355,9 @@ export const listUserGroups: RequestHandler = async (req, res, next) => { const result = await withPagination(qb, { cursorSelect: 'lastActivity', - orderBy: [desc(groupsWithLastMessage.lastActivity)], + orderBy: [desc(userGroupsWithLastMessage.lastActivity)], where: cursor - ? lt(groupsWithLastMessage.lastActivity, cursor) + ? lt(userGroupsWithLastMessage.lastActivity, cursor) : undefined, limit, }) diff --git a/server/src/modules/messages/messages.controller.ts b/server/src/modules/messages/messages.controller.ts index 87315f7..dc92a11 100644 --- a/server/src/modules/messages/messages.controller.ts +++ b/server/src/modules/messages/messages.controller.ts @@ -49,8 +49,12 @@ export const listMessages: RequestHandler = async (req, res, next) => { groupId ? eq(messagesTable.groupId, groupId) : undefined, partnerId ? or( - eq(messagesTable.receiverId, partnerId), and( + eq(messagesTable.receiverId, partnerId), + eq(messagesTable.senderId, req.user!.id), + ), + and( + eq(messagesTable.receiverId, req.user!.id), eq(messagesTable.senderId, partnerId), isNull(messagesTable.groupId), ), diff --git a/server/src/modules/messages/messages.service.ts b/server/src/modules/messages/messages.service.ts index d502558..5d5deca 100644 --- a/server/src/modules/messages/messages.service.ts +++ b/server/src/modules/messages/messages.service.ts @@ -134,14 +134,14 @@ export const markMessageAsRead = async ( export const markChatMessagesAsRead = async ({ groupId, - receiverId, + partnerId, recipientId, }: { groupId?: number - receiverId?: number + partnerId?: number recipientId: number }) => { - if (!groupId && !receiverId) { + if (!groupId && !partnerId) { throw new Error( 'markChatMessagesAsRead: message does not belongs to either group or dm', ) @@ -169,7 +169,12 @@ export const markChatMessagesAsRead = async ({ .where( and( groupId ? eq(messagesTable.groupId, groupId) : undefined, - receiverId ? eq(messagesTable.receiverId, receiverId) : undefined, + partnerId + ? and( + eq(messagesTable.senderId, partnerId), + eq(messagesTable.receiverId, recipientId), + ) + : undefined, isNull(messageRecipientsTable.messageId), ), ) diff --git a/server/src/modules/users/users.routes.ts b/server/src/modules/users/users.routes.ts index 7d2c4f7..ad52a50 100644 --- a/server/src/modules/users/users.routes.ts +++ b/server/src/modules/users/users.routes.ts @@ -1,6 +1,6 @@ import { auth } from '@/middlewares' import { Router } from 'express' -import { listUserGroups } from '../groups/groups.controller' +import { listUserChats } from '../groups/groups.controller' import { getUser, getUsers, loginUser, signUpUser } from './users.controller' export const router = Router() @@ -11,4 +11,4 @@ router.post('/login', loginUser) router.get('/', auth, getUsers) router.get('/:userId', auth, getUser) -router.get('/:userId/groups', auth, listUserGroups) +router.get('/:userId/groups', auth, listUserChats) diff --git a/server/src/scripts/seed.ts b/server/src/scripts/seed.ts index 1efc4cd..d63e177 100644 --- a/server/src/scripts/seed.ts +++ b/server/src/scripts/seed.ts @@ -10,9 +10,9 @@ import { hash } from 'argon2' import 'colors' const USER_PASSWORD = 'bob@123' -const USER_COUNT = 50 +const USER_COUNT = 100 -const GROUP_COUNT_PER_USER = 5 +const GROUP_COUNT_PER_USER = 20 const MEMBER_COUNT_PER_GROUP = 5 const MESSAGE_PER_MEMBER = 5 const BATCH_SIZE = 100 diff --git a/server/src/socket/events.ts b/server/src/socket/events.ts index c5bdad6..95a2dc0 100644 --- a/server/src/socket/events.ts +++ b/server/src/socket/events.ts @@ -145,17 +145,17 @@ export const registerSocketEvents = (io: TypedIOServer) => { io.to(roomKeys.USER_KEY(messageSenderId)).emit('messageRead', messageId) }) - socket.on('markChatMessagesAsRead', async ({ groupId, receiverId }) => { + socket.on('markChatMessagesAsRead', async ({ groupId, partnerId }) => { const unreadMessages = await markChatMessagesAsRead({ groupId, - receiverId, + partnerId, recipientId: socket.data.user.id, }) // let the current user know that the unread messages of the group is marked as read io.to(roomKeys.USER_KEY(socket.data.user.id)).emit('chatMarkedAsRead', { groupId, - receiverId, + partnerId, }) // let the message senders know their message is read for (const message of unreadMessages) { diff --git a/server/src/socket/socket.interface.ts b/server/src/socket/socket.interface.ts index 448de53..5365ad4 100644 --- a/server/src/socket/socket.interface.ts +++ b/server/src/socket/socket.interface.ts @@ -18,7 +18,7 @@ export interface ServerToClientEvents { groupDeleted: (groupId: number) => void messageRead: (messageId: number) => void messageDeleted: (messageId: number) => void - chatMarkedAsRead: (args: { groupId?: number; receiverId?: number }) => void + chatMarkedAsRead: (args: { groupId?: number; partnerId?: number }) => void typingUsers: (users: { id: number; username: string }[]) => void } @@ -37,7 +37,7 @@ export interface ClientToServerEvents { markMessageAsRead: (messageId: number) => void markChatMessagesAsRead: (args: { groupId?: number - receiverId?: number + partnerId?: number }) => void typing: (args: { chatId: number; mode: ChatMode; isTyping: boolean }) => void } diff --git a/web/src/features/group/components/UserChatList.tsx b/web/src/features/group/components/UserChatList.tsx index 003c6ef..22ffe41 100644 --- a/web/src/features/group/components/UserChatList.tsx +++ b/web/src/features/group/components/UserChatList.tsx @@ -1,39 +1,19 @@ import { Alert } from '@/components/Alert' import { Skeleton } from '@/components/Skeleton' -import { useAuth } from '@/hooks/useAuth' import { useInView } from '@/hooks/useInView' -import { useInfiniteQuery } from '@tanstack/react-query' import { Fragment, useRef } from 'react' -import { fetchUserGroups } from '../group.service' import { useChatSocketHandle } from '../hooks/useChatSocketHandle' import { JoinGroupsForm } from './JoinGroupForm' import { UserChatItem } from './UserChatItem' export const UserChatList = () => { - const { auth } = useAuth() const { data, isLoading, isSuccess, hasNextPage, fetchNextPage, error } = - useInfiniteQuery({ - queryKey: ['userGroups', auth], - queryFn: async ({ pageParam }) => { - return fetchUserGroups({ - userId: auth!.id, - limit: 15, - cursor: pageParam, - }) - }, - initialPageParam: null as number | null, - getNextPageParam(lastPage) { - return lastPage.cursor ? lastPage.cursor : undefined - }, - enabled: Boolean(auth?.id), - }) + useChatSocketHandle() const listRef = useRef(null) const watchElement = useInView(listRef, fetchNextPage, hasNextPage) - useChatSocketHandle() - let content if (error) { diff --git a/web/src/features/group/group.service.ts b/web/src/features/group/group.service.ts index 2be8a90..ab2ddcf 100644 --- a/web/src/features/group/group.service.ts +++ b/web/src/features/group/group.service.ts @@ -12,7 +12,7 @@ import { TGetUserGroupsQueryVariables, } from './group.interface' -export const fetchUserGroups = async ({ +export const fetchUserChats = async ({ userId, ...params }: TGetUserGroupsQueryVariables): Promise> => diff --git a/web/src/features/group/hooks/useChatSocketHandle.ts b/web/src/features/group/hooks/useChatSocketHandle.ts index 1cf6c2f..32fa1aa 100644 --- a/web/src/features/group/hooks/useChatSocketHandle.ts +++ b/web/src/features/group/hooks/useChatSocketHandle.ts @@ -1,28 +1,41 @@ import { IMessage } from '@/features/message/message.interface' -import { useAuth } from '@/hooks/useAuth' import { getSocketIO } from '@/utils/socket' import { useQueryClient } from '@tanstack/react-query' import { produce } from 'immer' import { useEffect } from 'react' import { useNavigate, useParams } from 'react-router-dom' import { IChat, IGroup, IPaginatedInfiniteChats } from '../group.interface' +import { useChats } from './useChats' export const useChatSocketHandle = () => { - const { auth } = useAuth() const queryClient = useQueryClient() const navigate = useNavigate() const params = useParams() + const { + auth, + data, + isLoading, + isSuccess, + hasNextPage, + fetchNextPage, + error, + } = useChats() + function getUnreadCount(checker: (chat: IChat) => boolean) { if (!auth) return 0 + const chatListData = queryClient.getQueryData([ - 'userGroups', + 'userChats', auth, ]) let unreadCount = 0 + + console.log(chatListData) chatListData?.pages.forEach(page => page.data.forEach(chat => { if (checker(chat)) { + console.log('made it here') unreadCount = chat.unreadCount } }), @@ -30,49 +43,42 @@ export const useChatSocketHandle = () => { return unreadCount } + function updateChatList( + dataUpdater: (data: IPaginatedInfiniteChats) => IPaginatedInfiniteChats, + ) { + if (!auth) return + queryClient.setQueryData( + ['userChats', auth], + data => { + if (!data) return + return dataUpdater(data) + }, + ) + } + useEffect(() => { + if (!auth || !isSuccess) return + + const socket = getSocketIO() + const partnerId = Number(params.partnerId) + const groupId = Number(params.groupId) if (partnerId) { - const socket = getSocketIO() socket.emit('joinDm', partnerId) const unreadCount = getUnreadCount(chat => chat.partnerId === partnerId) if (unreadCount) { - socket.emit('markChatMessagesAsRead', { receiverId: partnerId }) + socket.emit('markChatMessagesAsRead', { partnerId }) } } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [params.partnerId]) - useEffect(() => { - const groupId = Number(params.groupId) if (groupId) { - const socket = getSocketIO() socket.emit('joinGroup', groupId) const unreadCount = getUnreadCount(chat => chat.groupId === groupId) if (unreadCount) { socket.emit('markChatMessagesAsRead', { groupId }) } } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [params.groupId]) - - useEffect(() => { - if (!auth) return - - const socket = getSocketIO() - - function updateChatList( - dataUpdater: (data: IPaginatedInfiniteChats) => IPaginatedInfiniteChats, - ) { - queryClient.setQueryData( - ['userGroups', auth], - data => { - if (!data) return - return dataUpdater(data) - }, - ) - } function handleNewGroup(group: IGroup) { updateChatList(data => { @@ -137,18 +143,16 @@ export const useChatSocketHandle = () => { function handleGroupMarkedAsRead({ groupId, - receiverId, + partnerId, }: { groupId?: number - receiverId?: number + partnerId?: number }) { updateChatList(data => { return produce(data, draft => { draft.pages.forEach(page => { const chat = page.data.find(chat => - groupId - ? chat.groupId === groupId - : chat.partnerId === receiverId, + groupId ? chat.groupId === groupId : chat.partnerId === partnerId, ) if (chat) { chat.unreadCount = 0 @@ -215,5 +219,14 @@ export const useChatSocketHandle = () => { socket.off('memberLeft', handleMemberLeft) } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [auth, params.groupId, params.partnerId]) + }, [auth, isSuccess, params.groupId, params.partnerId]) + + return { + data, + isLoading, + isSuccess, + hasNextPage, + fetchNextPage, + error, + } } diff --git a/web/src/features/group/hooks/useChats.ts b/web/src/features/group/hooks/useChats.ts new file mode 100644 index 0000000..1f8a058 --- /dev/null +++ b/web/src/features/group/hooks/useChats.ts @@ -0,0 +1,33 @@ +import { useAuth } from '@/hooks/useAuth' +import { useInfiniteQuery } from '@tanstack/react-query' +import { fetchUserChats } from '../group.service' + +export const useChats = () => { + const { auth } = useAuth() + const { data, isLoading, isSuccess, hasNextPage, fetchNextPage, error } = + useInfiniteQuery({ + queryKey: ['userChats', auth], + queryFn: async ({ pageParam }) => { + return fetchUserChats({ + userId: auth!.id, + limit: 15, + cursor: pageParam, + }) + }, + initialPageParam: null as number | null, + getNextPageParam(lastPage) { + return lastPage.cursor ? lastPage.cursor : undefined + }, + enabled: !!auth?.id, + }) + + return { + auth, + data, + isLoading, + isSuccess, + hasNextPage, + fetchNextPage, + error, + } +} diff --git a/web/src/interfaces/socket.interface.ts b/web/src/interfaces/socket.interface.ts index d59fbfd..02357db 100644 --- a/web/src/interfaces/socket.interface.ts +++ b/web/src/interfaces/socket.interface.ts @@ -15,7 +15,7 @@ export interface ServerToClientEvents { groupDeleted: (groupId: number) => void messageRead: (messageId: number) => void messageDeleted: (messageId: number) => void - chatMarkedAsRead: (args: { groupId?: number; receiverId?: number }) => void + chatMarkedAsRead: (args: { groupId?: number; partnerId?: number }) => void typingUsers: (users: { id: number; username: string }[]) => void } @@ -34,7 +34,7 @@ export interface ClientToServerEvents { markMessageAsRead: (messageId: number) => void markChatMessagesAsRead: (args: { groupId?: number - receiverId?: number + partnerId?: number }) => void typing: (args: { chatId: number; mode: ChatMode; isTyping: boolean }) => void }