diff --git a/server/drizzle/0002_dizzy_donald_blake.sql b/server/drizzle/0002_dizzy_donald_blake.sql new file mode 100644 index 0000000..7b57104 --- /dev/null +++ b/server/drizzle/0002_dizzy_donald_blake.sql @@ -0,0 +1 @@ +ALTER TABLE "messages" ADD COLUMN "is_deleted" boolean DEFAULT false; \ No newline at end of file diff --git a/server/drizzle/0003_brave_liz_osborn.sql b/server/drizzle/0003_brave_liz_osborn.sql new file mode 100644 index 0000000..8fa9d0a --- /dev/null +++ b/server/drizzle/0003_brave_liz_osborn.sql @@ -0,0 +1,6 @@ +ALTER TABLE "messages" ADD COLUMN "parent_message_id" bigint;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "messages" ADD CONSTRAINT "messages_parent_message_id_messages_id_fk" FOREIGN KEY ("parent_message_id") REFERENCES "public"."messages"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; diff --git a/server/drizzle/meta/0002_snapshot.json b/server/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..0eada91 --- /dev/null +++ b/server/drizzle/meta/0002_snapshot.json @@ -0,0 +1,464 @@ +{ + "id": "1fc884df-d5fc-4a6f-b360-6a410e92f075", + "prevId": "eb079195-edd8-4dee-9be2-5450e83c8ab6", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.groups": { + "name": "groups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "name": { + "name": "name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "groups_owner_id_users_id_fk": { + "name": "groups_owner_id_users_id_fk", + "tableFrom": "groups", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.members": { + "name": "members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "user_id": { + "name": "user_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + } + }, + "indexes": { + "members_user_id_index": { + "name": "members_user_id_index", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "members_group_id_index": { + "name": "members_group_id_index", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "members_user_id_users_id_fk": { + "name": "members_user_id_users_id_fk", + "tableFrom": "members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "members_group_id_groups_id_fk": { + "name": "members_group_id_groups_id_fk", + "tableFrom": "members", + "tableTo": "groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "members_user_id_group_id_unique": { + "name": "members_user_id_group_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "group_id" + ] + } + } + }, + "public.message_recipients": { + "name": "message_recipients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "message_id": { + "name": "message_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "recipient_id": { + "name": "recipient_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "message_recipients_message_id_messages_id_fk": { + "name": "message_recipients_message_id_messages_id_fk", + "tableFrom": "message_recipients", + "tableTo": "messages", + "columnsFrom": [ + "message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "message_recipients_recipient_id_users_id_fk": { + "name": "message_recipients_recipient_id_users_id_fk", + "tableFrom": "message_recipients", + "tableTo": "users", + "columnsFrom": [ + "recipient_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "message_recipients_message_id_recipient_id_unique": { + "name": "message_recipients_message_id_recipient_id_unique", + "nullsNotDistinct": false, + "columns": [ + "message_id", + "recipient_id" + ] + } + } + }, + "public.messages": { + "name": "messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "sender_id": { + "name": "sender_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "receiver_id": { + "name": "receiver_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "group_id": { + "name": "group_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_deleted": { + "name": "is_deleted", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + } + }, + "indexes": { + "messages_group_id_index": { + "name": "messages_group_id_index", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "messages_created_at_index": { + "name": "messages_created_at_index", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "messages_sender_id_users_id_fk": { + "name": "messages_sender_id_users_id_fk", + "tableFrom": "messages", + "tableTo": "users", + "columnsFrom": [ + "sender_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "messages_receiver_id_users_id_fk": { + "name": "messages_receiver_id_users_id_fk", + "tableFrom": "messages", + "tableTo": "users", + "columnsFrom": [ + "receiver_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "messages_group_id_groups_id_fk": { + "name": "messages_group_id_groups_id_fk", + "tableFrom": "messages", + "tableTo": "groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "username": { + "name": "username", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "full_name": { + "name": "full_name", + "type": "varchar", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + } + } + }, + "enums": { + "public.member_role": { + "name": "member_role", + "schema": "public", + "values": [ + "member", + "admin", + "owner" + ] + } + }, + "schemas": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/server/drizzle/meta/0003_snapshot.json b/server/drizzle/meta/0003_snapshot.json new file mode 100644 index 0000000..4afefd6 --- /dev/null +++ b/server/drizzle/meta/0003_snapshot.json @@ -0,0 +1,483 @@ +{ + "id": "c610215a-9bf1-45c7-a73a-7855fedf5590", + "prevId": "1fc884df-d5fc-4a6f-b360-6a410e92f075", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.groups": { + "name": "groups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "name": { + "name": "name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "groups_owner_id_users_id_fk": { + "name": "groups_owner_id_users_id_fk", + "tableFrom": "groups", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.members": { + "name": "members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "user_id": { + "name": "user_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + } + }, + "indexes": { + "members_user_id_index": { + "name": "members_user_id_index", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "members_group_id_index": { + "name": "members_group_id_index", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "members_user_id_users_id_fk": { + "name": "members_user_id_users_id_fk", + "tableFrom": "members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "members_group_id_groups_id_fk": { + "name": "members_group_id_groups_id_fk", + "tableFrom": "members", + "tableTo": "groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "members_user_id_group_id_unique": { + "name": "members_user_id_group_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "group_id" + ] + } + } + }, + "public.message_recipients": { + "name": "message_recipients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "message_id": { + "name": "message_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "recipient_id": { + "name": "recipient_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "message_recipients_message_id_messages_id_fk": { + "name": "message_recipients_message_id_messages_id_fk", + "tableFrom": "message_recipients", + "tableTo": "messages", + "columnsFrom": [ + "message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "message_recipients_recipient_id_users_id_fk": { + "name": "message_recipients_recipient_id_users_id_fk", + "tableFrom": "message_recipients", + "tableTo": "users", + "columnsFrom": [ + "recipient_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "message_recipients_message_id_recipient_id_unique": { + "name": "message_recipients_message_id_recipient_id_unique", + "nullsNotDistinct": false, + "columns": [ + "message_id", + "recipient_id" + ] + } + } + }, + "public.messages": { + "name": "messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "sender_id": { + "name": "sender_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "receiver_id": { + "name": "receiver_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "group_id": { + "name": "group_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_message_id": { + "name": "parent_message_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "is_deleted": { + "name": "is_deleted", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + } + }, + "indexes": { + "messages_group_id_index": { + "name": "messages_group_id_index", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "messages_created_at_index": { + "name": "messages_created_at_index", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "messages_sender_id_users_id_fk": { + "name": "messages_sender_id_users_id_fk", + "tableFrom": "messages", + "tableTo": "users", + "columnsFrom": [ + "sender_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "messages_receiver_id_users_id_fk": { + "name": "messages_receiver_id_users_id_fk", + "tableFrom": "messages", + "tableTo": "users", + "columnsFrom": [ + "receiver_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "messages_group_id_groups_id_fk": { + "name": "messages_group_id_groups_id_fk", + "tableFrom": "messages", + "tableTo": "groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "messages_parent_message_id_messages_id_fk": { + "name": "messages_parent_message_id_messages_id_fk", + "tableFrom": "messages", + "tableTo": "messages", + "columnsFrom": [ + "parent_message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "username": { + "name": "username", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "full_name": { + "name": "full_name", + "type": "varchar", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + } + } + }, + "enums": { + "public.member_role": { + "name": "member_role", + "schema": "public", + "values": [ + "member", + "admin", + "owner" + ] + } + }, + "schemas": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/server/drizzle/meta/_journal.json b/server/drizzle/meta/_journal.json index 25caf01..b70d7a9 100644 --- a/server/drizzle/meta/_journal.json +++ b/server/drizzle/meta/_journal.json @@ -15,6 +15,20 @@ "when": 1721039070174, "tag": "0001_melodic_black_crow", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1722240925314, + "tag": "0002_dizzy_donald_blake", + "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1722252325916, + "tag": "0003_brave_liz_osborn", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/src/modules/groups/groups.routes.ts b/server/src/modules/groups/groups.routes.ts index 519599f..d2a51e4 100644 --- a/server/src/modules/groups/groups.routes.ts +++ b/server/src/modules/groups/groups.routes.ts @@ -1,7 +1,6 @@ import { hasChatPermission } from '@/middlewares' import { Router } from 'express' import { getGroupMember, getGroupMembers } from '../members/members.controller' -import { createMessage } from '../messages/messages.controller' import { addGroupMembers, changeMemberRole, @@ -48,8 +47,4 @@ router.get( '/:groupId/non-members', hasChatPermission('admin'), getNonGroupMembers, -) - -// Message handler - -router.post('/:groupId/messages', hasChatPermission('member'), createMessage) +) \ No newline at end of file diff --git a/server/src/modules/messages/messages.controller.ts b/server/src/modules/messages/messages.controller.ts index 48cba42..16131d6 100644 --- a/server/src/modules/messages/messages.controller.ts +++ b/server/src/modules/messages/messages.controller.ts @@ -1,38 +1,44 @@ import { db } from '@/database' import { getPaginationParams, withPagination } from '@/database/helpers' +import { roomKeys } from '@/socket/helpers' +import { TypedIOServer } from '@/socket/socket.interface' +import { notAuthorized } from '@/utils/api' import { and, desc, eq, getTableColumns, lt, or } from 'drizzle-orm' +import { alias } from 'drizzle-orm/pg-core' import { RequestHandler } from 'express' import { usersTable } from '../users/users.schema' -import { messagesTable } from './messages.schema' - -export const createMessage: RequestHandler = async (req, res, next) => { - try { - const [message] = await db - .insert(messagesTable) - .values({ - groupId: req.body.groupId, - content: req.body.text, - senderId: req.user!.id, - }) - .returning() - res.status(201).json(message) - } catch (error) { - next(error) - } -} +import { messageRecipientsTable, messagesTable } from './messages.schema' +import { checkMessageOwnerShip } from './messages.service' 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 parentMessageTable = alias(messagesTable, 'parentMessage') + const parentMessageUserTable = alias(usersTable, 'parentMessageUsersTable') + const result = await withPagination( db .select({ ...getTableColumns(messagesTable), username: usersTable.username, + parentMessage: { + id: parentMessageTable.id, + content: parentMessageTable.content, + isDeleted: parentMessageTable.isDeleted, + username: parentMessageUserTable.username, + }, }) .from(messagesTable) + .leftJoin( + parentMessageTable, + eq(messagesTable.parentMessageId, parentMessageTable.id), + ) + .leftJoin( + parentMessageUserTable, + eq(parentMessageTable.senderId, parentMessageUserTable.id), + ) .innerJoin(usersTable, eq(messagesTable.senderId, usersTable.id)) .$dynamic(), { @@ -57,3 +63,64 @@ export const listMessages: RequestHandler = async (req, res, next) => { next(error) } } + +export const listMessageRecipients: RequestHandler = async (req, res, next) => { + try { + const messageId = Number(req.params.messageId) + const { isOwner } = await checkMessageOwnerShip(messageId, req.user!.id) + + if (!isOwner) { + return notAuthorized(res) + } + + const recipients = await db + .select({ + messageId: messageRecipientsTable.messageId, + userId: messageRecipientsTable.recipientId, + username: usersTable.username, + fullName: usersTable.fullName, + readAt: messageRecipientsTable.createdAt, + }) + .from(messageRecipientsTable) + .innerJoin( + usersTable, + eq(usersTable.id, messageRecipientsTable.recipientId), + ) + .where(eq(messageRecipientsTable.messageId, messageId)) + + res.json(recipients) + } catch (error) { + next(error) + } +} + +export const deleteMessage: RequestHandler = async (req, res, next) => { + try { + const messageId = Number(req.params.messageId) + const { isOwner, message } = await checkMessageOwnerShip( + messageId, + req.user!.id, + ) + + if (!isOwner) { + return notAuthorized(res) + } + + await db + .update(messagesTable) + .set({ isDeleted: true, content: 'this message is deleted' }) + .where(eq(messagesTable.id, messageId)) + + const io: TypedIOServer = req.app.get('io') + + io.to( + message.receiverId + ? roomKeys.CURRENT_DM_KEY(message.senderId, message.receiverId) + : roomKeys.CURRENT_GROUP_KEY(message.groupId!), + ).emit('messageDeleted', messageId) + + res.sendStatus(200) + } catch (error) { + next(error) + } +} diff --git a/server/src/modules/messages/messages.routes.ts b/server/src/modules/messages/messages.routes.ts index d839d01..46f05f3 100644 --- a/server/src/modules/messages/messages.routes.ts +++ b/server/src/modules/messages/messages.routes.ts @@ -1,7 +1,15 @@ import { hasChatPermission } from '@/middlewares' import { Router } from 'express' -import { listMessages } from './messages.controller' +import { + deleteMessage, + listMessageRecipients, + listMessages, +} from './messages.controller' export const router = Router() router.get('/', hasChatPermission('member'), listMessages) + +router.delete('/:messageId', deleteMessage) + +router.get('/:messageId/recipients', listMessageRecipients) diff --git a/server/src/modules/messages/messages.schema.ts b/server/src/modules/messages/messages.schema.ts index 88df2aa..b616046 100644 --- a/server/src/modules/messages/messages.schema.ts +++ b/server/src/modules/messages/messages.schema.ts @@ -1,5 +1,13 @@ import { baseSchema } from '@/database/constants' -import { bigint, index, pgTable, text, unique } from 'drizzle-orm/pg-core' +import { + AnyPgColumn, + bigint, + boolean, + index, + pgTable, + text, + unique, +} from 'drizzle-orm/pg-core' import { groupsTable } from '../groups/groups.schema' import { usersTable } from '../users/users.schema' @@ -18,6 +26,10 @@ export const messagesTable = pgTable( { onDelete: 'cascade' }, ), content: text('content').notNull(), + parentMessageId: bigint('parent_message_id', { mode: 'number' }).references( + (): AnyPgColumn => messagesTable.id, + ), + isDeleted: boolean('is_deleted').default(false), }, table => ({ groupIdIndex: index().on(table.groupId), diff --git a/server/src/modules/messages/messages.service.ts b/server/src/modules/messages/messages.service.ts index 8c6d03a..7c9ebe8 100644 --- a/server/src/modules/messages/messages.service.ts +++ b/server/src/modules/messages/messages.service.ts @@ -10,11 +10,13 @@ export const insertMessage = async ({ receiverId, content, senderId, + parentMessageId, }: { groupId?: number receiverId?: number content: string senderId: number + parentMessageId?: number }) => { let chatName = '' if (groupId) { @@ -44,6 +46,7 @@ export const insertMessage = async ({ receiverId, content, senderId, + parentMessageId, }) .returning() return { ...message, chatName } @@ -145,3 +148,20 @@ export const markChatMessagesAsRead = async ({ } return unreadMessages } + +export const checkMessageOwnerShip = async ( + messageId: number, + userId: number, +) => { + const [message] = await db + .select({ + senderId: messagesTable.senderId, + receiverId: messagesTable.receiverId, + groupId: messagesTable.groupId, + }) + .from(messagesTable) + .where(eq(messagesTable.id, messageId)) + .limit(1) + + return { isOwner: message?.senderId === userId, message } +} diff --git a/server/src/modules/users/users.controller.ts b/server/src/modules/users/users.controller.ts index fc41476..7bd3b10 100644 --- a/server/src/modules/users/users.controller.ts +++ b/server/src/modules/users/users.controller.ts @@ -10,7 +10,6 @@ import { usersTable } from './users.schema' export const signUpUser: RequestHandler = async (req, res, next) => { try { const { username, password, fullName } = req.body - console.log('req.body', req.body) const rows = await db .select({ username: usersTable.username }) .from(usersTable) diff --git a/server/src/redis/handlers.ts b/server/src/redis/handlers.ts index 6692cc0..ab13f45 100644 --- a/server/src/redis/handlers.ts +++ b/server/src/redis/handlers.ts @@ -38,7 +38,6 @@ export const isRefreshTokenValid = async ( redisKeys.USER_TOKEN(userId), tokenId, ) - console.log('redis access token', redisToken, tokenId) return redisToken === token } diff --git a/server/src/socket/events.ts b/server/src/socket/events.ts index d78f71b..c5bdad6 100644 --- a/server/src/socket/events.ts +++ b/server/src/socket/events.ts @@ -86,51 +86,55 @@ export const registerSocketEvents = (io: TypedIOServer) => { await emitTypingUsers(socket, { chatId, mode }) }) - socket.on('createMessage', async ({ groupId, receiverId, text }, cb) => { - try { - 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, + socket.on( + 'createMessage', + async ({ groupId, receiverId, text, parentMessageId }, cb) => { + try { + 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, + parentMessageId, }) + + 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 }) } - cb({ message }) - } catch (error) { - cb({ error }) - } - }) + }, + ) socket.on('markMessageAsRead', async messageId => { const messageSenderId = await markMessageAsRead( diff --git a/server/src/socket/socket.interface.ts b/server/src/socket/socket.interface.ts index 307ba41..448de53 100644 --- a/server/src/socket/socket.interface.ts +++ b/server/src/socket/socket.interface.ts @@ -17,6 +17,7 @@ export interface ServerToClientEvents { newGroup: (group: Group) => void groupDeleted: (groupId: number) => void messageRead: (messageId: number) => void + messageDeleted: (messageId: number) => void chatMarkedAsRead: (args: { groupId?: number; receiverId?: number }) => void typingUsers: (users: { id: number; username: string }[]) => void } @@ -25,7 +26,12 @@ export interface ClientToServerEvents { joinGroup: (groupId: number) => void joinDm: (partnerId: number) => void createMessage: ( - args: { groupId?: number; receiverId?: number; text: string }, + args: { + groupId?: number + receiverId?: number + text: string + parentMessageId?: number + }, callback: (response: { message?: Message; error?: unknown }) => void, ) => void markMessageAsRead: (messageId: number) => void diff --git a/web/index.html b/web/index.html index 05f1b17..4a9d82f 100644 --- a/web/index.html +++ b/web/index.html @@ -1,4 +1,4 @@ - + @@ -6,7 +6,7 @@ mChat - +
diff --git a/web/src/App.tsx b/web/src/App.tsx index 1a4f3a7..b5560ce 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,4 +1,5 @@ import { Outlet } from 'react-router-dom' +import { ConfirmDialogProvider } from './components/ConfirmDialog' import { PageLoader } from './components/PageLoader' import { Toaster } from './components/Toaster' import { useAuthRedirect } from './hooks/useAuthRedirect' @@ -12,7 +13,9 @@ const AuthWrapper = ({ children }: React.PropsWithChildren) => { export default function App() { return ( - + + + ) diff --git a/web/src/components/AutoComplete.tsx b/web/src/components/AutoComplete.tsx index 9386e19..a846640 100644 --- a/web/src/components/AutoComplete.tsx +++ b/web/src/components/AutoComplete.tsx @@ -1,7 +1,6 @@ -import { cn } from '@/utils/style' -import React, { useMemo, useRef } from 'react' -import { createPortal } from 'react-dom' +import React, { useRef } from 'react' import { Alert } from './Alert' +import { Menu, MenuItem } from './Menu' import { Skeleton } from './Skeleton' type AutoCompleteProps = { @@ -22,14 +21,6 @@ type AutoCompleteProps = { error?: TError } -type SuggestionListProps = { - wrapperRef: React.RefObject - suggestions: TSuggestion[] - suggestionLabel: keyof TSuggestion - highlightedIndex: number - onSelect: (suggestion: TSuggestion) => void -} - export const AutoComplete = < TSuggestion extends { id: number }, TError = Error, @@ -70,13 +61,24 @@ export const AutoComplete = < ) } else if (isDropdownVisible && suggestions.length > 0) { content = ( - + + {suggestions.map((suggestion, index) => ( + handleSelect(suggestion)} + > + {suggestion[suggestionLabel] as string} + + ))} + ) } else if (isDropdownVisible) { content = ( @@ -117,51 +119,3 @@ export const AutoComplete = < ) } - -const SuggestionList = ({ - wrapperRef, - suggestions, - suggestionLabel, - highlightedIndex, - onSelect, -}: SuggestionListProps) => { - const styles = useMemo(() => { - const wrapperRect = wrapperRef.current?.getBoundingClientRect() - return wrapperRect - ? { - top: wrapperRect.bottom, - left: wrapperRect.left, - width: wrapperRect.width, - } - : {} - }, [wrapperRef]) - - return createPortal( -
    - {suggestions.map((suggestion, index) => ( -
  • { - e.stopPropagation() - onSelect(suggestion) - }} - tabIndex={-1} - > - {suggestion[suggestionLabel] as string} -
  • - ))} -
, - document.body, - ) -} diff --git a/web/src/components/Button.tsx b/web/src/components/Button.tsx index aa69a8d..e9f652a 100644 --- a/web/src/components/Button.tsx +++ b/web/src/components/Button.tsx @@ -15,6 +15,7 @@ const buttonVariants = cva( }, color: { default: '', + info: 'border-blue-500', error: 'border-red-500', success: 'border-green-500', }, @@ -45,6 +46,16 @@ const buttonVariants = cva( color: 'success', class: 'text-green-500 ', }, + { + variant: 'primary', + color: 'success', + class: 'bg-blue-500', + }, + { + variant: 'secondary', + color: 'success', + class: 'text-blue-500 ', + }, ], }, ) diff --git a/web/src/components/ConfirmDialog.tsx b/web/src/components/ConfirmDialog.tsx new file mode 100644 index 0000000..f840d28 --- /dev/null +++ b/web/src/components/ConfirmDialog.tsx @@ -0,0 +1,79 @@ +import { toast } from '@/hooks/useToast' +import { createContext, useCallback, useState } from 'react' +import { Button } from './Button' +import { Dialog } from './Dialog' + +type ConfirmDialogState = { + title: React.ReactNode + description: React.ReactNode + severity: 'success' | 'info' | 'error' + onConfirm: () => void | Promise +} + +type ConfirmDialogContextType = (state: ConfirmDialogState) => void + +export const ConfirmDialogContext = createContext( + () => {}, +) + +export const ConfirmDialogProvider = ({ + children, +}: React.PropsWithChildren) => { + const [state, setState] = useState(null) + const [isSubmitting, setIsSubmitting] = useState(false) + + const confirm = useCallback( + (state: ConfirmDialogState) => setState(state), + [], + ) + + const closeConfirm = useCallback(() => setState(null), []) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + if (!state) return + setIsSubmitting(true) + try { + await state.onConfirm() + closeConfirm() + } catch (error) { + toast({ title: (error as Error).message, severity: 'error' }) + } finally { + setIsSubmitting(false) + } + } + + return ( + + {children} + + + + + ) +} diff --git a/web/src/components/Menu.tsx b/web/src/components/Menu.tsx new file mode 100644 index 0000000..1d0af2f --- /dev/null +++ b/web/src/components/Menu.tsx @@ -0,0 +1,212 @@ +import { useClickOutside } from '@/hooks/useClickOutside' +import { cn } from '@/utils/style' +import { useEffect, useRef, useState } from 'react' +import { createPortal } from 'react-dom' + +type MenuVerticalPosition = 'top' | 'center' | 'bottom' +type MenuHorizontalPosition = 'left' | 'center' | 'right' + +type MenuOrigin = { + vertical: MenuVerticalPosition + horizontal: MenuHorizontalPosition +} + +// https://mui.com/material-ui/react-popover/#anchor-playground + +const getMenuStyles = ( + anchorRef: React.RefObject, + anchorOrigin: MenuOrigin = { vertical: 'bottom', horizontal: 'left' }, + transformOrigin: MenuOrigin = { vertical: 'bottom', horizontal: 'left' }, + anchorFullWidth = false, +) => { + let menuStyles: React.CSSProperties = {} + + if (anchorRef.current) { + const anchorElRect = anchorRef.current.getBoundingClientRect() + + // Compute the top and left positions based on the anchor position + let top: number + let left: number + + switch (anchorOrigin.vertical) { + case 'top': + top = anchorElRect.top + break + case 'center': + top = anchorElRect.top + anchorElRect.height / 2 + break + case 'bottom': + top = anchorElRect.bottom + break + } + + switch (anchorOrigin.horizontal) { + case 'left': + left = anchorElRect.left + break + case 'center': + left = anchorElRect.left + anchorElRect.width / 2 + break + case 'right': + left = anchorElRect.right + break + } + + // Compute the transform styles based on the transform position + let transform = '' + + switch (transformOrigin.vertical) { + case 'top': + transform += 'translateY(0%) ' + break + case 'center': + transform += 'translateY(-50%) ' + break + case 'bottom': + transform += 'translateY(-100%) ' + break + } + + switch (transformOrigin.horizontal) { + case 'left': + transform += 'translateX(0%)' + break + case 'center': + transform += 'translateX(-50%)' + break + case 'right': + transform += 'translateX(-100%)' + break + } + + menuStyles = { + position: 'absolute', + top, + left, + transform, + width: anchorFullWidth ? anchorElRect.width : undefined, + } + } + + return menuStyles +} + +const preventDefault = (e: Event) => { + e.preventDefault() +} + +const disableScroll = () => { + document.addEventListener('wheel', preventDefault, { passive: false }) + document.addEventListener('touchmove', preventDefault, { passive: false }) +} + +const enableScroll = () => { + document.removeEventListener('wheel', preventDefault) + document.removeEventListener('touchmove', preventDefault) +} + +export const Menu = ({ + children, + className, + style, + anchorRef, + anchorOrigin = { vertical: 'bottom', horizontal: 'left' }, + transformOrigin = { vertical: 'top', horizontal: 'left' }, + anchorFullWidth = false, + onClickAway, + ...props +}: React.HTMLAttributes & { + anchorRef: React.RefObject + anchorOrigin?: MenuOrigin + transformOrigin?: MenuOrigin + anchorFullWidth?: boolean + onClickAway?: () => void +}) => { + const menuRef = useRef(null) + const [menuStyles, setMenuStyles] = useState( + getMenuStyles(anchorRef, anchorOrigin, transformOrigin, anchorFullWidth), + ) + + useClickOutside(menuRef, onClickAway) + + useEffect(() => { + if (!anchorRef.current) return + + disableScroll() + function updateMenuStyles() { + setMenuStyles( + getMenuStyles( + anchorRef, + anchorOrigin, + transformOrigin, + anchorFullWidth, + ), + ) + } + window.addEventListener('resize', updateMenuStyles) + + return () => { + enableScroll() + window.removeEventListener('resize', updateMenuStyles) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [anchorRef]) + + return createPortal( +
    + {children} +
, + document.body, + ) +} + +type MenuItemProps = Omit< + React.HTMLAttributes, + 'onMouseDown' +> & { + onSelect: () => void + isSelected?: boolean + isHighlighted?: boolean + isDisabled?: boolean +} + +export const MenuItem = ({ + children, + className, + onSelect, + isSelected = false, + isHighlighted = false, + isDisabled = false, + ...props +}: MenuItemProps) => { + return ( +
  • { + e.stopPropagation() + if (!isDisabled) { + onSelect() + } + }} + className={cn( + 'cursor-pointer px-2 py-1 hover:bg-gray-400', + isSelected && 'bg-gray-300', + isDisabled && 'cursor-not-allowed text-gray-400', + isHighlighted && 'bg-gray-400', + className, + )} + > + {children} +
  • + ) +} diff --git a/web/src/components/Select.tsx b/web/src/components/Select.tsx index 6e34674..aeb2ea8 100644 --- a/web/src/components/Select.tsx +++ b/web/src/components/Select.tsx @@ -1,7 +1,8 @@ import { useDisclosure } from '@/hooks/useDisclosure' +import { useKeyboardListNavigation } from '@/hooks/useKeyboardListNavigation' import { cn } from '@/utils/style' -import { useRef, useState } from 'react' -import { createPortal } from 'react-dom' +import { useRef } from 'react' +import { Menu, MenuItem } from './Menu' export const Select = (props: { options: { @@ -24,19 +25,15 @@ export const Select = (props: { disabled, } = props - const wrapperRef = useRef(null) const { isOpen, toggle, close } = useDisclosure() - const [highlightedIndex, setHighlightedIndex] = useState(-1) + const anchorRef = useRef(null) - const selectStyles = (() => { - const wrapperRect = wrapperRef.current?.getBoundingClientRect() - if (!wrapperRect) return - return { - width: wrapperRect.width, - top: wrapperRect.bottom, - left: wrapperRect.left, - } - })() + const { handleKeyDown, highlightedIndex } = useKeyboardListNavigation({ + listLength: options.length, + onEnter(highlightedIndex) { + handleSelect(options[highlightedIndex]) + }, + }) const handleSelect = (option: (typeof options)[0]) => { if (!option.disabled || option.value === value) { @@ -45,24 +42,6 @@ export const Select = (props: { } } - const handleKeyDown = (e: React.KeyboardEvent) => { - e.stopPropagation() - switch (e.key) { - case 'ArrowDown': - setHighlightedIndex(prevIndex => - prevIndex >= options.length - 1 ? 0 : prevIndex + 1, - ) - break - case 'ArrowUp': - setHighlightedIndex(prevIndex => - prevIndex <= 0 ? options.length - 1 : prevIndex - 1, - ) - break - case 'Enter': - handleSelect(options[highlightedIndex]) - } - } - return (
    (props: { onKeyDown={handleKeyDown} role='combobox' aria-disabled={disabled} - ref={wrapperRef} + ref={anchorRef} >
    {displayValue(value) || placeholder}
    @@ -91,37 +70,24 @@ export const Select = (props: {
    - {isOpen && - !disabled && - createPortal( -
      - {options.map((option, index) => ( -
    • { - e.stopPropagation() - handleSelect(option) - }} - aria-selected={option.value === value} - aria-disabled={option.disabled} - className={cn( - 'cursor-pointer px-2 py-1 hover:bg-gray-400', - option.value === value && 'bg-gray-300', - option.disabled && 'cursor-not-allowed text-gray-400', - highlightedIndex === index && 'bg-gray-400', - )} - > - {option.label} -
    • - ))} -
    , - document.body, - )} + {isOpen && !disabled && ( + + {options.map((option, index) => ( + { + handleSelect(option) + }} + isSelected={option.value === value} + isDisabled={option.disabled} + isHighlighted={highlightedIndex === index} + > + {option.label} + + ))} + + )}
    ) } diff --git a/web/src/features/member/components/AddMembers.tsx b/web/src/features/member/components/AddMembers.tsx index 91e701f..cce1ab7 100644 --- a/web/src/features/member/components/AddMembers.tsx +++ b/web/src/features/member/components/AddMembers.tsx @@ -42,7 +42,6 @@ const AddMemberForm = ({ onComplete }: { onComplete: () => void }) => { const handleSubmit = (e: React.FormEvent) => { e.preventDefault() - console.log(e) const memberIds = Object.keys(userSelectProps.users).map(Number) if (!memberIds.length) { return toast({ title: 'Select at least one member', severity: 'error' }) diff --git a/web/src/features/message/components/MessageActions.tsx b/web/src/features/message/components/MessageActions.tsx new file mode 100644 index 0000000..619dd45 --- /dev/null +++ b/web/src/features/message/components/MessageActions.tsx @@ -0,0 +1,86 @@ +import { Dialog } from '@/components/Dialog' +import { Menu, MenuItem } from '@/components/Menu' +import { useConfirmDialog } from '@/hooks/useConfirmDialog' +import { useDisclosure } from '@/hooks/useDisclosure' +import { toast } from '@/hooks/useToast' +import { useMutation } from '@tanstack/react-query' +import { IMessage } from '../message.interface' +import { deleteMessage } from '../message.service' +import { MessageInfo } from './MessageInfo' +import { MessageItem } from './MessageItem' + +export const MessageActions = ({ + message, + anchorRef, + onClose, +}: { + message: IMessage + anchorRef: React.RefObject + onClose: () => void +}) => { + const { + isOpen: isInfoDialogOpen, + close: closeInfoDialog, + open: openInfoDialog, + } = useDisclosure() + + const { mutateAsync } = useMutation({ mutationFn: deleteMessage }) + + const confirm = useConfirmDialog() + + const confirmDelete = () => { + async function handleMessageDelete() { + try { + await mutateAsync(message.id) + toast({ title: 'Message deleted', severity: 'success' }) + } catch (error) { + toast({ title: (error as Error).message, severity: 'success' }) + } + } + + confirm({ + title: 'Delete message!', + description: ( +
    +
    + +
    +

    + Are you sure you want delete this message? +

    +
    + ), + severity: 'error', + onConfirm: handleMessageDelete, + }) + } + + return ( + <> + + Info + Delete + + + + + + ) +} diff --git a/web/src/features/message/components/MessageComposer.tsx b/web/src/features/message/components/MessageComposer.tsx index e2fb4c3..444f599 100644 --- a/web/src/features/message/components/MessageComposer.tsx +++ b/web/src/features/message/components/MessageComposer.tsx @@ -4,15 +4,20 @@ import { useAutoFocus } from '@/hooks/useAutoFocus' import { useToast } from '@/hooks/useToast' import { getSocketIO } from '@/utils/socket' import { useRef, useState } from 'react' +import { IMessage } from '../message.interface' interface MessageComposerProps { groupId?: number receiverId?: number + replyMessage?: IMessage + cancelReply?: () => void } export const MessageComposer = ({ groupId, receiverId, + replyMessage, + cancelReply, }: MessageComposerProps) => { const { toast } = useToast() const [text, setText] = useState('') @@ -21,7 +26,7 @@ export const MessageComposer = ({ const timeoutRef = useRef() const textAreaRef = useRef(null) - useAutoFocus(textAreaRef, [groupId, receiverId]) + useAutoFocus(textAreaRef, [groupId, receiverId, replyMessage]) const handleChange = (e: React.ChangeEvent) => { setText(e.target.value) @@ -49,10 +54,16 @@ export const MessageComposer = ({ try { const response = await socketRef.current .timeout(5000) - .emitWithAck('createMessage', { groupId, receiverId, text }) + .emitWithAck('createMessage', { + groupId, + receiverId, + text, + parentMessageId: replyMessage?.id, + }) if (response.message) { setText('') + if (cancelReply) cancelReply() } else if (response.error) { throw response.error } @@ -81,19 +92,47 @@ export const MessageComposer = ({ } return ( -
    - - + + {replyMessage && ( +
    +

    Reply

    +
    +
    +
    +
    + {replyMessage.username} +

    {replyMessage.content}

    +
    +
    + +
    +
    + )} +
    + + +
    ) } diff --git a/web/src/features/message/components/MessageContainer.tsx b/web/src/features/message/components/MessageContainer.tsx new file mode 100644 index 0000000..1b6419f --- /dev/null +++ b/web/src/features/message/components/MessageContainer.tsx @@ -0,0 +1,39 @@ +import { TypingIndicator } from '@/features/chat/components' +import { useCallback, useState } from 'react' +import { IMessage } from '../message.interface' +import { MessageComposer } from './MessageComposer' +import { MessageList } from './MessageList' + +export const MessageContainer = ({ + groupId, + partnerId, +}: { + groupId?: number + partnerId?: number +}) => { + const [replyMessage, setReplyMessage] = useState() + + const handleReplyMessage = useCallback( + (message: IMessage) => setReplyMessage(message), + [], + ) + + const unsetReplyMessage = useCallback(() => setReplyMessage(undefined), []) + + return ( + <> + + + + + ) +} diff --git a/web/src/features/message/components/MessageInfo.tsx b/web/src/features/message/components/MessageInfo.tsx new file mode 100644 index 0000000..da6435c --- /dev/null +++ b/web/src/features/message/components/MessageInfo.tsx @@ -0,0 +1,59 @@ +import { Alert } from '@/components/Alert' +import { ArraySkeleton } from '@/components/Skeleton' +import { useQuery } from '@tanstack/react-query' +import { CheckCheck } from 'lucide-react' +import { IMessage } from '../message.interface' +import { fetchMessageRecipients } from '../message.service' +import { MessageItem } from './MessageItem' + +export const MessageInfo = ({ message }: { message: IMessage }) => { + const { + data: recipients, + isLoading, + error, + } = useQuery({ + queryKey: ['messages', message.id, 'recipients'], + queryFn: ({ queryKey }) => fetchMessageRecipients(queryKey[1] as number), + }) + + let content + + if (isLoading) { + content = + } else if (error) { + content = {error.message} + } else if (recipients?.length) { + content = recipients.map(recipient => ( +
  • +
    +
    + {recipient.username}{' '} + ({recipient.fullName}) +
    +
    +
  • + )) + } else { + content = Message is not seen by anyone + } + + return ( +
    +

    Message info

    +
    + +
    +
    +
    + Seen by + +
    +
      {content}
    +
    +
    + ) +} diff --git a/web/src/features/message/components/MessageItem.tsx b/web/src/features/message/components/MessageItem.tsx index 8a7ae50..6cbab21 100644 --- a/web/src/features/message/components/MessageItem.tsx +++ b/web/src/features/message/components/MessageItem.tsx @@ -1,26 +1,109 @@ import { formateChatDate } from '@/utils/date' import { cn } from '@/utils/style' +import { Check, CircleSlash, Ellipsis, Reply } from 'lucide-react' +import { useRef } from 'react' import { IMessage } from '../message.interface' interface MessageItemProps { message: IMessage isCurrentUser: boolean + hasActionAnchor: boolean + onMessageAction?: ( + message: IMessage, + ref: React.RefObject, + ) => void + onReplyAction?: (message: IMessage) => void } -export const MessageItem = ({ message, isCurrentUser }: MessageItemProps) => { +export const MessageItem = ({ + message, + isCurrentUser, + hasActionAnchor, + onMessageAction, + onReplyAction, +}: MessageItemProps) => { + const messageAnchorRef = useRef(null) return (
    - {!isCurrentUser && {message.username}} -

    {message.content}

    - - {formateChatDate(message.createdAt)} - +
    +
    + {!isCurrentUser && {message.username}} + {message.parentMessage?.id && ( + +
    +
    + {message.parentMessage.username} +
    + {message.parentMessage.isDeleted && ( + + )} +

    {message.parentMessage.content}

    +
    +
    +
    + )} +
    + {message.isDeleted && ( + + )} +

    {message.content}

    +
    +
    + + {formateChatDate(message.createdAt)} + + {isCurrentUser && } +
    +
    + {isCurrentUser && !message.isDeleted && onMessageAction && ( + + )} +
    + {onReplyAction && ( + + )}
    ) } diff --git a/web/src/features/message/components/MessageList.tsx b/web/src/features/message/components/MessageList.tsx index 8adea26..99ed39c 100644 --- a/web/src/features/message/components/MessageList.tsx +++ b/web/src/features/message/components/MessageList.tsx @@ -1,27 +1,31 @@ import { Alert } from '@/components/Alert' import { Skeleton } from '@/components/Skeleton' -import { - IMessage, - TMessageInfiniteData, -} from '@/features/message/message.interface' +import { IMessage } from '@/features/message/message.interface' import { useAuth } from '@/hooks/useAuth' import { useInView } from '@/hooks/useInView' -import { getSocketIO } from '@/utils/socket' -import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query' -import { produce } from 'immer' -import { Fragment, useEffect, useRef } from 'react' +import { useInfiniteQuery } from '@tanstack/react-query' +import { Fragment, useCallback, useRef, useState } from 'react' +import { useMessageSocketHandle } from '../hooks/useMessageSocketHandle' import { fetchMessages } from '../message.service' +import { MessageActions } from './MessageActions' import { MessageItem } from './MessageItem' interface MessageListProps { groupId?: number partnerId?: number + onReplyAction?: (message: IMessage) => void } -export const MessageList = ({ groupId, partnerId }: MessageListProps) => { +export const MessageList = ({ + groupId, + partnerId, + onReplyAction, +}: MessageListProps) => { const { auth } = useAuth() - - const queryClient = useQueryClient() + const [messageAnchor, setMessageAnchor] = useState<{ + message: IMessage + anchorRef: React.RefObject + } | null>() const { data, hasNextPage, fetchNextPage, isLoading, error, isSuccess } = useInfiniteQuery({ @@ -40,45 +44,26 @@ export const MessageList = ({ groupId, partnerId }: MessageListProps) => { const listRef = useRef(null) - useEffect(() => { - const socket = getSocketIO() + function scrollToBottom() { + setTimeout(() => { + listRef.current?.scrollTo(0, listRef.current?.scrollHeight) + }, 100) + } - function updateMessage(message: IMessage) { - 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, partnerId }], - data => { - if (!data) return + useMessageSocketHandle({ + groupId, + partnerId, + afterNewMessage: scrollToBottom, + }) - const updatedData = produce(data, draft => { - draft.pages[0].data.unshift(message) - }) - return updatedData - }, - ) - setTimeout(scrollToBottom, 100) - } + const handleMessageAction = useCallback( + (message: IMessage, anchorRef: React.RefObject) => { + setMessageAnchor({ message, anchorRef }) + }, + [], + ) - socket.on('newMessage', updateMessage) - return () => { - socket.off('newMessage', updateMessage) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [groupId, partnerId]) + const resetMessageAction = useCallback(() => setMessageAnchor(null), []) const scrollElement = useInView(listRef, fetchNextPage, hasNextPage) @@ -98,6 +83,9 @@ export const MessageList = ({ groupId, partnerId }: MessageListProps) => { key={message.id} message={message} isCurrentUser={message.senderId === auth?.id} + onMessageAction={handleMessageAction} + hasActionAnchor={message.id === messageAnchor?.message.id} + onReplyAction={onReplyAction} /> ))} @@ -110,10 +98,17 @@ export const MessageList = ({ groupId, partnerId }: MessageListProps) => {
    {content} {scrollElement} + {messageAnchor && ( + + )}
    ) diff --git a/web/src/features/message/components/index.ts b/web/src/features/message/components/index.ts index 6acfab8..307ca5c 100644 --- a/web/src/features/message/components/index.ts +++ b/web/src/features/message/components/index.ts @@ -1,3 +1,2 @@ -export { MessageComposer } from './MessageComposer' +export { MessageContainer } from './MessageContainer' export { MessageItem } from './MessageItem' -export { MessageList } from './MessageList' diff --git a/web/src/features/message/hooks/index.ts b/web/src/features/message/hooks/index.ts new file mode 100644 index 0000000..e69de29 diff --git a/web/src/features/message/hooks/useMessageSocketHandle.ts b/web/src/features/message/hooks/useMessageSocketHandle.ts new file mode 100644 index 0000000..7f6af29 --- /dev/null +++ b/web/src/features/message/hooks/useMessageSocketHandle.ts @@ -0,0 +1,86 @@ +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 { IMessage, TMessageInfiniteData } from '../message.interface' + +export const useMessageSocketHandle = ({ + groupId, + partnerId, + afterNewMessage, +}: { + groupId?: number + partnerId?: number + afterNewMessage: () => void +}) => { + const queryClient = useQueryClient() + const { auth } = useAuth() + useEffect(() => { + const socket = getSocketIO() + + function updateMessageList( + updater: (data: TMessageInfiniteData) => TMessageInfiniteData, + ) { + queryClient.setQueryData( + ['messages', { groupId, partnerId }], + data => { + if (!data) return + + return updater(data) + }, + ) + } + + function handleNewMessage(message: IMessage) { + // check whether message belongs to current group + if (groupId && message.groupId !== groupId) { + return + } + // check whether message belongs to current dm + if ( + partnerId && + ![message.senderId, message.receiverId].includes(partnerId) + ) { + return + } + + updateMessageList(data => { + return produce(data, draft => { + draft.pages[0].data.unshift(message) + }) + }) + afterNewMessage() + + if ( + message.receiverId === auth?.id || + (groupId && message.groupId === groupId) + ) { + socket.emit('markMessageAsRead', message.id) + } + } + + function handleMessageDelete(messageId: number) { + updateMessageList(data => { + return produce(data, draft => { + draft.pages.forEach(page => { + page.data.forEach(message => { + if (message.id === messageId) { + message.isDeleted = true + message.content = 'this message is deleted' + } + }) + }) + }) + }) + } + + socket.on('newMessage', handleNewMessage) + socket.on('messageDeleted', handleMessageDelete) + return () => { + socket.off('newMessage', handleNewMessage) + socket.off('messageDeleted', handleMessageDelete) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [groupId, partnerId]) +} diff --git a/web/src/features/message/message.interface.ts b/web/src/features/message/message.interface.ts index 904b4d6..9becfd3 100644 --- a/web/src/features/message/message.interface.ts +++ b/web/src/features/message/message.interface.ts @@ -12,6 +12,9 @@ export interface IMessage { username: string content: string createdAt: string + isDeleted: boolean + parentMessageId?: number + parentMessage?: Pick } export interface IGetChatMessagesArgs extends TPaginatedParams { @@ -23,3 +26,11 @@ export type TMessageInfiniteData = InfiniteData< IPaginatedResult, string > + +export interface IMessageRecipient { + messageId: number + userId: number + username: string + fullName: string + readAt: string +} diff --git a/web/src/features/message/message.service.ts b/web/src/features/message/message.service.ts index bfab1dd..474fb76 100644 --- a/web/src/features/message/message.service.ts +++ b/web/src/features/message/message.service.ts @@ -1,8 +1,19 @@ import { IPaginatedResult } from '@/interfaces/common.interface' import { fetcher, stringifyQueryParams } from '@/utils/api' -import { IGetChatMessagesArgs, IMessage } from './message.interface' +import { + IGetChatMessagesArgs, + IMessage, + IMessageRecipient, +} from './message.interface' export const fetchMessages = async ({ ...params }: IGetChatMessagesArgs): Promise> => fetcher(`messages?${stringifyQueryParams(params)}`) + +export const fetchMessageRecipients = async ( + messageId: number, +): Promise => fetcher(`messages/${messageId}/recipients`) + +export const deleteMessage = async (messageId: number) => + fetcher(`messages/${messageId}`, { method: 'DELETE' }) diff --git a/web/src/hooks/useClickOutside.ts b/web/src/hooks/useClickOutside.ts index c987345..5ddbb99 100644 --- a/web/src/hooks/useClickOutside.ts +++ b/web/src/hooks/useClickOutside.ts @@ -2,17 +2,19 @@ import { useEffect } from 'react' export const useClickOutside = ( ref: React.RefObject, - callback: () => void, + callback?: () => void, ) => { useEffect(() => { - const handleOutsideClick = (e: MouseEvent) => { - if (ref.current && !ref.current.contains(e.target as Node)) { - callback() + if (callback) { + const handleOutsideClick = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) { + callback() + } + } + document.addEventListener('mousedown', handleOutsideClick) + return () => { + document.removeEventListener('mousedown', handleOutsideClick) } - } - document.addEventListener('mousedown', handleOutsideClick) - return () => { - document.removeEventListener('mousedown', handleOutsideClick) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [ref, callback]) diff --git a/web/src/hooks/useConfirmDialog.ts b/web/src/hooks/useConfirmDialog.ts new file mode 100644 index 0000000..71cc2a7 --- /dev/null +++ b/web/src/hooks/useConfirmDialog.ts @@ -0,0 +1,4 @@ +import { ConfirmDialogContext } from '@/components/ConfirmDialog' +import { useContext } from 'react' + +export const useConfirmDialog = () => useContext(ConfirmDialogContext) diff --git a/web/src/hooks/useKeyboardListNavigation.ts b/web/src/hooks/useKeyboardListNavigation.ts new file mode 100644 index 0000000..8e18054 --- /dev/null +++ b/web/src/hooks/useKeyboardListNavigation.ts @@ -0,0 +1,60 @@ +import { useCallback, useState } from 'react' + +export const useKeyboardListNavigation = ({ + listLength, + onEnter, + onArrowUp, + onArrowDown, + onTab, + onEscape, +}: { + listLength: number + onEnter?: (highlightedIndex: number) => void + onArrowUp?: () => void + onArrowDown?: () => void + onTab?: () => void + onEscape?: () => void +}) => { + const [highlightedIndex, setHighlightedIndex] = useState(-1) + + const resetHighlightedIndex = useCallback(() => setHighlightedIndex(-1), []) + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + e.stopPropagation() + + if (!listLength) return + + switch (e.key) { + case 'ArrowDown': + setHighlightedIndex(prevIndex => + prevIndex === listLength - 1 ? 0 : prevIndex + 1, + ) + if (onArrowDown) onArrowDown() + break + case 'ArrowUp': + setHighlightedIndex(prevIndex => + prevIndex === 0 ? listLength - 1 : prevIndex - 1, + ) + if (onArrowUp) onArrowUp() + break + case 'Enter': + if (highlightedIndex >= 0 && onEnter) onEnter(highlightedIndex) + break + case 'Tab': + if (onTab) onTab() + break + case 'Escape': + if (onEscape) onEscape() + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [highlightedIndex], + ) + + return { + highlightedIndex, + resetHighlightedIndex, + handleKeyDown, + } +} diff --git a/web/src/interfaces/socket.interface.ts b/web/src/interfaces/socket.interface.ts index 4d9ae31..d59fbfd 100644 --- a/web/src/interfaces/socket.interface.ts +++ b/web/src/interfaces/socket.interface.ts @@ -14,6 +14,7 @@ export interface ServerToClientEvents { newGroup: (group: IGroup) => void groupDeleted: (groupId: number) => void messageRead: (messageId: number) => void + messageDeleted: (messageId: number) => void chatMarkedAsRead: (args: { groupId?: number; receiverId?: number }) => void typingUsers: (users: { id: number; username: string }[]) => void } @@ -22,7 +23,12 @@ export interface ClientToServerEvents { joinGroup: (groupId: number) => void joinDm: (partnerId: number) => void createMessage: ( - args: { groupId?: number; receiverId?: number; text: string }, + args: { + groupId?: number + receiverId?: number + text: string + parentMessageId?: number + }, callback: (response: { message?: IMessage; error?: unknown }) => void, ) => void markMessageAsRead: (messageId: number) => void diff --git a/web/src/pages/ChatDM.tsx b/web/src/pages/ChatDM.tsx index 277c004..7f44ad8 100644 --- a/web/src/pages/ChatDM.tsx +++ b/web/src/pages/ChatDM.tsx @@ -1,6 +1,6 @@ import { PageLoader } from '@/components/PageLoader' -import { ChatHeader, TypingIndicator } from '@/features/chat/components' -import { MessageComposer, MessageList } from '@/features/message/components' +import { ChatHeader } from '@/features/chat/components' +import { MessageContainer } from '@/features/message/components' import { fetchUser } from '@/features/user/user.service' import { useQuery } from '@tanstack/react-query' import { useParams } from 'react-router-dom' @@ -32,9 +32,7 @@ export const Component = () => { chatName={receiver?.username} error={error} /> - - - + ) diff --git a/web/src/pages/ChatRoom.tsx b/web/src/pages/ChatGroup.tsx similarity index 85% rename from web/src/pages/ChatRoom.tsx rename to web/src/pages/ChatGroup.tsx index 0aaab89..b9b3016 100644 --- a/web/src/pages/ChatRoom.tsx +++ b/web/src/pages/ChatGroup.tsx @@ -1,5 +1,5 @@ import { PageLoader } from '@/components/PageLoader' -import { ChatHeader, TypingIndicator } from '@/features/chat/components' +import { ChatHeader } from '@/features/chat/components' import { GroupInfo } from '@/features/chat/layouts' import { DeleteGroup } from '@/features/group/components/DeleteGroup' import { fetchGroup } from '@/features/group/group.service' @@ -9,7 +9,7 @@ import { MemberList, } from '@/features/member/components' import { useHasPermission } from '@/features/member/hooks' -import { MessageComposer, MessageList } from '@/features/message/components' +import { MessageContainer } from '@/features/message/components' import { useDisclosure } from '@/hooks/useDisclosure' import { cn } from '@/utils/style' import { useQuery } from '@tanstack/react-query' @@ -52,9 +52,7 @@ export const Component = () => { error={error} toggleGroupInfo={toggle} /> - - - + @@ -68,4 +66,4 @@ export const Component = () => { ) } -Component.displayName = 'ChatRoom' +Component.displayName = 'ChatGroup' diff --git a/web/src/router.tsx b/web/src/router.tsx index b795246..6e0b091 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -22,7 +22,7 @@ export const router = createBrowserRouter([ lazy: () => import('./features/chat/layouts/ChatLayout'), children: [ { path: '', lazy: () => import('./pages/ChatHome') }, - { path: 'group/:groupId', lazy: () => import('./pages/ChatRoom') }, + { path: 'group/:groupId', lazy: () => import('./pages/ChatGroup') }, { path: 'direct/:partnerId', lazy: () => import('./pages/ChatDM'),