├── README.md
├── backend
├── .env
├── .eslintrc.js
├── .gitignore
├── .prettierrc
├── README.md
├── docker-compose.yml
├── nest-cli.json
├── package-lock.json
├── package.json
├── prisma
│ ├── migrations
│ │ ├── 20230831104504_init
│ │ │ └── migration.sql
│ │ ├── 20230902100011_next
│ │ │ └── migration.sql
│ │ ├── 20230902100732_next
│ │ │ └── migration.sql
│ │ ├── 20230902101221_next
│ │ │ └── migration.sql
│ │ └── migration_lock.toml
│ └── schema.prisma
├── public
│ └── images
│ │ ├── 1693611219759-jondoe_realistic_photo_of_a_beautiful_well_built_woman_fully_en_e0b8b2fa-a638-4437-97fa-e8ed73ba61bb.png
│ │ ├── 1693612998162-jondoe_realistic_photo_of_old_nikola_tesla_time_travelling_colo_b3f94220-a44a-4a87-9e3f-a3c83d62a909.png
│ │ ├── 1693650241693-jondoe_realistic_photo_of_a_beautiful_well_built_woman_in_sport_56d3932e-1dec-46d4-a200-6484f1795aa3.png
│ │ ├── 1693786214852-jondoe_dr_gabor_mate._nikon_d750_16f6067f-a165-439e-85eb-324f3ac63c4a.png
│ │ ├── 1693856301820-jondoe_realistic_photo_of_a_beautiful_well_built_woman_fully_en_e0b8b2fa-a638-4437-97fa-e8ed73ba61bb.png
│ │ ├── 1693859329740-jondoe_realistic_photo_of_a_beautiful_well_built_woman_fully_en_e0b8b2fa-a638-4437-97fa-e8ed73ba61bb.png
│ │ ├── 1693861399297-jondoe_realistic_photo_of_a_beautiful_well_built_woman_fully_en_e0b8b2fa-a638-4437-97fa-e8ed73ba61bb.png
│ │ ├── 1693861664756-jondoe_realistic_photo_of_a_beautiful_well_built_woman_fully_en_e0b8b2fa-a638-4437-97fa-e8ed73ba61bb.png
│ │ ├── 1693861737878-jondoe_realistic_photo_of_a_beautiful_well_built_woman_in_sport_56d3932e-1dec-46d4-a200-6484f1795aa3.png
│ │ ├── 2d1f7cb9-0b93-4f03-baa3-39a1ea5576bc_1j7rqt.jpg
│ │ ├── a04accf7-5ab3-4e14-80d3-2c908add516a_2023-06-14 22_22_17-did we evolve from neanderthals – Google Suche.png
│ │ ├── ab8534c3-066b-49d2-9006-805579398316_gekko_end.png
│ │ ├── b147838e-0814-4f99-98ed-164b4f39c47b_jondoe_create_a_beautiful_engaging_youtube_thumbnail_showing_a__df4bc82d-9dbe-449c-b69d-3ecf404abd07.png
│ │ └── fd8886d0-d01c-4f9c-a937-8aaa378cae94_jondoe_realistic_photo_of_a_beautiful_well_built_woman_in_sport_56d3932e-1dec-46d4-a200-6484f1795aa3.png
├── src
│ ├── app.controller.spec.ts
│ ├── app.controller.ts
│ ├── app.module.ts
│ ├── app.service.ts
│ ├── auth
│ │ ├── auth.module.ts
│ │ ├── auth.resolver.spec.ts
│ │ ├── auth.resolver.ts
│ │ ├── auth.service.spec.ts
│ │ ├── auth.service.ts
│ │ ├── dto.ts
│ │ ├── graphql-auth.guard.ts
│ │ └── types.ts
│ ├── chatroom
│ │ ├── chatroom.module.ts
│ │ ├── chatroom.resolver.spec.ts
│ │ ├── chatroom.resolver.ts
│ │ ├── chatroom.service.spec.ts
│ │ ├── chatroom.service.ts
│ │ ├── chatroom.types.ts
│ │ └── dto.ts
│ ├── filters
│ │ └── custom-exception.filter.ts
│ ├── live-chatroom
│ │ ├── live-chatroom.module.ts
│ │ ├── live-chatroom.resolver.spec.ts
│ │ ├── live-chatroom.resolver.ts
│ │ ├── live-chatroom.service.spec.ts
│ │ └── live-chatroom.service.ts
│ ├── main.ts
│ ├── prisma.service.ts
│ ├── schema.gql
│ ├── token
│ │ ├── token.service.spec.ts
│ │ └── token.service.ts
│ ├── types.d.ts
│ └── user
│ │ ├── user.module.ts
│ │ ├── user.resolver.spec.ts
│ │ ├── user.resolver.ts
│ │ ├── user.service.spec.ts
│ │ ├── user.service.ts
│ │ └── user.type.ts
├── test
│ ├── app.e2e-spec.ts
│ └── jest-e2e.json
├── tsconfig.build.json
└── tsconfig.json
└── frontend
├── .eslintrc.cjs
├── .gitignore
├── README.md
├── codegen.ts
├── index.html
├── package-lock.json
├── package.json
├── public
└── vite.svg
├── src
├── App.css
├── App.tsx
├── apolloClient.ts
├── assets
│ └── react.svg
├── components
│ ├── AddChatroom.tsx
│ ├── AuthOverlay.tsx
│ ├── Chatwindow.tsx
│ ├── JoinRoomOrChatwindow.tsx
│ ├── MessageBubble.tsx
│ ├── OverlappingAvatars.tsx
│ ├── ProfileSettings.tsx
│ ├── ProtectedRoutes.tsx
│ ├── RoomList.tsx
│ └── Sidebar.tsx
├── gql
│ ├── fragment-masking.ts
│ ├── gql.ts
│ ├── graphql.ts
│ └── index.ts
├── graphql
│ ├── mutations
│ │ ├── AddUsersToChatroom.ts
│ │ ├── CreateChatroom.ts
│ │ ├── DeleteChatroom.ts
│ │ ├── EnterChatroom.ts
│ │ ├── LeaveChatroom.ts
│ │ ├── Login.ts
│ │ ├── Logout.ts
│ │ ├── Register.ts
│ │ ├── SendMessage.ts
│ │ ├── UpdateUserProfile.ts
│ │ ├── UserStartedTypingMutation.ts
│ │ └── UserStoppedTypingMutation.ts
│ ├── queries
│ │ ├── GetChatroomsForUser.ts
│ │ ├── GetMessagesForChatroom.ts
│ │ ├── GetUsersOfChatroom.ts
│ │ └── SearchUsers.ts
│ └── subscriptions
│ │ ├── LiveUsers.ts
│ │ ├── NewMessage.ts
│ │ ├── UserStartedTyping.ts
│ │ └── UserStoppedTyping.ts
├── index.css
├── layouts
│ └── MainLayout.tsx
├── main.tsx
├── pages
│ └── Home.tsx
├── stores
│ ├── generalStore.ts
│ └── userStore.ts
└── vite-env.d.ts
├── tsconfig.json
├── tsconfig.node.json
└── vite.config.ts
/README.md:
--------------------------------------------------------------------------------
1 | # nestjs_graphql_react_chat_app
2 |
--------------------------------------------------------------------------------
/backend/.env:
--------------------------------------------------------------------------------
1 | DATABASE_URL=postgresql://johndoe:123@localhost:5432/chatapp
2 | REFRESH_TOKEN_SECRET="mysecret"
3 | ACCESS_TOKEN_SECRET="mysecret"
4 | APP_URL=http://localhost:3000
5 | IMAGE_PATH=/images
--------------------------------------------------------------------------------
/backend/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | parser: '@typescript-eslint/parser',
3 | parserOptions: {
4 | project: 'tsconfig.json',
5 | tsconfigRootDir: __dirname,
6 | sourceType: 'module',
7 | },
8 | plugins: ['@typescript-eslint/eslint-plugin'],
9 | extends: ['plugin:@typescript-eslint/recommended'],
10 | root: true,
11 | env: {
12 | node: true,
13 | jest: true,
14 | },
15 | ignorePatterns: ['.eslintrc.js'],
16 | rules: {
17 | '@typescript-eslint/interface-name-prefix': 'off',
18 | '@typescript-eslint/explicit-function-return-type': 'off',
19 | '@typescript-eslint/explicit-module-boundary-types': 'off',
20 | '@typescript-eslint/no-explicit-any': 'off',
21 | },
22 | };
23 |
--------------------------------------------------------------------------------
/backend/.gitignore:
--------------------------------------------------------------------------------
1 | # compiled output
2 | /dist
3 | /node_modules
4 |
5 | # Logs
6 | logs
7 | *.log
8 | npm-debug.log*
9 | pnpm-debug.log*
10 | yarn-debug.log*
11 | yarn-error.log*
12 | lerna-debug.log*
13 |
14 | # OS
15 | .DS_Store
16 |
17 | # Tests
18 | /coverage
19 | /.nyc_output
20 |
21 | # IDEs and editors
22 | /.idea
23 | .project
24 | .classpath
25 | .c9/
26 | *.launch
27 | .settings/
28 | *.sublime-workspace
29 |
30 | # IDE - VSCode
31 | .vscode/*
32 | !.vscode/settings.json
33 | !.vscode/tasks.json
34 | !.vscode/launch.json
35 | !.vscode/extensions.json
--------------------------------------------------------------------------------
/backend/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "singleQuote": true,
3 | "trailingComma": "all"
4 | }
--------------------------------------------------------------------------------
/backend/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | [circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
6 | [circleci-url]: https://circleci.com/gh/nestjs/nest
7 |
8 | A progressive Node.js framework for building efficient and scalable server-side applications.
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
24 |
25 | ## Description
26 |
27 | [Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
28 |
29 | ## Installation
30 |
31 | ```bash
32 | $ npm install
33 | ```
34 |
35 | ## Running the app
36 |
37 | ```bash
38 | # development
39 | $ npm run start
40 |
41 | # watch mode
42 | $ npm run start:dev
43 |
44 | # production mode
45 | $ npm run start:prod
46 | ```
47 |
48 | ## Test
49 |
50 | ```bash
51 | # unit tests
52 | $ npm run test
53 |
54 | # e2e tests
55 | $ npm run test:e2e
56 |
57 | # test coverage
58 | $ npm run test:cov
59 | ```
60 |
61 | ## Support
62 |
63 | Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
64 |
65 | ## Stay in touch
66 |
67 | - Author - [Kamil Myśliwiec](https://kamilmysliwiec.com)
68 | - Website - [https://nestjs.com](https://nestjs.com/)
69 | - Twitter - [@nestframework](https://twitter.com/nestframework)
70 |
71 | ## License
72 |
73 | Nest is [MIT licensed](LICENSE).
74 |
--------------------------------------------------------------------------------
/backend/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3.1'
2 |
3 | services:
4 | postgres:
5 | image: postgres:10
6 | container_name: my_postgres_container2
7 | ports:
8 | - "5432:5432"
9 | environment:
10 | POSTGRES_USER: johndoe
11 | POSTGRES_PASSWORD: 123
12 | POSTGRES_DB: chatapp
13 | volumes:
14 | - my_postgres_data:/var/lib/postgresql/data
15 |
16 |
17 | redis:
18 | image: redis:latest
19 | ports:
20 | - "6379:6379"
21 |
22 | volumes:
23 | my_postgres_data:
--------------------------------------------------------------------------------
/backend/nest-cli.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/nest-cli",
3 | "collection": "@nestjs/schematics",
4 | "sourceRoot": "src",
5 | "compilerOptions": {
6 | "deleteOutDir": true
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/backend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "backend",
3 | "version": "0.0.1",
4 | "description": "",
5 | "author": "",
6 | "private": true,
7 | "license": "UNLICENSED",
8 | "scripts": {
9 | "build": "nest build",
10 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
11 | "start": "nest start",
12 | "start:dev": "nest start --watch",
13 | "start:debug": "nest start --debug --watch",
14 | "start:prod": "node dist/main",
15 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
16 | "test": "jest",
17 | "test:watch": "jest --watch",
18 | "test:cov": "jest --coverage",
19 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
20 | "test:e2e": "jest --config ./test/jest-e2e.json"
21 | },
22 | "dependencies": {
23 | "@apollo/server": "^4.9.3",
24 | "@nestjs/apollo": "^12.0.7",
25 | "@nestjs/common": "^9.0.0",
26 | "@nestjs/config": "^3.0.1",
27 | "@nestjs/core": "^9.0.0",
28 | "@nestjs/graphql": "^12.0.8",
29 | "@nestjs/jwt": "^10.1.0",
30 | "@nestjs/platform-express": "^9.0.0",
31 | "@nestjs/serve-static": "^4.0.0",
32 | "@prisma/client": "^5.2.0",
33 | "bcrypt": "^5.1.1",
34 | "class-transformer": "^0.5.1",
35 | "class-validator": "^0.14.0",
36 | "cookie-parser": "^1.4.6",
37 | "graphql": "^16.8.0",
38 | "graphql-redis-subscriptions": "^2.6.0",
39 | "graphql-upload": "^14.0.0",
40 | "ioredis": "^5.3.2",
41 | "reflect-metadata": "^0.1.13",
42 | "rxjs": "^7.2.0"
43 | },
44 | "devDependencies": {
45 | "@nestjs/cli": "^9.0.0",
46 | "@nestjs/schematics": "^9.0.0",
47 | "@nestjs/testing": "^9.0.0",
48 | "@types/express": "^4.17.13",
49 | "@types/jest": "29.5.0",
50 | "@types/node": "18.15.11",
51 | "@types/supertest": "^2.0.11",
52 | "@typescript-eslint/eslint-plugin": "^5.0.0",
53 | "@typescript-eslint/parser": "^5.0.0",
54 | "eslint": "^8.0.1",
55 | "eslint-config-prettier": "^8.3.0",
56 | "eslint-plugin-prettier": "^4.0.0",
57 | "jest": "29.5.0",
58 | "prettier": "^2.3.2",
59 | "prisma": "^5.2.0",
60 | "source-map-support": "^0.5.20",
61 | "supertest": "^6.1.3",
62 | "ts-jest": "29.0.5",
63 | "ts-loader": "^9.2.3",
64 | "ts-node": "^10.0.0",
65 | "tsconfig-paths": "4.2.0",
66 | "typescript": "^4.7.4"
67 | },
68 | "jest": {
69 | "moduleFileExtensions": [
70 | "js",
71 | "json",
72 | "ts"
73 | ],
74 | "rootDir": "src",
75 | "testRegex": ".*\\.spec\\.ts$",
76 | "transform": {
77 | "^.+\\.(t|j)s$": "ts-jest"
78 | },
79 | "collectCoverageFrom": [
80 | "**/*.(t|j)s"
81 | ],
82 | "coverageDirectory": "../coverage",
83 | "testEnvironment": "node"
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/backend/prisma/migrations/20230831104504_init/migration.sql:
--------------------------------------------------------------------------------
1 | -- CreateTable
2 | CREATE TABLE "User" (
3 | "id" SERIAL NOT NULL,
4 | "fullname" TEXT NOT NULL,
5 | "avatarUrl" TEXT,
6 | "email" TEXT NOT NULL,
7 | "emailVerifiedAt" TIMESTAMP(3),
8 | "password" TEXT NOT NULL,
9 | "rememberToken" TEXT,
10 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
11 | "updatedAt" TIMESTAMP(3) NOT NULL,
12 |
13 | CONSTRAINT "User_pkey" PRIMARY KEY ("id")
14 | );
15 |
16 | -- CreateTable
17 | CREATE TABLE "Chatroom" (
18 | "id" SERIAL NOT NULL,
19 | "name" TEXT NOT NULL,
20 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
21 | "updatedAt" TIMESTAMP(3) NOT NULL,
22 |
23 | CONSTRAINT "Chatroom_pkey" PRIMARY KEY ("id")
24 | );
25 |
26 | -- CreateTable
27 | CREATE TABLE "Message" (
28 | "id" SERIAL NOT NULL,
29 | "content" TEXT NOT NULL,
30 | "imageUrl" TEXT,
31 | "userId" INTEGER NOT NULL,
32 | "chatroomId" INTEGER NOT NULL,
33 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
34 | "updatedAt" TIMESTAMP(3) NOT NULL,
35 |
36 | CONSTRAINT "Message_pkey" PRIMARY KEY ("id")
37 | );
38 |
39 | -- CreateTable
40 | CREATE TABLE "ChatroomUsers" (
41 | "chatroomId" INTEGER NOT NULL,
42 | "userId" INTEGER NOT NULL,
43 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
44 | "updatedAt" TIMESTAMP(3) NOT NULL,
45 |
46 | CONSTRAINT "ChatroomUsers_pkey" PRIMARY KEY ("chatroomId","userId")
47 | );
48 |
49 | -- CreateTable
50 | CREATE TABLE "_ChatroomUsers" (
51 | "A" INTEGER NOT NULL,
52 | "B" INTEGER NOT NULL
53 | );
54 |
55 | -- CreateIndex
56 | CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
57 |
58 | -- CreateIndex
59 | CREATE UNIQUE INDEX "_ChatroomUsers_AB_unique" ON "_ChatroomUsers"("A", "B");
60 |
61 | -- CreateIndex
62 | CREATE INDEX "_ChatroomUsers_B_index" ON "_ChatroomUsers"("B");
63 |
64 | -- AddForeignKey
65 | ALTER TABLE "Message" ADD CONSTRAINT "Message_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
66 |
67 | -- AddForeignKey
68 | ALTER TABLE "Message" ADD CONSTRAINT "Message_chatroomId_fkey" FOREIGN KEY ("chatroomId") REFERENCES "Chatroom"("id") ON DELETE CASCADE ON UPDATE CASCADE;
69 |
70 | -- AddForeignKey
71 | ALTER TABLE "ChatroomUsers" ADD CONSTRAINT "ChatroomUsers_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
72 |
73 | -- AddForeignKey
74 | ALTER TABLE "ChatroomUsers" ADD CONSTRAINT "ChatroomUsers_chatroomId_fkey" FOREIGN KEY ("chatroomId") REFERENCES "Chatroom"("id") ON DELETE CASCADE ON UPDATE CASCADE;
75 |
76 | -- AddForeignKey
77 | ALTER TABLE "_ChatroomUsers" ADD CONSTRAINT "_ChatroomUsers_A_fkey" FOREIGN KEY ("A") REFERENCES "Chatroom"("id") ON DELETE CASCADE ON UPDATE CASCADE;
78 |
79 | -- AddForeignKey
80 | ALTER TABLE "_ChatroomUsers" ADD CONSTRAINT "_ChatroomUsers_B_fkey" FOREIGN KEY ("B") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
81 |
--------------------------------------------------------------------------------
/backend/prisma/migrations/20230902100011_next/migration.sql:
--------------------------------------------------------------------------------
1 | /*
2 | Warnings:
3 |
4 | - You are about to drop the `ChatroomUsers` table. If the table is not empty, all the data it contains will be lost.
5 | - You are about to drop the `_ChatroomUsers` table. If the table is not empty, all the data it contains will be lost.
6 |
7 | */
8 | -- DropForeignKey
9 | ALTER TABLE "ChatroomUsers" DROP CONSTRAINT "ChatroomUsers_chatroomId_fkey";
10 |
11 | -- DropForeignKey
12 | ALTER TABLE "ChatroomUsers" DROP CONSTRAINT "ChatroomUsers_userId_fkey";
13 |
14 | -- DropForeignKey
15 | ALTER TABLE "_ChatroomUsers" DROP CONSTRAINT "_ChatroomUsers_A_fkey";
16 |
17 | -- DropForeignKey
18 | ALTER TABLE "_ChatroomUsers" DROP CONSTRAINT "_ChatroomUsers_B_fkey";
19 |
20 | -- DropTable
21 | DROP TABLE "ChatroomUsers";
22 |
23 | -- DropTable
24 | DROP TABLE "_ChatroomUsers";
25 |
--------------------------------------------------------------------------------
/backend/prisma/migrations/20230902100732_next/migration.sql:
--------------------------------------------------------------------------------
1 | -- DropForeignKey
2 | ALTER TABLE "Message" DROP CONSTRAINT "Message_userId_fkey";
3 |
4 | -- AlterTable
5 | ALTER TABLE "User" ADD COLUMN "chatroomId" INTEGER;
6 |
7 | -- AddForeignKey
8 | ALTER TABLE "User" ADD CONSTRAINT "User_chatroomId_fkey" FOREIGN KEY ("chatroomId") REFERENCES "Chatroom"("id") ON DELETE SET NULL ON UPDATE CASCADE;
9 |
10 | -- AddForeignKey
11 | ALTER TABLE "Message" ADD CONSTRAINT "Message_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
12 |
--------------------------------------------------------------------------------
/backend/prisma/migrations/20230902101221_next/migration.sql:
--------------------------------------------------------------------------------
1 | /*
2 | Warnings:
3 |
4 | - You are about to drop the column `chatroomId` on the `User` table. All the data in the column will be lost.
5 |
6 | */
7 | -- DropForeignKey
8 | ALTER TABLE "Message" DROP CONSTRAINT "Message_userId_fkey";
9 |
10 | -- DropForeignKey
11 | ALTER TABLE "User" DROP CONSTRAINT "User_chatroomId_fkey";
12 |
13 | -- AlterTable
14 | ALTER TABLE "User" DROP COLUMN "chatroomId";
15 |
16 | -- CreateTable
17 | CREATE TABLE "ChatroomUsers" (
18 | "chatroomId" INTEGER NOT NULL,
19 | "userId" INTEGER NOT NULL,
20 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
21 | "updatedAt" TIMESTAMP(3) NOT NULL,
22 |
23 | CONSTRAINT "ChatroomUsers_pkey" PRIMARY KEY ("chatroomId","userId")
24 | );
25 |
26 | -- CreateTable
27 | CREATE TABLE "_ChatroomUsers" (
28 | "A" INTEGER NOT NULL,
29 | "B" INTEGER NOT NULL
30 | );
31 |
32 | -- CreateIndex
33 | CREATE UNIQUE INDEX "_ChatroomUsers_AB_unique" ON "_ChatroomUsers"("A", "B");
34 |
35 | -- CreateIndex
36 | CREATE INDEX "_ChatroomUsers_B_index" ON "_ChatroomUsers"("B");
37 |
38 | -- AddForeignKey
39 | ALTER TABLE "Message" ADD CONSTRAINT "Message_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
40 |
41 | -- AddForeignKey
42 | ALTER TABLE "ChatroomUsers" ADD CONSTRAINT "ChatroomUsers_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
43 |
44 | -- AddForeignKey
45 | ALTER TABLE "ChatroomUsers" ADD CONSTRAINT "ChatroomUsers_chatroomId_fkey" FOREIGN KEY ("chatroomId") REFERENCES "Chatroom"("id") ON DELETE CASCADE ON UPDATE CASCADE;
46 |
47 | -- AddForeignKey
48 | ALTER TABLE "_ChatroomUsers" ADD CONSTRAINT "_ChatroomUsers_A_fkey" FOREIGN KEY ("A") REFERENCES "Chatroom"("id") ON DELETE CASCADE ON UPDATE CASCADE;
49 |
50 | -- AddForeignKey
51 | ALTER TABLE "_ChatroomUsers" ADD CONSTRAINT "_ChatroomUsers_B_fkey" FOREIGN KEY ("B") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
52 |
--------------------------------------------------------------------------------
/backend/prisma/migrations/migration_lock.toml:
--------------------------------------------------------------------------------
1 | # Please do not edit this file manually
2 | # It should be added in your version-control system (i.e. Git)
3 | provider = "postgresql"
--------------------------------------------------------------------------------
/backend/prisma/schema.prisma:
--------------------------------------------------------------------------------
1 | generator client {
2 | provider = "prisma-client-js"
3 | }
4 |
5 | datasource db {
6 | provider = "postgresql"
7 | url = env("DATABASE_URL")
8 | }
9 |
10 | model User {
11 | id Int @id @default(autoincrement())
12 | fullname String
13 | avatarUrl String?
14 | email String @unique
15 | emailVerifiedAt DateTime?
16 | password String
17 | rememberToken String?
18 | createdAt DateTime @default(now())
19 | updatedAt DateTime @updatedAt
20 | chatrooms Chatroom[] @relation("ChatroomUsers")
21 | messages Message[]
22 | ChatroomUsers ChatroomUsers[]
23 | }
24 |
25 | model Chatroom {
26 | id Int @id @default(autoincrement())
27 | name String
28 | createdAt DateTime @default(now())
29 | updatedAt DateTime @updatedAt
30 | users User[] @relation("ChatroomUsers")
31 | messages Message[]
32 | ChatroomUsers ChatroomUsers[]
33 | }
34 |
35 | model Message {
36 | id Int @id @default(autoincrement())
37 | content String
38 | imageUrl String?
39 | userId Int
40 | chatroomId Int
41 | createdAt DateTime @default(now())
42 | updatedAt DateTime @updatedAt
43 | user User @relation(fields: [userId], references: [id], onDelete: Cascade)
44 | chatroom Chatroom @relation(fields: [chatroomId], references: [id], onDelete: Cascade)
45 | }
46 |
47 | model ChatroomUsers {
48 | chatroomId Int
49 | userId Int
50 | createdAt DateTime @default(now())
51 | updatedAt DateTime @updatedAt
52 | user User @relation(fields: [userId], references: [id], onDelete: Cascade)
53 | chatroom Chatroom @relation(fields: [chatroomId], references: [id], onDelete: Cascade)
54 |
55 | @@id([chatroomId, userId])
56 | }
57 |
--------------------------------------------------------------------------------
/backend/public/images/1693611219759-jondoe_realistic_photo_of_a_beautiful_well_built_woman_fully_en_e0b8b2fa-a638-4437-97fa-e8ed73ba61bb.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/thebeautyofcoding/nestjs_graphql_react_chat_app/e8af7140dcf95f8516735146b6d7445dad66a64d/backend/public/images/1693611219759-jondoe_realistic_photo_of_a_beautiful_well_built_woman_fully_en_e0b8b2fa-a638-4437-97fa-e8ed73ba61bb.png
--------------------------------------------------------------------------------
/backend/public/images/1693612998162-jondoe_realistic_photo_of_old_nikola_tesla_time_travelling_colo_b3f94220-a44a-4a87-9e3f-a3c83d62a909.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/thebeautyofcoding/nestjs_graphql_react_chat_app/e8af7140dcf95f8516735146b6d7445dad66a64d/backend/public/images/1693612998162-jondoe_realistic_photo_of_old_nikola_tesla_time_travelling_colo_b3f94220-a44a-4a87-9e3f-a3c83d62a909.png
--------------------------------------------------------------------------------
/backend/public/images/1693650241693-jondoe_realistic_photo_of_a_beautiful_well_built_woman_in_sport_56d3932e-1dec-46d4-a200-6484f1795aa3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/thebeautyofcoding/nestjs_graphql_react_chat_app/e8af7140dcf95f8516735146b6d7445dad66a64d/backend/public/images/1693650241693-jondoe_realistic_photo_of_a_beautiful_well_built_woman_in_sport_56d3932e-1dec-46d4-a200-6484f1795aa3.png
--------------------------------------------------------------------------------
/backend/public/images/1693786214852-jondoe_dr_gabor_mate._nikon_d750_16f6067f-a165-439e-85eb-324f3ac63c4a.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/thebeautyofcoding/nestjs_graphql_react_chat_app/e8af7140dcf95f8516735146b6d7445dad66a64d/backend/public/images/1693786214852-jondoe_dr_gabor_mate._nikon_d750_16f6067f-a165-439e-85eb-324f3ac63c4a.png
--------------------------------------------------------------------------------
/backend/public/images/1693856301820-jondoe_realistic_photo_of_a_beautiful_well_built_woman_fully_en_e0b8b2fa-a638-4437-97fa-e8ed73ba61bb.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/thebeautyofcoding/nestjs_graphql_react_chat_app/e8af7140dcf95f8516735146b6d7445dad66a64d/backend/public/images/1693856301820-jondoe_realistic_photo_of_a_beautiful_well_built_woman_fully_en_e0b8b2fa-a638-4437-97fa-e8ed73ba61bb.png
--------------------------------------------------------------------------------
/backend/public/images/1693859329740-jondoe_realistic_photo_of_a_beautiful_well_built_woman_fully_en_e0b8b2fa-a638-4437-97fa-e8ed73ba61bb.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/thebeautyofcoding/nestjs_graphql_react_chat_app/e8af7140dcf95f8516735146b6d7445dad66a64d/backend/public/images/1693859329740-jondoe_realistic_photo_of_a_beautiful_well_built_woman_fully_en_e0b8b2fa-a638-4437-97fa-e8ed73ba61bb.png
--------------------------------------------------------------------------------
/backend/public/images/1693861399297-jondoe_realistic_photo_of_a_beautiful_well_built_woman_fully_en_e0b8b2fa-a638-4437-97fa-e8ed73ba61bb.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/thebeautyofcoding/nestjs_graphql_react_chat_app/e8af7140dcf95f8516735146b6d7445dad66a64d/backend/public/images/1693861399297-jondoe_realistic_photo_of_a_beautiful_well_built_woman_fully_en_e0b8b2fa-a638-4437-97fa-e8ed73ba61bb.png
--------------------------------------------------------------------------------
/backend/public/images/1693861664756-jondoe_realistic_photo_of_a_beautiful_well_built_woman_fully_en_e0b8b2fa-a638-4437-97fa-e8ed73ba61bb.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/thebeautyofcoding/nestjs_graphql_react_chat_app/e8af7140dcf95f8516735146b6d7445dad66a64d/backend/public/images/1693861664756-jondoe_realistic_photo_of_a_beautiful_well_built_woman_fully_en_e0b8b2fa-a638-4437-97fa-e8ed73ba61bb.png
--------------------------------------------------------------------------------
/backend/public/images/1693861737878-jondoe_realistic_photo_of_a_beautiful_well_built_woman_in_sport_56d3932e-1dec-46d4-a200-6484f1795aa3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/thebeautyofcoding/nestjs_graphql_react_chat_app/e8af7140dcf95f8516735146b6d7445dad66a64d/backend/public/images/1693861737878-jondoe_realistic_photo_of_a_beautiful_well_built_woman_in_sport_56d3932e-1dec-46d4-a200-6484f1795aa3.png
--------------------------------------------------------------------------------
/backend/public/images/2d1f7cb9-0b93-4f03-baa3-39a1ea5576bc_1j7rqt.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/thebeautyofcoding/nestjs_graphql_react_chat_app/e8af7140dcf95f8516735146b6d7445dad66a64d/backend/public/images/2d1f7cb9-0b93-4f03-baa3-39a1ea5576bc_1j7rqt.jpg
--------------------------------------------------------------------------------
/backend/public/images/a04accf7-5ab3-4e14-80d3-2c908add516a_2023-06-14 22_22_17-did we evolve from neanderthals – Google Suche.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/thebeautyofcoding/nestjs_graphql_react_chat_app/e8af7140dcf95f8516735146b6d7445dad66a64d/backend/public/images/a04accf7-5ab3-4e14-80d3-2c908add516a_2023-06-14 22_22_17-did we evolve from neanderthals – Google Suche.png
--------------------------------------------------------------------------------
/backend/public/images/ab8534c3-066b-49d2-9006-805579398316_gekko_end.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/thebeautyofcoding/nestjs_graphql_react_chat_app/e8af7140dcf95f8516735146b6d7445dad66a64d/backend/public/images/ab8534c3-066b-49d2-9006-805579398316_gekko_end.png
--------------------------------------------------------------------------------
/backend/public/images/b147838e-0814-4f99-98ed-164b4f39c47b_jondoe_create_a_beautiful_engaging_youtube_thumbnail_showing_a__df4bc82d-9dbe-449c-b69d-3ecf404abd07.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/thebeautyofcoding/nestjs_graphql_react_chat_app/e8af7140dcf95f8516735146b6d7445dad66a64d/backend/public/images/b147838e-0814-4f99-98ed-164b4f39c47b_jondoe_create_a_beautiful_engaging_youtube_thumbnail_showing_a__df4bc82d-9dbe-449c-b69d-3ecf404abd07.png
--------------------------------------------------------------------------------
/backend/public/images/fd8886d0-d01c-4f9c-a937-8aaa378cae94_jondoe_realistic_photo_of_a_beautiful_well_built_woman_in_sport_56d3932e-1dec-46d4-a200-6484f1795aa3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/thebeautyofcoding/nestjs_graphql_react_chat_app/e8af7140dcf95f8516735146b6d7445dad66a64d/backend/public/images/fd8886d0-d01c-4f9c-a937-8aaa378cae94_jondoe_realistic_photo_of_a_beautiful_well_built_woman_in_sport_56d3932e-1dec-46d4-a200-6484f1795aa3.png
--------------------------------------------------------------------------------
/backend/src/app.controller.spec.ts:
--------------------------------------------------------------------------------
1 | import { Test, TestingModule } from '@nestjs/testing';
2 | import { AppController } from './app.controller';
3 | import { AppService } from './app.service';
4 |
5 | describe('AppController', () => {
6 | let appController: AppController;
7 |
8 | beforeEach(async () => {
9 | const app: TestingModule = await Test.createTestingModule({
10 | controllers: [AppController],
11 | providers: [AppService],
12 | }).compile();
13 |
14 | appController = app.get(AppController);
15 | });
16 |
17 | describe('root', () => {
18 | it('should return "Hello World!"', () => {
19 | expect(appController.getHello()).toBe('Hello World!');
20 | });
21 | });
22 | });
23 |
--------------------------------------------------------------------------------
/backend/src/app.controller.ts:
--------------------------------------------------------------------------------
1 | import { Controller, Get } from '@nestjs/common';
2 | import { AppService } from './app.service';
3 |
4 | @Controller()
5 | export class AppController {
6 | constructor(private readonly appService: AppService) {}
7 |
8 | @Get()
9 | getHello(): string {
10 | return this.appService.getHello();
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/backend/src/app.module.ts:
--------------------------------------------------------------------------------
1 | import { Module } from '@nestjs/common';
2 | import { AppController } from './app.controller';
3 | import { AppService } from './app.service';
4 | import { AuthModule } from './auth/auth.module';
5 | import { UserModule } from './user/user.module';
6 | import { GraphQLModule } from '@nestjs/graphql';
7 | import { ApolloDriver } from '@nestjs/apollo';
8 | import { join } from 'path';
9 | import { ConfigModule, ConfigService } from '@nestjs/config';
10 | import { ServeStaticModule } from '@nestjs/serve-static';
11 | import { RedisPubSub } from 'graphql-redis-subscriptions';
12 | import { TokenService } from './token/token.service';
13 | import { ChatroomModule } from './chatroom/chatroom.module';
14 | import { LiveChatroomModule } from './live-chatroom/live-chatroom.module';
15 | const pubSub = new RedisPubSub({
16 | connection: {
17 | host: process.env.REDIS_HOST || 'localhost',
18 | port: parseInt(process.env.REDIS_PORT || '6379', 10),
19 | retryStrategy: (times) => {
20 | // retry strategy
21 | return Math.min(times * 50, 2000);
22 | },
23 | },
24 | });
25 |
26 | @Module({
27 | imports: [
28 | ServeStaticModule.forRoot({
29 | rootPath: join(__dirname, '..', 'public'),
30 | serveRoot: '/',
31 | }),
32 | AuthModule,
33 | UserModule,
34 | GraphQLModule.forRootAsync({
35 | imports: [ConfigModule, AppModule],
36 | inject: [ConfigService],
37 | driver: ApolloDriver,
38 | useFactory: async (
39 | configService: ConfigService,
40 |
41 | tokenService: TokenService,
42 | ) => {
43 | return {
44 | installSubscriptionHandlers: true,
45 | playground: true,
46 | autoSchemaFile: join(process.cwd(), 'src/schema.gql'),
47 | sortSchema: true,
48 | subscriptions: {
49 | 'graphql-ws': true,
50 | 'subscriptions-transport-ws': true,
51 | },
52 | onConnect: (connectionParams) => {
53 | const token = tokenService.extractToken(connectionParams);
54 |
55 | if (!token) {
56 | throw new Error('Token not provided');
57 | }
58 | const user = tokenService.validateToken(token);
59 | if (!user) {
60 | throw new Error('Invalid token');
61 | }
62 | return { user };
63 | },
64 | context: ({ req, res, connection }) => {
65 | if (connection) {
66 | return { req, res, user: connection.context.user, pubSub }; // Injecting pubSub into context
67 | }
68 | return { req, res };
69 | },
70 | };
71 | },
72 | }),
73 | ConfigModule.forRoot({
74 | isGlobal: true,
75 | }),
76 | ChatroomModule,
77 | LiveChatroomModule,
78 | ],
79 | controllers: [AppController],
80 | providers: [AppService],
81 | })
82 | export class AppModule {}
83 |
--------------------------------------------------------------------------------
/backend/src/app.service.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from '@nestjs/common';
2 |
3 | @Injectable()
4 | export class AppService {
5 | getHello(): string {
6 | return 'Hello World!';
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/backend/src/auth/auth.module.ts:
--------------------------------------------------------------------------------
1 | import { Module } from '@nestjs/common';
2 | import { AuthResolver } from './auth.resolver';
3 | import { AuthService } from './auth.service';
4 | import { JwtService } from '@nestjs/jwt';
5 | import { PrismaService } from 'src/prisma.service';
6 |
7 | @Module({
8 | providers: [AuthResolver, AuthService, JwtService, PrismaService],
9 | })
10 | export class AuthModule {}
11 |
--------------------------------------------------------------------------------
/backend/src/auth/auth.resolver.spec.ts:
--------------------------------------------------------------------------------
1 | import { Test, TestingModule } from '@nestjs/testing';
2 | import { AuthResolver } from './auth.resolver';
3 |
4 | describe('AuthResolver', () => {
5 | let resolver: AuthResolver;
6 |
7 | beforeEach(async () => {
8 | const module: TestingModule = await Test.createTestingModule({
9 | providers: [AuthResolver],
10 | }).compile();
11 |
12 | resolver = module.get(AuthResolver);
13 | });
14 |
15 | it('should be defined', () => {
16 | expect(resolver).toBeDefined();
17 | });
18 | });
19 |
--------------------------------------------------------------------------------
/backend/src/auth/auth.resolver.ts:
--------------------------------------------------------------------------------
1 | import { Resolver, Query, Mutation, Args, Context } from '@nestjs/graphql';
2 | import { AuthService } from './auth.service';
3 | import { LoginResponse, RegisterResponse } from './types';
4 | import { LoginDto, RegisterDto } from './dto';
5 | import { BadRequestException, UseFilters } from '@nestjs/common';
6 | import { Request, Response } from 'express';
7 | import { GraphQLErrorFilter } from 'src/filters/custom-exception.filter';
8 |
9 | @UseFilters(GraphQLErrorFilter)
10 | @Resolver()
11 | export class AuthResolver {
12 | constructor(private readonly authService: AuthService) {}
13 |
14 | @Mutation(() => RegisterResponse)
15 | async register(
16 | @Args('registerInput') registerDto: RegisterDto,
17 | @Context() context: { res: Response },
18 | ) {
19 | if (registerDto.password !== registerDto.confirmPassword) {
20 | throw new BadRequestException({
21 | confirmPassword: 'Password and confirm password are not the same.',
22 | });
23 | }
24 | const { user } = await this.authService.register(registerDto, context.res);
25 | return { user };
26 | }
27 |
28 | @Mutation(() => LoginResponse)
29 | async login(
30 | @Args('loginInput') loginDto: LoginDto,
31 | @Context() context: { res: Response },
32 | ) {
33 | return this.authService.login(loginDto, context.res);
34 | }
35 |
36 | @Mutation(() => String)
37 | async logout(@Context() context: { res: Response }) {
38 | return this.authService.logout(context.res);
39 | }
40 |
41 | @Query(() => String)
42 | async hello() {
43 | return 'hello';
44 | }
45 | @Mutation(() => String)
46 | async refreshToken(@Context() context: { req: Request; res: Response }) {
47 | try {
48 | return this.authService.refreshToken(context.req, context.res);
49 | } catch (error) {
50 | throw new BadRequestException(error.message);
51 | }
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/backend/src/auth/auth.service.spec.ts:
--------------------------------------------------------------------------------
1 | import { Test, TestingModule } from '@nestjs/testing';
2 | import { AuthService } from './auth.service';
3 |
4 | describe('AuthService', () => {
5 | let service: AuthService;
6 |
7 | beforeEach(async () => {
8 | const module: TestingModule = await Test.createTestingModule({
9 | providers: [AuthService],
10 | }).compile();
11 |
12 | service = module.get(AuthService);
13 | });
14 |
15 | it('should be defined', () => {
16 | expect(service).toBeDefined();
17 | });
18 | });
19 |
--------------------------------------------------------------------------------
/backend/src/auth/auth.service.ts:
--------------------------------------------------------------------------------
1 | import {
2 | BadRequestException,
3 | Injectable,
4 | UnauthorizedException,
5 | } from '@nestjs/common';
6 | import { JwtService } from '@nestjs/jwt';
7 | import { PrismaService } from '../prisma.service';
8 | import { ConfigService } from '@nestjs/config';
9 | import { Request, Response } from 'express';
10 | import { User } from '@prisma/client';
11 | import { LoginDto, RegisterDto } from './dto';
12 | import * as bcrypt from 'bcrypt';
13 | @Injectable()
14 | export class AuthService {
15 | constructor(
16 | private readonly jwtService: JwtService,
17 | private readonly prisma: PrismaService,
18 | private readonly configService: ConfigService,
19 | ) {}
20 |
21 | async refreshToken(req: Request, res: Response) {
22 | const refreshToken = req.cookies['refresh_token'];
23 |
24 | if (!refreshToken) {
25 | throw new UnauthorizedException('Refresh token not found');
26 | }
27 | let payload;
28 |
29 | try {
30 | payload = this.jwtService.verify(refreshToken, {
31 | secret: this.configService.get('REFRESH_TOKEN_SECRET'),
32 | });
33 | } catch (error) {
34 | throw new UnauthorizedException('Invalid or expired refresh token');
35 | }
36 | const userExists = await this.prisma.user.findUnique({
37 | where: { id: payload.sub },
38 | });
39 |
40 | if (!userExists) {
41 | throw new BadRequestException('User no longer exists');
42 | }
43 |
44 | const expiresIn = 15000;
45 | const expiration = Math.floor(Date.now() / 1000) + expiresIn;
46 | const accessToken = this.jwtService.sign(
47 | { ...payload, exp: expiration },
48 | {
49 | secret: this.configService.get('ACCESS_TOKEN_SECRET'),
50 | },
51 | );
52 | res.cookie('access_token', accessToken, { httpOnly: true });
53 |
54 | return accessToken;
55 | }
56 | private async issueTokens(user: User, response: Response) {
57 | const payload = { username: user.fullname, sub: user.id };
58 |
59 | const accessToken = this.jwtService.sign(
60 | { ...payload },
61 | {
62 | secret: this.configService.get('ACCESS_TOKEN_SECRET'),
63 | expiresIn: '150sec',
64 | },
65 | );
66 | const refreshToken = this.jwtService.sign(payload, {
67 | secret: this.configService.get('REFRESH_TOKEN_SECRET'),
68 | expiresIn: '7d',
69 | });
70 |
71 | response.cookie('access_token', accessToken, { httpOnly: true });
72 | response.cookie('refresh_token', refreshToken, {
73 | httpOnly: true,
74 | });
75 | return { user };
76 | }
77 |
78 | async validateUser(loginDto: LoginDto) {
79 | const user = await this.prisma.user.findUnique({
80 | where: { email: loginDto.email },
81 | });
82 | if (user && (await bcrypt.compare(loginDto.password, user.password))) {
83 | return user;
84 | }
85 | return null;
86 | }
87 | async register(registerDto: RegisterDto, response: Response) {
88 | const existingUser = await this.prisma.user.findUnique({
89 | where: { email: registerDto.email },
90 | });
91 | if (existingUser) {
92 | throw new BadRequestException({ email: 'Email already in use' });
93 | }
94 | const hashedPassword = await bcrypt.hash(registerDto.password, 10);
95 | const user = await this.prisma.user.create({
96 | data: {
97 | fullname: registerDto.fullname,
98 | password: hashedPassword,
99 | email: registerDto.email,
100 | },
101 | });
102 | return this.issueTokens(user, response);
103 | }
104 |
105 | async login(loginDto: LoginDto, response: Response) {
106 | const user = await this.validateUser(loginDto);
107 | if (!user) {
108 | throw new BadRequestException({
109 | invalidCredentials: 'Invalid credentials',
110 | });
111 | }
112 | return this.issueTokens(user, response);
113 | }
114 | async logout(response: Response) {
115 | response.clearCookie('access_token');
116 | response.clearCookie('refresh_token');
117 | return 'Successfully logged out';
118 | }
119 | }
120 |
--------------------------------------------------------------------------------
/backend/src/auth/dto.ts:
--------------------------------------------------------------------------------
1 | import { InputType, Field } from '@nestjs/graphql';
2 | import { IsEmail, IsNotEmpty, MinLength, IsString } from 'class-validator';
3 |
4 | @InputType()
5 | export class RegisterDto {
6 | @Field()
7 | @IsNotEmpty({ message: 'Fullname is required.' })
8 | @IsString({ message: 'Fullname must be a string.' })
9 | fullname: string;
10 |
11 | @Field()
12 | @IsNotEmpty({ message: 'Password is required.' })
13 | @MinLength(8, { message: 'Password must be at least 8 characters.' })
14 | password: string;
15 |
16 | // confirm password must be the same as password
17 |
18 | @Field()
19 | @IsNotEmpty({ message: 'Confirm Password is required.' })
20 | // must be the same as password
21 | confirmPassword: string;
22 |
23 | @Field()
24 | @IsNotEmpty({ message: 'Email is required.' })
25 | @IsEmail({}, { message: 'Email must be valid.' })
26 | email: string;
27 | }
28 |
29 | @InputType()
30 | export class LoginDto {
31 | @Field()
32 | @IsNotEmpty({ message: 'Email is required.' })
33 | @IsEmail({}, { message: 'Email must be valid.' })
34 | email: string;
35 |
36 | @Field()
37 | @IsNotEmpty({ message: 'Password is required.' })
38 | password: string;
39 | }
40 |
--------------------------------------------------------------------------------
/backend/src/auth/graphql-auth.guard.ts:
--------------------------------------------------------------------------------
1 | import {
2 | CanActivate,
3 | ExecutionContext,
4 | Injectable,
5 | UnauthorizedException,
6 | } from '@nestjs/common';
7 | import { JwtService } from '@nestjs/jwt';
8 | import { ConfigService } from '@nestjs/config';
9 | import { Request } from 'express';
10 |
11 | @Injectable()
12 | export class GraphqlAuthGuard implements CanActivate {
13 | constructor(
14 | private jwtService: JwtService,
15 | private configService: ConfigService,
16 | ) {}
17 |
18 | async canActivate(context: ExecutionContext): Promise {
19 | const gqlCtx = context.getArgByIndex(2);
20 | const request: Request = gqlCtx.req;
21 | const token = this.extractTokenFromCookie(request);
22 |
23 | if (!token) {
24 | throw new UnauthorizedException();
25 | }
26 | try {
27 | const payload = await this.jwtService.verifyAsync(token, {
28 | secret: this.configService.get('ACCESS_TOKEN_SECRET'),
29 | });
30 |
31 | request['user'] = payload;
32 | } catch (err) {
33 | throw new UnauthorizedException();
34 | }
35 |
36 | return true;
37 | }
38 |
39 | private extractTokenFromCookie(request: Request): string | undefined {
40 | return request.cookies?.access_token;
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/backend/src/auth/types.ts:
--------------------------------------------------------------------------------
1 | import { Field, ObjectType } from '@nestjs/graphql';
2 | import { User } from 'src/user/user.type';
3 |
4 | @ObjectType()
5 | export class RegisterResponse {
6 | @Field(() => User, { nullable: true })
7 | user?: User;
8 | }
9 | @ObjectType()
10 | export class LoginResponse {
11 | @Field(() => User)
12 | user: User;
13 | }
14 |
--------------------------------------------------------------------------------
/backend/src/chatroom/chatroom.module.ts:
--------------------------------------------------------------------------------
1 | import { Module } from '@nestjs/common';
2 | import { ChatroomService } from './chatroom.service';
3 | import { ChatroomResolver } from './chatroom.resolver';
4 | import { PrismaService } from 'src/prisma.service';
5 | import { UserService } from 'src/user/user.service';
6 | import { JwtService } from '@nestjs/jwt';
7 |
8 | @Module({
9 | providers: [
10 | ChatroomService,
11 | ChatroomResolver,
12 | PrismaService,
13 | UserService,
14 | JwtService,
15 | ],
16 | })
17 | export class ChatroomModule {}
18 |
--------------------------------------------------------------------------------
/backend/src/chatroom/chatroom.resolver.spec.ts:
--------------------------------------------------------------------------------
1 | import { Test, TestingModule } from '@nestjs/testing';
2 | import { ChatroomResolver } from './chatroom.resolver';
3 |
4 | describe('ChatroomResolver', () => {
5 | let resolver: ChatroomResolver;
6 |
7 | beforeEach(async () => {
8 | const module: TestingModule = await Test.createTestingModule({
9 | providers: [ChatroomResolver],
10 | }).compile();
11 |
12 | resolver = module.get(ChatroomResolver);
13 | });
14 |
15 | it('should be defined', () => {
16 | expect(resolver).toBeDefined();
17 | });
18 | });
19 |
--------------------------------------------------------------------------------
/backend/src/chatroom/chatroom.resolver.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Args,
3 | Context,
4 | Mutation,
5 | Query,
6 | Resolver,
7 | Subscription,
8 | } from '@nestjs/graphql';
9 | import { ChatroomService } from './chatroom.service';
10 | import { UserService } from 'src/user/user.service';
11 | import { GraphQLErrorFilter } from 'src/filters/custom-exception.filter';
12 | import { UseFilters, UseGuards } from '@nestjs/common';
13 | import { GraphqlAuthGuard } from 'src/auth/graphql-auth.guard';
14 | import { Chatroom, Message } from './chatroom.types';
15 | import { Request } from 'express';
16 | import { PubSub } from 'graphql-subscriptions';
17 | import { User } from 'src/user/user.type';
18 |
19 | import * as GraphQLUpload from 'graphql-upload/GraphQLUpload.js';
20 |
21 | @Resolver()
22 | export class ChatroomResolver {
23 | public pubSub: PubSub;
24 | constructor(
25 | private readonly chatroomService: ChatroomService,
26 | private readonly userService: UserService,
27 | ) {
28 | this.pubSub = new PubSub();
29 | }
30 |
31 | @Subscription((returns) => Message, {
32 | nullable: true,
33 | resolve: (value) => value.newMessage,
34 | })
35 | newMessage(@Args('chatroomId') chatroomId: number) {
36 | return this.pubSub.asyncIterator(`newMessage.${chatroomId}`);
37 | }
38 | @Subscription(() => User, {
39 | nullable: true,
40 | resolve: (value) => value.user,
41 | filter: (payload, variables) => {
42 | console.log('payload1', variables, payload.typingUserId);
43 | return variables.userId !== payload.typingUserId;
44 | },
45 | })
46 | userStartedTyping(
47 | @Args('chatroomId') chatroomId: number,
48 | @Args('userId') userId: number,
49 | ) {
50 | return this.pubSub.asyncIterator(`userStartedTyping.${chatroomId}`);
51 | }
52 |
53 | @Subscription(() => User, {
54 | nullable: true,
55 | resolve: (value) => value.user,
56 | filter: (payload, variables) => {
57 | return variables.userId !== payload.typingUserId;
58 | },
59 | })
60 | userStoppedTyping(
61 | @Args('chatroomId') chatroomId: number,
62 | @Args('userId') userId: number,
63 | ) {
64 | return this.pubSub.asyncIterator(`userStoppedTyping.${chatroomId}`);
65 | }
66 |
67 | @UseFilters(GraphQLErrorFilter)
68 | @UseGuards(GraphqlAuthGuard)
69 | @Mutation((returns) => User)
70 | async userStartedTypingMutation(
71 | @Args('chatroomId') chatroomId: number,
72 | @Context() context: { req: Request },
73 | ) {
74 | const user = await this.userService.getUser(context.req.user.sub);
75 | await this.pubSub.publish(`userStartedTyping.${chatroomId}`, {
76 | user,
77 | typingUserId: user.id,
78 | });
79 | return user;
80 | }
81 | @UseFilters(GraphQLErrorFilter)
82 | @UseGuards(GraphqlAuthGuard)
83 | @Mutation(() => User, {})
84 | async userStoppedTypingMutation(
85 | @Args('chatroomId') chatroomId: number,
86 | @Context() context: { req: Request },
87 | ) {
88 | const user = await this.userService.getUser(context.req.user.sub);
89 |
90 | await this.pubSub.publish(`userStoppedTyping.${chatroomId}`, {
91 | user,
92 | typingUserId: user.id,
93 | });
94 |
95 | return user;
96 | }
97 |
98 | @UseGuards(GraphqlAuthGuard)
99 | @Mutation(() => Message)
100 | async sendMessage(
101 | @Args('chatroomId') chatroomId: number,
102 | @Args('content') content: string,
103 | @Context() context: { req: Request },
104 | @Args('image', { type: () => GraphQLUpload, nullable: true })
105 | image?: GraphQLUpload,
106 | ) {
107 | let imagePath = null;
108 | if (image) imagePath = await this.chatroomService.saveImage(image);
109 | const newMessage = await this.chatroomService.sendMessage(
110 | chatroomId,
111 | content,
112 | context.req.user.sub,
113 | imagePath,
114 | );
115 | await this.pubSub
116 | .publish(`newMessage.${chatroomId}`, { newMessage })
117 | .then((res) => {
118 | console.log('published', res);
119 | })
120 | .catch((err) => {
121 | console.log('err', err);
122 | });
123 |
124 | return newMessage;
125 | }
126 |
127 | @UseFilters(GraphQLErrorFilter)
128 | @UseGuards(GraphqlAuthGuard)
129 | @Mutation(() => Chatroom)
130 | async createChatroom(
131 | @Args('name') name: string,
132 | @Context() context: { req: Request },
133 | ) {
134 | return this.chatroomService.createChatroom(name, context.req.user.sub);
135 | }
136 |
137 | @Mutation(() => Chatroom)
138 | async addUsersToChatroom(
139 | @Args('chatroomId') chatroomId: number,
140 | @Args('userIds', { type: () => [Number] }) userIds: number[],
141 | ) {
142 | return this.chatroomService.addUsersToChatroom(chatroomId, userIds);
143 | }
144 |
145 | @Query(() => [Chatroom])
146 | async getChatroomsForUser(@Args('userId') userId: number) {
147 | return this.chatroomService.getChatroomsForUser(userId);
148 | }
149 |
150 | @Query(() => [Message])
151 | async getMessagesForChatroom(@Args('chatroomId') chatroomId: number) {
152 | return this.chatroomService.getMessagesForChatroom(chatroomId);
153 | }
154 | @Mutation(() => String)
155 | async deleteChatroom(@Args('chatroomId') chatroomId: number) {
156 | await this.chatroomService.deleteChatroom(chatroomId);
157 | return 'Chatroom deleted successfully';
158 | }
159 | }
160 |
--------------------------------------------------------------------------------
/backend/src/chatroom/chatroom.service.spec.ts:
--------------------------------------------------------------------------------
1 | import { Test, TestingModule } from '@nestjs/testing';
2 | import { ChatroomService } from './chatroom.service';
3 |
4 | describe('ChatroomService', () => {
5 | let service: ChatroomService;
6 |
7 | beforeEach(async () => {
8 | const module: TestingModule = await Test.createTestingModule({
9 | providers: [ChatroomService],
10 | }).compile();
11 |
12 | service = module.get(ChatroomService);
13 | });
14 |
15 | it('should be defined', () => {
16 | expect(service).toBeDefined();
17 | });
18 | });
19 |
--------------------------------------------------------------------------------
/backend/src/chatroom/chatroom.service.ts:
--------------------------------------------------------------------------------
1 | import { BadRequestException, Injectable } from '@nestjs/common';
2 | import { ConfigService } from '@nestjs/config';
3 | import { createWriteStream } from 'fs';
4 | import { PrismaService } from 'src/prisma.service';
5 |
6 | @Injectable()
7 | export class ChatroomService {
8 | constructor(
9 | private readonly prisma: PrismaService,
10 | private readonly configService: ConfigService,
11 | ) {}
12 |
13 | async getChatroom(id: string) {
14 | return this.prisma.chatroom.findUnique({
15 | where: {
16 | id: parseInt(id),
17 | },
18 | });
19 | }
20 |
21 | async createChatroom(name: string, sub: number) {
22 | const existingChatroom = await this.prisma.chatroom.findFirst({
23 | where: {
24 | name,
25 | },
26 | });
27 | if (existingChatroom) {
28 | throw new BadRequestException({ name: 'Chatroom already exists' });
29 | }
30 | return this.prisma.chatroom.create({
31 | data: {
32 | name,
33 | users: {
34 | connect: {
35 | id: sub,
36 | },
37 | },
38 | },
39 | });
40 | }
41 |
42 | async addUsersToChatroom(chatroomId: number, userIds: number[]) {
43 | const existingChatroom = await this.prisma.chatroom.findUnique({
44 | where: {
45 | id: chatroomId,
46 | },
47 | });
48 | if (!existingChatroom) {
49 | throw new BadRequestException({ chatroomId: 'Chatroom does not exist' });
50 | }
51 |
52 | return await this.prisma.chatroom.update({
53 | where: {
54 | id: chatroomId,
55 | },
56 | data: {
57 | users: {
58 | connect: userIds.map((id) => ({ id: id })),
59 | },
60 | },
61 | include: {
62 | users: true, // Eager loading users
63 | },
64 | });
65 | }
66 | async getChatroomsForUser(userId: number) {
67 | return this.prisma.chatroom.findMany({
68 | where: {
69 | users: {
70 | some: {
71 | id: userId,
72 | },
73 | },
74 | },
75 | include: {
76 | users: {
77 | orderBy: {
78 | createdAt: 'desc',
79 | },
80 | }, // Eager loading users
81 |
82 | messages: {
83 | take: 1,
84 | orderBy: {
85 | createdAt: 'desc',
86 | },
87 | },
88 | },
89 | });
90 | }
91 | async sendMessage(
92 | chatroomId: number,
93 | message: string,
94 | userId: number,
95 | imagePath: string,
96 | ) {
97 | return await this.prisma.message.create({
98 | data: {
99 | content: message,
100 | imageUrl: imagePath,
101 | chatroomId,
102 | userId,
103 | },
104 | include: {
105 | chatroom: {
106 | include: {
107 | users: true, // Eager loading users
108 | },
109 | }, // Eager loading Chatroom
110 | user: true, // Eager loading User
111 | },
112 | });
113 | }
114 |
115 | async saveImage(image: {
116 | createReadStream: () => any;
117 | filename: string;
118 | mimetype: string;
119 | }) {
120 | const validImageTypes = ['image/jpeg', 'image/png', 'image/gif'];
121 | if (!validImageTypes.includes(image.mimetype)) {
122 | throw new BadRequestException({ image: 'Invalid image type' });
123 | }
124 |
125 | const imageName = `${Date.now()}-${image.filename}`;
126 | const imagePath = `${this.configService.get('IMAGE_PATH')}/${imageName}`;
127 | const stream = image.createReadStream();
128 | const outputPath = `public${imagePath}`;
129 | const writeStream = createWriteStream(outputPath);
130 | stream.pipe(writeStream);
131 |
132 | await new Promise((resolve, reject) => {
133 | stream.on('end', resolve);
134 | stream.on('error', reject);
135 | });
136 |
137 | return imagePath;
138 | }
139 | async getMessagesForChatroom(chatroomId: number) {
140 | return await this.prisma.message.findMany({
141 | where: {
142 | chatroomId: chatroomId,
143 | },
144 | include: {
145 | chatroom: {
146 | include: {
147 | users: {
148 | orderBy: {
149 | createdAt: 'asc',
150 | },
151 | }, // Eager loading users
152 | },
153 | }, // Eager loading Chatroom
154 | user: true, // Eager loading User
155 | },
156 | });
157 | }
158 |
159 | async deleteChatroom(chatroomId: number) {
160 | return this.prisma.chatroom.delete({
161 | where: {
162 | id: chatroomId,
163 | },
164 | });
165 | }
166 | }
167 |
--------------------------------------------------------------------------------
/backend/src/chatroom/chatroom.types.ts:
--------------------------------------------------------------------------------
1 | import { Field, ObjectType, ID } from '@nestjs/graphql';
2 | import { User } from 'src/user/user.type';
3 |
4 | @ObjectType()
5 | export class Chatroom {
6 | @Field(() => ID, { nullable: true })
7 | id?: string;
8 |
9 | @Field({ nullable: true })
10 | name?: string;
11 |
12 | @Field({ nullable: true })
13 | createdAt?: Date;
14 |
15 | @Field({ nullable: true })
16 | updatedAt?: Date;
17 |
18 | @Field(() => [User], { nullable: true }) // array of user IDs
19 | users?: User[];
20 |
21 | @Field(() => [Message], { nullable: true }) // array of message IDs
22 | messages?: Message[];
23 | }
24 |
25 | @ObjectType()
26 | export class Message {
27 | @Field(() => ID, { nullable: true })
28 | id?: string;
29 |
30 | @Field({ nullable: true })
31 | imageUrl?: string;
32 |
33 | @Field({ nullable: true })
34 | content?: string;
35 |
36 | @Field({ nullable: true })
37 | createdAt?: Date;
38 |
39 | @Field({ nullable: true })
40 | updatedAt?: Date;
41 |
42 | @Field(() => Chatroom, { nullable: true }) // array of user IDs
43 | chatroom?: Chatroom;
44 |
45 | @Field(() => User, { nullable: true }) // array of user IDs
46 | user?: User;
47 | }
48 |
49 | @ObjectType()
50 | export class UserTyping {
51 | @Field(() => User, { nullable: true })
52 | user?: User;
53 |
54 | @Field({ nullable: true })
55 | chatroomId?: number;
56 | }
57 |
58 | @ObjectType()
59 | export class UserStoppedTyping extends UserTyping {}
60 |
--------------------------------------------------------------------------------
/backend/src/chatroom/dto.ts:
--------------------------------------------------------------------------------
1 | import { InputType, Field } from '@nestjs/graphql';
2 | import { IsNotEmpty, IsString, IsArray } from 'class-validator';
3 |
4 | @InputType()
5 | export class CreateChatroomDto {
6 | @Field()
7 | @IsString()
8 | @IsNotEmpty({ message: 'Name is required.' })
9 | name: string;
10 | @IsArray()
11 | @Field(() => [String])
12 | userIds: string[];
13 | }
14 |
--------------------------------------------------------------------------------
/backend/src/filters/custom-exception.filter.ts:
--------------------------------------------------------------------------------
1 | import { ApolloError } from 'apollo-server-express';
2 | import { ArgumentsHost, Catch, BadRequestException } from '@nestjs/common';
3 |
4 | import { GqlExceptionFilter } from '@nestjs/graphql';
5 | @Catch(BadRequestException)
6 | export class GraphQLErrorFilter implements GqlExceptionFilter {
7 | catch(exception: BadRequestException) {
8 | const response = exception.getResponse();
9 |
10 | if (typeof response === 'object') {
11 | // Directly throw ApolloError with the response object.
12 | throw new ApolloError('Validation error', 'VALIDATION_ERROR', response);
13 | } else {
14 | throw new ApolloError('Bad Request');
15 | }
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/backend/src/live-chatroom/live-chatroom.module.ts:
--------------------------------------------------------------------------------
1 | import { Module } from '@nestjs/common';
2 | import { LiveChatroomResolver } from './live-chatroom.resolver';
3 | import { LiveChatroomService } from './live-chatroom.service';
4 | import { UserService } from 'src/user/user.service';
5 | import { PrismaService } from 'src/prisma.service';
6 | import { JwtService } from '@nestjs/jwt';
7 |
8 | @Module({
9 | providers: [
10 | LiveChatroomResolver,
11 | LiveChatroomService,
12 | UserService,
13 | PrismaService,
14 | JwtService,
15 | ],
16 | })
17 | export class LiveChatroomModule {}
18 |
--------------------------------------------------------------------------------
/backend/src/live-chatroom/live-chatroom.resolver.spec.ts:
--------------------------------------------------------------------------------
1 | import { Test, TestingModule } from '@nestjs/testing';
2 | import { LiveChatroomResolver } from './live-chatroom.resolver';
3 |
4 | describe('LiveChatroomResolver', () => {
5 | let resolver: LiveChatroomResolver;
6 |
7 | beforeEach(async () => {
8 | const module: TestingModule = await Test.createTestingModule({
9 | providers: [LiveChatroomResolver],
10 | }).compile();
11 |
12 | resolver = module.get(LiveChatroomResolver);
13 | });
14 |
15 | it('should be defined', () => {
16 | expect(resolver).toBeDefined();
17 | });
18 | });
19 |
--------------------------------------------------------------------------------
/backend/src/live-chatroom/live-chatroom.resolver.ts:
--------------------------------------------------------------------------------
1 | import { Resolver } from '@nestjs/graphql';
2 | import { PubSub } from 'graphql-subscriptions';
3 | import { User } from 'src/user/user.type';
4 | import { LiveChatroomService } from './live-chatroom.service';
5 | import { UserService } from 'src/user/user.service';
6 | import { Subscription, Args, Context, Mutation } from '@nestjs/graphql';
7 | import { Request } from 'express';
8 | import { UseFilters, UseGuards } from '@nestjs/common';
9 | import { GraphqlAuthGuard } from 'src/auth/graphql-auth.guard';
10 | import { GraphQLErrorFilter } from 'src/filters/custom-exception.filter';
11 | @Resolver()
12 | export class LiveChatroomResolver {
13 | private pubSub: PubSub;
14 | constructor(
15 | private readonly liveChatroomService: LiveChatroomService,
16 | private readonly userService: UserService,
17 | ) {
18 | this.pubSub = new PubSub();
19 | }
20 |
21 | @Subscription(() => [User], {
22 | nullable: true,
23 | resolve: (value) => value.liveUsers,
24 | filter: (payload, variables) => {
25 | return payload.chatroomId === variables.chatroomId;
26 | },
27 | })
28 | liveUsersInChatroom(@Args('chatroomId') chatroomId: number) {
29 | return this.pubSub.asyncIterator(`liveUsersInChatroom.${chatroomId}`);
30 | }
31 |
32 | @UseFilters(GraphQLErrorFilter)
33 | @UseGuards(GraphqlAuthGuard)
34 | @Mutation(() => Boolean)
35 | async enterChatroom(
36 | @Args('chatroomId') chatroomId: number,
37 | @Context() context: { req: Request },
38 | ) {
39 | const user = await this.userService.getUser(context.req.user.sub);
40 | await this.liveChatroomService.addLiveUserToChatroom(chatroomId, user);
41 | const liveUsers = await this.liveChatroomService
42 | .getLiveUsersForChatroom(chatroomId)
43 | .catch((err) => {
44 | console.log('getLiveUsersForChatroom error', err);
45 | });
46 |
47 | await this.pubSub
48 | .publish(`liveUsersInChatroom.${chatroomId}`, {
49 | liveUsers,
50 | chatroomId,
51 | })
52 | .catch((err) => {
53 | console.log('pubSub error', err);
54 | });
55 | return true;
56 | }
57 |
58 | @UseFilters(GraphQLErrorFilter)
59 | @UseGuards(GraphqlAuthGuard)
60 | @Mutation(() => Boolean)
61 | async leaveChatroom(
62 | @Args('chatroomId') chatroomId: number,
63 | @Context() context: { req: Request },
64 | ) {
65 | const user = await this.userService.getUser(context.req.user.sub);
66 | await this.liveChatroomService.removeLiveUserFromChatroom(chatroomId, user);
67 | const liveUsers = await this.liveChatroomService.getLiveUsersForChatroom(
68 | chatroomId,
69 | );
70 | await this.pubSub.publish(`liveUsersInChatroom.${chatroomId}`, {
71 | liveUsers,
72 | chatroomId,
73 | });
74 |
75 | return true;
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/backend/src/live-chatroom/live-chatroom.service.spec.ts:
--------------------------------------------------------------------------------
1 | import { Test, TestingModule } from '@nestjs/testing';
2 | import { LiveChatroomService } from './live-chatroom.service';
3 |
4 | describe('LiveChatroomService', () => {
5 | let service: LiveChatroomService;
6 |
7 | beforeEach(async () => {
8 | const module: TestingModule = await Test.createTestingModule({
9 | providers: [LiveChatroomService],
10 | }).compile();
11 |
12 | service = module.get(LiveChatroomService);
13 | });
14 |
15 | it('should be defined', () => {
16 | expect(service).toBeDefined();
17 | });
18 | });
19 |
--------------------------------------------------------------------------------
/backend/src/live-chatroom/live-chatroom.service.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from '@nestjs/common';
2 | import Redis from 'ioredis';
3 | import { User } from '../user/user.type';
4 |
5 | @Injectable()
6 | export class LiveChatroomService {
7 | private redisClient: Redis;
8 |
9 | constructor() {
10 | this.redisClient = new Redis({
11 | host: process.env.REDIS_HOST || 'localhost',
12 | port: parseInt(process.env.REDIS_PORT || '6379', 10),
13 | });
14 | }
15 |
16 | async addLiveUserToChatroom(chatroomId: number, user: User): Promise {
17 | const existingLiveUsers = await this.getLiveUsersForChatroom(chatroomId);
18 |
19 | const existingUser = existingLiveUsers.find(
20 | (liveUser) => liveUser.id === user.id,
21 | );
22 | if (existingUser) {
23 | return;
24 | }
25 | await this.redisClient.sadd(
26 | `liveUsers:chatroom:${chatroomId}`,
27 | JSON.stringify(user),
28 | );
29 | }
30 |
31 | async removeLiveUserFromChatroom(
32 | chatroomId: number,
33 | user: User,
34 | ): Promise {
35 | await this.redisClient
36 | .srem(`liveUsers:chatroom:${chatroomId}`, JSON.stringify(user))
37 | .catch((err) => {
38 | console.log('removeLiveUserFromChatroom error', err);
39 | })
40 | .then((res) => {
41 | console.log('removeLiveUserFromChatroom res', res);
42 | });
43 | }
44 | async getLiveUsersForChatroom(chatroomId: number): Promise {
45 | const users = await this.redisClient.smembers(
46 | `liveUsers:chatroom:${chatroomId}`,
47 | );
48 |
49 | return users.map((user) => JSON.parse(user));
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/backend/src/main.ts:
--------------------------------------------------------------------------------
1 | import { NestFactory } from '@nestjs/core';
2 | import { AppModule } from './app.module';
3 | import * as cookieParser from 'cookie-parser';
4 |
5 | import * as graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.js';
6 | import { BadRequestException, ValidationPipe } from '@nestjs/common';
7 |
8 | async function bootstrap() {
9 | const app = await NestFactory.create(AppModule);
10 | app.enableCors({
11 | origin: 'http://localhost:5173',
12 | credentials: true,
13 | // all headers that client are allowed to use
14 | allowedHeaders: [
15 | 'Accept',
16 | 'Authorization',
17 | 'Content-Type',
18 | 'X-Requested-With',
19 | 'apollo-require-preflight',
20 | ],
21 | methods: ['GET', 'PUT', 'POST', 'DELETE', 'OPTIONS'],
22 | });
23 | app.use(cookieParser());
24 | app.use(graphqlUploadExpress({ maxFileSize: 10000000000, maxFiles: 1 }));
25 | app.useGlobalPipes(
26 | new ValidationPipe({
27 | whitelist: true,
28 | transform: true,
29 | exceptionFactory: (errors) => {
30 | const formattedErrors = errors.reduce((accumulator, error) => {
31 | accumulator[error.property] = Object.values(error.constraints).join(
32 | ', ',
33 | );
34 | return accumulator;
35 | }, {});
36 |
37 | throw new BadRequestException(formattedErrors);
38 | },
39 | }),
40 | );
41 | await app.listen(3000);
42 | }
43 | bootstrap();
44 |
--------------------------------------------------------------------------------
/backend/src/prisma.service.ts:
--------------------------------------------------------------------------------
1 | import { Injectable, OnModuleInit } from '@nestjs/common';
2 | import { PrismaClient } from '@prisma/client';
3 |
4 | @Injectable()
5 | export class PrismaService extends PrismaClient implements OnModuleInit {
6 | async onModuleInit() {
7 | await this.$connect();
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/backend/src/schema.gql:
--------------------------------------------------------------------------------
1 | # ------------------------------------------------------
2 | # THIS FILE WAS AUTOMATICALLY GENERATED (DO NOT MODIFY)
3 | # ------------------------------------------------------
4 |
5 | type Chatroom {
6 | createdAt: DateTime
7 | id: ID
8 | messages: [Message!]
9 | name: String
10 | updatedAt: DateTime
11 | users: [User!]
12 | }
13 |
14 | """
15 | A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date-time format.
16 | """
17 | scalar DateTime
18 |
19 | input LoginDto {
20 | email: String!
21 | password: String!
22 | }
23 |
24 | type LoginResponse {
25 | user: User!
26 | }
27 |
28 | type Message {
29 | chatroom: Chatroom
30 | content: String
31 | createdAt: DateTime
32 | id: ID
33 | imageUrl: String
34 | updatedAt: DateTime
35 | user: User
36 | }
37 |
38 | type Mutation {
39 | addUsersToChatroom(chatroomId: Float!, userIds: [Float!]!): Chatroom!
40 | createChatroom(name: String!): Chatroom!
41 | deleteChatroom(chatroomId: Float!): String!
42 | enterChatroom(chatroomId: Float!): Boolean!
43 | leaveChatroom(chatroomId: Float!): Boolean!
44 | login(loginInput: LoginDto!): LoginResponse!
45 | logout: String!
46 | refreshToken: String!
47 | register(registerInput: RegisterDto!): RegisterResponse!
48 | sendMessage(chatroomId: Float!, content: String!, image: Upload): Message!
49 | updateProfile(file: Upload, fullname: String!): User!
50 | userStartedTypingMutation(chatroomId: Float!): User!
51 | userStoppedTypingMutation(chatroomId: Float!): User!
52 | }
53 |
54 | type Query {
55 | getChatroomsForUser(userId: Float!): [Chatroom!]!
56 | getMessagesForChatroom(chatroomId: Float!): [Message!]!
57 | getUsersOfChatroom(chatroomId: Float!): [User!]!
58 | hello: String!
59 | searchUsers(fullname: String!): [User!]!
60 | }
61 |
62 | input RegisterDto {
63 | confirmPassword: String!
64 | email: String!
65 | fullname: String!
66 | password: String!
67 | }
68 |
69 | type RegisterResponse {
70 | user: User
71 | }
72 |
73 | type Subscription {
74 | liveUsersInChatroom(chatroomId: Float!): [User!]
75 | newMessage(chatroomId: Float!): Message
76 | userStartedTyping(chatroomId: Float!, userId: Float!): User
77 | userStoppedTyping(chatroomId: Float!, userId: Float!): User
78 | }
79 |
80 | """The `Upload` scalar type represents a file upload."""
81 | scalar Upload
82 |
83 | type User {
84 | avatarUrl: String
85 | createdAt: DateTime
86 | email: String!
87 | fullname: String!
88 | id: Float
89 | password: String
90 | updatedAt: DateTime
91 | }
--------------------------------------------------------------------------------
/backend/src/token/token.service.spec.ts:
--------------------------------------------------------------------------------
1 | import { Test, TestingModule } from '@nestjs/testing';
2 | import { TokenService } from './token.service';
3 |
4 | describe('TokenService', () => {
5 | let service: TokenService;
6 |
7 | beforeEach(async () => {
8 | const module: TestingModule = await Test.createTestingModule({
9 | providers: [TokenService],
10 | }).compile();
11 |
12 | service = module.get(TokenService);
13 | });
14 |
15 | it('should be defined', () => {
16 | expect(service).toBeDefined();
17 | });
18 | });
19 |
--------------------------------------------------------------------------------
/backend/src/token/token.service.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from '@nestjs/common';
2 | import { ConfigService } from '@nestjs/config';
3 | import { verify } from 'jsonwebtoken';
4 | @Injectable()
5 | export class TokenService {
6 | constructor(private configService: ConfigService) {}
7 |
8 | extractToken(connectionParams: any): string | null {
9 | return connectionParams?.token || null;
10 | }
11 |
12 | validateToken(token: string): any {
13 | const refreshTokenSecret = this.configService.get(
14 | 'REFRESH_TOKEN_SECRET',
15 | );
16 | try {
17 | return verify(token, refreshTokenSecret);
18 | } catch (error) {
19 | return null;
20 | }
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/backend/src/types.d.ts:
--------------------------------------------------------------------------------
1 | declare namespace Express {
2 | export interface Request {
3 | user?: {
4 | username: string;
5 | sub: number;
6 | };
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/backend/src/user/user.module.ts:
--------------------------------------------------------------------------------
1 | import { Module } from '@nestjs/common';
2 | import { UserService } from './user.service';
3 | import { UserResolver } from './user.resolver';
4 | import { Prisma } from '@prisma/client';
5 | import { PrismaService } from 'src/prisma.service';
6 | import { JwtService } from '@nestjs/jwt';
7 |
8 | @Module({
9 | providers: [UserService, UserResolver, PrismaService, JwtService],
10 | })
11 | export class UserModule {}
12 |
--------------------------------------------------------------------------------
/backend/src/user/user.resolver.spec.ts:
--------------------------------------------------------------------------------
1 | import { Test, TestingModule } from '@nestjs/testing';
2 | import { UserResolver } from './user.resolver';
3 |
4 | describe('UserResolver', () => {
5 | let resolver: UserResolver;
6 |
7 | beforeEach(async () => {
8 | const module: TestingModule = await Test.createTestingModule({
9 | providers: [UserResolver],
10 | }).compile();
11 |
12 | resolver = module.get(UserResolver);
13 | });
14 |
15 | it('should be defined', () => {
16 | expect(resolver).toBeDefined();
17 | });
18 | });
19 |
--------------------------------------------------------------------------------
/backend/src/user/user.resolver.ts:
--------------------------------------------------------------------------------
1 | import { Resolver, Query, Context, Mutation, Args } from '@nestjs/graphql';
2 | import { UserService } from './user.service';
3 | import { User } from './user.type';
4 | import { Request } from 'express';
5 | import { UseGuards } from '@nestjs/common';
6 | import { GraphqlAuthGuard } from 'src/auth/graphql-auth.guard';
7 | import { createWriteStream } from 'fs';
8 | import { join } from 'path';
9 | import { v4 as uuidv4 } from 'uuid';
10 | import * as GraphQLUpload from 'graphql-upload/GraphQLUpload.js';
11 | @Resolver()
12 | export class UserResolver {
13 | constructor(private readonly userService: UserService) {}
14 |
15 | @UseGuards(GraphqlAuthGuard)
16 | @Mutation(() => User)
17 | async updateProfile(
18 | @Args('fullname') fullname: string,
19 | @Args('file', { type: () => GraphQLUpload, nullable: true })
20 | file: GraphQLUpload.FileUpload,
21 | @Context() context: { req: Request },
22 | ) {
23 | const imageUrl = file ? await this.storeImageAndGetUrl(file) : null;
24 | const userId = context.req.user.sub;
25 | return this.userService.updateProfile(userId, fullname, imageUrl);
26 | }
27 |
28 | private async storeImageAndGetUrl(file: GraphQLUpload) {
29 | const { createReadStream, filename } = await file;
30 | const uniqueFilename = `${uuidv4()}_${filename}`;
31 | const imagePath = join(process.cwd(), 'public', 'images', uniqueFilename);
32 | const imageUrl = `${process.env.APP_URL}/images/${uniqueFilename}`;
33 | const readStream = createReadStream();
34 | readStream.pipe(createWriteStream(imagePath));
35 | return imageUrl;
36 | }
37 |
38 | @UseGuards(GraphqlAuthGuard)
39 | @Query(() => [User])
40 | async searchUsers(
41 | @Args('fullname') fullname: string,
42 | @Context() context: { req: Request },
43 | ) {
44 | return this.userService.searchUsers(fullname, context.req.user.sub);
45 | }
46 |
47 | @UseGuards(GraphqlAuthGuard)
48 | @Query(() => [User])
49 | getUsersOfChatroom(@Args('chatroomId') chatroomId: number) {
50 | return this.userService.getUsersOfChatroom(chatroomId);
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/backend/src/user/user.service.spec.ts:
--------------------------------------------------------------------------------
1 | import { Test, TestingModule } from '@nestjs/testing';
2 | import { UserService } from './user.service';
3 |
4 | describe('UserService', () => {
5 | let service: UserService;
6 |
7 | beforeEach(async () => {
8 | const module: TestingModule = await Test.createTestingModule({
9 | providers: [UserService],
10 | }).compile();
11 |
12 | service = module.get(UserService);
13 | });
14 |
15 | it('should be defined', () => {
16 | expect(service).toBeDefined();
17 | });
18 | });
19 |
--------------------------------------------------------------------------------
/backend/src/user/user.service.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from '@nestjs/common';
2 | import { PrismaService } from 'src/prisma.service';
3 | import * as fs from 'fs';
4 | import { join } from 'path';
5 | @Injectable()
6 | export class UserService {
7 | constructor(private readonly prisma: PrismaService) {}
8 |
9 | async updateProfile(userId: number, fullname: string, avatarUrl: string) {
10 | if (avatarUrl) {
11 | const oldUser = await this.prisma.user.findUnique({
12 | where: { id: userId },
13 | });
14 | const updatedUser = await this.prisma.user.update({
15 | where: { id: userId },
16 | data: {
17 | fullname,
18 | avatarUrl,
19 | },
20 | });
21 |
22 | if (oldUser.avatarUrl) {
23 | const imageName = oldUser.avatarUrl.split('/').pop();
24 | const imagePath = join(
25 | __dirname,
26 | '..',
27 | '..',
28 | 'public',
29 | 'images',
30 | imageName,
31 | );
32 | if (fs.existsSync(imagePath)) {
33 | fs.unlinkSync(imagePath);
34 | }
35 | }
36 |
37 | return updatedUser;
38 | }
39 | return await this.prisma.user.update({
40 | where: { id: userId },
41 | data: {
42 | fullname,
43 | },
44 | });
45 | }
46 | async searchUsers(fullname: string, userId: number) {
47 | // make sure that users are found that contain part of the fullname
48 | // and exclude the current user
49 | return this.prisma.user.findMany({
50 | where: {
51 | fullname: {
52 | contains: fullname,
53 | },
54 | id: {
55 | not: userId,
56 | },
57 | },
58 | });
59 | }
60 |
61 | async getUsersOfChatroom(chatroomId: number) {
62 | return this.prisma.user.findMany({
63 | where: {
64 | chatrooms: {
65 | some: {
66 | id: chatroomId,
67 | },
68 | },
69 | },
70 | orderBy: {
71 | createdAt: 'desc',
72 | },
73 | });
74 | }
75 |
76 | async getUser(userId: number) {
77 | return this.prisma.user.findUnique({
78 | where: {
79 | id: userId,
80 | },
81 | });
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/backend/src/user/user.type.ts:
--------------------------------------------------------------------------------
1 | import { Field, ObjectType } from '@nestjs/graphql';
2 |
3 | @ObjectType()
4 | export class User {
5 | @Field({ nullable: true })
6 | id?: number;
7 |
8 | @Field()
9 | fullname: string;
10 |
11 | @Field()
12 | email?: string;
13 |
14 | @Field({ nullable: true })
15 | avatarUrl?: string;
16 |
17 | @Field({ nullable: true })
18 | password?: string;
19 |
20 | @Field({ nullable: true })
21 | createdAt?: Date;
22 |
23 | @Field({ nullable: true })
24 | updatedAt?: Date;
25 | }
26 |
--------------------------------------------------------------------------------
/backend/test/app.e2e-spec.ts:
--------------------------------------------------------------------------------
1 | import { Test, TestingModule } from '@nestjs/testing';
2 | import { INestApplication } from '@nestjs/common';
3 | import * as request from 'supertest';
4 | import { AppModule } from './../src/app.module';
5 |
6 | describe('AppController (e2e)', () => {
7 | let app: INestApplication;
8 |
9 | beforeEach(async () => {
10 | const moduleFixture: TestingModule = await Test.createTestingModule({
11 | imports: [AppModule],
12 | }).compile();
13 |
14 | app = moduleFixture.createNestApplication();
15 | await app.init();
16 | });
17 |
18 | it('/ (GET)', () => {
19 | return request(app.getHttpServer())
20 | .get('/')
21 | .expect(200)
22 | .expect('Hello World!');
23 | });
24 | });
25 |
--------------------------------------------------------------------------------
/backend/test/jest-e2e.json:
--------------------------------------------------------------------------------
1 | {
2 | "moduleFileExtensions": ["js", "json", "ts"],
3 | "rootDir": ".",
4 | "testEnvironment": "node",
5 | "testRegex": ".e2e-spec.ts$",
6 | "transform": {
7 | "^.+\\.(t|j)s$": "ts-jest"
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/backend/tsconfig.build.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
4 | }
5 |
--------------------------------------------------------------------------------
/backend/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "module": "commonjs",
4 | "declaration": true,
5 | "removeComments": true,
6 | "emitDecoratorMetadata": true,
7 | "experimentalDecorators": true,
8 | "allowSyntheticDefaultImports": true,
9 | "target": "es2017",
10 | "sourceMap": true,
11 | "outDir": "./dist",
12 | "baseUrl": "./",
13 | "incremental": true,
14 | "skipLibCheck": true,
15 | "strictNullChecks": false,
16 | "noImplicitAny": false,
17 | "strictBindCallApply": false,
18 | "forceConsistentCasingInFileNames": false,
19 | "noFallthroughCasesInSwitch": false
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/frontend/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 | env: { browser: true, es2020: true },
4 | extends: [
5 | 'eslint:recommended',
6 | 'plugin:@typescript-eslint/recommended',
7 | 'plugin:react-hooks/recommended',
8 | ],
9 | ignorePatterns: ['dist', '.eslintrc.cjs'],
10 | parser: '@typescript-eslint/parser',
11 | plugins: ['react-refresh'],
12 | rules: {
13 | 'react-refresh/only-export-components': [
14 | 'warn',
15 | { allowConstantExport: true },
16 | ],
17 | },
18 | }
19 |
--------------------------------------------------------------------------------
/frontend/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
--------------------------------------------------------------------------------
/frontend/README.md:
--------------------------------------------------------------------------------
1 | # React + TypeScript + Vite
2 |
3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
4 |
5 | Currently, two official plugins are available:
6 |
7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
9 |
10 | ## Expanding the ESLint configuration
11 |
12 | If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
13 |
14 | - Configure the top-level `parserOptions` property like this:
15 |
16 | ```js
17 | parserOptions: {
18 | ecmaVersion: 'latest',
19 | sourceType: 'module',
20 | project: ['./tsconfig.json', './tsconfig.node.json'],
21 | tsconfigRootDir: __dirname,
22 | },
23 | ```
24 |
25 | - Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
26 | - Optionally add `plugin:@typescript-eslint/stylistic-type-checked`
27 | - Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list
28 |
--------------------------------------------------------------------------------
/frontend/codegen.ts:
--------------------------------------------------------------------------------
1 | import { CodegenConfig } from "@graphql-codegen/cli"
2 |
3 | const config: CodegenConfig = {
4 | schema: "http://localhost:3000/graphql",
5 | documents: ["src/graphql/**/*.ts"],
6 | ignoreNoDocuments: true, // for better experience with the watcher
7 | generates: {
8 | "./src/gql/": {
9 | preset: "client",
10 | plugins: ["typescript"],
11 | },
12 | },
13 | }
14 |
15 | export default config
16 |
--------------------------------------------------------------------------------
/frontend/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Vite + React + TS
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/frontend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "frontend",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "tsc && vite build",
9 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
10 | "preview": "vite preview"
11 | },
12 | "dependencies": {
13 | "@apollo/client": "^3.8.1",
14 | "@emotion/react": "^11.11.1",
15 | "@graphql-codegen/typescript-operations": "^4.0.1",
16 | "@mantine/core": "^6.0.19",
17 | "@mantine/dropzone": "^6.0.19",
18 | "@mantine/form": "^6.0.19",
19 | "@mantine/hooks": "^6.0.19",
20 | "@mantine/modals": "^6.0.19",
21 | "@tabler/icons-react": "^2.32.0",
22 | "apollo-upload-client": "^17.0.0",
23 | "graphql": "^16.8.0",
24 | "react": "^18.2.0",
25 | "react-dom": "^18.2.0",
26 | "react-router-dom": "^6.15.0",
27 | "subscriptions-transport-ws": "^0.11.0",
28 | "zustand": "^4.4.1"
29 | },
30 | "devDependencies": {
31 | "@graphql-codegen/cli": "^5.0.0",
32 | "@graphql-codegen/client-preset": "^4.1.0",
33 | "@parcel/watcher": "^2.3.0",
34 | "@types/apollo-upload-client": "^17.0.2",
35 | "@types/react": "^18.2.15",
36 | "@types/react-dom": "^18.2.7",
37 | "@typescript-eslint/eslint-plugin": "^6.0.0",
38 | "@typescript-eslint/parser": "^6.0.0",
39 | "@vitejs/plugin-react": "^4.0.3",
40 | "eslint": "^8.45.0",
41 | "eslint-plugin-react-hooks": "^4.6.0",
42 | "eslint-plugin-react-refresh": "^0.4.3",
43 | "ts-node": "^10.9.1",
44 | "typescript": "^5.2.2",
45 | "vite": "^4.4.5"
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/frontend/public/vite.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/frontend/src/App.css:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/thebeautyofcoding/nestjs_graphql_react_chat_app/e8af7140dcf95f8516735146b6d7445dad66a64d/frontend/src/App.css
--------------------------------------------------------------------------------
/frontend/src/App.tsx:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/thebeautyofcoding/nestjs_graphql_react_chat_app/e8af7140dcf95f8516735146b6d7445dad66a64d/frontend/src/App.tsx
--------------------------------------------------------------------------------
/frontend/src/apolloClient.ts:
--------------------------------------------------------------------------------
1 | import {
2 | ApolloClient,
3 | InMemoryCache,
4 | NormalizedCacheObject,
5 | gql,
6 | Observable,
7 | ApolloLink,
8 | split,
9 | } from "@apollo/client"
10 | import { WebSocketLink } from "@apollo/client/link/ws"
11 | import { createUploadLink } from "apollo-upload-client"
12 | import { getMainDefinition } from "@apollo/client/utilities"
13 | import { loadErrorMessages, loadDevMessages } from "@apollo/client/dev"
14 | import { useUserStore } from "./stores/userStore"
15 | import { onError } from "@apollo/client/link/error"
16 |
17 | loadErrorMessages()
18 | loadDevMessages()
19 |
20 | async function refreshToken(client: ApolloClient) {
21 | try {
22 | const { data } = await client.mutate({
23 | mutation: gql`
24 | mutation RefreshToken {
25 | refreshToken
26 | }
27 | `,
28 | })
29 | const newAccessToken = data?.refreshToken
30 | if (!newAccessToken) {
31 | throw new Error("New access token not received.")
32 | }
33 | return `Bearer ${newAccessToken}`
34 | } catch (err) {
35 | throw new Error("Error getting new access token.")
36 | }
37 | }
38 | let retryCount = 0
39 | const maxRetry = 3
40 |
41 | const wsLink = new WebSocketLink({
42 | uri: `ws://localhost:3000/graphql`,
43 | options: {
44 | reconnect: true,
45 | connectionParams: {
46 | Authorization: `Bearer ${localStorage.getItem("accessToken")}`,
47 | },
48 | },
49 | })
50 | const errorLink = onError(({ graphQLErrors, operation, forward }) => {
51 | for (const err of graphQLErrors) {
52 | if (err.extensions.code === "UNAUTHENTICATED" && retryCount < maxRetry) {
53 | retryCount++
54 | return new Observable((observer) => {
55 | refreshToken(client)
56 | .then((token) => {
57 | console.log("token", token)
58 | operation.setContext((previousContext: any) => ({
59 | headers: {
60 | ...previousContext.headers,
61 | authorization: token,
62 | },
63 | }))
64 | const forward$ = forward(operation)
65 | forward$.subscribe(observer)
66 | })
67 | .catch((error) => observer.error(error))
68 | })
69 | }
70 |
71 | if (err.message === "Refresh token not found") {
72 | console.log("refresh token not found!")
73 | useUserStore.setState({
74 | id: undefined,
75 | fullname: "",
76 | email: "",
77 | })
78 | }
79 | }
80 | })
81 |
82 | const uploadLink = createUploadLink({
83 | uri: "http://localhost:3000/graphql",
84 | credentials: "include",
85 | headers: {
86 | "apollo-require-preflight": "true",
87 | },
88 | })
89 | const link = split(
90 | // Split based on operation type
91 | ({ query }) => {
92 | const definition = getMainDefinition(query)
93 | return (
94 | definition.kind === "OperationDefinition" &&
95 | definition.operation === "subscription"
96 | )
97 | },
98 | wsLink,
99 | ApolloLink.from([errorLink, uploadLink])
100 | )
101 | export const client = new ApolloClient({
102 | uri: "http://localhost:3000/graphql",
103 | cache: new InMemoryCache({}),
104 | credentials: "include",
105 | headers: {
106 | "Content-Type": "application/json",
107 | },
108 | link: link,
109 | })
110 |
--------------------------------------------------------------------------------
/frontend/src/assets/react.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/frontend/src/components/AddChatroom.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react"
2 | import {
3 | Stepper,
4 | Button,
5 | Group,
6 | Modal,
7 | TextInput,
8 | MultiSelect,
9 | } from "@mantine/core"
10 | import { useGeneralStore } from "../stores/generalStore"
11 | import { CREATE_CHATROOM } from "../graphql/mutations/CreateChatroom"
12 | import {
13 | AddUsersToChatroomMutation,
14 | Chatroom,
15 | CreateChatroomMutation,
16 | SearchUsersQuery,
17 | User,
18 | } from "../gql/graphql"
19 | import { useMutation, useQuery } from "@apollo/client"
20 | import { useForm } from "@mantine/form"
21 | import { IconPlus } from "@tabler/icons-react"
22 | import { SEARCH_USERS } from "../graphql/queries/SearchUsers"
23 | import { ADD_USERS_TO_CHATROOM } from "../graphql/mutations/AddUsersToChatroom"
24 |
25 | function AddChatroom() {
26 | const [active, setActive] = useState(1)
27 | const [highestStepVisited, setHighestStepVisited] = useState(active)
28 |
29 | const isCreateRoomModalOpen = useGeneralStore(
30 | (state) => state.isCreateRoomModalOpen
31 | )
32 | const toggleCreateRoomModal = useGeneralStore(
33 | (state) => state.toggleCreateRoomModal
34 | )
35 |
36 | const handleStepChange = (nextStep: number) => {
37 | const isOutOfBounds = nextStep > 2 || nextStep < 0
38 |
39 | if (isOutOfBounds) {
40 | return
41 | }
42 |
43 | setActive(nextStep)
44 | setHighestStepVisited((hSC) => Math.max(hSC, nextStep))
45 | }
46 |
47 | const [createChatroom, { loading }] =
48 | useMutation(CREATE_CHATROOM)
49 |
50 | const form = useForm({
51 | initialValues: {
52 | name: "",
53 | },
54 | validate: {
55 | name: (value: string) =>
56 | value.trim().length >= 3 ? null : "Name must be at least 3 characters",
57 | },
58 | })
59 | const [newlyCreatedChatroom, setNewlyCreatedChatroom] =
60 | useState(null)
61 |
62 | const handleCreateChatroom = async () => {
63 | await createChatroom({
64 | variables: {
65 | name: form.values.name,
66 | },
67 | onCompleted: (data) => {
68 | console.log(data)
69 | setNewlyCreatedChatroom(data.createChatroom)
70 | handleStepChange(active + 1)
71 | },
72 | onError: (error) => {
73 | form.setErrors({
74 | name: error.graphQLErrors[0].extensions?.name as string,
75 | })
76 | },
77 | refetchQueries: ["GetChatroomsForUser"],
78 | })
79 | }
80 | const [searchTerm, setSearchTerm] = useState("")
81 | const { data, refetch } = useQuery(SEARCH_USERS, {
82 | variables: { fullname: searchTerm },
83 | })
84 | const [addUsersToChatroom, { loading: loadingAddUsers }] =
85 | useMutation(ADD_USERS_TO_CHATROOM, {
86 | refetchQueries: ["GetChatroomsForUser"],
87 | })
88 |
89 | const [selectedUsers, setSelectedUsers] = useState([])
90 | const handleAddUsersToChatroom = async () => {
91 | await addUsersToChatroom({
92 | variables: {
93 | chatroomId:
94 | newlyCreatedChatroom?.id && parseInt(newlyCreatedChatroom?.id),
95 | userIds: selectedUsers.map((userId) => parseInt(userId)),
96 | },
97 | onCompleted: () => {
98 | handleStepChange(1)
99 | toggleCreateRoomModal()
100 | setSelectedUsers([])
101 | setNewlyCreatedChatroom(null)
102 | form.reset()
103 | },
104 | onError: (error) => {
105 | form.setErrors({
106 | name: error.graphQLErrors[0].extensions?.name as string,
107 | })
108 | },
109 | })
110 | }
111 | let debounceTimeout: NodeJS.Timeout
112 |
113 | const handleSearchChange = (term: string) => {
114 | // Set the state variable to trigger a re-render and show a loading indicator
115 | setSearchTerm(term)
116 | // Debounce the refetching so you're not bombarding the server on every keystroke
117 | clearTimeout(debounceTimeout)
118 | debounceTimeout = setTimeout(() => {
119 | refetch()
120 | }, 300)
121 | }
122 |
123 | type SelectItem = {
124 | label: string
125 | value: string
126 | // other properties if required
127 | }
128 | const selectItems: SelectItem[] =
129 | data?.searchUsers?.map((user) => ({
130 | label: user.fullname,
131 | value: String(user.id),
132 | })) || []
133 |
134 | return (
135 |
136 |
137 |
138 | Create a Chatroom
139 |
140 |
141 |
154 |
155 |
156 | setSelectedUsers(values)}
165 | />
166 |
167 |
168 |
169 |
170 |
173 | {/* */}
174 |
175 | {selectedUsers.length > 0 && (
176 |
184 | )}
185 |
186 |
187 | )
188 | }
189 |
190 | export default AddChatroom
191 |
--------------------------------------------------------------------------------
/frontend/src/components/AuthOverlay.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Button,
3 | Col,
4 | Grid,
5 | Group,
6 | Modal,
7 | Paper,
8 | Text,
9 | TextInput,
10 | } from "@mantine/core"
11 | import React, { useState } from "react"
12 | import { useGeneralStore } from "../stores/generalStore"
13 | import { useForm } from "@mantine/form"
14 | import { useUserStore } from "../stores/userStore"
15 | import { GraphQLErrorExtensions } from "graphql"
16 | import { useMutation } from "@apollo/client"
17 | import { LoginUserMutation, RegisterUserMutation } from "../gql/graphql"
18 | import { REGISTER_USER } from "../graphql/mutations/Register"
19 | import { LOGIN_USER } from "../graphql/mutations/Login"
20 | function AuthOverlay() {
21 | const isLoginModalOpen = useGeneralStore((state) => state.isLoginModalOpen)
22 | const toggleLoginModal = useGeneralStore((state) => state.toggleLoginModal)
23 | const [isRegister, setIsRegister] = useState(true)
24 | const toggleForm = () => {
25 | setIsRegister(!isRegister)
26 | }
27 |
28 | const Register = () => {
29 | const form = useForm({
30 | initialValues: {
31 | fullname: "",
32 | email: "",
33 | password: "",
34 | confirmPassword: "",
35 | },
36 | validate: {
37 | fullname: (value: string) =>
38 | value.trim().length >= 3
39 | ? null
40 | : "Username must be at least 3 characters",
41 | email: (value: string) =>
42 | value.includes("@") ? null : "Invalid email",
43 | password: (value: string) =>
44 | value.trim().length >= 3
45 | ? null
46 | : "Password must be at least 3 characters",
47 | confirmPassword: (value: string, values) =>
48 | value.trim().length >= 3 && value === values.password
49 | ? null
50 | : "Passwords do not match",
51 | },
52 | })
53 | const setUser = useUserStore((state) => state.setUser)
54 | const setIsLoginOpen = useGeneralStore((state) => state.toggleLoginModal)
55 |
56 | const [errors, setErrors] = React.useState({})
57 |
58 | const [registerUser, { loading }] =
59 | useMutation(REGISTER_USER)
60 |
61 | const handleRegister = async () => {
62 | setErrors({})
63 |
64 | await registerUser({
65 | variables: {
66 | email: form.values.email,
67 | password: form.values.password,
68 | fullname: form.values.fullname,
69 | confirmPassword: form.values.confirmPassword,
70 | },
71 | onCompleted: (data) => {
72 | setErrors({})
73 | if (data?.register.user)
74 | setUser({
75 | id: data?.register.user.id,
76 | email: data?.register.user.email,
77 | fullname: data?.register.user.fullname,
78 | })
79 | setIsLoginOpen()
80 | },
81 | }).catch((err) => {
82 | console.log(err.graphQLErrors, "ERROR")
83 | setErrors(err.graphQLErrors[0].extensions)
84 | useGeneralStore.setState({ isLoginModalOpen: true })
85 | })
86 | }
87 |
88 | return (
89 |
90 |
91 | Register
92 |
93 |
94 |
163 |
164 | )
165 | }
166 |
167 | const Login = () => {
168 | const [loginUser, { loading, error, data }] =
169 | useMutation(LOGIN_USER)
170 | const setUser = useUserStore((state) => state.setUser)
171 | const setIsLoginOpen = useGeneralStore((state) => state.toggleLoginModal)
172 | const [errors, setErrors] = React.useState({})
173 | const [invalidCredentials, setInvalidCredentials] = React.useState("")
174 | const form = useForm({
175 | initialValues: {
176 | email: "",
177 | password: "",
178 | },
179 | validate: {
180 | email: (value: string) =>
181 | value.includes("@") ? null : "Invalid email",
182 | password: (value: string) =>
183 | value.trim().length >= 3
184 | ? null
185 | : "Password must be at least 3 characters",
186 | },
187 | })
188 |
189 | const handleLogin = async () => {
190 | await loginUser({
191 | variables: {
192 | email: form.values.email,
193 | password: form.values.password,
194 | },
195 | onCompleted: (data) => {
196 | setErrors({})
197 | if (data?.login.user) {
198 | setUser({
199 | id: data?.login.user.id,
200 | email: data?.login.user.email,
201 | fullname: data?.login.user.fullname,
202 | avatarUrl: data?.login.user.avatarUrl,
203 | })
204 | setIsLoginOpen()
205 | }
206 | },
207 | }).catch((err) => {
208 | setErrors(err.graphQLErrors[0].extensions)
209 | if (err.graphQLErrors[0].extensions?.invalidCredentials)
210 | setInvalidCredentials(
211 | err.graphQLErrors[0].extensions.invalidCredentials
212 | )
213 | useGeneralStore.setState({ isLoginModalOpen: true })
214 | })
215 | }
216 | return (
217 |
218 |
219 | Login
220 |
221 |
271 |
272 | )
273 | }
274 | return (
275 |
276 | {isRegister ? : }
277 |
278 | )
279 | }
280 | export default AuthOverlay
281 |
--------------------------------------------------------------------------------
/frontend/src/components/Chatwindow.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from "react"
2 |
3 | import {
4 | TextInput,
5 | Image,
6 | Button,
7 | Paper,
8 | useMantineTheme,
9 | Flex,
10 | Avatar,
11 | Tooltip,
12 | ScrollArea,
13 | Divider,
14 | Card,
15 | Text,
16 | List,
17 | } from "@mantine/core"
18 | import { useDropzone } from "react-dropzone"
19 | import { useMutation, useQuery, useSubscription } from "@apollo/client"
20 | import { SEND_MESSAGE } from "../graphql/mutations/SendMessage"
21 | import { IconMichelinBibGourmand } from "@tabler/icons-react"
22 | import { useParams } from "react-router-dom"
23 | import {
24 | GetMessagesForChatroomQuery,
25 | GetUsersOfChatroomQuery,
26 | LiveUsersInChatroomSubscription,
27 | Message,
28 | NewMessageSubscription,
29 | SendMessageMutation,
30 | User,
31 | UserStartedTypingSubscription,
32 | UserStoppedTypingSubscription,
33 | } from "../gql/graphql"
34 | import { GET_MESSAGES_FOR_CHATROOM } from "../graphql/queries/GetMessagesForChatroom"
35 | import { useUserStore } from "../stores/userStore"
36 |
37 | import { NEW_MESSAGE_SUBSCRIPTION } from "../graphql/subscriptions/NewMessage"
38 |
39 | import OverlappingAvatars from "./OverlappingAvatars"
40 | import { USER_STARTED_TYPING_SUBSCRIPTION } from "../graphql/subscriptions/UserStartedTyping"
41 | import { USER_STOPPED_TYPING_SUBSCRIPTION } from "../graphql/subscriptions/UserStoppedTyping"
42 | import { LIVE_USERS_SUBSCRIPTION } from "../graphql/subscriptions/LiveUsers"
43 | import { ENTER_CHATROOM } from "../graphql/mutations/EnterChatroom"
44 | import { LEAVE_CHATROOM } from "../graphql/mutations/LeaveChatroom"
45 | import { GET_USERS_OF_CHATROOM } from "../graphql/queries/GetUsersOfChatroom"
46 | import { GET_CHATROOMS_FOR_USER } from "../graphql/queries/GetChatroomsForUser"
47 | import { useMediaQuery } from "@mantine/hooks"
48 | import { USER_STARTED_TYPING_MUTATION } from "../graphql/mutations/UserStartedTypingMutation"
49 | import { USER_STOPPED_TYPING_MUTATION } from "../graphql/mutations/UserStoppedTypingMutation"
50 | import MessageBubble from "./MessageBubble"
51 | function Chatwindow() {
52 | const [messageContent, setMessageContent] = useState("")
53 | const [sendMessage] = useMutation(SEND_MESSAGE)
54 | const [selectedFile, setSelectedFile] = useState(null)
55 | const { getRootProps, getInputProps } = useDropzone({
56 | onDrop: (acceptedFiles) => {
57 | const file = acceptedFiles[0]
58 |
59 | if (file) {
60 | setSelectedFile(file) // You are saving the binary file now
61 | }
62 | },
63 | //
64 | })
65 | const previewUrl = selectedFile ? URL.createObjectURL(selectedFile) : null
66 | const { id } = useParams<{ id: string }>()
67 | const user = useUserStore((state) => state)
68 | const { data: typingData } = useSubscription(
69 | USER_STARTED_TYPING_SUBSCRIPTION,
70 | {
71 | variables: {
72 | chatroomId: parseInt(id!),
73 | userId: user.id,
74 | },
75 | }
76 | )
77 | const { data: stoppedTypingData } =
78 | useSubscription(
79 | USER_STOPPED_TYPING_SUBSCRIPTION,
80 | {
81 | variables: { chatroomId: parseInt(id!), userId: user.id },
82 | }
83 | )
84 | const [userStartedTypingMutation] = useMutation(
85 | USER_STARTED_TYPING_MUTATION,
86 | {
87 | onCompleted: () => {
88 | console.log("User started typing")
89 | },
90 | variables: { chatroomId: parseInt(id!) },
91 | }
92 | )
93 | const [userStoppedTypingMutation] = useMutation(
94 | USER_STOPPED_TYPING_MUTATION,
95 | {
96 | onCompleted: () => {
97 | console.log("User stopped typing")
98 | },
99 | variables: { chatroomId: parseInt(id!) },
100 | }
101 | )
102 |
103 | const [typingUsers, setTypingUsers] = useState([])
104 |
105 | useEffect(() => {
106 | const user = typingData?.userStartedTyping
107 | if (user && user.id) {
108 | setTypingUsers((prevUsers) => {
109 | if (!prevUsers.find((u) => u.id === user.id)) {
110 | return [...prevUsers, user]
111 | }
112 | return prevUsers
113 | })
114 | }
115 | }, [typingData])
116 |
117 | const typingTimeoutsRef = React.useRef<{ [key: number]: NodeJS.Timeout }>({})
118 |
119 | useEffect(() => {
120 | const user = stoppedTypingData?.userStoppedTyping
121 | if (user && user.id) {
122 | clearTimeout(typingTimeoutsRef.current[user.id])
123 | setTypingUsers((prevUsers) => prevUsers.filter((u) => u.id !== user.id))
124 | }
125 | }, [stoppedTypingData])
126 |
127 | const userId = useUserStore((state) => state.id)
128 |
129 | const handleUserStartedTyping = async () => {
130 | await userStartedTypingMutation()
131 |
132 | if (userId && typingTimeoutsRef.current[userId]) {
133 | clearTimeout(typingTimeoutsRef.current[userId])
134 | }
135 | if (userId) {
136 | typingTimeoutsRef.current[userId] = setTimeout(async () => {
137 | setTypingUsers((prevUsers) =>
138 | prevUsers.filter((user) => user.id !== userId)
139 | )
140 | await userStoppedTypingMutation()
141 | }, 2000)
142 | }
143 | }
144 |
145 | const { data: liveUsersData, loading: liveUsersLoading } =
146 | useSubscription(LIVE_USERS_SUBSCRIPTION, {
147 | variables: {
148 | chatroomId: parseInt(id!),
149 | },
150 | })
151 |
152 | const [liveUsers, setLiveUsers] = useState([])
153 |
154 | useEffect(() => {
155 | if (liveUsersData?.liveUsersInChatroom) {
156 | setLiveUsers(liveUsersData.liveUsersInChatroom)
157 | }
158 | }, [liveUsersData?.liveUsersInChatroom])
159 | const [enterChatroom] = useMutation(ENTER_CHATROOM)
160 | const [leaveChatroom] = useMutation(LEAVE_CHATROOM)
161 | const chatroomId = parseInt(id!)
162 |
163 | const handleEnter = async () => {
164 | await enterChatroom({ variables: { chatroomId } })
165 | .then((response) => {
166 | if (response.data.enterChatroom) {
167 | console.log("Successfully entered chatroom!")
168 | }
169 | })
170 | .catch((error) => {
171 | console.error("Error entering chatroom:", error)
172 | })
173 | }
174 |
175 | const handleLeave = async () => {
176 | await leaveChatroom({ variables: { chatroomId } })
177 | .then((response) => {
178 | if (response.data.leaveChatroom) {
179 | console.log("Successfully left chatroom!")
180 | }
181 | })
182 | .catch((error) => {
183 | console.error("Error leaving chatroom:", error)
184 | })
185 | }
186 |
187 | const [isUserPartOfChatroom, setIsUserPartOfChatroom] =
188 | useState<() => boolean | undefined>()
189 |
190 | const { data: dataUsersOfChatroom } = useQuery(
191 | GET_USERS_OF_CHATROOM,
192 | {
193 | variables: {
194 | chatroomId: chatroomId,
195 | },
196 | }
197 | )
198 |
199 | useEffect(() => {
200 | setIsUserPartOfChatroom(() =>
201 | dataUsersOfChatroom?.getUsersOfChatroom.some((user) => user.id === userId)
202 | )
203 | }, [dataUsersOfChatroom?.getUsersOfChatroom, userId])
204 |
205 | useEffect(() => {
206 | handleEnter()
207 | if (liveUsersData?.liveUsersInChatroom) {
208 | setLiveUsers(liveUsersData.liveUsersInChatroom)
209 | setIsUserPartOfChatroom(() =>
210 | dataUsersOfChatroom?.getUsersOfChatroom.some(
211 | (user) => user.id === userId
212 | )
213 | )
214 | }
215 | }, [chatroomId])
216 |
217 | useEffect(() => {
218 | window.addEventListener("beforeunload", handleLeave)
219 | return () => {
220 | window.removeEventListener("beforeunload", handleLeave)
221 | }
222 | }, [])
223 |
224 | useEffect(() => {
225 | handleEnter()
226 | if (liveUsersData?.liveUsersInChatroom) {
227 | setLiveUsers(liveUsersData.liveUsersInChatroom)
228 | }
229 |
230 | return () => {
231 | handleLeave()
232 | }
233 | }, [chatroomId])
234 |
235 | const scrollAreaRef = React.useRef(null)
236 |
237 | const { data, loading } = useQuery(
238 | GET_MESSAGES_FOR_CHATROOM,
239 | {
240 | variables: {
241 | chatroomId: chatroomId,
242 | },
243 | }
244 | )
245 |
246 | const [messages, setMessages] = useState([])
247 | useEffect(() => {
248 | if (data?.getMessagesForChatroom) {
249 | setMessages(data.getMessagesForChatroom)
250 | }
251 | }, [data?.getMessagesForChatroom])
252 |
253 | const handleSendMessage = async () => {
254 | await sendMessage({
255 | variables: {
256 | chatroomId: chatroomId,
257 | content: messageContent,
258 | image: selectedFile,
259 | },
260 | refetchQueries: [
261 | {
262 | query: GET_CHATROOMS_FOR_USER,
263 | variables: {
264 | userId: userId,
265 | },
266 | },
267 | ],
268 | })
269 | setMessageContent("")
270 | setSelectedFile(null)
271 | }
272 | const scrollToBottom = () => {
273 | if (scrollAreaRef.current) {
274 | console.log("Scrolling to bottom")
275 | const scrollElement = scrollAreaRef.current
276 | console.log(scrollElement.scrollHeight, scrollElement.clientHeight)
277 | scrollElement.scrollTo({
278 | top: scrollElement.scrollHeight,
279 | behavior: "smooth",
280 | })
281 | }
282 | }
283 | useEffect(() => {
284 | if (data?.getMessagesForChatroom) {
285 | const uniqueMessages = Array.from(
286 | new Set(data.getMessagesForChatroom.map((m) => m.id))
287 | ).map((id) => data.getMessagesForChatroom.find((m) => m.id === id))
288 | setMessages(uniqueMessages as Message[])
289 | scrollToBottom()
290 | }
291 | }, [data?.getMessagesForChatroom])
292 |
293 | const { data: dataSub } = useSubscription(
294 | NEW_MESSAGE_SUBSCRIPTION,
295 | {
296 | variables: { chatroomId },
297 | }
298 | )
299 |
300 | useEffect(() => {
301 | scrollToBottom()
302 | if (dataSub?.newMessage) {
303 | if (!messages.find((m) => m.id === dataSub.newMessage?.id)) {
304 | setMessages((prevMessages) => [...prevMessages, dataSub.newMessage!])
305 | }
306 | }
307 | }, [dataSub?.newMessage, messages])
308 | const isMediumDevice = useMediaQuery("(max-width: 992px)")
309 | return (
310 |
316 | {!liveUsersLoading && isUserPartOfChatroom ? (
317 |
318 |
319 |
320 |
326 |
327 |
328 | Chat with
329 |
330 | {dataUsersOfChatroom?.getUsersOfChatroom && (
331 |
334 | )}
335 |
336 |
341 |
342 |
343 | Live users
344 |
345 |
346 | {liveUsersData?.liveUsersInChatroom?.map((user) => (
347 |
354 |
359 |
360 |
369 | {user.fullname}
370 |
371 | ))}
372 |
373 |
374 |
375 |
376 |
377 |
385 | {loading ? (
386 |
387 | Loading...
388 |
389 | ) : (
390 | messages.map((message) => {
391 | return (
392 |
397 | )
398 | })
399 | )}
400 |
401 |
402 |
413 |
414 |
424 |
436 |
437 | {typingUsers.map((user) => (
438 |
439 |
443 |
444 | ))}
445 |
446 |
447 | {typingUsers.length > 0 && (
448 |
449 | is typing...
450 |
451 | )}
452 |
453 |
454 |
461 |
462 | {selectedFile && (
463 |
471 | )}
472 | }>
473 |
474 |
475 | setMessageContent(e.currentTarget.value)}
480 | placeholder="Type your message..."
481 | rightSection={
482 | }
486 | >
487 | Send
488 |
489 | }
490 | />
491 |
492 |
493 |
494 |
495 |
496 | ) : (
497 | <>>
498 | )}
499 |
500 | )
501 | }
502 |
503 | export default Chatwindow
504 |
--------------------------------------------------------------------------------
/frontend/src/components/JoinRoomOrChatwindow.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from "react"
2 | import { useParams } from "react-router-dom"
3 | import ChatWindow from "./Chatwindow"
4 | import { Flex, Text } from "@mantine/core"
5 | import { useMediaQuery } from "@mantine/hooks"
6 |
7 | function JoinRoomOrChatwindow() {
8 | const { id } = useParams<{ id: string }>()
9 |
10 | const [content, setContent] = React.useState("")
11 |
12 | useEffect(() => {
13 | if (!id) {
14 | setContent("Please choose a room")
15 | } else {
16 | setContent()
17 | }
18 | }, [setContent, id])
19 |
20 | return (
21 |
22 |
23 | {content}
24 |
25 |
26 | )
27 | }
28 |
29 | export default JoinRoomOrChatwindow
30 |
--------------------------------------------------------------------------------
/frontend/src/components/MessageBubble.tsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import { Message } from "../gql/graphql"
3 | import {
4 | Avatar,
5 | Flex,
6 | Image,
7 | Paper,
8 | Text,
9 | useMantineTheme,
10 | } from "@mantine/core"
11 |
12 | interface MessageProps {
13 | message: Message
14 | currentUserId: number
15 | }
16 |
17 | const MessageBubble: React.FC = ({ message, currentUserId }) => {
18 | const theme = useMantineTheme()
19 | if (!message?.user?.id) return null
20 | const isSentByCurrentUser = message.user.id === currentUserId
21 |
22 | return (
23 |
29 | {!isSentByCurrentUser && (
30 |
35 | )}
36 |
37 | {isSentByCurrentUser ? (
38 | Me
39 | ) : (
40 | {message.user.fullname}
41 | )}
42 |
54 | {message.content}
55 | {message.imageUrl && (
56 |
63 | )}
64 |
65 |
70 | {new Date(message.createdAt).toLocaleString()}
71 |
72 |
73 |
74 | {isSentByCurrentUser && (
75 |
81 | )}
82 |
83 | )
84 | }
85 |
86 | export default MessageBubble
87 |
--------------------------------------------------------------------------------
/frontend/src/components/OverlappingAvatars.tsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import { User } from "../gql/graphql"
3 | import { Avatar, Tooltip } from "@mantine/core"
4 |
5 | function OverlappingAvatars({ users }: { users: User[] }) {
6 | const remainingUsers = users.length > 3 ? users.slice(3) : []
7 |
8 | const remainingNames = remainingUsers.map((user) => user.fullname).join(", ")
9 |
10 | return (
11 |
12 |
13 | <>
14 | {users.slice(0, 3).map((user) => {
15 | return (
16 |
17 |
23 |
24 | )
25 | })}
26 |
27 | {users.length > 3 && (
28 |
29 |
30 |
31 | )}
32 | >
33 |
34 |
35 | )
36 | }
37 |
38 | export default OverlappingAvatars
39 |
--------------------------------------------------------------------------------
/frontend/src/components/ProfileSettings.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from "react"
2 | import { useGeneralStore } from "../stores/generalStore"
3 | import { useUserStore } from "../stores/userStore"
4 | import { useForm } from "@mantine/form"
5 | import { useMutation } from "@apollo/client"
6 | import { UPDATE_PROFILE } from "../graphql/mutations/UpdateUserProfile"
7 | import {
8 | Avatar,
9 | Button,
10 | FileInput,
11 | Flex,
12 | Group,
13 | Modal,
14 | TextInput,
15 | } from "@mantine/core"
16 | import { IconEditCircle } from "@tabler/icons-react"
17 |
18 | function ProfileSettings() {
19 | const isProfileSettingsModalOpen = useGeneralStore(
20 | (state) => state.isProfileSettingsModalOpen
21 | )
22 | const toggleProfileSettingsModal = useGeneralStore(
23 | (state) => state.toggleProfileSettingsModal
24 | )
25 | const profileImage = useUserStore((state) => state.avatarUrl)
26 | const updateProfileImage = useUserStore((state) => state.updateProfileImage)
27 | const fullname = useUserStore((state) => state.fullname)
28 | const updateUsername = useUserStore((state) => state.updateUsername)
29 | const [imageFile, setImageFile] = useState(null)
30 | const imagePreview = imageFile ? URL.createObjectURL(imageFile) : null
31 |
32 | const fileInputRef = React.useRef(null)
33 |
34 | const form = useForm({
35 | initialValues: {
36 | fullname: fullname,
37 | profileImage: profileImage,
38 | },
39 | validate: {
40 | fullname: (value: string) =>
41 | value.trim().length >= 3
42 | ? null
43 | : "Username must be at least 3 characters",
44 | },
45 | })
46 |
47 | const [updateProfile] = useMutation(UPDATE_PROFILE, {
48 | variables: {
49 | fullname: form.values.fullname,
50 | file: imageFile,
51 | },
52 | // run liveUsersInChatroom subscription after mutation is completed
53 |
54 | onCompleted: (data) => {
55 | updateProfileImage(data.updateProfile.avatarUrl)
56 | updateUsername(data.updateProfile.fullname)
57 | },
58 | })
59 | const handleSave = async () => {
60 | if (form.validate().hasErrors) return
61 | await updateProfile().then(() => {
62 | toggleProfileSettingsModal()
63 | })
64 | }
65 | return (
66 |
71 |
124 |
125 | )
126 | }
127 |
128 | export default ProfileSettings
129 |
--------------------------------------------------------------------------------
/frontend/src/components/ProtectedRoutes.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from "react"
2 | import { useUserStore } from "../stores/userStore"
3 | import { useGeneralStore } from "../stores/generalStore"
4 |
5 | const ProtectedRoutes = ({ children }: { children: React.ReactNode }) => {
6 | const userId = useUserStore((state) => state.id)
7 | const toggleLoginModal = useGeneralStore((state) => state.toggleLoginModal)
8 |
9 | useEffect(() => {
10 | if (!userId) {
11 | toggleLoginModal()
12 | }
13 | }, [toggleLoginModal, userId])
14 | if (userId) {
15 | return children
16 | }
17 | return <>Protected>
18 | }
19 |
20 | export default ProtectedRoutes
21 |
--------------------------------------------------------------------------------
/frontend/src/components/RoomList.tsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 |
3 | import {
4 | Button,
5 | Card,
6 | Text,
7 | Flex,
8 | Group,
9 | Loader,
10 | ScrollArea,
11 | } from "@mantine/core"
12 | import { useMediaQuery } from "@mantine/hooks"
13 | import { IconPlus, IconX } from "@tabler/icons-react"
14 | import { useGeneralStore } from "../stores/generalStore"
15 | import { useUserStore } from "../stores/userStore"
16 | import { Link, useNavigate, useParams } from "react-router-dom"
17 | import { Chatroom, GetChatroomsForUserQuery } from "../gql/graphql"
18 | import { GET_CHATROOMS_FOR_USER } from "../graphql/queries/GetChatroomsForUser"
19 | import { DELETE_CHATROOM } from "../graphql/mutations/DeleteChatroom"
20 | import { useMutation, useQuery } from "@apollo/client"
21 | import OverlappingAvatars from "./OverlappingAvatars"
22 | function RoomList() {
23 | const toggleCreateRoomModal = useGeneralStore(
24 | (state) => state.toggleCreateRoomModal
25 | )
26 | const userId = useUserStore((state) => state.id)
27 |
28 | const { data, loading, error } = useQuery(
29 | GET_CHATROOMS_FOR_USER,
30 | {
31 | variables: {
32 | userId: userId,
33 | },
34 | }
35 | )
36 | const isSmallDevice = useMediaQuery("(max-width: 768px)")
37 | const defaultTextStyles: React.CSSProperties = {
38 | textOverflow: isSmallDevice ? "unset" : "ellipsis",
39 | whiteSpace: isSmallDevice ? "unset" : "nowrap",
40 | overflow: isSmallDevice ? "unset" : "hidden",
41 | }
42 |
43 | const defaultFlexStyles: React.CSSProperties = {
44 | maxWidth: isSmallDevice ? "unset" : "200px",
45 | }
46 |
47 | const [activeRoomId, setActiveRoomId] = React.useState(
48 | parseInt(useParams<{ id: string }>().id || "0")
49 | )
50 | const navigate = useNavigate()
51 |
52 | const [deleteChatroom] = useMutation(DELETE_CHATROOM, {
53 | variables: {
54 | chatroomId: activeRoomId,
55 | },
56 | refetchQueries: [
57 | {
58 | query: GET_CHATROOMS_FOR_USER,
59 | variables: {
60 | userId: userId,
61 | },
62 | },
63 | ],
64 | onCompleted: () => {
65 | navigate("/")
66 | },
67 | })
68 | const isMediumDevice = useMediaQuery("(max-width: 992px)")
69 | return (
70 |
71 |
72 |
73 |
74 | }
78 | >
79 | Create a room
80 |
81 |
82 |
86 |
87 |
88 | {loading && (
89 |
90 |
91 |
92 | Loading...
93 |
94 |
95 | )}
96 |
97 | {data?.getChatroomsForUser.map((chatroom) => (
98 | setActiveRoomId(parseInt(chatroom.id || "0"))}
106 | >
107 |
118 |
119 | {chatroom.users && (
120 |
121 |
122 |
123 | )}
124 | {chatroom.messages && chatroom.messages.length > 0 ? (
125 |
132 |
133 |
134 | {chatroom.name}
135 |
136 |
137 | {chatroom.messages[0].content}
138 |
139 |
140 | {new Date(
141 | chatroom.messages[0].createdAt
142 | ).toLocaleString()}
143 |
144 |
145 |
146 | ) : (
147 |
148 |
149 | No Messages
150 |
151 |
152 | )}
153 | {chatroom?.users && chatroom.users[0].id === userId && (
154 |
155 |
166 |
167 | )}
168 |
169 |
170 |
171 | ))}
172 |
173 |
174 |
175 |
176 |
177 | )
178 | }
179 |
180 | export default RoomList
181 |
--------------------------------------------------------------------------------
/frontend/src/components/Sidebar.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react"
2 | import { useGeneralStore } from "../stores/generalStore"
3 | import { useUserStore } from "../stores/userStore"
4 | import {
5 | Navbar,
6 | Center,
7 | Tooltip,
8 | UnstyledButton,
9 | createStyles,
10 | Stack,
11 | rem,
12 | } from "@mantine/core"
13 |
14 | import {
15 | IconUser,
16 | IconLogout,
17 | IconBrandMessenger,
18 | IconBrandWechat,
19 | IconLogin,
20 | } from "@tabler/icons-react"
21 | import { useMutation } from "@apollo/client"
22 | import { LOGOUT_USER } from "../graphql/mutations/Logout"
23 |
24 | const useStyles = createStyles((theme) => {
25 | return {
26 | link: {
27 | width: rem(50),
28 | height: rem(50),
29 | borderRadius: theme.radius.md,
30 | display: "flex",
31 | alignItems: "center",
32 | justifyContent: "center",
33 | color:
34 | theme.colorScheme === "dark"
35 | ? theme.colors.dark[0]
36 | : theme.colors.gray[7],
37 |
38 | "&:hover": {
39 | backgroundColor:
40 | theme.colorScheme === "dark"
41 | ? theme.colors.dark[5]
42 | : theme.colors.gray[0],
43 | },
44 | },
45 | active: {
46 | "&, &:hover": {
47 | backgroundColor: theme.fn.variant({
48 | variant: "light",
49 | color: theme.primaryColor,
50 | }).background,
51 | color: theme.fn.variant({ variant: "light", color: theme.primaryColor })
52 | .color,
53 | },
54 | },
55 | }
56 | })
57 |
58 | interface NavbarLinkProps {
59 | icon: React.FC
60 | label: string
61 | active?: boolean
62 | onClick?(): void
63 | }
64 |
65 | function NavbarLink({ icon: Icon, label, active, onClick }: NavbarLinkProps) {
66 | const { classes, cx } = useStyles()
67 | return (
68 |
74 |
78 |
79 |
80 |
81 | )
82 | }
83 | const mockdata = [{ icon: IconBrandWechat, label: "Chatrooms" }]
84 |
85 | function Sidebar() {
86 | const toggleProfileSettingsModal = useGeneralStore(
87 | (state) => state.toggleProfileSettingsModal
88 | )
89 | const [active, setActive] = useState(0)
90 |
91 | const links = mockdata.map((link, index) => (
92 | setActive(index)}
97 | />
98 | ))
99 | const userId = useUserStore((state) => state.id)
100 | const user = useUserStore((state) => state)
101 | const setUser = useUserStore((state) => state.setUser)
102 |
103 | const toggleLoginModal = useGeneralStore((state) => state.toggleLoginModal)
104 | const [logoutUser, { loading, error }] = useMutation(LOGOUT_USER, {
105 | onCompleted: () => {
106 | toggleLoginModal()
107 | },
108 | })
109 |
110 | const handleLogout = async () => {
111 | await logoutUser()
112 | setUser({
113 | id: undefined,
114 | fullname: "",
115 | avatarUrl: null,
116 | email: "",
117 | })
118 | }
119 |
120 | return (
121 |
122 |
123 |
124 |
125 |
126 |
127 | {userId && links}
128 |
129 |
130 |
131 |
132 | {userId && (
133 |
138 | )}
139 |
140 | {userId ? (
141 |
146 | ) : (
147 |
152 | )}
153 |
154 |
155 |
156 | )
157 | }
158 |
159 | export default Sidebar
160 |
--------------------------------------------------------------------------------
/frontend/src/gql/fragment-masking.ts:
--------------------------------------------------------------------------------
1 | import { ResultOf, DocumentTypeDecoration, TypedDocumentNode } from '@graphql-typed-document-node/core';
2 | import { FragmentDefinitionNode } from 'graphql';
3 | import { Incremental } from './graphql';
4 |
5 |
6 | export type FragmentType> = TDocumentType extends DocumentTypeDecoration<
7 | infer TType,
8 | any
9 | >
10 | ? [TType] extends [{ ' $fragmentName'?: infer TKey }]
11 | ? TKey extends string
12 | ? { ' $fragmentRefs'?: { [key in TKey]: TType } }
13 | : never
14 | : never
15 | : never;
16 |
17 | // return non-nullable if `fragmentType` is non-nullable
18 | export function useFragment(
19 | _documentNode: DocumentTypeDecoration,
20 | fragmentType: FragmentType>
21 | ): TType;
22 | // return nullable if `fragmentType` is nullable
23 | export function useFragment(
24 | _documentNode: DocumentTypeDecoration,
25 | fragmentType: FragmentType> | null | undefined
26 | ): TType | null | undefined;
27 | // return array of non-nullable if `fragmentType` is array of non-nullable
28 | export function useFragment(
29 | _documentNode: DocumentTypeDecoration,
30 | fragmentType: ReadonlyArray>>
31 | ): ReadonlyArray;
32 | // return array of nullable if `fragmentType` is array of nullable
33 | export function useFragment(
34 | _documentNode: DocumentTypeDecoration,
35 | fragmentType: ReadonlyArray>> | null | undefined
36 | ): ReadonlyArray | null | undefined;
37 | export function useFragment(
38 | _documentNode: DocumentTypeDecoration,
39 | fragmentType: FragmentType> | ReadonlyArray>> | null | undefined
40 | ): TType | ReadonlyArray | null | undefined {
41 | return fragmentType as any;
42 | }
43 |
44 |
45 | export function makeFragmentData<
46 | F extends DocumentTypeDecoration,
47 | FT extends ResultOf
48 | >(data: FT, _fragment: F): FragmentType {
49 | return data as FragmentType;
50 | }
51 | export function isFragmentReady(
52 | queryNode: DocumentTypeDecoration,
53 | fragmentNode: TypedDocumentNode,
54 | data: FragmentType, any>> | null | undefined
55 | ): data is FragmentType {
56 | const deferredFields = (queryNode as { __meta__?: { deferredFields: Record } }).__meta__
57 | ?.deferredFields;
58 |
59 | if (!deferredFields) return true;
60 |
61 | const fragDef = fragmentNode.definitions[0] as FragmentDefinitionNode | undefined;
62 | const fragName = fragDef?.name?.value;
63 |
64 | const fields = (fragName && deferredFields[fragName]) || [];
65 | return fields.length > 0 && fields.every(field => data && field in data);
66 | }
67 |
--------------------------------------------------------------------------------
/frontend/src/gql/gql.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | import * as types from './graphql';
3 | import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core';
4 |
5 | /**
6 | * Map of all GraphQL operations in the project.
7 | *
8 | * This map has several performance disadvantages:
9 | * 1. It is not tree-shakeable, so it will include all operations in the project.
10 | * 2. It is not minifiable, so the string of a GraphQL query will be multiple times inside the bundle.
11 | * 3. It does not support dead code elimination, so it will add unused operations.
12 | *
13 | * Therefore it is highly recommended to use the babel or swc plugin for production.
14 | */
15 | const documents = {
16 | "\n mutation AddUsersToChatroom($chatroomId: Float!, $userIds: [Float!]!) {\n addUsersToChatroom(chatroomId: $chatroomId, userIds: $userIds) {\n name\n id\n }\n }\n": types.AddUsersToChatroomDocument,
17 | "\n mutation CreateChatroom($name: String!) {\n createChatroom(name: $name) {\n name\n id\n }\n }\n": types.CreateChatroomDocument,
18 | "\n mutation DeleteChatroom($chatroomId: Float!) {\n deleteChatroom(chatroomId: $chatroomId)\n }\n": types.DeleteChatroomDocument,
19 | "\n mutation EnterChatroom($chatroomId: Float!) {\n enterChatroom(chatroomId: $chatroomId)\n }\n": types.EnterChatroomDocument,
20 | "\n mutation LeaveChatroom($chatroomId: Float!) {\n leaveChatroom(chatroomId: $chatroomId)\n }\n": types.LeaveChatroomDocument,
21 | "\n mutation LoginUser($email: String!, $password: String!) {\n login(loginInput: { email: $email, password: $password }) {\n user {\n email\n id\n fullname\n avatarUrl\n }\n }\n }\n": types.LoginUserDocument,
22 | "\n mutation LogoutUser {\n logout\n }\n": types.LogoutUserDocument,
23 | "\n mutation RegisterUser(\n $fullname: String!\n $email: String!\n $password: String!\n $confirmPassword: String!\n ) {\n register(\n registerInput: {\n fullname: $fullname\n email: $email\n password: $password\n confirmPassword: $confirmPassword\n }\n ) {\n user {\n id\n fullname\n email\n }\n }\n }\n": types.RegisterUserDocument,
24 | "\n mutation SendMessage($chatroomId: Float!, $content: String!, $image: Upload) {\n sendMessage(chatroomId: $chatroomId, content: $content, image: $image) {\n id\n content\n imageUrl\n user {\n id\n fullname\n email\n }\n }\n }\n": types.SendMessageDocument,
25 | "\n mutation UpdateProfile($fullname: String!, $file: Upload) {\n updateProfile(fullname: $fullname, file: $file) {\n id\n fullname\n avatarUrl\n }\n }\n": types.UpdateProfileDocument,
26 | "\n mutation UserStartedTypingMutation($chatroomId: Float!) {\n userStartedTypingMutation(chatroomId: $chatroomId) {\n id\n fullname\n email\n }\n }\n": types.UserStartedTypingMutationDocument,
27 | "\n mutation UserStoppedTypingMutation($chatroomId: Float!) {\n userStoppedTypingMutation(chatroomId: $chatroomId) {\n id\n fullname\n email\n }\n }\n": types.UserStoppedTypingMutationDocument,
28 | "\n query GetChatroomsForUser($userId: Float!) {\n getChatroomsForUser(userId: $userId) {\n id\n name\n messages {\n id\n content\n createdAt\n user {\n id\n fullname\n }\n }\n users {\n avatarUrl\n id\n fullname\n email\n }\n }\n }\n": types.GetChatroomsForUserDocument,
29 | "\n query GetMessagesForChatroom($chatroomId: Float!) {\n getMessagesForChatroom(chatroomId: $chatroomId) {\n id\n content\n imageUrl\n createdAt\n user {\n id\n fullname\n email\n avatarUrl\n }\n chatroom {\n id\n name\n users {\n id\n fullname\n email\n avatarUrl\n }\n }\n }\n }\n": types.GetMessagesForChatroomDocument,
30 | "\n query GetUsersOfChatroom($chatroomId: Float!) {\n getUsersOfChatroom(chatroomId: $chatroomId) {\n id\n fullname\n email\n avatarUrl\n }\n }\n": types.GetUsersOfChatroomDocument,
31 | "\n query SearchUsers($fullname: String!) {\n searchUsers(fullname: $fullname) {\n id\n fullname\n email\n }\n }\n": types.SearchUsersDocument,
32 | "\n subscription LiveUsersInChatroom($chatroomId: Float!) {\n liveUsersInChatroom(chatroomId: $chatroomId) {\n id\n fullname\n avatarUrl\n email\n }\n }\n": types.LiveUsersInChatroomDocument,
33 | "\n subscription NewMessage($chatroomId: Float!) {\n newMessage(chatroomId: $chatroomId) {\n id\n content\n imageUrl\n createdAt\n user {\n id\n fullname\n email\n avatarUrl\n }\n }\n }\n": types.NewMessageDocument,
34 | "\n subscription UserStartedTyping($chatroomId: Float!, $userId: Float!) {\n userStartedTyping(chatroomId: $chatroomId, userId: $userId) {\n id\n fullname\n email\n avatarUrl\n }\n }\n": types.UserStartedTypingDocument,
35 | "\n subscription UserStoppedTyping($chatroomId: Float!, $userId: Float!) {\n userStoppedTyping(chatroomId: $chatroomId, userId: $userId) {\n id\n fullname\n email\n avatarUrl\n }\n }\n": types.UserStoppedTypingDocument,
36 | };
37 |
38 | /**
39 | * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
40 | *
41 | *
42 | * @example
43 | * ```ts
44 | * const query = graphql(`query GetUser($id: ID!) { user(id: $id) { name } }`);
45 | * ```
46 | *
47 | * The query argument is unknown!
48 | * Please regenerate the types.
49 | */
50 | export function graphql(source: string): unknown;
51 |
52 | /**
53 | * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
54 | */
55 | export function graphql(source: "\n mutation AddUsersToChatroom($chatroomId: Float!, $userIds: [Float!]!) {\n addUsersToChatroom(chatroomId: $chatroomId, userIds: $userIds) {\n name\n id\n }\n }\n"): (typeof documents)["\n mutation AddUsersToChatroom($chatroomId: Float!, $userIds: [Float!]!) {\n addUsersToChatroom(chatroomId: $chatroomId, userIds: $userIds) {\n name\n id\n }\n }\n"];
56 | /**
57 | * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
58 | */
59 | export function graphql(source: "\n mutation CreateChatroom($name: String!) {\n createChatroom(name: $name) {\n name\n id\n }\n }\n"): (typeof documents)["\n mutation CreateChatroom($name: String!) {\n createChatroom(name: $name) {\n name\n id\n }\n }\n"];
60 | /**
61 | * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
62 | */
63 | export function graphql(source: "\n mutation DeleteChatroom($chatroomId: Float!) {\n deleteChatroom(chatroomId: $chatroomId)\n }\n"): (typeof documents)["\n mutation DeleteChatroom($chatroomId: Float!) {\n deleteChatroom(chatroomId: $chatroomId)\n }\n"];
64 | /**
65 | * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
66 | */
67 | export function graphql(source: "\n mutation EnterChatroom($chatroomId: Float!) {\n enterChatroom(chatroomId: $chatroomId)\n }\n"): (typeof documents)["\n mutation EnterChatroom($chatroomId: Float!) {\n enterChatroom(chatroomId: $chatroomId)\n }\n"];
68 | /**
69 | * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
70 | */
71 | export function graphql(source: "\n mutation LeaveChatroom($chatroomId: Float!) {\n leaveChatroom(chatroomId: $chatroomId)\n }\n"): (typeof documents)["\n mutation LeaveChatroom($chatroomId: Float!) {\n leaveChatroom(chatroomId: $chatroomId)\n }\n"];
72 | /**
73 | * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
74 | */
75 | export function graphql(source: "\n mutation LoginUser($email: String!, $password: String!) {\n login(loginInput: { email: $email, password: $password }) {\n user {\n email\n id\n fullname\n avatarUrl\n }\n }\n }\n"): (typeof documents)["\n mutation LoginUser($email: String!, $password: String!) {\n login(loginInput: { email: $email, password: $password }) {\n user {\n email\n id\n fullname\n avatarUrl\n }\n }\n }\n"];
76 | /**
77 | * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
78 | */
79 | export function graphql(source: "\n mutation LogoutUser {\n logout\n }\n"): (typeof documents)["\n mutation LogoutUser {\n logout\n }\n"];
80 | /**
81 | * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
82 | */
83 | export function graphql(source: "\n mutation RegisterUser(\n $fullname: String!\n $email: String!\n $password: String!\n $confirmPassword: String!\n ) {\n register(\n registerInput: {\n fullname: $fullname\n email: $email\n password: $password\n confirmPassword: $confirmPassword\n }\n ) {\n user {\n id\n fullname\n email\n }\n }\n }\n"): (typeof documents)["\n mutation RegisterUser(\n $fullname: String!\n $email: String!\n $password: String!\n $confirmPassword: String!\n ) {\n register(\n registerInput: {\n fullname: $fullname\n email: $email\n password: $password\n confirmPassword: $confirmPassword\n }\n ) {\n user {\n id\n fullname\n email\n }\n }\n }\n"];
84 | /**
85 | * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
86 | */
87 | export function graphql(source: "\n mutation SendMessage($chatroomId: Float!, $content: String!, $image: Upload) {\n sendMessage(chatroomId: $chatroomId, content: $content, image: $image) {\n id\n content\n imageUrl\n user {\n id\n fullname\n email\n }\n }\n }\n"): (typeof documents)["\n mutation SendMessage($chatroomId: Float!, $content: String!, $image: Upload) {\n sendMessage(chatroomId: $chatroomId, content: $content, image: $image) {\n id\n content\n imageUrl\n user {\n id\n fullname\n email\n }\n }\n }\n"];
88 | /**
89 | * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
90 | */
91 | export function graphql(source: "\n mutation UpdateProfile($fullname: String!, $file: Upload) {\n updateProfile(fullname: $fullname, file: $file) {\n id\n fullname\n avatarUrl\n }\n }\n"): (typeof documents)["\n mutation UpdateProfile($fullname: String!, $file: Upload) {\n updateProfile(fullname: $fullname, file: $file) {\n id\n fullname\n avatarUrl\n }\n }\n"];
92 | /**
93 | * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
94 | */
95 | export function graphql(source: "\n mutation UserStartedTypingMutation($chatroomId: Float!) {\n userStartedTypingMutation(chatroomId: $chatroomId) {\n id\n fullname\n email\n }\n }\n"): (typeof documents)["\n mutation UserStartedTypingMutation($chatroomId: Float!) {\n userStartedTypingMutation(chatroomId: $chatroomId) {\n id\n fullname\n email\n }\n }\n"];
96 | /**
97 | * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
98 | */
99 | export function graphql(source: "\n mutation UserStoppedTypingMutation($chatroomId: Float!) {\n userStoppedTypingMutation(chatroomId: $chatroomId) {\n id\n fullname\n email\n }\n }\n"): (typeof documents)["\n mutation UserStoppedTypingMutation($chatroomId: Float!) {\n userStoppedTypingMutation(chatroomId: $chatroomId) {\n id\n fullname\n email\n }\n }\n"];
100 | /**
101 | * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
102 | */
103 | export function graphql(source: "\n query GetChatroomsForUser($userId: Float!) {\n getChatroomsForUser(userId: $userId) {\n id\n name\n messages {\n id\n content\n createdAt\n user {\n id\n fullname\n }\n }\n users {\n avatarUrl\n id\n fullname\n email\n }\n }\n }\n"): (typeof documents)["\n query GetChatroomsForUser($userId: Float!) {\n getChatroomsForUser(userId: $userId) {\n id\n name\n messages {\n id\n content\n createdAt\n user {\n id\n fullname\n }\n }\n users {\n avatarUrl\n id\n fullname\n email\n }\n }\n }\n"];
104 | /**
105 | * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
106 | */
107 | export function graphql(source: "\n query GetMessagesForChatroom($chatroomId: Float!) {\n getMessagesForChatroom(chatroomId: $chatroomId) {\n id\n content\n imageUrl\n createdAt\n user {\n id\n fullname\n email\n avatarUrl\n }\n chatroom {\n id\n name\n users {\n id\n fullname\n email\n avatarUrl\n }\n }\n }\n }\n"): (typeof documents)["\n query GetMessagesForChatroom($chatroomId: Float!) {\n getMessagesForChatroom(chatroomId: $chatroomId) {\n id\n content\n imageUrl\n createdAt\n user {\n id\n fullname\n email\n avatarUrl\n }\n chatroom {\n id\n name\n users {\n id\n fullname\n email\n avatarUrl\n }\n }\n }\n }\n"];
108 | /**
109 | * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
110 | */
111 | export function graphql(source: "\n query GetUsersOfChatroom($chatroomId: Float!) {\n getUsersOfChatroom(chatroomId: $chatroomId) {\n id\n fullname\n email\n avatarUrl\n }\n }\n"): (typeof documents)["\n query GetUsersOfChatroom($chatroomId: Float!) {\n getUsersOfChatroom(chatroomId: $chatroomId) {\n id\n fullname\n email\n avatarUrl\n }\n }\n"];
112 | /**
113 | * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
114 | */
115 | export function graphql(source: "\n query SearchUsers($fullname: String!) {\n searchUsers(fullname: $fullname) {\n id\n fullname\n email\n }\n }\n"): (typeof documents)["\n query SearchUsers($fullname: String!) {\n searchUsers(fullname: $fullname) {\n id\n fullname\n email\n }\n }\n"];
116 | /**
117 | * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
118 | */
119 | export function graphql(source: "\n subscription LiveUsersInChatroom($chatroomId: Float!) {\n liveUsersInChatroom(chatroomId: $chatroomId) {\n id\n fullname\n avatarUrl\n email\n }\n }\n"): (typeof documents)["\n subscription LiveUsersInChatroom($chatroomId: Float!) {\n liveUsersInChatroom(chatroomId: $chatroomId) {\n id\n fullname\n avatarUrl\n email\n }\n }\n"];
120 | /**
121 | * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
122 | */
123 | export function graphql(source: "\n subscription NewMessage($chatroomId: Float!) {\n newMessage(chatroomId: $chatroomId) {\n id\n content\n imageUrl\n createdAt\n user {\n id\n fullname\n email\n avatarUrl\n }\n }\n }\n"): (typeof documents)["\n subscription NewMessage($chatroomId: Float!) {\n newMessage(chatroomId: $chatroomId) {\n id\n content\n imageUrl\n createdAt\n user {\n id\n fullname\n email\n avatarUrl\n }\n }\n }\n"];
124 | /**
125 | * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
126 | */
127 | export function graphql(source: "\n subscription UserStartedTyping($chatroomId: Float!, $userId: Float!) {\n userStartedTyping(chatroomId: $chatroomId, userId: $userId) {\n id\n fullname\n email\n avatarUrl\n }\n }\n"): (typeof documents)["\n subscription UserStartedTyping($chatroomId: Float!, $userId: Float!) {\n userStartedTyping(chatroomId: $chatroomId, userId: $userId) {\n id\n fullname\n email\n avatarUrl\n }\n }\n"];
128 | /**
129 | * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
130 | */
131 | export function graphql(source: "\n subscription UserStoppedTyping($chatroomId: Float!, $userId: Float!) {\n userStoppedTyping(chatroomId: $chatroomId, userId: $userId) {\n id\n fullname\n email\n avatarUrl\n }\n }\n"): (typeof documents)["\n subscription UserStoppedTyping($chatroomId: Float!, $userId: Float!) {\n userStoppedTyping(chatroomId: $chatroomId, userId: $userId) {\n id\n fullname\n email\n avatarUrl\n }\n }\n"];
132 |
133 | export function graphql(source: string) {
134 | return (documents as any)[source] ?? {};
135 | }
136 |
137 | export type DocumentType> = TDocumentNode extends DocumentNode< infer TType, any> ? TType : never;
--------------------------------------------------------------------------------
/frontend/src/gql/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./fragment-masking";
2 | export * from "./gql";
--------------------------------------------------------------------------------
/frontend/src/graphql/mutations/AddUsersToChatroom.ts:
--------------------------------------------------------------------------------
1 | import { gql } from "@apollo/client"
2 |
3 | export const ADD_USERS_TO_CHATROOM = gql`
4 | mutation AddUsersToChatroom($chatroomId: Float!, $userIds: [Float!]!) {
5 | addUsersToChatroom(chatroomId: $chatroomId, userIds: $userIds) {
6 | name
7 | id
8 | }
9 | }
10 | `
11 |
--------------------------------------------------------------------------------
/frontend/src/graphql/mutations/CreateChatroom.ts:
--------------------------------------------------------------------------------
1 | import { gql } from "@apollo/client"
2 |
3 | export const CREATE_CHATROOM = gql`
4 | mutation CreateChatroom($name: String!) {
5 | createChatroom(name: $name) {
6 | name
7 | id
8 | }
9 | }
10 | `
11 |
--------------------------------------------------------------------------------
/frontend/src/graphql/mutations/DeleteChatroom.ts:
--------------------------------------------------------------------------------
1 | import { gql } from "@apollo/client"
2 |
3 | export const DELETE_CHATROOM = gql`
4 | mutation DeleteChatroom($chatroomId: Float!) {
5 | deleteChatroom(chatroomId: $chatroomId)
6 | }
7 | `
8 |
--------------------------------------------------------------------------------
/frontend/src/graphql/mutations/EnterChatroom.ts:
--------------------------------------------------------------------------------
1 | import { gql } from "@apollo/client"
2 |
3 | export const ENTER_CHATROOM = gql`
4 | mutation EnterChatroom($chatroomId: Float!) {
5 | enterChatroom(chatroomId: $chatroomId)
6 | }
7 | `
8 |
--------------------------------------------------------------------------------
/frontend/src/graphql/mutations/LeaveChatroom.ts:
--------------------------------------------------------------------------------
1 | import { gql } from "@apollo/client"
2 | export const LEAVE_CHATROOM = gql`
3 | mutation LeaveChatroom($chatroomId: Float!) {
4 | leaveChatroom(chatroomId: $chatroomId)
5 | }
6 | `
7 |
--------------------------------------------------------------------------------
/frontend/src/graphql/mutations/Login.ts:
--------------------------------------------------------------------------------
1 | import { gql } from "@apollo/client"
2 |
3 | export const LOGIN_USER = gql`
4 | mutation LoginUser($email: String!, $password: String!) {
5 | login(loginInput: { email: $email, password: $password }) {
6 | user {
7 | email
8 | id
9 | fullname
10 | avatarUrl
11 | }
12 | }
13 | }
14 | `
15 |
--------------------------------------------------------------------------------
/frontend/src/graphql/mutations/Logout.ts:
--------------------------------------------------------------------------------
1 | import { gql } from "@apollo/client"
2 |
3 | export const LOGOUT_USER = gql`
4 | mutation LogoutUser {
5 | logout
6 | }
7 | `
8 |
--------------------------------------------------------------------------------
/frontend/src/graphql/mutations/Register.ts:
--------------------------------------------------------------------------------
1 | import { gql } from "@apollo/client"
2 |
3 | export const REGISTER_USER = gql`
4 | mutation RegisterUser(
5 | $fullname: String!
6 | $email: String!
7 | $password: String!
8 | $confirmPassword: String!
9 | ) {
10 | register(
11 | registerInput: {
12 | fullname: $fullname
13 | email: $email
14 | password: $password
15 | confirmPassword: $confirmPassword
16 | }
17 | ) {
18 | user {
19 | id
20 | fullname
21 | email
22 | }
23 | }
24 | }
25 | `
26 |
--------------------------------------------------------------------------------
/frontend/src/graphql/mutations/SendMessage.ts:
--------------------------------------------------------------------------------
1 | import { gql } from "@apollo/client"
2 |
3 | export const SEND_MESSAGE = gql`
4 | mutation SendMessage($chatroomId: Float!, $content: String!, $image: Upload) {
5 | sendMessage(chatroomId: $chatroomId, content: $content, image: $image) {
6 | id
7 | content
8 | imageUrl
9 | user {
10 | id
11 | fullname
12 | email
13 | }
14 | }
15 | }
16 | `
17 |
--------------------------------------------------------------------------------
/frontend/src/graphql/mutations/UpdateUserProfile.ts:
--------------------------------------------------------------------------------
1 | import { gql } from "@apollo/client"
2 |
3 | export const UPDATE_PROFILE = gql`
4 | mutation UpdateProfile($fullname: String!, $file: Upload) {
5 | updateProfile(fullname: $fullname, file: $file) {
6 | id
7 | fullname
8 | avatarUrl
9 | }
10 | }
11 | `
12 |
--------------------------------------------------------------------------------
/frontend/src/graphql/mutations/UserStartedTypingMutation.ts:
--------------------------------------------------------------------------------
1 | import { gql } from "@apollo/client"
2 |
3 | export const USER_STARTED_TYPING_MUTATION = gql`
4 | mutation UserStartedTypingMutation($chatroomId: Float!) {
5 | userStartedTypingMutation(chatroomId: $chatroomId) {
6 | id
7 | fullname
8 | email
9 | }
10 | }
11 | `
12 |
--------------------------------------------------------------------------------
/frontend/src/graphql/mutations/UserStoppedTypingMutation.ts:
--------------------------------------------------------------------------------
1 | import { gql } from "@apollo/client"
2 |
3 | export const USER_STOPPED_TYPING_MUTATION = gql`
4 | mutation UserStoppedTypingMutation($chatroomId: Float!) {
5 | userStoppedTypingMutation(chatroomId: $chatroomId) {
6 | id
7 | fullname
8 | email
9 | }
10 | }
11 | `
12 |
--------------------------------------------------------------------------------
/frontend/src/graphql/queries/GetChatroomsForUser.ts:
--------------------------------------------------------------------------------
1 | import { gql } from "@apollo/client"
2 |
3 | export const GET_CHATROOMS_FOR_USER = gql`
4 | query GetChatroomsForUser($userId: Float!) {
5 | getChatroomsForUser(userId: $userId) {
6 | id
7 | name
8 | messages {
9 | id
10 | content
11 | createdAt
12 | user {
13 | id
14 | fullname
15 | }
16 | }
17 | users {
18 | avatarUrl
19 | id
20 | fullname
21 | email
22 | }
23 | }
24 | }
25 | `
26 |
--------------------------------------------------------------------------------
/frontend/src/graphql/queries/GetMessagesForChatroom.ts:
--------------------------------------------------------------------------------
1 | import { gql } from "@apollo/client"
2 |
3 | export const GET_MESSAGES_FOR_CHATROOM = gql`
4 | query GetMessagesForChatroom($chatroomId: Float!) {
5 | getMessagesForChatroom(chatroomId: $chatroomId) {
6 | id
7 | content
8 | imageUrl
9 | createdAt
10 | user {
11 | id
12 | fullname
13 | email
14 | avatarUrl
15 | }
16 | chatroom {
17 | id
18 | name
19 | users {
20 | id
21 | fullname
22 | email
23 | avatarUrl
24 | }
25 | }
26 | }
27 | }
28 | `
29 |
--------------------------------------------------------------------------------
/frontend/src/graphql/queries/GetUsersOfChatroom.ts:
--------------------------------------------------------------------------------
1 | import { gql } from "@apollo/client"
2 |
3 | export const GET_USERS_OF_CHATROOM = gql`
4 | query GetUsersOfChatroom($chatroomId: Float!) {
5 | getUsersOfChatroom(chatroomId: $chatroomId) {
6 | id
7 | fullname
8 | email
9 | avatarUrl
10 | }
11 | }
12 | `
13 |
--------------------------------------------------------------------------------
/frontend/src/graphql/queries/SearchUsers.ts:
--------------------------------------------------------------------------------
1 | import { gql } from "@apollo/client"
2 |
3 | export const SEARCH_USERS = gql`
4 | query SearchUsers($fullname: String!) {
5 | searchUsers(fullname: $fullname) {
6 | id
7 | fullname
8 | email
9 | }
10 | }
11 | `
12 |
--------------------------------------------------------------------------------
/frontend/src/graphql/subscriptions/LiveUsers.ts:
--------------------------------------------------------------------------------
1 | import gql from "graphql-tag"
2 |
3 | export const LIVE_USERS_SUBSCRIPTION = gql`
4 | subscription LiveUsersInChatroom($chatroomId: Float!) {
5 | liveUsersInChatroom(chatroomId: $chatroomId) {
6 | id
7 | fullname
8 | avatarUrl
9 | email
10 | }
11 | }
12 | `
13 |
--------------------------------------------------------------------------------
/frontend/src/graphql/subscriptions/NewMessage.ts:
--------------------------------------------------------------------------------
1 | import { gql } from "@apollo/client"
2 |
3 | export const NEW_MESSAGE_SUBSCRIPTION = gql`
4 | subscription NewMessage($chatroomId: Float!) {
5 | newMessage(chatroomId: $chatroomId) {
6 | id
7 | content
8 | imageUrl
9 | createdAt
10 | user {
11 | id
12 | fullname
13 | email
14 | avatarUrl
15 | }
16 | }
17 | }
18 | `
19 |
--------------------------------------------------------------------------------
/frontend/src/graphql/subscriptions/UserStartedTyping.ts:
--------------------------------------------------------------------------------
1 | import { gql } from "@apollo/client"
2 |
3 | export const USER_STARTED_TYPING_SUBSCRIPTION = gql`
4 | subscription UserStartedTyping($chatroomId: Float!, $userId: Float!) {
5 | userStartedTyping(chatroomId: $chatroomId, userId: $userId) {
6 | id
7 | fullname
8 | email
9 | avatarUrl
10 | }
11 | }
12 | `
13 |
--------------------------------------------------------------------------------
/frontend/src/graphql/subscriptions/UserStoppedTyping.ts:
--------------------------------------------------------------------------------
1 | import { gql } from "@apollo/client"
2 |
3 | export const USER_STOPPED_TYPING_SUBSCRIPTION = gql`
4 | subscription UserStoppedTyping($chatroomId: Float!, $userId: Float!) {
5 | userStoppedTyping(chatroomId: $chatroomId, userId: $userId) {
6 | id
7 | fullname
8 | email
9 | avatarUrl
10 | }
11 | }
12 | `
13 |
--------------------------------------------------------------------------------
/frontend/src/index.css:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | body {
5 | margin: 0;
6 | display: flex;
7 | flex-direction: row;
8 |
9 |
10 | max-width: 100%;
11 |
12 |
13 |
14 |
15 | }
16 | a {
17 | text-decoration: none;
18 |
19 | }
20 | *{
21 |
22 | font-family: 'Roboto', sans-serif;
23 | padding: 0;
24 | margin: 0;
25 | box-sizing: border-box;
26 | font-size: 16px;
27 |
28 | }
29 |
30 | div{
31 |
32 | max-width: 100%;
33 | }
34 | ul{
35 | max-width: 100%;
36 | }
--------------------------------------------------------------------------------
/frontend/src/layouts/MainLayout.tsx:
--------------------------------------------------------------------------------
1 | import { Flex } from "@mantine/core"
2 |
3 | const MainLayout = ({ children }: { children: React.ReactElement }) => {
4 | return (
5 |
6 | {children}
7 |
8 | )
9 | }
10 |
11 | export default MainLayout
12 |
--------------------------------------------------------------------------------
/frontend/src/main.tsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import ReactDOM from "react-dom/client"
3 |
4 | import "./index.css"
5 | import { RouterProvider, createBrowserRouter } from "react-router-dom"
6 |
7 | import { ApolloProvider } from "@apollo/client"
8 | import { client } from "./apolloClient"
9 | import Home from "./pages/Home.tsx"
10 |
11 | const router = createBrowserRouter([
12 | {
13 | path: "/",
14 | element: ,
15 | children: [
16 | {
17 | path: "/chatrooms/:id",
18 | },
19 | ],
20 | },
21 | ])
22 |
23 | ReactDOM.createRoot(document.getElementById("root")!).render(
24 |
25 |
26 |
27 |
28 |
29 | )
30 |
--------------------------------------------------------------------------------
/frontend/src/pages/Home.tsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import MainLayout from "../layouts/MainLayout"
3 | import Sidebar from "../components/Sidebar"
4 | import ProtectedRoutes from "../components/ProtectedRoutes"
5 | import AuthOverlay from "../components/AuthOverlay"
6 | import ProfileSettings from "../components/ProfileSettings"
7 | import RoomList from "../components/RoomList"
8 | import { Flex } from "@mantine/core"
9 | import AddChatroom from "../components/AddChatroom"
10 | import JoinRoomOrChatwindow from "../components/JoinRoomOrChatwindow"
11 | function Home() {
12 | return (
13 |
14 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 | )
32 | }
33 |
34 | export default Home
35 |
--------------------------------------------------------------------------------
/frontend/src/stores/generalStore.ts:
--------------------------------------------------------------------------------
1 | import { create } from "zustand"
2 | import { persist } from "zustand/middleware"
3 |
4 | interface GeneralState {
5 | isProfileSettingsModalOpen: boolean
6 | isLoginModalOpen: boolean
7 | isCreateRoomModalOpen: boolean
8 | toggleProfileSettingsModal: () => void
9 | toggleLoginModal: () => void
10 | toggleCreateRoomModal: () => void
11 | }
12 |
13 | export const useGeneralStore = create()(
14 | persist(
15 | (set) => ({
16 | isProfileSettingsModalOpen: false,
17 | isLoginModalOpen: false,
18 | isCreateRoomModalOpen: false,
19 |
20 | toggleProfileSettingsModal: () =>
21 | set((state) => ({
22 | isProfileSettingsModalOpen: !state.isProfileSettingsModalOpen,
23 | })),
24 | toggleLoginModal: () =>
25 | set((state) => ({
26 | isLoginModalOpen: !state.isLoginModalOpen,
27 | })),
28 | toggleCreateRoomModal: () =>
29 | set((state) => ({
30 | isCreateRoomModalOpen: !state.isCreateRoomModalOpen,
31 | })),
32 | }),
33 | {
34 | name: "general-store",
35 | }
36 | )
37 | )
38 |
--------------------------------------------------------------------------------
/frontend/src/stores/userStore.ts:
--------------------------------------------------------------------------------
1 | import { create } from "zustand"
2 | import { persist } from "zustand/middleware"
3 | import { User } from "../gql/graphql"
4 |
5 | interface UserState {
6 | id: number | undefined
7 | avatarUrl: string | null
8 | fullname: string
9 | email?: string
10 | updateProfileImage: (image: string) => void
11 | updateUsername: (name: string) => void
12 | setUser: (user: User) => void
13 | }
14 |
15 | export const useUserStore = create()(
16 | persist(
17 | (set) => ({
18 | id: undefined,
19 | fullname: "",
20 | email: "",
21 | avatarUrl: null,
22 |
23 | updateProfileImage: (image: string) => set({ avatarUrl: image }),
24 | updateUsername: (name: string) => set({ fullname: name }),
25 | setUser: (user) =>
26 | set({
27 | id: user.id || undefined,
28 | avatarUrl: user.avatarUrl,
29 | fullname: user.fullname,
30 | email: user.email,
31 | }),
32 | }),
33 | {
34 | name: "user-store",
35 | }
36 | )
37 | )
38 |
--------------------------------------------------------------------------------
/frontend/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/frontend/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2020",
4 | "useDefineForClassFields": true,
5 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
6 | "module": "ESNext",
7 | "skipLibCheck": true,
8 |
9 | /* Bundler mode */
10 | "moduleResolution": "bundler",
11 | "allowImportingTsExtensions": true,
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "noEmit": true,
15 | "jsx": "react-jsx",
16 |
17 | /* Linting */
18 | "strict": true,
19 | "noUnusedLocals": true,
20 | "noUnusedParameters": true,
21 | "noFallthroughCasesInSwitch": true
22 | },
23 | "include": ["src"],
24 | "references": [{ "path": "./tsconfig.node.json" }]
25 | }
26 |
--------------------------------------------------------------------------------
/frontend/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "skipLibCheck": true,
5 | "module": "ESNext",
6 | "moduleResolution": "bundler",
7 | "allowSyntheticDefaultImports": true
8 | },
9 | "include": ["vite.config.ts"]
10 | }
11 |
--------------------------------------------------------------------------------
/frontend/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite'
2 | import react from '@vitejs/plugin-react'
3 |
4 | // https://vitejs.dev/config/
5 | export default defineConfig({
6 | plugins: [react()],
7 | })
8 |
--------------------------------------------------------------------------------