├── .eslintrc.json ├── jest.setup.js ├── public ├── favicon.ico ├── Struktur.pdf ├── sounds │ ├── send.mp3 │ ├── receive.mp3 │ └── notification.mp3 ├── macos-big-sur-apple-layers.jpeg └── vercel.svg ├── next.config.js ├── components ├── Loading │ ├── themes │ │ ├── index.ts │ │ └── index.module.scss │ ├── __tests__ │ │ └── Loading.test.tsx │ └── index.tsx └── Chat │ ├── Content │ ├── Message │ │ └── index.tsx │ ├── models.ts │ ├── index.tsx │ ├── Messages │ │ ├── __tests__ │ │ │ └── Messages.test.tsx │ │ └── index.tsx │ ├── Header │ │ └── index.tsx │ └── MessageForm │ │ └── index.tsx │ ├── SearchInList │ └── index.tsx │ ├── List │ └── index.tsx │ └── Context │ └── index.tsx ├── postcss.config.js ├── pages ├── _app.tsx ├── api │ └── hello.ts ├── rooms │ └── [id].tsx └── index.tsx ├── next-env.d.ts ├── styles ├── globals.css └── Home.module.scss ├── services ├── index.ts └── rooms │ └── index.ts ├── types └── next.ts ├── tsconfig.json ├── .gitignore ├── tailwind.config.js ├── jest.config.js ├── package.json ├── README.md └── server.js /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /jest.setup.js: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom/extend-expect' 2 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/profile/p2p-chat/main/public/favicon.ico -------------------------------------------------------------------------------- /public/Struktur.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/profile/p2p-chat/main/public/Struktur.pdf -------------------------------------------------------------------------------- /public/sounds/send.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/profile/p2p-chat/main/public/sounds/send.mp3 -------------------------------------------------------------------------------- /public/sounds/receive.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/profile/p2p-chat/main/public/sounds/receive.mp3 -------------------------------------------------------------------------------- /public/sounds/notification.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/profile/p2p-chat/main/public/sounds/notification.mp3 -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | module.exports = { 3 | reactStrictMode: true, 4 | } 5 | -------------------------------------------------------------------------------- /components/Loading/themes/index.ts: -------------------------------------------------------------------------------- 1 | import styles from "./index.module.scss" 2 | 3 | export const LoadingTheme = styles; -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/macos-big-sur-apple-layers.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/profile/p2p-chat/main/public/macos-big-sur-apple-layers.jpeg -------------------------------------------------------------------------------- /pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import '../styles/globals.css' 2 | import type { AppProps } from 'next/app' 3 | 4 | function MyApp({ Component, pageProps }: AppProps) { 5 | return 6 | } 7 | export default MyApp 8 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | /// 4 | 5 | // NOTE: This file should not be edited 6 | // see https://nextjs.org/docs/basic-features/typescript for more information. 7 | -------------------------------------------------------------------------------- /styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | 4 | 5 | /* body { 6 | font-family: "BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji"; 7 | } */ 8 | 9 | @tailwind utilities -------------------------------------------------------------------------------- /components/Chat/Content/Message/index.tsx: -------------------------------------------------------------------------------- 1 | import {IMessage} from "../models"; 2 | 3 | interface IProps { 4 | item: IMessage; 5 | } 6 | 7 | export function Message ({item}: IProps) { 8 | 9 | return ( 10 | 11 | {item.message} 12 | 13 | ) 14 | 15 | } -------------------------------------------------------------------------------- /services/index.ts: -------------------------------------------------------------------------------- 1 | export async function apiCall (url: string, params: any) { 2 | return await fetch(url, { 3 | ...params, 4 | method: params.method || "GET", 5 | headers: { 6 | "Content-Type": "application/json", 7 | }, 8 | body: JSON.stringify(params.body || {}), 9 | }).then(response => response.json()) 10 | } -------------------------------------------------------------------------------- /types/next.ts: -------------------------------------------------------------------------------- 1 | import { Server as NetServer, Socket } from "net"; 2 | import { NextApiResponse } from "next"; 3 | import { Server as SocketIOServer } from "socket.io"; 4 | 5 | export type NextApiResponseServerIO = NextApiResponse & { 6 | socket: Socket & { 7 | server: NetServer & { 8 | io: SocketIOServer; 9 | }; 10 | }; 11 | }; 12 | -------------------------------------------------------------------------------- /pages/api/hello.ts: -------------------------------------------------------------------------------- 1 | // Next.js API route support: https://nextjs.org/docs/api-routes/introduction 2 | import type { NextApiRequest, NextApiResponse } from 'next' 3 | 4 | type Data = { 5 | name: string 6 | } 7 | 8 | export default function handler( 9 | req: NextApiRequest, 10 | res: NextApiResponse 11 | ) { 12 | res.status(200).json({ name: 'John Doe' }) 13 | } 14 | -------------------------------------------------------------------------------- /components/Chat/Content/models.ts: -------------------------------------------------------------------------------- 1 | import {IUser} from "../Context"; 2 | 3 | export interface IChatContext { 4 | room?: { 5 | name: string, 6 | id: string, 7 | users: IUser[] 8 | }, 9 | socket?: any; 10 | chat?: any; 11 | user?: IUser | null 12 | } 13 | 14 | export interface IMessage { 15 | socketId?: string; 16 | message?: string; 17 | user?: any; 18 | sentDate: Date; 19 | } -------------------------------------------------------------------------------- /styles/Home.module.scss: -------------------------------------------------------------------------------- 1 | // .container { 2 | // min-height: 100vh; 3 | // padding: 0 0.5rem; 4 | // display: flex; 5 | // flex-direction: column; 6 | // justify-content: center; 7 | // align-items: center; 8 | // height: 100vh; 9 | // } 10 | 11 | // .main { 12 | // padding: 5rem 0; 13 | // flex: 1; 14 | // display: flex; 15 | // flex-direction: column; 16 | // justify-content: center; 17 | // align-items: center; 18 | // } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve" 16 | }, 17 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 18 | "exclude": ["node_modules"] 19 | } 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env.local 29 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | 33 | # vercel 34 | .vercel 35 | 36 | #local 37 | .idea 38 | -------------------------------------------------------------------------------- /components/Chat/SearchInList/index.tsx: -------------------------------------------------------------------------------- 1 | import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; 2 | import {faSearch} from "@fortawesome/free-solid-svg-icons"; 3 | 4 | export function SearchInList () { 5 | return
6 | 12 |
13 | } -------------------------------------------------------------------------------- /components/Loading/themes/index.module.scss: -------------------------------------------------------------------------------- 1 | .loader { 2 | &Bg { 3 | position: fixed; 4 | left: 0; 5 | top: 0; 6 | right: 0; 7 | bottom: 0; 8 | z-index: 11; 9 | display: flex; 10 | align-items: center; 11 | justify-content: center; 12 | background: rgba(255, 255, 255, 0.77); 13 | } 14 | 15 | margin: auto; 16 | border: 8px solid #EAF0F6; 17 | border-radius: 50%; 18 | border-top: 8px solid #FF7A59; 19 | width: 130px; 20 | height: 130px; 21 | animation: spinner 2s linear infinite; 22 | } 23 | 24 | @keyframes spinner { 25 | 0% { transform: rotate(0deg); } 26 | 100% { transform: rotate(360deg); } 27 | } -------------------------------------------------------------------------------- /components/Chat/List/index.tsx: -------------------------------------------------------------------------------- 1 | export function ChatList () { 2 | return <> 3 | { 4 | Array(50).fill(null).map((item, itemIndex) =>( 5 |
6 |

Kush Gibson

7 |
8 | Lorem ipsum asd d a d as das das d as d asd a s as Lorem ipsum asd d a d as das das d as d asd a s as 9 | 12m 10 |
11 |
12 | )) 13 | } 14 | 15 | } -------------------------------------------------------------------------------- /components/Loading/__tests__/Loading.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen, waitFor, getByText } from "@testing-library/react"; 2 | import {Loading} from "../index"; 3 | 4 | describe("Loading component", () => { 5 | 6 | it("should render default loading spinner", async () => { 7 | render(); 8 | expect(screen.findByTestId("defaultSpinner")).toBeTruthy(); 9 | }); 10 | 11 | it("should render message", () => { 12 | render(); 13 | expect(screen.getByText("visible")).toBeTruthy(); 14 | }) 15 | 16 | it("should not render message", () => { 17 | const { container } = render(visible} />); 18 | expect(container.querySelector(".visible")).toBeFalsy(); 19 | }) 20 | }); -------------------------------------------------------------------------------- /components/Chat/Content/index.tsx: -------------------------------------------------------------------------------- 1 | import {useChatContext} from "../Context"; 2 | import {Header as ContentHeader} from "./Header"; 3 | import {Messages} from "./Messages"; 4 | import {MessageForm} from "./MessageForm"; 5 | import {IChatContext} from "./models"; 6 | 7 | export function Content () { 8 | const chatContext: IChatContext = useChatContext(); 9 | 10 | return ( 11 |
12 | { 13 | chatContext?.room?.users && chatContext.user && ( 14 | <> 15 | 16 | 17 | 18 | 19 | ) 20 | } 21 |
22 | ) 23 | } -------------------------------------------------------------------------------- /components/Loading/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import {LoadingTheme} from "./themes"; 3 | 4 | interface ILoadingProps { 5 | /** Visible. */ 6 | visible: boolean; 7 | 8 | /** Message. */ 9 | message?: React.ReactNode; 10 | 11 | /** Children. */ 12 | children?: React.ReactNode; 13 | } 14 | 15 | function DefaultSpinner () { 16 | return ( 17 |
18 | ) 19 | } 20 | 21 | export function Loading({ visible, children, message = }: ILoadingProps) { 22 | 23 | return ( 24 |
25 | {visible && ( 26 |
27 | {message} 28 |
29 | )} 30 |
31 | {children} 32 |
33 |
34 | ) 35 | 36 | } -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | //TODO: uncomment when fix conditional className 3 | purge: [], //['./pages/**/*.{js,ts,jsx,tsx}', './components/**/*.{js,ts,jsx,tsx}'], 4 | darkMode: "class", // or 'media' or 'class' 5 | theme: { 6 | backgroundColor: theme => ({ 7 | ...theme('colors'), 8 | smooth: "hsl(252deg 19% 15%)", 9 | "chat-list": "rgb(37 35 49)", 10 | "chat-content": "rgb(37 35 49)", 11 | "message-item-sent": "#1b8be5", 12 | "message-item-receive": "#607d8b" 13 | }), 14 | fontFamily: { 15 | // sans: ['BlinkMacSystemFont', 'Helvetica', 'Arial', 'sans-serif'] 16 | }, 17 | extend: { 18 | height: { 19 | 730: "730px", 20 | "content-max": "970px", 21 | }, 22 | borderRadius: { 23 | smooth: '20px', 24 | 70: '70px', 25 | 4: '4px', 26 | }, 27 | borderColor: { 28 | 'tiny': "rgba(249, 250, 251, 0.5);" 29 | }, 30 | }, 31 | }, 32 | variants: { 33 | extend: {}, 34 | }, 35 | plugins: [], 36 | } 37 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /services/rooms/index.ts: -------------------------------------------------------------------------------- 1 | import {apiCall} from "../index"; 2 | 3 | /** Room create request model. */ 4 | export interface ICreateRoomRq { 5 | username: string; 6 | roomName: string; 7 | } 8 | /** Room join request model. */ 9 | export interface IJoinRoomRq { 10 | username: string; 11 | roomId: string; 12 | } 13 | 14 | export interface ISuccessRoomActionRs { 15 | roomId: string; 16 | userId: string; 17 | } 18 | 19 | export const roomsEndpoints = { 20 | base () { 21 | return "/api/rooms" 22 | }, 23 | create () { 24 | return `${this.base()}` 25 | }, 26 | join () { 27 | return `${this.base()}/join` 28 | }, 29 | } 30 | 31 | 32 | export const roomsServices = { 33 | async create(body: ICreateRoomRq): Promise { 34 | return await apiCall(roomsEndpoints.create(), { 35 | method: "POST", 36 | body 37 | }); 38 | }, 39 | async join(body: IJoinRoomRq): Promise { 40 | return await apiCall(roomsEndpoints.join(), { 41 | method: "POST", 42 | body 43 | }); 44 | } 45 | } -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | collectCoverageFrom: [ 3 | '**/*.{js,jsx,ts,tsx}', 4 | '!**/*.d.ts', 5 | '!**/node_modules/**', 6 | ], 7 | moduleNameMapper: { 8 | /* Handle CSS imports (with CSS modules) 9 | https://jestjs.io/docs/webpack#mocking-css-modules */ 10 | '^.+\\.module\\.(css|sass|scss)$': 'identity-obj-proxy', 11 | 12 | // Handle CSS imports (without CSS modules) 13 | '^.+\\.(css|sass|scss)$': '/__mocks__/styleMock.js', 14 | 15 | /* Handle image imports 16 | https://jestjs.io/docs/webpack#handling-static-assets */ 17 | '^.+\\.(jpg|jpeg|png|gif|webp|svg)$': '/__mocks__/fileMock.js', 18 | }, 19 | testPathIgnorePatterns: ['/node_modules/', '/.next/'], 20 | testEnvironment: 'jsdom', 21 | transform: { 22 | /* Use babel-jest to transpile tests with the next/babel preset 23 | https://jestjs.io/docs/configuration#transform-objectstring-pathtotransformer--pathtotransformer-object */ 24 | '^.+\\.(js|jsx|ts|tsx)$': ['babel-jest', { presets: ['next/babel'] }], 25 | }, 26 | transformIgnorePatterns: [ 27 | '/node_modules/', 28 | '^.+\\.module\\.(css|sass|scss)$', 29 | ], 30 | setupFilesAfterEnv: ['/jest.setup.js'] 31 | } -------------------------------------------------------------------------------- /components/Chat/Content/Messages/__tests__/Messages.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen, waitFor, getByText } from "@testing-library/react"; 2 | import {Messages} from "../index"; 3 | 4 | describe("Messages component", () => { 5 | 6 | it("should render default messages length properly", async () => { 7 | 8 | // We can use faker mock 9 | const user = { 10 | id:"1", 11 | roomId: "2", 12 | username: "hello", 13 | joined: new Date(), 14 | created: new Date(), 15 | }; 16 | const chat = [{user}, {user}, {user}] 17 | 18 | render(); 19 | expect(screen.getByTestId("messages").children.length).toBe(3) 20 | }); 21 | 22 | it("should properly render my and others message length", async () => { 23 | 24 | const user = { 25 | id:"1", 26 | roomId: "2", 27 | username: "hello", 28 | joined: new Date(), 29 | created: new Date(), 30 | }; 31 | 32 | const chat = [{ user:{ ...user, id: "3" } }, {user},{user}, {user}] 33 | const { container } = render(); 34 | 35 | expect(container.querySelectorAll(".isMine").length).toBe(3) 36 | expect(container.querySelectorAll(".isOther").length).toBe(1) 37 | }); 38 | }); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "p2p-chat", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "node server.js", 7 | "build": "next build", 8 | "start": "NODE_ENV=production node server.js", 9 | "test": "jest --watch", 10 | "lint": "next lint" 11 | }, 12 | "dependencies": { 13 | "@fortawesome/fontawesome-svg-core": "^1.2.36", 14 | "@fortawesome/free-brands-svg-icons": "^5.15.4", 15 | "@fortawesome/free-solid-svg-icons": "^5.15.4", 16 | "@fortawesome/react-fontawesome": "^0.1.15", 17 | "body-parser": "^1.19.0", 18 | "express": "^4.17.1", 19 | "next": "11.1.2", 20 | "react": "17.0.2", 21 | "react-dom": "17.0.2", 22 | "socket.io": "^4.2.0", 23 | "socket.io-client": "^4.2.0", 24 | "uuid": "^8.3.2" 25 | }, 26 | "devDependencies": { 27 | "@testing-library/jest-dom": "^5.14.1", 28 | "@testing-library/react": "^12.1.2", 29 | "@types/react": "17.0.27", 30 | "@types/socket.io-client": "^3.0.0", 31 | "autoprefixer": "^10.3.7", 32 | "babel-jest": "^27.2.5", 33 | "eslint": "8.0.0", 34 | "eslint-config-next": "11.1.2", 35 | "identity-obj-proxy": "^3.0.0", 36 | "jest": "^27.2.5", 37 | "postcss": "^8.3.9", 38 | "react-test-renderer": "^17.0.2", 39 | "sass": "^1.42.1", 40 | "tailwindcss": "^2.2.16", 41 | "typescript": "4.4.3" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | ``` 12 | 13 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 14 | 15 | You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file. 16 | 17 | [API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`. 18 | 19 | The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages. 20 | 21 | ## Learn More 22 | 23 | To learn more about Next.js, take a look at the following resources: 24 | 25 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 26 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 27 | 28 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 29 | 30 | ## Deploy on Vercel 31 | 32 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 33 | 34 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 35 | -------------------------------------------------------------------------------- /components/Chat/Content/Header/index.tsx: -------------------------------------------------------------------------------- 1 | import {IChatContext, IMessage} from "../models"; 2 | import {IUser} from "../../Context"; 3 | 4 | export function Header ({ room, user }: IChatContext) { 5 | const isMine = (chatUser: IUser) => { 6 | return chatUser.id === user?.id 7 | } 8 | return ( 9 |
10 |

