├── 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 | Nest Logo 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 | NPM Version 11 | Package License 12 | NPM Downloads 13 | CircleCI 14 | Coverage 15 | Discord 16 | Backers on Open Collective 17 | Sponsors on Open Collective 18 | 19 | Support us 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 |
handleCreateChatroom())}> 142 | 148 | {form.values.name && ( 149 | 152 | )} 153 | 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 |
{ 96 | handleRegister() 97 | })} 98 | > 99 | 100 | 101 | 107 | 108 | 109 | 110 | 117 | 118 | 119 | 127 | 128 | 129 | 140 | 141 | 142 | 143 | 146 | 147 | 148 | 149 | 150 | 158 | 161 | 162 |
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 |
{ 223 | handleLogin() 224 | })} 225 | > 226 | 227 | 228 | 235 | 236 | 237 | 245 | 246 | {/* Not registered yet? then render register component. use something like a text, not a button */} 247 | 248 | {invalidCredentials} 249 | 250 | 251 | 254 | 255 | 256 | {/* buttons: login or cancel */} 257 | 258 | 266 | 269 | 270 |
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 | Preview 471 | )} 472 | 473 | 474 | 475 | setMessageContent(e.currentTarget.value)} 480 | placeholder="Type your message..." 481 | rightSection={ 482 | 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 | Uploaded content 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 |
handleSave())}> 72 | fileInputRef.current?.click()} 78 | > 79 | 86 | 99 | setImageFile(file)} 106 | /> 107 | 108 | { 113 | form.setFieldValue("fullname", event.currentTarget.value) 114 | }} 115 | error={form.errors.fullname} 116 | /> 117 | 118 | 119 | 122 | 123 | 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 | 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 | --------------------------------------------------------------------------------