diff --git a/README.md b/README.md index 40b42d5..d21908c 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,7 @@ pnpm --filter web test - [x] leave group, transfer ownership, delete group - [x] delete group - [x] alert component +- [x] direct message - [ ] confirm dialog - [ ] read receipts - [ ] e2e encryption diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a423235..0f1dacf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,12 +41,9 @@ importers: server: dependencies: - '@socket.io/cluster-adapter': + '@socket.io/redis-streams-adapter': specifier: ^0.2.2 version: 0.2.2(socket.io-adapter@2.5.5) - '@socket.io/sticky': - specifier: ^1.0.4 - version: 1.0.4 argon2: specifier: ^0.40.3 version: 0.40.3 @@ -156,6 +153,9 @@ importers: immer: specifier: ^10.1.1 version: 10.1.1 + lucide-react: + specifier: ^0.408.0 + version: 0.408.0(react@18.3.1) react: specifier: ^18.2.0 version: 18.3.1 @@ -1417,6 +1417,10 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@msgpack/msgpack@2.8.0': + resolution: {integrity: sha512-h9u4u/jiIRKbq25PM+zymTyW6bhTzELvOoUd+AvYriWOAKpLGnIamaET3pnHYoI5iYphAHBI4ayx0MehR+VVPQ==} + engines: {node: '>= 10'} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -1578,17 +1582,14 @@ packages: '@sinclair/typebox@0.27.8': resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} - '@socket.io/cluster-adapter@0.2.2': - resolution: {integrity: sha512-/tNcY6qQx0BOgjl4mFk3YxX6pjaPdEyeWhP88Ea9gTlISY4SfA7t8VxbryeAs5/9QgXzChlvSN/i37Gog3kWag==} - engines: {node: '>=10.0.0'} - peerDependencies: - socket.io-adapter: ^2.4.0 - '@socket.io/component-emitter@3.1.2': resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} - '@socket.io/sticky@1.0.4': - resolution: {integrity: sha512-VuauT5CJLvzYtKIgouFSQ8rUaygseR+zRutnwh6ZA2QYcXx+8g52EoJ8V2SLxfo+Tfs3ELUDy08oEXxlWNrxaw==} + '@socket.io/redis-streams-adapter@0.2.2': + resolution: {integrity: sha512-BMPa6oGC0wFgpMXoGksbJ75zMBwk+79pxjHc2YusdoK+X0BxN4fTsqEBuFV7yeXi9ekbi87rwlsT61+WZGVW9g==} + engines: {node: '>=14.0.0'} + peerDependencies: + socket.io-adapter: ^2.5.4 '@surma/rollup-plugin-off-main-thread@2.2.3': resolution: {integrity: sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==} @@ -3399,6 +3400,11 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lucide-react@0.408.0: + resolution: {integrity: sha512-8kETAAeWmOvtGIr7HPHm51DXoxlfkNncQ5FZWXR+abX8saQwMYXANWIkUstaYtcKSo/imOe/q+tVFA8ANzdSVA==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + lz-string@1.5.0: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} hasBin: true @@ -5990,6 +5996,8 @@ snapshots: '@jridgewell/sourcemap-codec': 1.4.15 optional: true + '@msgpack/msgpack@2.8.0': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -6112,17 +6120,16 @@ snapshots: '@sinclair/typebox@0.27.8': {} - '@socket.io/cluster-adapter@0.2.2(socket.io-adapter@2.5.5)': + '@socket.io/component-emitter@3.1.2': {} + + '@socket.io/redis-streams-adapter@0.2.2(socket.io-adapter@2.5.5)': dependencies: + '@msgpack/msgpack': 2.8.0 debug: 4.3.5 socket.io-adapter: 2.5.5 transitivePeerDependencies: - supports-color - '@socket.io/component-emitter@3.1.2': {} - - '@socket.io/sticky@1.0.4': {} - '@surma/rollup-plugin-off-main-thread@2.2.3': dependencies: ejs: 3.1.10 @@ -8116,6 +8123,10 @@ snapshots: dependencies: yallist: 3.1.1 + lucide-react@0.408.0(react@18.3.1): + dependencies: + react: 18.3.1 + lz-string@1.5.0: {} magic-string@0.25.9: diff --git a/server/package.json b/server/package.json index 1ab0981..717b2d8 100644 --- a/server/package.json +++ b/server/package.json @@ -21,8 +21,7 @@ "author": "Aseer KT", "license": "ISC", "dependencies": { - "@socket.io/cluster-adapter": "^0.2.2", - "@socket.io/sticky": "^1.0.4", + "@socket.io/redis-streams-adapter": "^0.2.2", "argon2": "^0.40.3", "colors": "^1.4.0", "cors": "^2.8.5", diff --git a/server/src/database/helpers.ts b/server/src/database/helpers.ts index 4a4c2a8..adfa1d0 100644 --- a/server/src/database/helpers.ts +++ b/server/src/database/helpers.ts @@ -1,6 +1,13 @@ /* eslint-disable @typescript-eslint/ban-types */ import { isValidDate } from '@/utils/validations' -import { ColumnBaseConfig, ColumnDataType, SQL } from 'drizzle-orm' +import { + AnyColumn, + ColumnBaseConfig, + ColumnDataType, + sql, + SQL, + SQLWrapper, +} from 'drizzle-orm' import { PgColumn, PgSelect } from 'drizzle-orm/pg-core' import get from 'lodash/get' import { defaultLimit } from './constants' @@ -65,3 +72,30 @@ export const withPagination = async ( : null, } } + +export const rowNumber = () => { + return { + over: ({ + partitionBy, + orderBy, + as, + }: { + partitionBy: AnyColumn | SQLWrapper + orderBy: SQL + as: string + }) => + sql`ROW_NUMBER() OVER (PARTITION BY ${partitionBy} ORDER BY ${orderBy})`.as( + as, + ), + } +} + +export const coalesce = ( + value: SQL.Aliased | SQL | AnyColumn, + defaultValue: SQL.Aliased | SQL | AnyColumn | number, +) => sql`COALESCE (${value}, ${defaultValue})` + +export const nullAs = (as: string) => sql`null`.as(as) + +export const columnAs = (column: AnyColumn, as: string) => + sql`${column}`.as(as) diff --git a/server/src/index.ts b/server/src/index.ts index c0f0cc3..ff948bb 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1,21 +1,16 @@ -import { - createAdapter as createClusterAdapter, - setupPrimary, -} from '@socket.io/cluster-adapter' -import { setupMaster, setupWorker } from '@socket.io/sticky' +import { createAdapter } from '@socket.io/redis-streams-adapter' import 'colors' import cors from 'cors' import express from 'express' import helmet from 'helmet' import morgan from 'morgan' -import cluster from 'node:cluster' import { createServer } from 'node:http' -import { availableParallelism } from 'node:os' import { Server } from 'socket.io' import swaggerUi from 'swagger-ui-express' import { config } from './config' import { connectDB } from './database' import { errorHandler } from './middlewares' +import { getRedisClient } from './redis' import rootRouter from './routes' import { registerSocketEvents } from './socket/events' import { socketAuthMiddleware } from './socket/middlewares' @@ -28,40 +23,10 @@ import { import swaggerDocument from './swagger-output.json' const createApp = async () => { - if (cluster.isPrimary && config.isProd) { - console.log(`Primary ${process.pid} is running`) - - const numCPUs = availableParallelism() - - const httpServer = createServer() - - // setup sticky sessions - setupMaster(httpServer, { loadBalancingMethod: 'least-connection' }) - - // setup connection between the workers - setupPrimary() - - httpServer.listen(config.port, () => { - console.log(`Server running at http://localhost:${config.port}`.blue.bold) - }) - - for (let i = 0; i < numCPUs; i++) { - // Spawn a new worker process. - // This can only be called from the primary process. - cluster.fork() - } - - cluster.on('exit', worker => { - console.log(`worker ${worker.process.pid} died`) - cluster.fork() - }) - - return - } - console.log(`Worker ${process.pid} started`) await connectDB() + const redisClient = getRedisClient() const app = express() @@ -81,16 +46,9 @@ const createApp = async () => { SocketData >(server, { cors: { origin: config.corsOrigin }, + adapter: createAdapter(redisClient), }) - if (config.isProd) { - // use cluster adapter - io.adapter(createClusterAdapter()) - - // setup connection with primary process - setupWorker(io) - } - io.use(socketAuthMiddleware) registerSocketEvents(io) @@ -108,11 +66,9 @@ const createApp = async () => { app.use(errorHandler) - if (!config.isProd) { - server.listen(config.port, () => { - console.log(`Server running at http://localhost:${config.port}`.blue.bold) - }) - } + server.listen(config.port, () => { + console.log(`Server running at http://localhost:${config.port}`.blue.bold) + }) return { server } } diff --git a/server/src/middlewares.ts b/server/src/middlewares.ts index 3011fdf..a01a030 100644 --- a/server/src/middlewares.ts +++ b/server/src/middlewares.ts @@ -2,7 +2,7 @@ import { ErrorRequestHandler, RequestHandler } from 'express' import { config } from './config' import { MemberRole } from './modules/members/members.schema' import { checkPermission } from './modules/members/members.service' -import { notAuthenticated, notAuthorized } from './utils/api' +import { badRequest, notAuthenticated, notAuthorized } from './utils/api' import { verifyToken } from './utils/jwt' // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -32,7 +32,7 @@ export const auth: RequestHandler = (req, res, next) => { } } -export const hasGroupPermission = +export const hasChatPermission = (role: MemberRole): RequestHandler => async (req, res, next) => { try { @@ -40,23 +40,29 @@ export const hasGroupPermission = req.params.groupId || req.query.groupId || req.body.groupId, ) - if (Number.isNaN(groupId)) { - return notAuthorized(res) - } - - const { isAllowed, memberRole } = await checkPermission( - groupId, - req.user!.id, - role, + const partnerId = Number( + req.params.partnerId || req.query.partnerId || req.body.partnerId, ) - if (!isAllowed) { - return notAuthorized(res) + if (!groupId && !partnerId) { + return badRequest(res) } - req.member = { - groupId: groupId, - role: memberRole!, + if (groupId) { + const { isAllowed, memberRole } = await checkPermission( + groupId, + req.user!.id, + role, + ) + + if (!isAllowed) { + return notAuthorized(res) + } + + req.member = { + groupId: groupId, + role: memberRole!, + } } next() diff --git a/server/src/modules/groups/groups.controller.ts b/server/src/modules/groups/groups.controller.ts index b3828f2..904c44e 100644 --- a/server/src/modules/groups/groups.controller.ts +++ b/server/src/modules/groups/groups.controller.ts @@ -1,7 +1,13 @@ import { db } from '@/database' -import { getPaginationParams, withPagination } from '@/database/helpers' +import { + coalesce, + getPaginationParams, + nullAs, + rowNumber, + withPagination, +} from '@/database/helpers' import { deleteGroupRoles, deleteMemberRole } from '@/redis/handlers' -import { getGroupRoomId } from '@/socket/helpers' +import { roomKeys } from '@/socket/helpers' import { TypedIOServer } from '@/socket/socket.interface' import { badRequest, notAuthorized, notFound } from '@/utils/api' import { @@ -10,12 +16,16 @@ import { desc, eq, getTableColumns, + isNotNull, isNull, like, lt, + notExists, notInArray, + or, sql, } from 'drizzle-orm' +import { unionAll } from 'drizzle-orm/pg-core' import { RequestHandler } from 'express' import { members } from '../members/members.schema' import { addMembers } from '../members/members.service' @@ -131,79 +141,183 @@ export const listGroups: RequestHandler = async (req, res, next) => { export const listUserGroups: RequestHandler = async (req, res, next) => { try { - const messagesWithSequence = db.$with('messages_with_sequence').as( - db - .select({ - ...getTableColumns(messages), - seqNum: - sql`ROW_NUMBER() OVER (PARTITION BY ${messages.groupId} ORDER BY ${desc(messages.createdAt)})`.as( - 'seq_num', - ), - }) - .from(messages), - ) + const groupMessagesWithRowNumber = db + .$with('group_messages_with_row_number') + .as( + db + .select({ + ...getTableColumns(messages), + rowNumber: rowNumber().over({ + partitionBy: messages.groupId, + orderBy: desc(messages.createdAt), + as: 'row_number', + }), + }) + .from(messages) + .where(isNotNull(messages.groupId)), + ) const groupsWithLastMessage = db.$with('groups_with_last_message').as( db - .with(messagesWithSequence) + .with(groupMessagesWithRowNumber) .select({ - ...getTableColumns(groups), + chatName: groups.name, + groupId: groupMessagesWithRowNumber.groupId, + partnerId: groupMessagesWithRowNumber.receiverId, lastMessage: { - id: sql`${messagesWithSequence.id}`.as('message_id'), - content: messagesWithSequence.content, - senderId: messagesWithSequence.senderId, + messageId: sql`${groupMessagesWithRowNumber.id}`.as('message_id'), + content: groupMessagesWithRowNumber.content, }, - lastActivity: - sql`COALESCE (${messagesWithSequence.createdAt}, ${groups.createdAt})`.as( - 'last_activity', - ), + lastActivity: coalesce( + groupMessagesWithRowNumber.createdAt, + groups.createdAt, + ).as('last_activity'), }) .from(groups) .leftJoin( - messagesWithSequence, + groupMessagesWithRowNumber, and( - eq(groups.id, messagesWithSequence.groupId), - eq(messagesWithSequence.seqNum, 1), + eq(groups.id, groupMessagesWithRowNumber.groupId), + eq(groupMessagesWithRowNumber.rowNumber, 1), ), ) .innerJoin(members, eq(members.groupId, groups.id)) .where(eq(members.userId, req.user!.id)), ) - const unreadCounts = db.$with('unread_counts').as( - db - .select({ - groupId: groups.id, - unreadCount: count(messages.id).as('unread_count'), - }) - .from(groups) - .leftJoin(messages, eq(groups.id, messages.groupId)) - .leftJoin( - messageRecipients, - and( - eq(messageRecipients.messageId, messages.id), - eq(messageRecipients.recipientId, req.user!.id), + const directMessagesWithPartner = db + .$with('direct_messages_with_partner') + .as( + db + .select({ + ...getTableColumns(messages), + partnerId: sql` + CASE + WHEN ${messages.senderId} = ${req.user!.id} THEN ${messages.receiverId} + ELSE ${messages.senderId} + END + `.as('partner_id'), + rowNumber: rowNumber().over({ + partitionBy: sql` + CASE + WHEN ${messages.senderId} = ${req.user!.id} THEN ${messages.receiverId} + ELSE ${messages.senderId} + END + `, + orderBy: desc(messages.createdAt), + as: 'row_number', + }), + }) + .from(messages) + .where( + and( + isNull(messages.groupId), + or( + eq(messages.senderId, req.user!.id), + eq(messages.receiverId, req.user!.id), + ), + ), ), - ) - .where(isNull(messageRecipients.messageId)) - .groupBy(groups.id), + ) + + const directMessagesWithLastActivity = db + .$with('direct_messages_with_last_activity') + .as( + db + .with(directMessagesWithPartner) + .select({ + chatName: users.username, + groupId: directMessagesWithPartner.groupId, + partnerId: directMessagesWithPartner.partnerId, + lastMessage: { + messageId: sql`${directMessagesWithPartner.id}`.as('message_id'), + content: directMessagesWithPartner.content, + }, + lastActivity: directMessagesWithPartner.createdAt, + }) + .from(directMessagesWithPartner) + .innerJoin(users, eq(directMessagesWithPartner.partnerId, users.id)) + .where(eq(directMessagesWithPartner.rowNumber, 1)), + ) + + const unreadCounts = db.$with('unread_counts').as( + unionAll( + db + .select({ + groupId: sql`${groups.id}`.as('unread_group_id'), + receiverId: nullAs('unread_receiver_id'), + unreadCount: count(messages.id).as('unread_count'), + }) + .from(groups) + .leftJoin(messages, eq(groups.id, messages.groupId)) + .leftJoin( + messageRecipients, + and( + eq(messageRecipients.messageId, messages.id), + eq(messageRecipients.recipientId, req.user!.id), + ), + ) + .where(isNull(messageRecipients.messageId)) + .groupBy(groups.id), + db + .select({ + groupId: nullAs('unread_group_id'), + receiverId: sql`${messages.receiverId}`.as('unread_receiver_id'), + unreadCount: count(messages.id).as('unread_count'), + }) + .from(messages) + .where( + and( + isNull(messages.groupId), + eq(messages.receiverId, req.user!.id), + notExists( + db + .select() + .from(messageRecipients) + .where( + and( + eq(messageRecipients.messageId, messages.id), + eq(messageRecipients.recipientId, req.user!.id), + ), + ), + ), + ), + ) + .groupBy(messages.receiverId), + ), ) + const combinedChats = db + .$with('combined_chats') + .as( + unionAll( + db.with(groupsWithLastMessage).select().from(groupsWithLastMessage), + db + .with(directMessagesWithLastActivity) + .select() + .from(directMessagesWithLastActivity), + ), + ) + const qb = db - .with(groupsWithLastMessage, unreadCounts) + .with(combinedChats, unreadCounts) .select({ - lastMessage: groupsWithLastMessage.lastMessage, - lastActivity: groupsWithLastMessage.lastActivity, - id: groupsWithLastMessage.id, - name: groupsWithLastMessage.name, - unreadCount: sql`COALESCE (${unreadCounts.unreadCount}, 0)` + groupId: combinedChats.groupId, + partnerId: combinedChats.partnerId, + chatName: combinedChats.chatName, + lastMessage: combinedChats.lastMessage, + lastActivity: combinedChats.lastActivity, + unreadCount: coalesce(unreadCounts.unreadCount, 0) .mapWith(Number) .as('unread_count'), }) - .from(groupsWithLastMessage) + .from(combinedChats) .leftJoin( unreadCounts, - eq(unreadCounts.groupId, groupsWithLastMessage.id), + and( + eq(combinedChats.groupId, unreadCounts.groupId), + eq(combinedChats.partnerId, unreadCounts.receiverId), + ), ) .$dynamic() @@ -250,7 +364,10 @@ export const addGroupMembers: RequestHandler = async (req, res, next) => { // let existing members know new member is joined - io.to(getGroupRoomId(req.params.groupId)).emit('newMembers', newMembers) + io.to(roomKeys.CURRENT_GROUP_KEY(Number(req.params.groupId))).emit( + 'newMembers', + newMembers, + ) res.json(newMembers) } catch (error) { diff --git a/server/src/modules/groups/groups.routes.ts b/server/src/modules/groups/groups.routes.ts index a1f0f9d..519599f 100644 --- a/server/src/modules/groups/groups.routes.ts +++ b/server/src/modules/groups/groups.routes.ts @@ -1,7 +1,7 @@ -import { hasGroupPermission } from '@/middlewares' +import { hasChatPermission } from '@/middlewares' import { Router } from 'express' import { getGroupMember, getGroupMembers } from '../members/members.controller' -import { createMessage, listMessages } from '../messages/messages.controller' +import { createMessage } from '../messages/messages.controller' import { addGroupMembers, changeMemberRole, @@ -21,36 +21,35 @@ export const router = Router() router.post('/', createGroup) router.get('/', listGroups) -router.get('/:groupId', hasGroupPermission('member'), getGroup) -router.delete('/:groupId', hasGroupPermission('owner'), deleteGroup) +router.get('/:groupId', hasChatPermission('member'), getGroup) +router.delete('/:groupId', hasChatPermission('owner'), deleteGroup) // Group Member Handler -router.delete('/:groupId/leave', hasGroupPermission('member'), leaveGroup) +router.delete('/:groupId/leave', hasChatPermission('member'), leaveGroup) router.delete( '/:groupId/members/:memberId', - hasGroupPermission('admin'), + hasChatPermission('admin'), kickMember, ) -router.post('/:groupId/members', hasGroupPermission('admin'), addGroupMembers) -router.get('/:groupId/members', hasGroupPermission('member'), getGroupMembers) +router.post('/:groupId/members', hasChatPermission('admin'), addGroupMembers) +router.get('/:groupId/members', hasChatPermission('member'), getGroupMembers) router.get( '/:groupId/members/:userId', - hasGroupPermission('member'), + hasChatPermission('member'), getGroupMember, ) router.patch( '/:groupId/members/:userId', - hasGroupPermission('admin'), + hasChatPermission('admin'), changeMemberRole, ) router.get( '/:groupId/non-members', - hasGroupPermission('admin'), + hasChatPermission('admin'), getNonGroupMembers, ) // Message handler -router.get('/:groupId/messages', hasGroupPermission('member'), listMessages) -router.post('/:groupId/messages', hasGroupPermission('member'), createMessage) +router.post('/:groupId/messages', hasChatPermission('member'), createMessage) diff --git a/server/src/modules/members/members.controller.ts b/server/src/modules/members/members.controller.ts index b471d3c..a25ea4a 100644 --- a/server/src/modules/members/members.controller.ts +++ b/server/src/modules/members/members.controller.ts @@ -1,11 +1,7 @@ import { db } from '@/database' import { getPaginationParams, withPagination } from '@/database/helpers' -import { - checkOnlineUsers, - getMultipleUserSockets, - setGroupMemberRoleTxn, -} from '@/redis/handlers' -import { getGroupRoomId } from '@/socket/helpers' +import { checkOnlineUsers, setGroupMemberRoleTxn } from '@/redis/handlers' +import { roomKeys } from '@/socket/helpers' import { TypedIOServer } from '@/socket/socket.interface' import { badRequest, notFound } from '@/utils/api' import { and, asc, eq, getTableColumns, gt, like } from 'drizzle-orm' @@ -36,18 +32,23 @@ export const joinRooms: RequestHandler = async (req, res, next) => { rows.forEach(member => { groupMemberRoles[member.groupId] = [member.userId, 'member'] - io.to(getGroupRoomId(member.groupId)).emit('newMember', { + io.to(roomKeys.CURRENT_GROUP_KEY(member.groupId)).emit('newMember', { ...member, username: req.user!.username, }) }) - const currentUserSockets = await getMultipleUserSockets([req.user!.id]) + // get sockets for current user id - currentUserSockets.forEach(socketId => { - const socket = io.sockets.sockets.get(socketId) - socket?.join(rows.map(member => member.groupId.toString())) - }) + const currentUserSockets = await io + .in(roomKeys.USER_KEY(req.user!.id)) + .fetchSockets() + + // join group rooms + + for (const socket of currentUserSockets) { + socket?.join(rows.map(member => roomKeys.GROUP_KEY(member.groupId))) + } setGroupMemberRoleTxn(groupMemberRoles) diff --git a/server/src/modules/members/members.service.ts b/server/src/modules/members/members.service.ts index 209e4b8..c08abc1 100644 --- a/server/src/modules/members/members.service.ts +++ b/server/src/modules/members/members.service.ts @@ -1,9 +1,6 @@ import { db } from '@/database' -import { - getMemberRole, - getMultipleUserSockets, - setMemberRolesForAGroup, -} from '@/redis/handlers' +import { getMemberRole, setMemberRolesForAGroup } from '@/redis/handlers' +import { roomKeys } from '@/socket/helpers' import { TypedIOServer } from '@/socket/socket.interface' import { and, eq } from 'drizzle-orm' import { NodePgDatabase } from 'drizzle-orm/node-postgres' @@ -60,26 +57,12 @@ export const addMembers = async ( setMemberRolesForAGroup(group.id, memberRoles) - const userSockets = await joinMultiSocketRooms(io, memberIds, [group.id]) - - if (userSockets.length) { - io.to(userSockets).emit('newGroup', group) + for (const memberId of memberIds) { + // new member to join group room + io.in(roomKeys.USER_KEY(memberId)).socketsJoin(roomKeys.GROUP_KEY(group.id)) + // emit newGroup event to new member sockets + io.to(roomKeys.USER_KEY(memberId)).emit('newGroup', group) } return newMembers } - -export const joinMultiSocketRooms = async ( - io: TypedIOServer, - userIds: number[], - groupIds: number[], -) => { - const userSockets = await getMultipleUserSockets(userIds) - - for (const socketId of userSockets) { - const socket = io.sockets.sockets.get(socketId) - socket?.join(groupIds.map(String)) - } - - return userSockets -} diff --git a/server/src/modules/messages/messages.controller.ts b/server/src/modules/messages/messages.controller.ts index 1a35f8d..90499c7 100644 --- a/server/src/modules/messages/messages.controller.ts +++ b/server/src/modules/messages/messages.controller.ts @@ -1,6 +1,6 @@ import { db } from '@/database' import { getPaginationParams, withPagination } from '@/database/helpers' -import { and, desc, eq, getTableColumns, lt } from 'drizzle-orm' +import { and, desc, eq, getTableColumns, lt, or } from 'drizzle-orm' import { RequestHandler } from 'express' import { users } from '../users/users.schema' import { messages } from './messages.schema' @@ -23,6 +23,8 @@ export const createMessage: RequestHandler = async (req, res, next) => { export const listMessages: RequestHandler = async (req, res, next) => { try { + const groupId = Number(req.query.groupId) + const partnerId = Number(req.query.partnerId) const { cursor, limit } = getPaginationParams(req.query, 'number') const result = await withPagination( db @@ -35,7 +37,13 @@ export const listMessages: RequestHandler = async (req, res, next) => { cursorSelect: 'id', orderBy: [desc(messages.id)], where: and( - eq(messages.groupId, Number(req.params.groupId)), + groupId ? eq(messages.groupId, groupId) : undefined, + partnerId + ? or( + eq(messages.receiverId, partnerId), + eq(messages.senderId, partnerId), + ) + : undefined, cursor ? lt(messages.id, cursor as number) : undefined, ), }, diff --git a/server/src/modules/messages/messages.routes.ts b/server/src/modules/messages/messages.routes.ts new file mode 100644 index 0000000..d839d01 --- /dev/null +++ b/server/src/modules/messages/messages.routes.ts @@ -0,0 +1,7 @@ +import { hasChatPermission } from '@/middlewares' +import { Router } from 'express' +import { listMessages } from './messages.controller' + +export const router = Router() + +router.get('/', hasChatPermission('member'), listMessages) diff --git a/server/src/modules/messages/messages.service.ts b/server/src/modules/messages/messages.service.ts index f8de1a5..a3a5814 100644 --- a/server/src/modules/messages/messages.service.ts +++ b/server/src/modules/messages/messages.service.ts @@ -1,32 +1,52 @@ import { db } from '@/database' -import { getUserSockets } from '@/redis/handlers' import { and, eq, isNull } from 'drizzle-orm' import { groups } from '../groups/groups.schema' import { checkPermission } from '../members/members.service' +import { users } from '../users/users.schema' import { messageRecipients, messages } from './messages.schema' -export const insertMessage = async ( - groupId: number, - content: string, - senderId: number, -) => { - const { isAllowed } = await checkPermission(groupId, senderId, 'member') - if (!isAllowed) { - throw new Error('createMessage: Not authorized') +export const insertMessage = async ({ + groupId, + receiverId, + content, + senderId, +}: { + groupId?: number + receiverId?: number + content: string + senderId: number +}) => { + let chatName = '' + if (groupId) { + const { isAllowed } = await checkPermission(groupId, senderId, 'member') + if (!isAllowed) { + throw new Error('createMessage: Not authorized') + } + const [group] = await db + .select({ name: groups.name }) + .from(groups) + .where(eq(groups.id, groupId)) + chatName = group.name + } + + if (receiverId) { + const [receiver] = await db + .select({ username: users.username }) + .from(users) + .where(eq(users.id, receiverId)) + chatName = receiver.username } - const [group] = await db - .select({ groupName: groups.name }) - .from(groups) - .where(eq(groups.id, groupId)) + const [message] = await db .insert(messages) .values({ groupId, + receiverId, content, senderId, }) .returning() - return { ...message, groupName: group.groupName } + return { ...message, chatName } } export const markMessageAsRead = async ( @@ -34,45 +54,68 @@ export const markMessageAsRead = async ( recipientId: number, ) => { const [message] = await db - .select({ senderId: messages.senderId, groupId: messages.groupId }) + .select({ + senderId: messages.senderId, + receiverId: messages.receiverId, + groupId: messages.groupId, + }) .from(messages) .where(eq(messages.id, messageId)) .limit(1) - if (!message?.groupId) { - throw new Error('markMessageAsRead: message does not belongs to a group') + if (!message.groupId && !message.receiverId) { + throw new Error( + 'markMessageAsRead: message does not belongs to either group or dm', + ) } - const { isAllowed } = await checkPermission( - message.groupId, - recipientId, - 'member', - ) - - if (!isAllowed) { - throw new Error( - "markMessageAsRead: you don't have permission to mark the message as read", + if (message.groupId) { + const { isAllowed } = await checkPermission( + message.groupId, + recipientId, + 'member', ) + + if (!isAllowed) { + throw new Error( + "markMessageAsRead: you don't have permission to mark the message as read", + ) + } } - await db.insert(messageRecipients).values({ - messageId, - recipientId, - }) + await db + .insert(messageRecipients) + .values({ + messageId, + recipientId, + }) + .onConflictDoNothing() - return getUserSockets(message.senderId) + return message.senderId } -export const markGroupMessagesAsRead = async ( - groupId: number, - recipientId: number, -) => { - const { isAllowed } = await checkPermission(groupId, recipientId, 'member') - - if (!isAllowed) { +export const markChatMessagesAsRead = async ({ + groupId, + receiverId, + recipientId, +}: { + groupId?: number + receiverId?: number + recipientId: number +}) => { + if (!groupId && !receiverId) { throw new Error( - "markGroupMessagesAsRead: you don't have permission to mark the message as read", + 'markChatMessagesAsRead: message does not belongs to either group or dm', ) } + if (groupId) { + const { isAllowed } = await checkPermission(groupId, recipientId, 'member') + + if (!isAllowed) { + throw new Error( + "markGroupMessagesAsRead: you don't have permission to mark the message as read", + ) + } + } const unreadMessages = await db .select({ messageId: messages.id, senderId: messages.senderId }) @@ -85,7 +128,11 @@ export const markGroupMessagesAsRead = async ( ), ) .where( - and(eq(messages.groupId, groupId), isNull(messageRecipients.messageId)), + and( + groupId ? eq(messages.groupId, groupId) : undefined, + receiverId ? eq(messages.receiverId, receiverId) : undefined, + isNull(messageRecipients.messageId), + ), ) if (unreadMessages.length) { @@ -95,7 +142,6 @@ export const markGroupMessagesAsRead = async ( recipientId, })), ) - - return getUserSockets(recipientId) } + return unreadMessages } diff --git a/server/src/modules/users/users.controller.ts b/server/src/modules/users/users.controller.ts index 6c7e730..b24212e 100644 --- a/server/src/modules/users/users.controller.ts +++ b/server/src/modules/users/users.controller.ts @@ -1,4 +1,5 @@ import { db } from '@/database' +import { notFound } from '@/utils/api' import { signToken } from '@/utils/jwt' import { removeAttrFromObject } from '@/utils/object' import { hash, verify } from 'argon2' @@ -95,3 +96,22 @@ export const getUsers: RequestHandler = async (req, res, next) => { next(error) } } + +export const getUser: RequestHandler = async (req, res, next) => { + try { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { password, ...columns } = getTableColumns(users) + + const [user] = await db + .select(columns) + .from(users) + .where(eq(users.id, Number(req.params.userId))) + .limit(1) + if (!user) { + return notFound(res, 'User') + } + res.json(user) + } catch (error) { + next(error) + } +} diff --git a/server/src/modules/users/users.routes.ts b/server/src/modules/users/users.routes.ts index 7b9b882..7d2c4f7 100644 --- a/server/src/modules/users/users.routes.ts +++ b/server/src/modules/users/users.routes.ts @@ -1,12 +1,14 @@ import { auth } from '@/middlewares' import { Router } from 'express' import { listUserGroups } from '../groups/groups.controller' -import { getUsers, loginUser, signUpUser } from './users.controller' +import { getUser, getUsers, loginUser, signUpUser } from './users.controller' export const router = Router() router.post('/', signUpUser) +router.post('/login', loginUser) + router.get('/', auth, getUsers) +router.get('/:userId', auth, getUser) -router.post('/login', loginUser) router.get('/:userId/groups', auth, listUserGroups) diff --git a/server/src/redis/handlers.ts b/server/src/redis/handlers.ts index c87d1f6..08ad863 100644 --- a/server/src/redis/handlers.ts +++ b/server/src/redis/handlers.ts @@ -1,4 +1,5 @@ import { MemberRole } from '@/modules/members/members.schema' +import { ChatMode } from '@/socket/socket.interface' import { getRedisClient } from '.' const redisClient = getRedisClient() @@ -8,7 +9,8 @@ const redisClient = getRedisClient() export const redisKeys = { ONLINE_USERS: 'online_users', SOCKET_MAP: (userId: number) => `user:${userId}:sockets`, - TYPING_USERS: (groupId: number) => `group:${groupId}:typing_users`, + TYPING_USERS: (chatId: number, mode: ChatMode) => + `typing_users:${mode}:${chatId}:`, MEMBER_ROLES: (groupId: number) => `group:${groupId}:member_roles`, } @@ -72,40 +74,42 @@ export const checkOnlineUsers = async (userIds: number[]) => { // TYPING USERS -export const getTypingUsers = async (groupId: number) => { - const typingUsers = await redisClient.hgetall(redisKeys.TYPING_USERS(groupId)) +export const getTypingUsers = async (chatId: number, mode: ChatMode) => { + const typingUsers = await redisClient.hgetall( + redisKeys.TYPING_USERS(chatId, mode), + ) return Object.keys(typingUsers).map(key => ({ id: Number(key), username: typingUsers[key], })) } -export const setTypingUser = async ( - groupId: number, - userId: number, - username: string, -) => { - await redisClient.hset(redisKeys.TYPING_USERS(groupId), userId, username) -} -export const removeTypingUser = async (groupId: number, userId: number) => { - await redisClient.hdel(redisKeys.TYPING_USERS(groupId), userId.toString()) -} - -// USER SOCKETS - -export const addUserSocket = async (userId: number, socketId: string) => { - return redisClient.sadd(redisKeys.SOCKET_MAP(userId), socketId) -} - -export const removeUserSocket = async (userId: number, socketId: string) => { - return redisClient.srem(redisKeys.SOCKET_MAP(userId), socketId) -} - -export const getUserSockets = async (userId: number) => { - return redisClient.smembers(redisKeys.SOCKET_MAP(userId)) -} - -export const getMultipleUserSockets = async (userIds: number[]) => { - if (!userIds.length) return [] - return redisClient.sunion(userIds.map(uid => redisKeys.SOCKET_MAP(uid))) +export const setTypingUser = async ({ + chatId, + mode, + userId, + username, +}: { + chatId: number + mode: ChatMode + userId: number + username: string +}) => { + const cacheKey = redisKeys.TYPING_USERS(chatId, mode) + await redisClient.hset(cacheKey, userId, username) + await redisClient.expire(cacheKey, 180) // 3 minutes expiry +} +export const removeTypingUser = async ({ + chatId, + mode, + userId, +}: { + chatId: number + mode: ChatMode + userId: number +}) => { + await redisClient.hdel( + redisKeys.TYPING_USERS(chatId, mode), + userId.toString(), + ) } diff --git a/server/src/routes.ts b/server/src/routes.ts index a5a86da..a79b07b 100644 --- a/server/src/routes.ts +++ b/server/src/routes.ts @@ -3,6 +3,7 @@ import { auth } from './middlewares' import { router as groupRoutes } from '@/modules/groups/groups.routes' import { router as memberRoutes } from '@/modules/members/members.routes' +import { router as messageRoutes } from '@/modules/messages/messages.routes' import { router as userRoutes } from '@/modules/users/users.routes' const rootRouter = Router() @@ -10,5 +11,6 @@ const rootRouter = Router() rootRouter.use('/api/users', userRoutes) rootRouter.use('/api/groups', auth, groupRoutes) rootRouter.use('/api/members', auth, memberRoutes) +rootRouter.use('/api/messages', auth, messageRoutes) export default rootRouter diff --git a/server/src/socket/events.ts b/server/src/socket/events.ts index 2fe4051..b817c5c 100644 --- a/server/src/socket/events.ts +++ b/server/src/socket/events.ts @@ -3,34 +3,42 @@ import { groups } from '@/modules/groups/groups.schema' import { members } from '@/modules/members/members.schema' import { insertMessage, - markGroupMessagesAsRead, + markChatMessagesAsRead, markMessageAsRead, } from '@/modules/messages/messages.service' import { - addUserSocket, getTypingUsers, markUserOffline, markUserOnline, removeTypingUser, - removeUserSocket, setTypingUser, } from '@/redis/handlers' import { eq } from 'drizzle-orm' +import { Socket } from 'socket.io' import { config } from '../config' -import { getGroupRoomId } from './helpers' -import { TypedIOServer, TypedSocket } from './socket.interface' - -export const groupRoomPrefix = 'group' - -async function emitTypingUsers(socket: TypedSocket, groupId: number) { - const typingUsers = await getTypingUsers(groupId) - socket.broadcast.to(getGroupRoomId(groupId)).emit('typingUsers', typingUsers) +import { + currentDmRoomPrefix, + currentGroupRoomPrefix, + roomKeys, +} from './helpers' +import { ChatMode, TypedIOServer, TypedSocket } from './socket.interface' + +function leavePreviousChat(socket: Socket) { + const rooms = Array.from(socket.rooms) + rooms.forEach(room => { + if ( + room.startsWith(currentGroupRoomPrefix) || + room.startsWith(currentDmRoomPrefix) + ) { + // leave previous group/dm room + socket.leave(room) + } + }) } export const registerSocketEvents = (io: TypedIOServer) => { io.on('connection', async socket => { await markUserOnline(socket.data.user.id) - await addUserSocket(socket.data.user.id, socket.id) socket.broadcast.emit('userOnline', socket.data.user.id) const userGroups = await db @@ -38,40 +46,86 @@ export const registerSocketEvents = (io: TypedIOServer) => { .from(groups) .innerJoin(members, eq(members.groupId, groups.id)) .where(eq(members.userId, socket.data.user.id)) - socket.join(userGroups.map(group => group.id.toString())) + + socket.join(roomKeys.USER_KEY(socket.data.user.id)) + socket.join(userGroups.map(group => roomKeys.GROUP_KEY(group.id))) socket.on('joinGroup', async (groupId: number) => { - const rooms = Array.from(socket.rooms) - rooms.forEach(room => { - if (room !== socket.id && room.startsWith(groupRoomPrefix)) { - // don't leave the default room - socket.leave(room) - } - }) - socket.join(getGroupRoomId(groupId)) + leavePreviousChat(socket) + socket.join(roomKeys.CURRENT_GROUP_KEY(groupId)) }) - socket.on('userStartedTyping', async groupId => { - await setTypingUser( - groupId, - socket.data.user.id, - socket.data.user.username, - ) - await emitTypingUsers(socket, groupId) + socket.on('joinDm', async (partnerId: number) => { + leavePreviousChat(socket) + socket.join(roomKeys.CURRENT_DM_KEY(socket.data.user.id, partnerId)) }) - socket.on('userStoppedTyping', async groupId => { - await removeTypingUser(groupId, socket.data.user.id) - await emitTypingUsers(socket, groupId) + async function emitTypingUsers( + socket: TypedSocket, + { chatId, mode }: { chatId: number; mode: ChatMode }, + ) { + const typingUsers = await getTypingUsers(chatId, mode) + const room = + mode === 'group' + ? roomKeys.CURRENT_GROUP_KEY(chatId) + : roomKeys.CURRENT_DM_KEY(socket.data.user.id, chatId) + socket.broadcast.to(room).emit('typingUsers', typingUsers) + } + + socket.on('typing', async ({ chatId, mode, isTyping }) => { + if (isTyping) { + await setTypingUser({ + chatId, + mode, + userId: socket.data.user.id, + username: socket.data.user.username, + }) + } else { + await removeTypingUser({ chatId, mode, userId: socket.data.user.id }) + } + await emitTypingUsers(socket, { chatId, mode }) }) - socket.on('createMessage', async ({ groupId, text }, cb) => { + socket.on('createMessage', async ({ groupId, receiverId, text }, cb) => { try { - const message = await insertMessage(groupId, text, socket.data.user.id) - io.to(groupId.toString()).emit('newMessage', { + if (!groupId && !receiverId) { + throw new Error('Please provide either group id or receiver id') + } + + const message = await insertMessage({ + groupId, + receiverId, + content: text, + senderId: socket.data.user.id, + }) + + const newMessage = { ...message, username: socket.data.user.username, - }) + } + + // group message + if (message.groupId) { + io.to(roomKeys.GROUP_KEY(message.groupId)).emit( + 'newMessage', + newMessage, + ) + } + + // direct message + if (message.receiverId) { + // emit event to sender + io.to(roomKeys.USER_KEY(socket.data.user.id)).emit( + 'newMessage', + newMessage, + ) + + // emit event to receiver + io.to(roomKeys.USER_KEY(message.receiverId)).emit('newMessage', { + ...newMessage, + chatName: newMessage.username, + }) + } cb({ message }) } catch (error) { cb({ error }) @@ -79,24 +133,32 @@ export const registerSocketEvents = (io: TypedIOServer) => { }) socket.on('markMessageAsRead', async messageId => { - const messageSenderSocketIds = await markMessageAsRead( + const messageSenderId = await markMessageAsRead( messageId, socket.data.user.id, ) // let message sender know that his message is read by the current socket user - io.to(messageSenderSocketIds).emit('messageRead', messageId) + io.to(roomKeys.USER_KEY(messageSenderId)).emit('messageRead', messageId) }) - socket.on('markGroupMessagesAsRead', async groupId => { - const socketIds = await markGroupMessagesAsRead( + socket.on('markChatMessagesAsRead', async ({ groupId, receiverId }) => { + const unreadMessages = await markChatMessagesAsRead({ groupId, - socket.data.user.id, - ) + receiverId, + recipientId: socket.data.user.id, + }) - if (socketIds?.length) { - // let the current user know that the unread messages of the group is marked as read - io.to(socketIds).emit('groupMarkedAsRead', groupId) - // TODO: let the message senders know their message is read + // 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, + }) + // let the message senders know their message is read + for (const message of unreadMessages) { + io.to(roomKeys.USER_KEY(message.senderId)).emit( + 'messageRead', + message.messageId, + ) } }) @@ -106,7 +168,6 @@ export const registerSocketEvents = (io: TypedIOServer) => { socket.on('disconnect', async () => { await markUserOffline(socket.data.user.id) - await removeUserSocket(socket.data.user.id, socket.id) socket.broadcast.emit('userOffline', socket.data.user.id) }) diff --git a/server/src/socket/helpers.ts b/server/src/socket/helpers.ts index f2cf1da..c7fcdbd 100644 --- a/server/src/socket/helpers.ts +++ b/server/src/socket/helpers.ts @@ -1,4 +1,14 @@ -import { groupRoomPrefix } from './events' +export const currentGroupRoomPrefix = 'current_group' +export const currentDmRoomPrefix = 'current_dm' -export const getGroupRoomId = (groupId: number | string) => - `${groupRoomPrefix}:${groupId}` +const orderedId = (id1: number, id2: number, separator = ':') => + [id1, id2].sort().join(separator) + +export const roomKeys = { + GROUP_KEY: (groupId: number) => `group:${groupId}`, + CURRENT_GROUP_KEY: (groupId: number) => + `${currentGroupRoomPrefix}:${groupId}`, + CURRENT_DM_KEY: (senderId: number, receiverId: number) => + `${currentDmRoomPrefix}:${orderedId(senderId, receiverId)}`, + USER_KEY: (userId: number) => `user:${userId}`, +} diff --git a/server/src/socket/socket.interface.ts b/server/src/socket/socket.interface.ts index df47793..307ba41 100644 --- a/server/src/socket/socket.interface.ts +++ b/server/src/socket/socket.interface.ts @@ -3,11 +3,13 @@ import { Member } from '@/modules/members/members.schema' import { Message } from '@/modules/messages/messages.schema' import { Server, Socket } from 'socket.io' +export type ChatMode = 'group' | 'direct' + export interface ServerToClientEvents { userOnline: (userId: number) => void userOffline: (userId: number) => void newMessage: ( - message: Message & { username: string; groupName: string }, + message: Message & { username: string; chatName: string }, ) => void newMember: (member: Member & { username: string }) => void newMembers: (member: Member[]) => void @@ -15,20 +17,23 @@ export interface ServerToClientEvents { newGroup: (group: Group) => void groupDeleted: (groupId: number) => void messageRead: (messageId: number) => void - groupMarkedAsRead: (groupId: number) => void + chatMarkedAsRead: (args: { groupId?: number; receiverId?: number }) => void typingUsers: (users: { id: number; username: string }[]) => void } export interface ClientToServerEvents { joinGroup: (groupId: number) => void + joinDm: (partnerId: number) => void createMessage: ( - args: { groupId: number; text: string }, + args: { groupId?: number; receiverId?: number; text: string }, callback: (response: { message?: Message; error?: unknown }) => void, ) => void markMessageAsRead: (messageId: number) => void - markGroupMessagesAsRead: (groupId: number) => void - userStartedTyping: (groupId: number) => void - userStoppedTyping: (groupId: number) => void + markChatMessagesAsRead: (args: { + groupId?: number + receiverId?: number + }) => void + typing: (args: { chatId: number; mode: ChatMode; isTyping: boolean }) => void } export interface InterServerEvents { diff --git a/server/src/utils/api.ts b/server/src/utils/api.ts index 8d81730..bce9a32 100644 --- a/server/src/utils/api.ts +++ b/server/src/utils/api.ts @@ -12,6 +12,6 @@ export function notAuthorized(res: Response) { res.status(403).json({ message: 'Not authorized' }) } -export function badRequest(res: Response, message = 'Something went wrong') { +export function badRequest(res: Response, message = 'Bad request') { res.status(400).json({ message }) } diff --git a/web/package.json b/web/package.json index f4a04d7..1b83f6a 100644 --- a/web/package.json +++ b/web/package.json @@ -15,6 +15,7 @@ "clsx": "^2.1.1", "cva": "npm:class-variance-authority@^0.7.0", "immer": "^10.1.1", + "lucide-react": "^0.408.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.23.1", diff --git a/web/src/components/Alert.tsx b/web/src/components/Alert.tsx index b070704..0132148 100644 --- a/web/src/components/Alert.tsx +++ b/web/src/components/Alert.tsx @@ -1,20 +1,26 @@ import { cva, VariantProps } from 'cva' -const alertVariants = cva('min-h-8 rounded p-3', { +const alertVariants = cva('rounded ', { variants: { severity: { info: 'bg-blue-200 text-blue-800', success: 'bg-green-200 text-green-800', error: 'bg-red-200 text-red-800', }, + size: { + sm: 'text-sm p-2', + lg: 'text-base p-3', + }, }, defaultVariants: { severity: 'info', + size: 'lg', }, }) export const Alert = ({ severity, + size, className, children, }: VariantProps & { @@ -22,7 +28,7 @@ export const Alert = ({ children: React.ReactNode }) => { return ( -
+
{children}
) diff --git a/web/src/components/AutoComplete.tsx b/web/src/components/AutoComplete.tsx index c2a6e35..9386e19 100644 --- a/web/src/components/AutoComplete.tsx +++ b/web/src/components/AutoComplete.tsx @@ -79,7 +79,11 @@ export const AutoComplete = < /> ) } else if (isDropdownVisible) { - content = No results + content = ( + + No results + + ) } return ( @@ -87,7 +91,7 @@ export const AutoComplete = < {label && }
{ + if (!name) return null + const initials = getInitials(name) const { bgColor, textColor } = stringToColor(name + id) diff --git a/web/src/features/chat/chat.interface.ts b/web/src/features/chat/chat.interface.ts index 84cc14a..193fe99 100644 --- a/web/src/features/chat/chat.interface.ts +++ b/web/src/features/chat/chat.interface.ts @@ -2,3 +2,5 @@ export interface ITypingUser { id: number username: string } + +export type ChatMode = 'group' | 'direct' diff --git a/web/src/features/chat/components/ChatHeader.tsx b/web/src/features/chat/components/ChatHeader.tsx index 5d64ecd..5b0e156 100644 --- a/web/src/features/chat/components/ChatHeader.tsx +++ b/web/src/features/chat/components/ChatHeader.tsx @@ -1,27 +1,57 @@ -import logoutSvg from '@/assets/logout-2-svgrepo-com.svg' -import { Logo } from '@/components/Logo' -import { useAuth } from '@/hooks/useAuth' -import { getSocketIO } from '@/utils/socket' -import { removeToken } from '@/utils/token' +import backArrow from '@/assets/back-svgrepo-com.svg' +import usersSvg from '@/assets/users-svgrepo-com.svg' +import { Alert } from '@/components/Alert' +import { NavLink } from 'react-router-dom' -export const ChatHeader = () => { - const { setAuth } = useAuth() +interface ChatHeaderProps { + error: Error | null + chatId?: number + chatName?: string + toggleGroupInfo?: () => void +} + +export const ChatHeader = ({ + error, + chatId, + chatName, + toggleGroupInfo, +}: ChatHeaderProps) => { + let content - const logout = () => { - const socket = getSocketIO() - socket.disconnect() - setAuth(undefined) - removeToken() + if (chatId) { + content = ( + <> +

{chatName}

+ {toggleGroupInfo && ( + + )} + + ) + } else if (error) { + content = ( + + Unable to fetch chat + + ) } return ( -
- -
- -
+
+ + back-arrow + + {content}
) } diff --git a/web/src/features/chat/components/Header.tsx b/web/src/features/chat/components/Header.tsx new file mode 100644 index 0000000..ae2d202 --- /dev/null +++ b/web/src/features/chat/components/Header.tsx @@ -0,0 +1,27 @@ +import logoutSvg from '@/assets/logout-2-svgrepo-com.svg' +import { Logo } from '@/components/Logo' +import { useAuth } from '@/hooks/useAuth' +import { getSocketIO } from '@/utils/socket' +import { removeToken } from '@/utils/token' + +export const Header = () => { + const { setAuth } = useAuth() + + const logout = () => { + const socket = getSocketIO() + socket.disconnect() + setAuth(undefined) + removeToken() + } + + return ( +
+ +
+ +
+
+ ) +} diff --git a/web/src/features/chat/components/TypingIndicators.tsx b/web/src/features/chat/components/TypingIndicators.tsx index b7fe578..c03e806 100644 --- a/web/src/features/chat/components/TypingIndicators.tsx +++ b/web/src/features/chat/components/TypingIndicators.tsx @@ -10,8 +10,13 @@ export const TypingIndicator = () => { } else { content = ( - {users.map(user => user.username).join(', ')} - {users.length === 1 ? 'is' : 'are'} typing... + + {users + .map(user => user.username) + .slice(0, 3) + .join(', ')}{' '} + + {users.length === 1 ? 'is' : `${ users.length }are`} typing... ) } diff --git a/web/src/features/chat/components/index.ts b/web/src/features/chat/components/index.ts index 90fe8ab..1af610a 100644 --- a/web/src/features/chat/components/index.ts +++ b/web/src/features/chat/components/index.ts @@ -1,3 +1,4 @@ export { ChatHeader } from './ChatHeader' export { ChatUser } from './ChatUser' +export { Header } from './Header' export { TypingIndicator } from './TypingIndicators' diff --git a/web/src/features/chat/layouts/ChatLayout.tsx b/web/src/features/chat/layouts/ChatLayout.tsx index ac745d6..a298736 100644 --- a/web/src/features/chat/layouts/ChatLayout.tsx +++ b/web/src/features/chat/layouts/ChatLayout.tsx @@ -1,11 +1,12 @@ import { PageLoader } from '@/components/PageLoader' -import { ChatHeader } from '@/features/chat/components' +import { Header } from '@/features/chat/components' import { ChatUser } from '@/features/chat/components/ChatUser' import { CreateGroup, JoinGroup, UserGroupList, } from '@/features/group/components' +import { CreateDM } from '@/features/message/components/CreateDM' import { useSocketConnect } from '@/hooks/useSocketConnect' import { cn } from '@/utils/style' import { Outlet, useParams } from 'react-router-dom' @@ -20,19 +21,20 @@ const ChatLayout = () => { return (
- +
-
+
+
diff --git a/web/src/features/group/components/CreateGroup.tsx b/web/src/features/group/components/CreateGroup.tsx index 1644271..50e89ac 100644 --- a/web/src/features/group/components/CreateGroup.tsx +++ b/web/src/features/group/components/CreateGroup.tsx @@ -24,7 +24,7 @@ const CreateGroupForm = ({ onComplete }: { onComplete: () => void }) => { if (result.id) { toast({ title: `Group "${name}" created`, severity: 'success' }) onComplete() - navigate(`/chat/${result.id}`) + navigate(`/chat/group/${result.id}`) } }, onError: error => { diff --git a/web/src/features/group/components/GroupHeader.tsx b/web/src/features/group/components/GroupHeader.tsx deleted file mode 100644 index a963f98..0000000 --- a/web/src/features/group/components/GroupHeader.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import backArrow from '@/assets/back-svgrepo-com.svg' -import usersSvg from '@/assets/users-svgrepo-com.svg' -import { Skeleton } from '@/components/Skeleton' -import { useQuery } from '@tanstack/react-query' -import { NavLink } from 'react-router-dom' -import { fetchGroup } from '../group.service' - -interface GroupHeaderProps { - groupId: number - showMembers: () => void -} - -export const GroupHeader = ({ groupId, showMembers }: GroupHeaderProps) => { - const { - data: group, - isLoading, - error, - } = useQuery({ - queryKey: ['currentGroup', groupId], - queryFn: ({ queryKey }) => fetchGroup(queryKey[1] as number), - }) - - let content - - if (isLoading) { - content = - } else if (group?.id) { - content = ( - <> -

{group.name}

- - - ) - } else if (error) { - content =
Unable to fetch group
- } - - return ( -
- - back-arrow - - {content} -
- ) -} diff --git a/web/src/features/group/components/UserGroupItem.tsx b/web/src/features/group/components/UserChatItem.tsx similarity index 54% rename from web/src/features/group/components/UserGroupItem.tsx rename to web/src/features/group/components/UserChatItem.tsx index 075bffc..4eb8e82 100644 --- a/web/src/features/group/components/UserGroupItem.tsx +++ b/web/src/features/group/components/UserChatItem.tsx @@ -1,50 +1,58 @@ import { Avatar } from '@/components/Avatar' import { formatGroupDate } from '@/utils/date' import { cn } from '@/utils/style' +import { Users } from 'lucide-react' import { NavLink } from 'react-router-dom' -import { IGroupWithLastMessage } from '../group.interface' +import { IChat } from '../group.interface' -interface UserGroupItemProps { - group: IGroupWithLastMessage +interface UserChatItemProps { + chat: IChat } -export const UserGroupItem = ({ group }: UserGroupItemProps) => { +export const UserChatItem = ({ chat }: UserChatItemProps) => { return ( cn('border-b p-4 hover:bg-slate-100', isActive ? 'bg-gray-300' : '') } >
- +
+ + {chat.groupId && ( +
+ {} +
+ )} +
- {group.name} + {chat.chatName} - {group.lastMessage?.content} + {chat.lastMessage?.content}
0 ? 'text-green-600' : 'text-gray-500', + chat.unreadCount > 0 ? 'text-green-600' : 'text-gray-500', )} > - {formatGroupDate(group.lastActivity)} + {formatGroupDate(chat.lastActivity)} 0 ? 'bg-green-600' : 'hidden', + chat.unreadCount > 0 ? 'bg-green-600' : 'hidden', )} > - {group.unreadCount} + {chat.unreadCount}
diff --git a/web/src/features/group/components/UserGroupList.tsx b/web/src/features/group/components/UserChatList.tsx similarity index 85% rename from web/src/features/group/components/UserGroupList.tsx rename to web/src/features/group/components/UserChatList.tsx index b55baa2..1bbadaf 100644 --- a/web/src/features/group/components/UserGroupList.tsx +++ b/web/src/features/group/components/UserChatList.tsx @@ -5,10 +5,10 @@ import { useInView } from '@/hooks/useInView' import { useInfiniteQuery } from '@tanstack/react-query' import { Fragment, useRef } from 'react' import { fetchUserGroups } from '../group.service' -import { useGroupSocketHandle } from '../hooks/useGroupSocketHandle' -import { UserGroupItem } from './UserGroupItem' +import { useChatSocketHandle } from '../hooks/useChatSocketHandle' +import { UserChatItem } from './UserChatItem' -export const UserGroupList = () => { +export const UserChatList = () => { const { auth } = useAuth() const { data, isLoading, isSuccess, hasNextPage, fetchNextPage, error } = useInfiniteQuery({ @@ -31,7 +31,7 @@ export const UserGroupList = () => { const watchElement = useInView(listRef, fetchNextPage, hasNextPage) - useGroupSocketHandle() + useChatSocketHandle() let content @@ -46,8 +46,8 @@ export const UserGroupList = () => {
    {data.pages.map((page, i) => ( - {page.data.map(group => ( - + {page.data.map(chat => ( + ))} ))} diff --git a/web/src/features/group/components/index.ts b/web/src/features/group/components/index.ts index bf8be06..49828de 100644 --- a/web/src/features/group/components/index.ts +++ b/web/src/features/group/components/index.ts @@ -1,5 +1,4 @@ export { CreateGroup } from './CreateGroup' -export { GroupHeader } from './GroupHeader' export { JoinGroup } from './JoinGroup' -export { UserGroupItem } from './UserGroupItem' -export { UserGroupList } from './UserGroupList' +export { UserChatItem as UserGroupItem } from './UserChatItem' +export { UserChatList as UserGroupList } from './UserChatList' diff --git a/web/src/features/group/group.interface.ts b/web/src/features/group/group.interface.ts index 4ff538f..2fc8bdd 100644 --- a/web/src/features/group/group.interface.ts +++ b/web/src/features/group/group.interface.ts @@ -11,20 +11,19 @@ export interface IGroup { createdAt: string } -export interface IGroupWithLastMessage - extends Omit { +export interface IChat { + groupId?: number + partnerId?: number + chatName: string lastMessage?: { - id: number + messageId: number content: string - senderId: number } unreadCount: number lastActivity: string } -export type IPaginatedInfiniteGroups = InfiniteData< - IPaginatedResult -> +export type IPaginatedInfiniteChats = InfiniteData> export type TGetUserGroupsQueryVariables = TPaginatedParams & { userId: number diff --git a/web/src/features/group/group.service.ts b/web/src/features/group/group.service.ts index a5dda94..2be8a90 100644 --- a/web/src/features/group/group.service.ts +++ b/web/src/features/group/group.service.ts @@ -5,9 +5,9 @@ import { import { fetcher, stringifyQueryParams } from '@/utils/api' import { IMember } from '../member/member.interface' import { + IChat, ICreateGroupArgs, IGroup, - IGroupWithLastMessage, IJoinGroupArgs, TGetUserGroupsQueryVariables, } from './group.interface' @@ -15,9 +15,8 @@ import { export const fetchUserGroups = async ({ userId, ...params -}: TGetUserGroupsQueryVariables): Promise< - IPaginatedResult -> => fetcher(`users/${userId}/groups?${stringifyQueryParams(params)}`) +}: TGetUserGroupsQueryVariables): Promise> => + fetcher(`users/${userId}/groups?${stringifyQueryParams(params)}`) export const fetchGroupsToJoin = async ( params: TPaginatedParams, diff --git a/web/src/features/group/hooks/useChatSocketHandle.ts b/web/src/features/group/hooks/useChatSocketHandle.ts new file mode 100644 index 0000000..d9c3dd1 --- /dev/null +++ b/web/src/features/group/hooks/useChatSocketHandle.ts @@ -0,0 +1,214 @@ +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' + +export const useChatSocketHandle = () => { + const { auth } = useAuth() + const queryClient = useQueryClient() + const navigate = useNavigate() + const params = useParams() + + function getUnreadCount(checker: (chat: IChat) => boolean) { + if (!auth) return 0 + const chatListData = queryClient.getQueryData([ + 'userGroups', + auth, + ]) + let unreadCount = 0 + chatListData?.pages.forEach(page => + page.data.forEach(chat => { + if (checker(chat)) { + unreadCount = chat.unreadCount + } + }), + ) + return unreadCount + } + + useEffect(() => { + const partnerId = Number(params.partnerId) + if (partnerId) { + const socket = getSocketIO() + socket.emit('joinDm', partnerId) + + const unreadCount = getUnreadCount(chat => chat.partnerId === partnerId) + if (unreadCount) { + socket.emit('markChatMessagesAsRead', { receiverId: 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 => { + const updatedData = produce(data, draft => { + draft.pages[0].data.unshift({ + ...group, + chatName: group.name, + lastActivity: group.createdAt, + unreadCount: 0, + }) + }) + return updatedData + }) + } + + function handleNewMessage(message: IMessage & { chatName: string }) { + updateChatList(data => { + return produce(data, draft => { + let chat: IChat | undefined + + draft.pages.forEach(page => { + const chatIndex = page.data.findIndex(chat => + message.groupId + ? chat.groupId === message.groupId + : chat.partnerId === message.receiverId || + chat.partnerId === message.senderId, + ) + if (chatIndex !== -1) { + chat = page.data[chatIndex] + page.data.splice(chatIndex, 1) + } + }) + + const updatedChat: IChat = { + groupId: chat?.groupId || message.groupId, + partnerId: + chat?.partnerId || + (message.receiverId === auth?.id + ? message.senderId + : message.receiverId), + chatName: chat?.chatName || message.chatName, + lastActivity: message.createdAt, + unreadCount: chat?.unreadCount || 0, + lastMessage: { + messageId: message.id, + content: message.content, + }, + } + + // if not current chat increment unread count + if ( + message.groupId?.toString() !== params.groupId || + updatedChat.partnerId?.toString() !== params.partnerId + ) { + updatedChat.unreadCount++ + } + draft.pages[0].data.unshift(updatedChat) + }) + }) + } + + function handleGroupMarkedAsRead({ + groupId, + receiverId, + }: { + groupId?: number + receiverId?: number + }) { + updateChatList(data => { + return produce(data, draft => { + draft.pages.forEach(page => { + const chat = page.data.find(chat => + groupId + ? chat.groupId === groupId + : chat.partnerId === receiverId, + ) + if (chat) { + chat.unreadCount = 0 + } + }) + }) + }) + } + + const deleteGroupEntry = ( + data: IPaginatedInfiniteChats, + groupId: number, + ) => { + const updatedData = produce(data, draft => + draft.pages.forEach(page => { + const groupIndex = page.data.findIndex( + group => group.groupId === groupId, + ) + if (groupIndex !== -1) { + page.data.splice(groupIndex, 1) + } + }), + ) + if (params.groupId === groupId.toString()) { + navigate('/chat', { replace: true }) + } + return updatedData + } + + function handleDeleteGroup(groupId: number) { + updateChatList(data => deleteGroupEntry(data, groupId)) + } + + function handleMemberLeft({ + groupId, + memberId, + }: { + groupId: number + memberId: number + }) { + updateChatList(data => { + if (auth?.id === memberId) { + return deleteGroupEntry(data, groupId) + } + return data + }) + } + + socket.on('newGroup', handleNewGroup) + socket.on('newMessage', handleNewMessage) + socket.on('chatMarkedAsRead', handleGroupMarkedAsRead) + socket.on('groupDeleted', handleDeleteGroup) + socket.on('memberLeft', handleMemberLeft) + + return () => { + socket.off('newGroup', handleNewGroup) + socket.off('newMessage', handleNewMessage) + socket.off('chatMarkedAsRead', handleGroupMarkedAsRead) + socket.off('groupDeleted', handleDeleteGroup) + socket.off('memberLeft', handleMemberLeft) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [auth, params.groupId, params.partnerId]) +} diff --git a/web/src/features/group/hooks/useGroupSocketHandle.ts b/web/src/features/group/hooks/useGroupSocketHandle.ts deleted file mode 100644 index 4b90e15..0000000 --- a/web/src/features/group/hooks/useGroupSocketHandle.ts +++ /dev/null @@ -1,154 +0,0 @@ -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 { - IGroup, - IGroupWithLastMessage, - IPaginatedInfiniteGroups, -} from '../group.interface' - -export const useGroupSocketHandle = () => { - const { auth } = useAuth() - const params = useParams() - const queryClient = useQueryClient() - const navigate = useNavigate() - - useEffect(() => { - if (!auth) return - - const socket = getSocketIO() - - function updateGroupList( - dataUpdater: (data: IPaginatedInfiniteGroups) => IPaginatedInfiniteGroups, - ) { - queryClient.setQueryData( - ['userGroups', auth], - data => { - if (!data) return - return dataUpdater(data) - }, - ) - } - - function handleNewGroup(group: IGroup) { - updateGroupList(data => { - const updatedData = produce(data, draft => { - draft.pages[0].data.unshift({ - ...group, - lastActivity: group.createdAt, - unreadCount: 0, - }) - }) - return updatedData - }) - } - - function handleNewMessage(message: IMessage & { groupName: string }) { - updateGroupList(data => { - const updatedData = produce(data, draft => { - let messageGroup: IGroupWithLastMessage | undefined - - draft.pages.forEach(page => { - const groupIndex = page.data.findIndex( - group => group.id === message.groupId, - ) - if (groupIndex !== -1) { - messageGroup = page.data[groupIndex] - page.data.splice(groupIndex, 1) - } - }) - - const group: IGroupWithLastMessage = { - id: messageGroup?.id || message.groupId, - name: messageGroup?.name || message.groupName, - lastActivity: message.createdAt, - unreadCount: messageGroup?.unreadCount || 0, - lastMessage: { - id: message.id, - content: message.content, - senderId: message.senderId, - }, - } - - if (params.groupId === message.groupId.toString()) { - socket.emit('markMessageAsRead', message.id) - } else { - group.unreadCount++ - } - draft.pages[0].data.unshift(group) - }) - return updatedData - }) - } - - function handleGroupMarkedAsRead(groupId: number) { - updateGroupList(data => { - const updatedData = produce(data, draft => { - draft.pages.forEach(page => { - const group = page.data.find(group => group.id === groupId) - if (group) { - group.unreadCount = 0 - } - }) - }) - return updatedData - }) - } - - const deleteGroupEntry = ( - data: IPaginatedInfiniteGroups, - groupId: number, - ) => { - const updatedData = produce(data, draft => - draft.pages.forEach(page => { - const groupIndex = page.data.findIndex(group => group.id === groupId) - if (groupIndex !== -1) { - page.data.splice(groupIndex, 1) - } - }), - ) - if (params.groupId === groupId.toString()) { - navigate('/chat', { replace: true }) - } - return updatedData - } - - function handleDeleteGroup(groupId: number) { - updateGroupList(data => deleteGroupEntry(data, groupId)) - } - - function handleMemberLeft({ - groupId, - memberId, - }: { - groupId: number - memberId: number - }) { - updateGroupList(data => { - if (auth?.id === memberId) { - return deleteGroupEntry(data, groupId) - } - return data - }) - } - - socket.on('newGroup', handleNewGroup) - socket.on('newMessage', handleNewMessage) - socket.on('groupMarkedAsRead', handleGroupMarkedAsRead) - socket.on('groupDeleted', handleDeleteGroup) - socket.on('memberLeft', handleMemberLeft) - - return () => { - socket.off('newGroup', handleNewGroup) - socket.off('newMessage', handleNewMessage) - socket.off('groupMarkedAsRead', handleGroupMarkedAsRead) - socket.off('groupDeleted', handleDeleteGroup) - socket.off('memberLeft', handleMemberLeft) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [auth, params.groupId]) -} diff --git a/web/src/features/message/components/CreateDM.tsx b/web/src/features/message/components/CreateDM.tsx new file mode 100644 index 0000000..4e10ed0 --- /dev/null +++ b/web/src/features/message/components/CreateDM.tsx @@ -0,0 +1,93 @@ +import { AutoComplete } from '@/components/AutoComplete' +import { Button } from '@/components/Button' +import Chip from '@/components/Chip' +import { Dialog } from '@/components/Dialog' +import { IUser } from '@/features/user/user.interface' +import { fetchUsers } from '@/features/user/user.service' +import { useDisclosure } from '@/hooks/useDisclosure' +import { useQueryAutoComplete } from '@/hooks/useQueryAutoComplete' +import { useToast } from '@/hooks/useToast' +import { getSocketIO } from '@/utils/socket' +import { useState } from 'react' + +const CreateDMForm = ({ onComplete }: { onComplete: () => void }) => { + const { toast } = useToast() + const [dmUser, setDmUser] = useState() + const { suggestions, ...autoComplete } = useQueryAutoComplete( + { + queryKey: ['users'], + queryFn: ({ queryKey }) => fetchUsers({ limit: 5, query: queryKey[1] }), + initialData: [] as IUser[], + }, + { + onSelect(user: IUser) { + setDmUser(user) + }, + }, + ) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + + if (!dmUser) { + toast({ title: 'Select user to direct message', severity: 'error' }) + } + + try { + const socket = getSocketIO() + + const result = await socket.emitWithAck('createMessage', { + receiverId: dmUser?.id, + text: `Hi`, + }) + if (result.message) { + onComplete() + } else if (result.error) { + toast({ + title: (result.error as Error)?.message || 'Something went wrong', + severity: 'error', + }) + } + } catch (error) { + toast({ + title: (error as Error)?.message || 'Something went wrong', + severity: 'error', + }) + } + } + + return ( +
    +

    Direct message

    +
    + suggestion.id !== dmUser?.id, + )} + {...autoComplete} + suggestionLabel='username' + placeholder='Search by username' + label='Search user' + > + {dmUser && } + +
    + +
    + ) +} + +export const CreateDM = () => { + const { isOpen, open, close } = useDisclosure() + + return ( + <> + + + + + + ) +} diff --git a/web/src/features/message/components/MessageComposer.tsx b/web/src/features/message/components/MessageComposer.tsx index 102700d..e2fb4c3 100644 --- a/web/src/features/message/components/MessageComposer.tsx +++ b/web/src/features/message/components/MessageComposer.tsx @@ -6,10 +6,14 @@ import { getSocketIO } from '@/utils/socket' import { useRef, useState } from 'react' interface MessageComposerProps { - groupId: number + groupId?: number + receiverId?: number } -export const MessageComposer = ({ groupId }: MessageComposerProps) => { +export const MessageComposer = ({ + groupId, + receiverId, +}: MessageComposerProps) => { const { toast } = useToast() const [text, setText] = useState('') const [disabled, setDisabled] = useState(false) @@ -17,17 +21,22 @@ export const MessageComposer = ({ groupId }: MessageComposerProps) => { const timeoutRef = useRef() const textAreaRef = useRef(null) - useAutoFocus(textAreaRef, [groupId]) + useAutoFocus(textAreaRef, [groupId, receiverId]) const handleChange = (e: React.ChangeEvent) => { setText(e.target.value) + + const payload = { + chatId: (groupId ?? receiverId)!, + mode: groupId ? 'group' : 'direct', + } as const if (timeoutRef.current) { clearTimeout(timeoutRef.current) } else { - socketRef.current.emit('userStartedTyping', groupId) + socketRef.current.emit('typing', { isTyping: true, ...payload }) } timeoutRef.current = setTimeout(() => { - socketRef.current.emit('userStoppedTyping', groupId) + socketRef.current.emit('typing', { isTyping: false, ...payload }) timeoutRef.current = undefined }, 1000) } @@ -40,7 +49,7 @@ export const MessageComposer = ({ groupId }: MessageComposerProps) => { try { const response = await socketRef.current .timeout(5000) - .emitWithAck('createMessage', { groupId, text }) + .emitWithAck('createMessage', { groupId, receiverId, text }) if (response.message) { setText('') diff --git a/web/src/features/message/components/MessageList.tsx b/web/src/features/message/components/MessageList.tsx index c3444c5..8adea26 100644 --- a/web/src/features/message/components/MessageList.tsx +++ b/web/src/features/message/components/MessageList.tsx @@ -10,23 +10,29 @@ import { getSocketIO } from '@/utils/socket' import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query' import { produce } from 'immer' import { Fragment, useEffect, useRef } from 'react' -import { fetchGroupMessages } from '../message.service' +import { fetchMessages } from '../message.service' import { MessageItem } from './MessageItem' interface MessageListProps { - groupId: number + groupId?: number + partnerId?: number } -export const MessageList = ({ groupId }: MessageListProps) => { +export const MessageList = ({ groupId, partnerId }: MessageListProps) => { const { auth } = useAuth() const queryClient = useQueryClient() const { data, hasNextPage, fetchNextPage, isLoading, error, isSuccess } = useInfiniteQuery({ - queryKey: ['messages', groupId], + queryKey: ['messages', { groupId, partnerId }], queryFn: ({ pageParam }) => - fetchGroupMessages({ groupId, limit: 15, cursor: pageParam }), + fetchMessages({ + groupId, + partnerId, + limit: 15, + cursor: pageParam, + }), initialPageParam: null as number | null, getNextPageParam: lastPage => lastPage.cursor ? lastPage.cursor : undefined, @@ -38,14 +44,23 @@ export const MessageList = ({ groupId }: MessageListProps) => { const socket = getSocketIO() function updateMessage(message: IMessage) { - if (message.groupId !== groupId) { + if (groupId && message.groupId !== groupId) { + return + } + if ( + partnerId && + ![message.senderId, message.receiverId].includes(partnerId) + ) { return } function scrollToBottom() { listRef.current?.scrollTo(0, listRef.current?.scrollHeight) + if (message.receiverId === auth?.id) { + socket.emit('markMessageAsRead', message.id) + } } queryClient.setQueryData( - ['messages', groupId], + ['messages', { groupId, partnerId }], data => { if (!data) return @@ -63,7 +78,7 @@ export const MessageList = ({ groupId }: MessageListProps) => { socket.off('newMessage', updateMessage) } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [groupId]) + }, [groupId, partnerId]) const scrollElement = useInView(listRef, fetchNextPage, hasNextPage) diff --git a/web/src/features/message/message.interface.ts b/web/src/features/message/message.interface.ts index 16f22e2..904b4d6 100644 --- a/web/src/features/message/message.interface.ts +++ b/web/src/features/message/message.interface.ts @@ -6,15 +6,17 @@ import { InfiniteData } from '@tanstack/react-query' export interface IMessage { id: number - groupId: number + groupId?: number + receiverId?: number senderId: number username: string content: string createdAt: string } -export interface IGetGroupMessagesArgs extends TPaginatedParams { - groupId: number +export interface IGetChatMessagesArgs extends TPaginatedParams { + groupId?: number + partnerId?: number } export type TMessageInfiniteData = InfiniteData< diff --git a/web/src/features/message/message.service.ts b/web/src/features/message/message.service.ts index 905200a..bfab1dd 100644 --- a/web/src/features/message/message.service.ts +++ b/web/src/features/message/message.service.ts @@ -1,9 +1,8 @@ import { IPaginatedResult } from '@/interfaces/common.interface' import { fetcher, stringifyQueryParams } from '@/utils/api' -import { IGetGroupMessagesArgs, IMessage } from './message.interface' +import { IGetChatMessagesArgs, IMessage } from './message.interface' -export const fetchGroupMessages = async ({ - groupId, +export const fetchMessages = async ({ ...params -}: IGetGroupMessagesArgs): Promise> => - fetcher(`groups/${groupId}/messages?${stringifyQueryParams(params)}`) +}: IGetChatMessagesArgs): Promise> => + fetcher(`messages?${stringifyQueryParams(params)}`) diff --git a/web/src/features/user/user.service.ts b/web/src/features/user/user.service.ts index 8a196f9..7d4936c 100644 --- a/web/src/features/user/user.service.ts +++ b/web/src/features/user/user.service.ts @@ -1,15 +1,14 @@ import { fetcher, stringifyQueryParams } from '@/utils/api' import { IUser, TGetUsersQueryArgs } from './user.interface' -export const fetchUsers = async ( - args: TGetUsersQueryArgs, -): Promise => { - return fetcher(`users?${stringifyQueryParams(args)}`) -} +export const fetchUsers = async (args: TGetUsersQueryArgs): Promise => + fetcher(`users?${stringifyQueryParams(args)}`) export const fetchGroupNonMembers = async ({ groupId, ...args -}: TGetUsersQueryArgs & { groupId: number }): Promise => { - return fetcher(`groups/${groupId}/non-members?${stringifyQueryParams(args)}`) -} +}: TGetUsersQueryArgs & { groupId: number }): Promise => + fetcher(`groups/${groupId}/non-members?${stringifyQueryParams(args)}`) + +export const fetchUser = async (userId: number): Promise => + fetcher(`users/${userId}`) diff --git a/web/src/interfaces/common.interface.ts b/web/src/interfaces/common.interface.ts index ff77caf..ce7b476 100644 --- a/web/src/interfaces/common.interface.ts +++ b/web/src/interfaces/common.interface.ts @@ -1,5 +1,5 @@ export interface IPaginatedResult< - TData extends { id: number }, + TData, TCursor extends number | string = number, > { data: TData[] diff --git a/web/src/interfaces/socket.interface.ts b/web/src/interfaces/socket.interface.ts index 7805496..4d9ae31 100644 --- a/web/src/interfaces/socket.interface.ts +++ b/web/src/interfaces/socket.interface.ts @@ -1,3 +1,4 @@ +import { ChatMode } from '@/features/chat/chat.interface' import { IGroup } from '@/features/group/group.interface' import { Socket } from 'socket.io-client' import { IMember } from '../features/member/member.interface' @@ -6,31 +7,30 @@ import { IMessage } from '../features/message/message.interface' export interface ServerToClientEvents { userOnline: (userId: number) => void userOffline: (userId: number) => void - newMessage: (message: IMessage & { groupName: string }) => void + newMessage: (message: IMessage & { chatName: string }) => void newMember: (member: IMember) => void newMembers: (member: IMember[]) => void memberLeft: (args: { groupId: number; memberId: number }) => void newGroup: (group: IGroup) => void groupDeleted: (groupId: number) => void messageRead: (messageId: number) => void - groupMarkedAsRead: (groupId: number) => void + chatMarkedAsRead: (args: { groupId?: number; receiverId?: number }) => void typingUsers: (users: { id: number; username: string }[]) => void } export interface ClientToServerEvents { joinGroup: (groupId: number) => void - memberJoin: ( - groupIds: number[], - cb: (res: { success: boolean; error?: unknown }) => void, - ) => void + joinDm: (partnerId: number) => void createMessage: ( - args: { groupId: number; text: string }, + args: { groupId?: number; receiverId?: number; text: string }, callback: (response: { message?: IMessage; error?: unknown }) => void, ) => void markMessageAsRead: (messageId: number) => void - markGroupMessagesAsRead: (groupId: number) => void - userStartedTyping: (groupId: number) => void - userStoppedTyping: (groupId: number) => void + markChatMessagesAsRead: (args: { + groupId?: number + receiverId?: number + }) => void + typing: (args: { chatId: number; mode: ChatMode; isTyping: boolean }) => void } export type TypedSocket = Socket diff --git a/web/src/pages/ChatDM.tsx b/web/src/pages/ChatDM.tsx new file mode 100644 index 0000000..277c004 --- /dev/null +++ b/web/src/pages/ChatDM.tsx @@ -0,0 +1,43 @@ +import { PageLoader } from '@/components/PageLoader' +import { ChatHeader, TypingIndicator } from '@/features/chat/components' +import { MessageComposer, MessageList } from '@/features/message/components' +import { fetchUser } from '@/features/user/user.service' +import { useQuery } from '@tanstack/react-query' +import { useParams } from 'react-router-dom' + +export const Component = () => { + const params = useParams<{ partnerId: string }>() + + const partnerId = Number(params.partnerId) + + const { + data: receiver, + isLoading, + error, + } = useQuery({ + queryKey: ['user', partnerId], + queryFn: ({ queryKey }) => fetchUser(queryKey[1] as number), + enabled: Boolean(partnerId), + }) + + if (isLoading) { + return + } + + return ( + <> +
    + + + + +
    + + ) +} + +Component.displayName = 'ChatDM' diff --git a/web/src/pages/ChatRoom.tsx b/web/src/pages/ChatRoom.tsx index 6d9dc1a..0aaab89 100644 --- a/web/src/pages/ChatRoom.tsx +++ b/web/src/pages/ChatRoom.tsx @@ -1,7 +1,8 @@ -import { TypingIndicator } from '@/features/chat/components' +import { PageLoader } from '@/components/PageLoader' +import { ChatHeader, TypingIndicator } from '@/features/chat/components' import { GroupInfo } from '@/features/chat/layouts' -import { GroupHeader } from '@/features/group/components' import { DeleteGroup } from '@/features/group/components/DeleteGroup' +import { fetchGroup } from '@/features/group/group.service' import { AddMembers, LeaveGroup, @@ -10,9 +11,8 @@ import { import { useHasPermission } from '@/features/member/hooks' import { MessageComposer, MessageList } from '@/features/message/components' import { useDisclosure } from '@/hooks/useDisclosure' -import { getSocketIO } from '@/utils/socket' import { cn } from '@/utils/style' -import { useEffect } from 'react' +import { useQuery } from '@tanstack/react-query' import { useParams } from 'react-router-dom' export const Component = () => { @@ -22,18 +22,21 @@ export const Component = () => { const { isOpen, toggle } = useDisclosure() - useEffect(() => { - const socket = getSocketIO() - if (groupId) { - socket.emit('joinGroup', Number(groupId)) - // TODO: only mark group as read if it has unread messages - socket.emit('markGroupMessagesAsRead', groupId) - } - }, [groupId]) - const { hasPermission } = useHasPermission(groupId) - if (!groupId) return null + const { + data: group, + isLoading, + error, + } = useQuery({ + queryKey: ['currentGroup', groupId], + queryFn: ({ queryKey }) => fetchGroup(queryKey[1] as number), + enabled: Boolean(groupId), + }) + + if (isLoading) { + return + } return ( <> @@ -43,7 +46,12 @@ export const Component = () => { isOpen && 'hidden md:flex', )} > - + diff --git a/web/src/router.tsx b/web/src/router.tsx index 7dd8e3b..530a38c 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -17,7 +17,11 @@ export const router = createBrowserRouter([ Component: lazy(() => import('./features/chat/layouts/ChatLayout')), children: [ { path: '', lazy: () => import('./pages/ChatHome') }, - { path: ':groupId', lazy: () => import('./pages/ChatRoom') }, + { path: 'group/:groupId', lazy: () => import('./pages/ChatRoom') }, + { + path: 'direct/:partnerId', + lazy: () => import('./pages/ChatDM'), + }, ], }, ],