11 | {room?.name} 12 |
13 | 14 | {room?.id} 15 | 16 |
17 |

18 |
19 | { 20 | Object.values(room?.users || {}).map((chatUser: IUser, index) => { 21 | return (
22 |
23 | {chatUser?.username} 28 |

29 | { 30 | isMine(chatUser) ? "You" : chatUser.username 31 | } 32 |

33 |
34 |
) 35 | }) 36 | } 37 |
38 |
39 |
40 | ) 41 | } -------------------------------------------------------------------------------- /components/Chat/Content/MessageForm/index.tsx: -------------------------------------------------------------------------------- 1 | import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; 2 | import {faPaperPlane} from "@fortawesome/free-solid-svg-icons"; 3 | import React, {useState} from "react"; 4 | import {useRouter} from "next/router"; 5 | import {IUser} from "../../Context"; 6 | 7 | interface IMessageFormProps { 8 | user: IUser 9 | } 10 | 11 | export function MessageForm ({ user }: IMessageFormProps) { 12 | 13 | const [message, setMessage] = useState(); 14 | const router = useRouter(); 15 | 16 | function handleInputTyping (e:React.SyntheticEvent) { 17 | setMessage((e.target as HTMLTextAreaElement).value) 18 | } 19 | function handleFormSubmit (e: React.SyntheticEvent) { 20 | e.preventDefault(); 21 | if (!message?.trim().length) { 22 | return alert("Empty message") 23 | } 24 | sendMessage() 25 | } 26 | 27 | const sendMessage = async () => { 28 | // build message obj 29 | const payload = { 30 | user, 31 | message, 32 | }; 33 | 34 | // dispatch message to other users 35 | const resp = await fetch("/api/send", { 36 | method: "POST", 37 | headers: { 38 | "Content-Type": "application/json", 39 | }, 40 | body: JSON.stringify(payload), 41 | }); 42 | 43 | 44 | if (!resp.ok) return 45 | 46 | setMessage(""); 47 | 48 | if(process.env.NODE_ENV == "test") return; 49 | 50 | const sendAudio = await new Audio('/sounds/send.mp3') 51 | sendAudio.play(); 52 | } 53 | 54 | return ( 55 |
56 |
57 |
58 | 59 |
60 | 63 |
64 |
65 | ) 66 | } -------------------------------------------------------------------------------- /components/Chat/Content/Messages/index.tsx: -------------------------------------------------------------------------------- 1 | import {Message} from "../Message"; 2 | import {useEffect, useRef} from "react"; 3 | import {IChatContext, IMessage} from "../models"; 4 | import {use} from "ast-types"; 5 | 6 | export function Messages({chat, user}: IChatContext) { 7 | 8 | const elRef = useRef(null); 9 | const isMine = (message: IMessage) => { 10 | return message.user.id === user?.id 11 | } 12 | 13 | const playReceiveSound = async () => { 14 | const receiveAudio = await new Audio('/sounds/receive.mp3') 15 | receiveAudio.play() 16 | }; 17 | 18 | useEffect(() => { 19 | if(!chat.length || process.env.NODE_ENV == "test") return 20 | 21 | playReceiveSound(); 22 | 23 | if(elRef.current) { 24 | //@ts-ignore 25 | elRef.current.scrollIntoView({ behavior: "smooth" }) 26 | } 27 | 28 | }, [chat.length]); 29 | 30 | const isNextSamePerson = (item: any, itemIndex: number) => { 31 | // const before = chat[itemIndex - 1]?.user.id === item?.user.id; 32 | return chat[itemIndex + 1]?.user.id === item?.user.id; 33 | } 34 | 35 | return ( 36 |
37 | { 38 | chat.map((item: IMessage, itemIndex: number) =>( 39 |
40 |
41 |
42 | { 43 | // !isNextSamePerson(item, itemIndex) //TODO: it works but I don`t like 44 | !isMine(item) && ( 45 |
46 | {/*

*/} 47 | {/* {item.user.username}*/} 48 | {/*

*/} 49 |
50 | 51 |
52 |
53 | ) 54 | } 55 |
56 |
57 |
58 | {item.user.username} 59 | 60 |
61 |
62 |
63 |
64 |

65 | {(new Date(item.sentDate)).toLocaleString()} 66 |

67 |
68 |
69 | )) 70 | } 71 |
72 | ) 73 | } -------------------------------------------------------------------------------- /components/Chat/Context/index.tsx: -------------------------------------------------------------------------------- 1 | import React, {createContext, useContext, useEffect, useState} from "react"; 2 | import { useRouter } from 'next/router' 3 | import SocketIOClient from "socket.io-client"; 4 | 5 | import {Loading} from "../../Loading"; 6 | 7 | export interface IMsg { 8 | user: string; 9 | msg: string; 10 | } 11 | 12 | export interface INotification { 13 | username: string; 14 | msg: string; 15 | } 16 | 17 | export interface IUser { 18 | id: string; 19 | username: string; 20 | joined: Date; 21 | created: Date; 22 | roomId: string; 23 | } 24 | 25 | export const ChatContext = createContext({}); 26 | 27 | export function useChatContext() { 28 | return useContext(ChatContext); 29 | } 30 | 31 | export function ChatContextWrapper ({children}: { children: React.ReactNode }) { 32 | const [room, setRoom] = useState({ initial: true }); 33 | const [userId, setUserId] = useState(null); 34 | const [user, setUser] = useState(null); 35 | const [socket, setSocket] = useState(); 36 | const [chat, setChat] = useState([]); 37 | // const [notification, setNotification] = useState(null); 38 | const router = useRouter(); 39 | 40 | useEffect(() => { 41 | setUserId(localStorage.getItem("userId")); 42 | }, []); 43 | 44 | 45 | useEffect(() => { 46 | if(!router.query?.id || !userId) return; 47 | // const notificationAudio = new Audio('/sounds/notification.mp3') //TODO: run when receive new message 48 | // connect to socket server 49 | //@ts-ignore 50 | const socketIO = SocketIOClient.connect(process.env.BASE_URL, { 51 | query: { 52 | roomId: router.query?.id, 53 | userId 54 | } 55 | }); 56 | 57 | // log socket connection 58 | socketIO.on("connect", () => { 59 | setSocket(socketIO); 60 | socketIO.emit("join", { 61 | roomId: router.query?.id, 62 | userId 63 | }); 64 | }); 65 | 66 | socketIO.on("joined", (result: any) => { 67 | setRoom(result?.room); 68 | setUser(result?.room?.users[userId] || null) 69 | }); 70 | 71 | socketIO.on("left", (result: any) => { 72 | setRoom(result?.room); 73 | }); 74 | 75 | // update chat on new message dispatched 76 | socketIO.on("message", (message: IMsg) => { 77 | chat.push(message); 78 | setChat([...chat]); 79 | // setNotification(message) 80 | }); 81 | 82 | // socket disconnet onUnmount if exists 83 | if (socketIO) return () => { 84 | socketIO.disconnect() 85 | setSocket(null); 86 | }; 87 | }, [router.query?.id, userId]); 88 | 89 | return ( 90 | 91 | {/*{*/} 92 | {/* notification && (*/} 93 | {/*
*/} 94 | {/*
*/} 95 | {/*
new Message from { notification.username }
*/} 96 | {/* */} 99 | {/*
*/} 100 | {/*
*/} 101 | {/* )*/} 102 | {/*}*/} 103 | 104 | {children} 105 | 106 |
107 | ) 108 | } -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | const app = require('express')() 2 | var bodyParser = require('body-parser') 3 | const server = require('http').Server(app) 4 | const io = require('socket.io')(server) 5 | const next = require('next'); 6 | const { v4: uuidv4 } = require('uuid'); 7 | 8 | const PORT = process.env.PORT || 3000; 9 | 10 | // parse application/json 11 | app.use(bodyParser.json()) 12 | 13 | const dev = process.env.NODE_ENV !== 'production' 14 | const nextApp = next({ dev }) 15 | const nextHandler = nextApp.getRequestHandler() 16 | 17 | // fake DB 18 | const rooms = {} 19 | 20 | // socket.io server 21 | io.on('connection', socket => { 22 | // socket.on('message', (data) => { 23 | // messages.push(data) 24 | // socket.broadcast.emit('message', data) 25 | // }); 26 | socket.on('disconnect', (data) => { 27 | const roomId = socket.handshake.query.roomId; 28 | const userId = socket.handshake.query.userId; 29 | const leftUser = rooms[roomId]?.users[socket.id]; 30 | delete rooms[roomId]?.users[userId]; 31 | if(!Object.keys(rooms[roomId]?.users || {}).length) { 32 | delete rooms[roomId] 33 | } 34 | 35 | io.in(roomId).emit('left', { 36 | leftUser, 37 | room: rooms[roomId] 38 | }); 39 | 40 | }); 41 | socket.on('join', (data) => { 42 | 43 | if(!rooms[data.roomId] || !rooms[data.roomId].users[data.userId]) { 44 | return console.log("roomId or userId not found") 45 | } 46 | 47 | const userFromRoom = { 48 | ...rooms[data.roomId].users[data.userId], 49 | joined: new Date(), 50 | socketId: socket.id 51 | }; 52 | 53 | rooms[data.roomId] = { 54 | ...rooms[data.roomId], 55 | users: { 56 | ...((rooms[data.roomId] || {}).users || {}), 57 | [userFromRoom.id]: userFromRoom 58 | } 59 | } 60 | 61 | socket.join(userFromRoom.roomId); 62 | io.in(userFromRoom.roomId).emit('joined', { 63 | user: userFromRoom, 64 | room: rooms[userFromRoom.roomId] 65 | }); 66 | }); 67 | }) 68 | 69 | nextApp.prepare().then(() => { 70 | app.post('/api/rooms', (req, res) => { 71 | const roomId = uuidv4(); 72 | const userId = uuidv4(); 73 | rooms[roomId] = { 74 | name: req?.body?.roomName || roomId, 75 | id: roomId, 76 | creator: userId, 77 | created: new Date(), 78 | users: { 79 | [userId]: { 80 | id: userId, 81 | roomId: roomId, 82 | username: req?.body?.username || `USER-${userId}`, 83 | created: new Date(), 84 | joined: null, 85 | } 86 | } 87 | } 88 | 89 | return res.status(200).json({ 90 | roomId, 91 | userId 92 | }); 93 | }); 94 | 95 | app.post('/api/rooms/join', (req, res) => { 96 | 97 | if(!rooms[req?.body?.roomId]) return res.status(404).json({ 98 | message: "NOT_FOUND" 99 | }) 100 | 101 | const userId = uuidv4(); 102 | rooms[req?.body?.roomId] = { 103 | ...rooms[req?.body?.roomId], 104 | users: { 105 | ...rooms[req?.body?.roomId].users, 106 | [userId]: { 107 | id: userId, 108 | created: new Date(), 109 | joined: null, 110 | roomId: req?.body?.roomId, 111 | username: req?.body?.username || `USER-${userId}` 112 | } 113 | } 114 | } 115 | 116 | return res.status(200).json({ 117 | roomId: req?.body?.roomId, 118 | userId 119 | }) 120 | 121 | }); 122 | 123 | app.post('/api/send', (req, res) => { 124 | 125 | 126 | if(!rooms[req.body?.user?.roomId]) { 127 | return res.status(404).json({ message: "NOT_FOUND" }) 128 | } 129 | 130 | if(!rooms[req.body.user.roomId].users[req.body.user.id]) { 131 | return res.status(403).json({ message: "FORBIDDEN" }) 132 | } 133 | 134 | io.in(req.body?.user?.roomId).emit('message', { 135 | ...req.body, 136 | sentDate: new Date() 137 | }) 138 | 139 | return res.status(200).json({ message: "SUCCESS" }) 140 | }) 141 | 142 | app.get('*', (req, res) => { 143 | return nextHandler(req, res) 144 | }) 145 | 146 | server.listen(PORT, (err) => { 147 | if (err) throw err 148 | console.log(`> Ready on http://localhost:${PORT}`) 149 | }) 150 | }) -------------------------------------------------------------------------------- /pages/rooms/[id].tsx: -------------------------------------------------------------------------------- 1 | import type { NextPage } from 'next' 2 | import Head from 'next/head' 3 | 4 | import { faPlus, faPaperPlane } from '@fortawesome/free-solid-svg-icons' 5 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' 6 | 7 | import { ChatList } from "../../components/Chat/List"; 8 | import { SearchInList } from "../../components/Chat/SearchInList"; 9 | import { Messages } from "../../components/Chat/Content/Messages"; 10 | import { Header as ContentHeader } from "../../components/Chat/Content/Header"; 11 | import {ChatContext, ChatContextWrapper, useChatContext} from "../../components/Chat/Context"; 12 | import {MessageForm} from "../../components/Chat/Content/MessageForm"; 13 | import {useContext, useEffect, useState} from "react"; 14 | import {Content} from "../../components/Chat/Content"; 15 | 16 | const ChatRoom: NextPage = () => { 17 | 18 | // const [userId, setUser] = useState(""); 19 | // 20 | // useEffect(() => { 21 | // 22 | // if(localStorage.getItem("userId")) { 23 | // setUser(localStorage.getItem("userId") || ""); 24 | // } 25 | // 26 | // }, []); 27 | 28 | return ( 29 |
30 | 31 | Create Next App 32 | 33 | 34 | 35 |
36 |
37 | 38 |
39 | 40 |
41 |
42 |
43 | {/*
*/} 44 |
45 | { 46 | false && ( 47 |
48 |
49 |

Recent

50 |
51 |
52 |
53 | 54 |
55 |
56 |
57 | 58 |
59 |
60 |
61 |
62 |
63 | 64 |
65 | 66 |
67 |
68 | ) 69 | } 70 |
71 | {/*
*/} 72 | {/*
*/} 73 | {/*
*/} 74 | 75 |
76 | {/*
*/} 77 |
78 |
79 |
80 |
81 | 82 |
83 |
84 | ) 85 | } 86 | 87 | export default ChatRoom 88 | -------------------------------------------------------------------------------- /pages/index.tsx: -------------------------------------------------------------------------------- 1 | import type { NextPage } from 'next' 2 | import Head from 'next/head' 3 | import {useState} from "react"; 4 | import {ISuccessRoomActionRs, roomsServices} from "../services/rooms"; 5 | 6 | export interface ICreateRoomFormValues { 7 | username: string; 8 | roomName: string; 9 | } 10 | 11 | export interface IJoinRoomFormValues { 12 | username: string; 13 | roomId: string; 14 | } 15 | 16 | interface IRoomFormValues extends ICreateRoomFormValues, IJoinRoomFormValues{ 17 | 18 | } 19 | 20 | 21 | function isValidFormValues (values:T): boolean { 22 | return Object.values(values).every( val => (val || "").trim().length >= 2) || false; 23 | } 24 | 25 | const services = { 26 | "create": { 27 | call: async (formValues: IRoomFormValues) => { 28 | const {roomId, ...rest} = formValues; 29 | if(!isValidFormValues(rest)) return alert("Required fields") 30 | 31 | const response: ISuccessRoomActionRs = await roomsServices.create(rest); 32 | localStorage.setItem("userId", response.userId); 33 | location.assign(`/rooms/${response.roomId}`) 34 | } 35 | }, 36 | "join": { 37 | call: async (formValues: IRoomFormValues) => { 38 | const {roomName, ...rest} = formValues; 39 | if(!isValidFormValues(rest)) return alert("Required fields") 40 | 41 | const response: ISuccessRoomActionRs = await roomsServices.join(rest); 42 | localStorage.setItem("userId", response.userId); 43 | location.assign(`/rooms/${response.roomId}`) 44 | } 45 | } 46 | } 47 | 48 | const Home: NextPage = () => { 49 | 50 | const [formValues, setFormValues] = useState({ 51 | username: "", 52 | roomName: "", 53 | roomId: "" 54 | }); 55 | 56 | const[loading, setLoading] = useState(false); 57 | 58 | const [currentTab, setCurrentTab] = useState("create"); 59 | 60 | const handleFormChange = (key:string, value:string) => { 61 | setFormValues(prev => ({ 62 | ...prev, 63 | [key] : value 64 | })) 65 | } 66 | 67 | 68 | const handleFormSubmit = async () => { 69 | setLoading(true); 70 | try { 71 | // @ts-ignore 72 | await services[currentTab].call(formValues); 73 | } catch (e) { 74 | console.log(e) 75 | alert("Something is wrong") 76 | } 77 | setLoading(false); 78 | } 79 | 80 | return ( 81 |
82 | 83 | Create Next App 84 | 85 | 86 | 87 | 88 |
89 |
90 |
91 | 94 |
95 | 98 |
99 |
100 | 103 |
104 |
105 |
106 | 110 | handleFormChange("username", e.target.value)} 116 | /> 117 |
118 | 119 | { 120 | currentTab === "create" ? ( 121 |
122 | 126 | handleFormChange("roomName", e.target.value)} 133 | /> 134 |
135 | ) : ( 136 |
137 | 140 | handleFormChange("roomId", e.target.value)} 147 | /> 148 |
149 | ) 150 | } 151 | 152 | 157 |
158 |
159 |
160 | 161 |
162 | ) 163 | } 164 | 165 | export default Home 166 | --------------------------------------------------------------------------------