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 = (
-
+
)
} 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(
+ ,
+ 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 && (
+
+ )}
)
}
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 (
+ <>
+
+
+ >
+ )
+}
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 (
-
)
}
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 (
+
+ )
+}
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.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'),