├── .dockerignore ├── .browserslistrc ├── .gitattributes ├── public ├── favicon.ico ├── logo192.png └── logo.svg ├── screenshots ├── home.png ├── chat_file.png ├── chat_text.png └── roomlist.png ├── react_client ├── constant │ ├── enum │ │ ├── message-chat.js │ │ ├── loading-status.js │ │ ├── media-chat.js │ │ └── localizable.js │ └── string │ │ └── localizable-strings.js ├── resource │ └── image │ │ ├── badge_3x.png │ │ ├── close_3x.png │ │ ├── go_back_3x.png │ │ ├── check_mark_3x.png │ │ ├── disable_audio_3x.png │ │ ├── disable_video_3x.png │ │ ├── enable_audio_3x.png │ │ ├── enable_video_3x.png │ │ ├── global_grey_3x.png │ │ ├── gobal_white_3x.png │ │ ├── person_speak_3x.png │ │ ├── present_video_3x.png │ │ ├── start_calling_3x.png │ │ ├── hang_up_calling_3x.png │ │ ├── dowload_new_file_3x.png │ │ ├── download_completed_3x.png │ │ ├── equality_disabled_3x.png │ │ ├── equality_enabled_3x.png │ │ ├── send_message_plane_3x.png │ │ ├── sound_volume_muted_3x.png │ │ ├── presentation_enabled_3x.png │ │ ├── sound_volume_unmuted_3x.png │ │ ├── audio_enabling_disabled_3x.png │ │ ├── cancel_media_presenting_3x.png │ │ ├── cancel_single_download_3x.png │ │ ├── presentation_disabled_3x.png │ │ ├── video_enabling_disabled_3x.png │ │ ├── send_message_bubble_disabled_3x.png │ │ └── send_message_bubble_enabled_3x.png ├── index.css ├── component │ ├── feature │ │ ├── chat │ │ │ ├── media │ │ │ │ ├── MediaAudioMutingSwitch.jsx │ │ │ │ ├── MediaVideoMutingSwitch.jsx │ │ │ │ ├── MediaVideoRenderer.jsx │ │ │ │ ├── MediaController.jsx │ │ │ │ ├── MediaConstraintCheckBox.jsx │ │ │ │ ├── MediaUserTag.jsx │ │ │ │ ├── MediaRenderer.jsx │ │ │ │ ├── MediaConstraintCheckableSwitch.jsx │ │ │ │ ├── CallingSwitch.jsx │ │ │ │ ├── MediaAudioEnablingSwitch.jsx │ │ │ │ ├── MediaVideoEnablingSwitch.jsx │ │ │ │ ├── MediaConstraintSwitch.jsx │ │ │ │ ├── MediaVideoVolumeController.jsx │ │ │ │ ├── MediaRenderingStyleSwitch.jsx │ │ │ │ ├── MediaVideo.jsx │ │ │ │ ├── MediaAudioRenderer.jsx │ │ │ │ ├── MediaVideoController.jsx │ │ │ │ └── MediaMultiVideoRenderer.jsx │ │ │ ├── ChatRoom.jsx │ │ │ └── message │ │ │ │ ├── TextMessage.jsx │ │ │ │ ├── MessageTypeSwitch.jsx │ │ │ │ └── MessageBox.jsx │ │ ├── membership │ │ │ ├── MembershipRenderer.jsx │ │ │ ├── MemberDetail.jsx │ │ │ └── MemberOverview.jsx │ │ ├── navigation │ │ │ ├── GoBackNavigator.jsx │ │ │ ├── NewRoomNavigator.jsx │ │ │ ├── SignoutNavigator.jsx │ │ │ └── NavigationBar.jsx │ │ ├── require_auth │ │ │ └── RequireAuth.jsx │ │ ├── localization │ │ │ └── LocalizationSwitch.jsx │ │ └── sign_in │ │ │ └── Signin.jsx │ └── generic │ │ ├── error │ │ └── ErrorPage.jsx │ │ ├── loading │ │ └── Loading.jsx │ │ ├── checkbox │ │ └── CheckBox.jsx │ │ └── switch │ │ ├── DropdownSwitch.jsx │ │ └── SingleTabSwitch.jsx ├── util │ ├── format-bytes.js │ └── time-since.js ├── index.html ├── hook │ └── use-beforeunload.js ├── context │ ├── localization-context.js │ ├── global-context.js │ ├── message-context.js │ ├── media-rendering-context.js │ └── file-message-context.js ├── store │ ├── membershipSlice.js │ ├── textChatSlice.js │ ├── store.js │ ├── roomSlice.js │ └── authSlice.js └── index.jsx ├── .env ├── webpack ├── webpack.prod.js ├── webpack.config.js ├── webpack.dev.js └── webpack.common.js ├── .gitignore ├── babel.config.json ├── express_server ├── controllers │ ├── sessionController.js │ ├── groupChatRoomController.js │ ├── mongoDBController.js │ ├── authorController.js │ └── authenticationController.js ├── routers │ └── apiRouter.js ├── models │ ├── groupChatRoom.js │ └── author.js ├── server.js └── signaling │ └── signaling.js ├── Dockerfile ├── tsconfig.json ├── docker-compose.yml ├── package.json └── README.md /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /.browserslistrc: -------------------------------------------------------------------------------- 1 | last 2 versions 2 | not dead 3 | > 0.2% -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | #exclude package-lock from git diff 2 | package-lock.json -diff 3 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/myan0020/webrtc-group-chat-demo/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/myan0020/webrtc-group-chat-demo/HEAD/public/logo192.png -------------------------------------------------------------------------------- /screenshots/home.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/myan0020/webrtc-group-chat-demo/HEAD/screenshots/home.png -------------------------------------------------------------------------------- /screenshots/chat_file.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/myan0020/webrtc-group-chat-demo/HEAD/screenshots/chat_file.png -------------------------------------------------------------------------------- /screenshots/chat_text.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/myan0020/webrtc-group-chat-demo/HEAD/screenshots/chat_text.png -------------------------------------------------------------------------------- /screenshots/roomlist.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/myan0020/webrtc-group-chat-demo/HEAD/screenshots/roomlist.png -------------------------------------------------------------------------------- /react_client/constant/enum/message-chat.js: -------------------------------------------------------------------------------- 1 | export const type = { 2 | MESSAGE_TYPE_TEXT: 1, 3 | MESSAGE_TYPE_FILE: 2, 4 | }; -------------------------------------------------------------------------------- /react_client/resource/image/badge_3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/myan0020/webrtc-group-chat-demo/HEAD/react_client/resource/image/badge_3x.png -------------------------------------------------------------------------------- /react_client/resource/image/close_3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/myan0020/webrtc-group-chat-demo/HEAD/react_client/resource/image/close_3x.png -------------------------------------------------------------------------------- /react_client/resource/image/go_back_3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/myan0020/webrtc-group-chat-demo/HEAD/react_client/resource/image/go_back_3x.png -------------------------------------------------------------------------------- /react_client/resource/image/check_mark_3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/myan0020/webrtc-group-chat-demo/HEAD/react_client/resource/image/check_mark_3x.png -------------------------------------------------------------------------------- /react_client/resource/image/disable_audio_3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/myan0020/webrtc-group-chat-demo/HEAD/react_client/resource/image/disable_audio_3x.png -------------------------------------------------------------------------------- /react_client/resource/image/disable_video_3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/myan0020/webrtc-group-chat-demo/HEAD/react_client/resource/image/disable_video_3x.png -------------------------------------------------------------------------------- /react_client/resource/image/enable_audio_3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/myan0020/webrtc-group-chat-demo/HEAD/react_client/resource/image/enable_audio_3x.png -------------------------------------------------------------------------------- /react_client/resource/image/enable_video_3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/myan0020/webrtc-group-chat-demo/HEAD/react_client/resource/image/enable_video_3x.png -------------------------------------------------------------------------------- /react_client/resource/image/global_grey_3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/myan0020/webrtc-group-chat-demo/HEAD/react_client/resource/image/global_grey_3x.png -------------------------------------------------------------------------------- /react_client/resource/image/gobal_white_3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/myan0020/webrtc-group-chat-demo/HEAD/react_client/resource/image/gobal_white_3x.png -------------------------------------------------------------------------------- /react_client/resource/image/person_speak_3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/myan0020/webrtc-group-chat-demo/HEAD/react_client/resource/image/person_speak_3x.png -------------------------------------------------------------------------------- /react_client/resource/image/present_video_3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/myan0020/webrtc-group-chat-demo/HEAD/react_client/resource/image/present_video_3x.png -------------------------------------------------------------------------------- /react_client/resource/image/start_calling_3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/myan0020/webrtc-group-chat-demo/HEAD/react_client/resource/image/start_calling_3x.png -------------------------------------------------------------------------------- /react_client/resource/image/hang_up_calling_3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/myan0020/webrtc-group-chat-demo/HEAD/react_client/resource/image/hang_up_calling_3x.png -------------------------------------------------------------------------------- /react_client/resource/image/dowload_new_file_3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/myan0020/webrtc-group-chat-demo/HEAD/react_client/resource/image/dowload_new_file_3x.png -------------------------------------------------------------------------------- /react_client/resource/image/download_completed_3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/myan0020/webrtc-group-chat-demo/HEAD/react_client/resource/image/download_completed_3x.png -------------------------------------------------------------------------------- /react_client/resource/image/equality_disabled_3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/myan0020/webrtc-group-chat-demo/HEAD/react_client/resource/image/equality_disabled_3x.png -------------------------------------------------------------------------------- /react_client/resource/image/equality_enabled_3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/myan0020/webrtc-group-chat-demo/HEAD/react_client/resource/image/equality_enabled_3x.png -------------------------------------------------------------------------------- /react_client/resource/image/send_message_plane_3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/myan0020/webrtc-group-chat-demo/HEAD/react_client/resource/image/send_message_plane_3x.png -------------------------------------------------------------------------------- /react_client/resource/image/sound_volume_muted_3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/myan0020/webrtc-group-chat-demo/HEAD/react_client/resource/image/sound_volume_muted_3x.png -------------------------------------------------------------------------------- /react_client/constant/enum/loading-status.js: -------------------------------------------------------------------------------- 1 | export const status = { 2 | IDLE: "idle", 3 | LOADING: "loading", 4 | SUCCEEDED: "succeeded", 5 | FAILED: "failed", 6 | }; -------------------------------------------------------------------------------- /react_client/resource/image/presentation_enabled_3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/myan0020/webrtc-group-chat-demo/HEAD/react_client/resource/image/presentation_enabled_3x.png -------------------------------------------------------------------------------- /react_client/resource/image/sound_volume_unmuted_3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/myan0020/webrtc-group-chat-demo/HEAD/react_client/resource/image/sound_volume_unmuted_3x.png -------------------------------------------------------------------------------- /react_client/resource/image/audio_enabling_disabled_3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/myan0020/webrtc-group-chat-demo/HEAD/react_client/resource/image/audio_enabling_disabled_3x.png -------------------------------------------------------------------------------- /react_client/resource/image/cancel_media_presenting_3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/myan0020/webrtc-group-chat-demo/HEAD/react_client/resource/image/cancel_media_presenting_3x.png -------------------------------------------------------------------------------- /react_client/resource/image/cancel_single_download_3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/myan0020/webrtc-group-chat-demo/HEAD/react_client/resource/image/cancel_single_download_3x.png -------------------------------------------------------------------------------- /react_client/resource/image/presentation_disabled_3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/myan0020/webrtc-group-chat-demo/HEAD/react_client/resource/image/presentation_disabled_3x.png -------------------------------------------------------------------------------- /react_client/resource/image/video_enabling_disabled_3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/myan0020/webrtc-group-chat-demo/HEAD/react_client/resource/image/video_enabling_disabled_3x.png -------------------------------------------------------------------------------- /react_client/resource/image/send_message_bubble_disabled_3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/myan0020/webrtc-group-chat-demo/HEAD/react_client/resource/image/send_message_bubble_disabled_3x.png -------------------------------------------------------------------------------- /react_client/resource/image/send_message_bubble_enabled_3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/myan0020/webrtc-group-chat-demo/HEAD/react_client/resource/image/send_message_bubble_enabled_3x.png -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | EXPRESS_SERVER_PORT = "8000" 2 | 3 | WEBPACK_DEV_SERVER_PORT = "3000" 4 | WEBPACK_DEV_SERVER_PROXY_AVALIABLE_PATHS = ["/api/login", "/api/logout", "/api/rooms"] 5 | 6 | TURN_SERVER_USER_NAME = "mingdongshensen" 7 | TURN_SERVER_CREDENTIAL = "123456" 8 | TURN_SERVER_URLS = ["turn:47.102.222.251:3478"] -------------------------------------------------------------------------------- /webpack/webpack.prod.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | devtool: "source-map", 3 | optimization: { 4 | runtimeChunk: "single", 5 | splitChunks: { 6 | chunks: "all", 7 | cacheGroups: { 8 | venders: { 9 | test: /[\\/]node_modules[\\/]/, 10 | name: "venders", 11 | }, 12 | }, 13 | }, 14 | }, 15 | }; 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # [Ignore node_modules folder] 2 | # 3 | # It's a third-party dependency folder. 4 | # Please run 'npm install' to create. 5 | node_modules 6 | 7 | # [Ignore dist folder] 8 | # 9 | # It's a webpack building output. 10 | # Please run 'npm run build' for production to create. 11 | build 12 | 13 | # [Ignore .DS_Store] 14 | # 15 | .DS_Store 16 | src/.DS_Store 17 | src/component/.DS_Store -------------------------------------------------------------------------------- /babel.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "modules": false, 7 | "useBuiltIns": "usage", 8 | "corejs": "3.29", 9 | "debug": true 10 | } 11 | ], 12 | [ 13 | "@babel/preset-react" 14 | ] 15 | ], 16 | "plugins": [ 17 | [ 18 | "babel-plugin-styled-components" 19 | ] 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /react_client/index.css: -------------------------------------------------------------------------------- 1 | /* Global Styles */ 2 | 3 | html { 4 | height: 100vh; 5 | overflow: hidden; 6 | } 7 | 8 | body { 9 | margin: 0px; 10 | padding: 0px; 11 | height: 100vh; 12 | overflow: hidden; 13 | } 14 | 15 | #root { 16 | height: 100%; 17 | width: 100%; 18 | } 19 | 20 | button { 21 | cursor: pointer; 22 | } 23 | 24 | button:enabled:hover { 25 | opacity: 0.7; 26 | } 27 | 28 | -------------------------------------------------------------------------------- /react_client/constant/enum/media-chat.js: -------------------------------------------------------------------------------- 1 | export const videoCallingInputType = { 2 | VIDEO_CALLING_INPUT_TYPE_NONE: "none", 3 | VIDEO_CALLING_INPUT_TYPE_CAMERA: "camera", 4 | VIDEO_CALLING_INPUT_TYPE_SCREEN: "screen", 5 | }; 6 | 7 | export const mediaAccessibilityType = { 8 | MEDIA_ACCESSIBILITY_TYPE_PRESENTATION: "free_for_presentation", 9 | MEDIA_ACCESSIBILITY_TYPE_EQUALITY: "free_for_equality", 10 | }; 11 | -------------------------------------------------------------------------------- /react_client/component/feature/chat/media/MediaAudioMutingSwitch.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import SingleTabSwitch from "../../../generic/switch/SingleTabSwitch"; 4 | 5 | export const MediaAudioMutingSwitchPropsBuilder = ({}) => { 6 | return {}; 7 | }; 8 | 9 | export default function MediaAudioMutingSwitch() { 10 | return ( 11 |
12 | 13 |
14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /react_client/component/feature/chat/media/MediaVideoMutingSwitch.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import SingleTabSwitch from "../../../generic/switch/SingleTabSwitch"; 4 | 5 | export const MediaVideoMutingSwitchPropsBuilder = ({}) => { 6 | return {}; 7 | }; 8 | 9 | export default function MediaVideoMutingSwitch({}) { 10 | return ( 11 |
12 | 13 |
14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /express_server/controllers/sessionController.js: -------------------------------------------------------------------------------- 1 | const session = require("express-session"); 2 | const sessionParser = session({ 3 | saveUninitialized: false, 4 | secret: "$eCuRiTy", 5 | resave: false, 6 | }); 7 | const websocketMap = new Map(); 8 | const authenticatedUserIds = new Set(); 9 | 10 | exports.sessionParser = sessionParser; 11 | exports.websocketMap = websocketMap; 12 | exports.authenticatedUserIds = authenticatedUserIds; -------------------------------------------------------------------------------- /react_client/util/format-bytes.js: -------------------------------------------------------------------------------- 1 | function formatBytes(bytes, decimals = 2) { 2 | if (bytes === 0) return "0Bytes"; 3 | 4 | const k = 1024; 5 | const dm = decimals < 0 ? 0 : decimals; 6 | const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]; 7 | 8 | const i = Math.floor(Math.log(bytes) / Math.log(k)); 9 | 10 | return `${parseFloat((bytes / k ** i).toFixed(dm))}${sizes[i]}`; 11 | } 12 | 13 | export { formatBytes }; 14 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1 2 | 3 | FROM node:16 4 | 5 | WORKDIR /app 6 | 7 | COPY ["package.json", "package-lock.json", "./"] 8 | 9 | RUN npm install 10 | 11 | COPY webpack ./webpack 12 | COPY .browserslistrc ./.browserslistrc 13 | COPY babel.config.json ./babel.config.json 14 | COPY .env ./.env 15 | COPY public ./public 16 | COPY react_client ./react_client 17 | COPY express_server ./express_server 18 | 19 | RUN npm run build 20 | 21 | ENTRYPOINT [ "/usr/local/bin/npm", "run", "express" ] -------------------------------------------------------------------------------- /express_server/controllers/groupChatRoomController.js: -------------------------------------------------------------------------------- 1 | const signaling = require("../signaling/signaling"); 2 | const sendSignalThroughResponse = signaling.sendThroughResponse; 3 | const signalTypeEnum = signaling.typeEnum; 4 | 5 | const rooms = {}; 6 | const userRoomMap = new Map(); 7 | 8 | exports.handleGetRooms = (req, res, next) => { 9 | sendSignalThroughResponse(res, signalTypeEnum.GET_ROOMS, { 10 | rooms: rooms, 11 | }); 12 | }; 13 | exports.rooms = rooms; 14 | exports.userRoomMap = userRoomMap; -------------------------------------------------------------------------------- /react_client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | <%= htmlWebpackPlugin.options.title %> 9 | 10 | 11 | 12 | 13 |
14 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /express_server/routers/apiRouter.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | 4 | /** 5 | * import all controller 6 | */ 7 | 8 | const groupChatRoomController = require("../controllers/groupChatRoomController"); 9 | const authenticationController = require('../controllers/authenticationController'); 10 | 11 | router.post("/login", authenticationController.handleLogin); 12 | 13 | router.post("/logout", authenticationController.handleLogout); 14 | 15 | router.get('/rooms', groupChatRoomController.handleGetRooms); 16 | 17 | 18 | 19 | module.exports = router; 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "paths": { 5 | "component/*": ["react_client/component/*"], 6 | "context/*": ["react_client/context/*"], 7 | "store/*": ["react_client/store/*"], 8 | "util/*": ["react_client/util/*"], 9 | "service/*": ["react_client/service/*"], 10 | "resource/*": ["react_client/resource/*"], 11 | "hook/*": ["react_client/hook/*"], 12 | "constant/*": ["react_client/constant/*"] 13 | }, 14 | "allowJs": true, 15 | "noEmit": true, 16 | }, 17 | "include": ["react_client/**/*"], 18 | } 19 | -------------------------------------------------------------------------------- /webpack/webpack.config.js: -------------------------------------------------------------------------------- 1 | const { merge } = require("webpack-merge"); 2 | const commonConfigCreator = require("./webpack.common"); 3 | const productionConfig = require("./webpack.prod"); 4 | const developmentConfig = require("./webpack.dev"); 5 | 6 | module.exports = (env, args) => { 7 | const commonConfig = commonConfigCreator(env, args); 8 | 9 | switch (args.mode) { 10 | case "development": { 11 | const config = merge(commonConfig, developmentConfig); 12 | return config; 13 | } 14 | case "production": { 15 | const config = merge(commonConfig, productionConfig); 16 | return config; 17 | } 18 | default: 19 | throw new Error("No matching configuration was found!"); 20 | } 21 | }; 22 | -------------------------------------------------------------------------------- /react_client/component/generic/error/ErrorPage.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useRouteError } from "react-router-dom"; 3 | import styled from "styled-components"; 4 | 5 | export default function ErrorPage() { 6 | const error = useRouteError(); 7 | return ( 8 | 9 |

Oops!

10 |

Sorry, an unexpected error has occurred.

11 |

12 | Error Text: {error.statusText} 13 |
14 | Error Message: {error.message} 15 |

16 |
17 | ); 18 | } 19 | 20 | const Wrapper = styled.div` 21 | display: flex; 22 | flex-direction: column; 23 | align-items: center; 24 | justify-content: center; 25 | width: 100%; 26 | `; 27 | -------------------------------------------------------------------------------- /express_server/models/groupChatRoom.js: -------------------------------------------------------------------------------- 1 | function GroupChatRoom(roomId, roomName) { 2 | // room related 3 | // 4 | this.id = roomId; 5 | this.name = roomName; 6 | 7 | // (normal) participant related 8 | // 9 | this.participants = new Map(); 10 | this.addParticipant = (userId, username) => { 11 | const participant = { 12 | id: userId, 13 | name: username, 14 | }; 15 | this.participants.set(userId, participant); 16 | }; 17 | 18 | this.deleteParticipant = (userId) => { 19 | this.participants.delete(userId); 20 | }; 21 | 22 | Object.defineProperties(this, { 23 | participantsSize: { 24 | get: () => { 25 | return this.participants.size; 26 | }, 27 | }, 28 | }); 29 | } 30 | 31 | module.exports = GroupChatRoom; 32 | -------------------------------------------------------------------------------- /webpack/webpack.dev.js: -------------------------------------------------------------------------------- 1 | require("dotenv").config(); 2 | 3 | module.exports = { 4 | devtool: "inline-source-map", 5 | devServer: { 6 | hot: true, 7 | static: "public", 8 | open: true, 9 | port: process.env.WEBPACK_DEV_SERVER_PORT, 10 | 11 | // Falling back to '/' request when sending a request with an unknown path (eg: /home, /contact, ...) 12 | historyApiFallback: true, 13 | 14 | // Allowing CORS requests to api server's origin from webpack dev server's origin, 15 | proxy: [ 16 | { 17 | context: JSON.parse(process.env.WEBPACK_DEV_SERVER_PROXY_AVALIABLE_PATHS), 18 | target: `http://localhost:${process.env.EXPRESS_SERVER_PORT}`, 19 | changeOrigin: true, 20 | secure: false, 21 | }, 22 | ], 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /react_client/component/feature/chat/media/MediaVideoRenderer.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import styled from "styled-components"; 3 | 4 | export default function MediaVideoRenderer({ videoStream }) { 5 | const addVideoStreamToVideoDOM = (videoDOM, videoStream) => { 6 | if (!videoDOM) return; 7 | if (!(videoStream instanceof MediaStream)) return; 8 | videoDOM.srcObject = videoStream; 9 | }; 10 | 11 | return ( 12 | 13 | 20 | ); 21 | } 22 | 23 | const Wrapper = styled.div` 24 | width: 100%; 25 | height: 100%; 26 | `; 27 | 28 | const Video = styled.video` 29 | display: block; 30 | width: 100%; 31 | height: 100%; 32 | `; 33 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | webrtc-group-chat-service: 3 | image: webrtc-group-chat-demo 4 | 5 | reverse-proxy-service: 6 | image: nginx 7 | volumes: 8 | - type: bind 9 | source: /etc/nginx/nginx.conf 10 | target: /etc/nginx/nginx.conf 11 | read_only: true 12 | - type: bind 13 | source: /etc/nginx/templates 14 | target: /etc/nginx/templates 15 | # read_only: true 16 | - type: bind 17 | source: /etc/nginx/certs 18 | target: /etc/nginx/certs 19 | read_only: true 20 | ports: 21 | - 80:80 22 | - 443:443 23 | environment: 24 | - WEBRTC_GROUP_CHAT_SERVICE_HOST_ALIAS=webrtc-group-chat-service 25 | - WEBRTC_GROUP_CHAT_SERVICE_PORT=8000 26 | 27 | turn-service: 28 | image: coturn/coturn 29 | network_mode: "host" # Note: Docker performs badly with large port ranges, so just use "host" 30 | volumes: 31 | - /etc/coturn/turnserver.conf:/etc/coturn/turnserver.conf 32 | 33 | -------------------------------------------------------------------------------- /public/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /express_server/controllers/mongoDBController.js: -------------------------------------------------------------------------------- 1 | const mongoose = require("mongoose"); 2 | const chalk = require("chalk"); 3 | 4 | exports.connectMongDB = () => { 5 | const mongooseUrl = "mongodb://mingdongshensen:mingdongshensen@localhost/admin"; 6 | const mongooseOptions = { dbName: "test" }; 7 | 8 | mongoose.connect(mongooseUrl, mongooseOptions); 9 | mongoose.connection.on("connecting", function () { 10 | console.log(chalk.yellow`express-server's mongodb connecting...`); 11 | }); 12 | mongoose.connection.on("connected", function () { 13 | console.log(chalk.yellow`express-server's mongodb connection established`); 14 | // send message through stdout write stream if mongodb connection is established 15 | console.log(chalk.yellow`express-server is running`); 16 | console.log(chalk.yellow`success`); 17 | }); 18 | mongoose.connection.on("disconnected", function () { 19 | console.log(chalk.yellow`express-server's mongodb connection closed`); 20 | }); 21 | mongoose.connection.on("error", () => { 22 | console.error(chalk.red`express-server's mongodb connection failed`); 23 | }); 24 | }; 25 | -------------------------------------------------------------------------------- /react_client/component/generic/loading/Loading.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import styled from "styled-components"; 3 | import { RotatingLines } from "react-loader-spinner"; 4 | 5 | const sharedStyleValues = { 6 | loadingContentWidth: 100, 7 | loadingContentHeight: 100, 8 | }; 9 | 10 | export default function Loading() { 11 | return ( 12 | 13 | 14 | 22 | 23 | 24 | ); 25 | } 26 | 27 | const Wrapper = styled.div` 28 | position: fixed; 29 | left: 0; 30 | top: 0; 31 | width: 100%; 32 | height: 100%; 33 | background-color: rgba(0, 0, 0, 0.3); 34 | z-index: 1; 35 | `; 36 | 37 | const ContentWrapper = styled.div` 38 | position: relative; 39 | top: 50%; 40 | left: 50%; 41 | transform: translate(-50%, -100%); 42 | width: ${sharedStyleValues.loadingContentWidth}px; 43 | height: ${sharedStyleValues.loadingContentHeight}px; 44 | `; 45 | -------------------------------------------------------------------------------- /react_client/hook/use-beforeunload.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from "react"; 2 | 3 | export default function useBeforeunload(handler) { 4 | const eventListenerRef = useRef(); 5 | 6 | useEffect(() => { 7 | eventListenerRef.current = (event) => { 8 | const returnValue = handler?.(event); 9 | // Handle legacy `event.returnValue` property 10 | // https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event 11 | if (typeof returnValue === "string") { 12 | return (event.returnValue = returnValue); 13 | } 14 | // Chrome doesn't support `event.preventDefault()` on `BeforeUnloadEvent`, 15 | // instead it requires `event.returnValue` to be set 16 | // https://developer.mozilla.org/en-US/docs/Web/API/WindowEventHandlers/onbeforeunload#browser_compatibility 17 | if (event.defaultPrevented) { 18 | return (event.returnValue = ""); 19 | } 20 | }; 21 | }, [handler]); 22 | 23 | useEffect(() => { 24 | const eventListener = (event) => eventListenerRef.current(event); 25 | window.addEventListener("beforeunload", eventListener); 26 | return () => { 27 | window.removeEventListener("beforeunload", eventListener); 28 | }; 29 | }, []); 30 | } 31 | -------------------------------------------------------------------------------- /react_client/component/feature/chat/media/MediaController.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import styled from "styled-components"; 3 | 4 | import MediaConstraintCheckableSwitch from "./MediaConstraintCheckableSwitch"; 5 | import CallingSwitch from "./CallingSwitch"; 6 | import MediaAudioEnablingSwitch from "./MediaAudioEnablingSwitch"; 7 | import MediaVideoEnablingSwitch from "./MediaVideoEnablingSwitch"; 8 | 9 | export const MediaControllerPropsBuilder = ({}) => { 10 | return {}; 11 | }; 12 | 13 | export default function MediaController({}) { 14 | return ( 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | ); 25 | } 26 | 27 | const Wrapper = styled.div` 28 | margin-bottom: 15px; 29 | width: 100%; 30 | height: 75px; 31 | box-sizing: border-box; 32 | display: flex; 33 | flex-direction: row; 34 | justify-content: space-around; 35 | align-items: center; 36 | `; 37 | 38 | const MediaEnablingSwitchWrapper = styled.div` 39 | margin-left: 5px; 40 | margin-right: 5px; 41 | display: flex; 42 | flex-direction: row; 43 | `; 44 | -------------------------------------------------------------------------------- /react_client/component/feature/chat/media/MediaConstraintCheckBox.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useDispatch, useSelector } from "react-redux"; 3 | import styled from "styled-components"; 4 | 5 | import CheckBox, { checkBoxPropsBuilder } from "../../../generic/checkbox/CheckBox"; 6 | import { 7 | selectEnableVideoCallingInput, 8 | selectIsCalling, 9 | updateVideoCallingInputEnabling, 10 | } from "store/mediaChatSlice"; 11 | 12 | export const MediaConstraintCheckBoxPropsBuilder = ({}) => { 13 | return {}; 14 | }; 15 | 16 | export default function MediaConstraintCheckBox() { 17 | const dispatch = useDispatch(); 18 | const isCalling = useSelector(selectIsCalling); 19 | const enableVideoCallingInput = useSelector(selectEnableVideoCallingInput); 20 | 21 | return ( 22 | 23 | { 29 | dispatch(updateVideoCallingInputEnabling(!enableVideoCallingInput)); 30 | }, 31 | })} 32 | /> 33 | 34 | ); 35 | } 36 | 37 | const Wrapper = styled.div` 38 | width: 20px; 39 | height: 20px; 40 | `; -------------------------------------------------------------------------------- /react_client/context/localization-context.js: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { localizableStrings } from "constant/string/localizable-strings"; 4 | import * as localizableEnum from "constant/enum/localizable"; 5 | 6 | const LocalizationContext = React.createContext(); 7 | LocalizationContext.displayName = "LocalizationContext"; 8 | 9 | function LocalizationContextProvider({ children }) { 10 | const [localeType, setLocaleType] = React.useState(localizableEnum.type.ENGLISH); 11 | 12 | const changeLocalization = (toLocaleType) => { 13 | switch (toLocaleType) { 14 | case localizableEnum.type.CHINESE: 15 | setLocaleType(localizableEnum.type.CHINESE); 16 | break; 17 | default: 18 | setLocaleType(localizableEnum.type.ENGLISH); 19 | break; 20 | } 21 | }; 22 | 23 | const resetLocalizationContext = () => { 24 | setLocaleType(localizableEnum.type.ENGLISH); 25 | } 26 | 27 | const contextValue = { 28 | localizedStrings: localizableStrings[localeType], 29 | changeLocalization, 30 | resetLocalizationContext, 31 | }; 32 | return ( 33 | {children} 34 | ); 35 | } 36 | 37 | export { LocalizationContextProvider, LocalizationContext }; 38 | -------------------------------------------------------------------------------- /react_client/component/feature/chat/media/MediaUserTag.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import styled from "styled-components"; 3 | 4 | import personSpeakImageUrl from "resource/image/person_speak_3x.png"; 5 | 6 | export default function MediaUserTag({ userName }) { 7 | const name = typeof userName === "string" && userName.length > 0 ? userName : "Unknown"; 8 | return ( 9 | 10 | 11 | {name} 12 | 13 | ); 14 | } 15 | 16 | const Wrapper = styled.div` 17 | max-width: 100%; 18 | height: 100%; 19 | background-color: rgba(0, 0, 0, 0.8); 20 | display: flex; 21 | flex-direction: row; 22 | align-items: center; 23 | box-sizing: border-box; 24 | min-width: 30px; 25 | `; 26 | 27 | const AvatarWrapper = styled.div` 28 | flex: 0 0 15px; 29 | height: 15px; 30 | width: 15px; 31 | margin-left: 8px; 32 | margin-right: 5px; 33 | background-image: url(${personSpeakImageUrl}); 34 | background-position: center; 35 | background-repeat: no-repeat; 36 | background-size: contain; 37 | `; 38 | 39 | const NameWrapper = styled.div` 40 | min-width: 20px; 41 | color: rgb(255, 255, 255); 42 | font-size: 12px; 43 | text-align: center; 44 | margin-right: 8px; 45 | white-space: nowrap; 46 | overflow: hidden; 47 | text-overflow: ellipsis; 48 | `; -------------------------------------------------------------------------------- /react_client/component/feature/chat/media/MediaRenderer.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import styled from "styled-components"; 3 | 4 | import MediaRenderingStyleSwitch from "./MediaRenderingStyleSwitch"; 5 | import MediaMultiVideoRenderer from "./MediaMultiVideoRenderer"; 6 | 7 | export const MediaRendererPropsBuilder = ({}) => { 8 | return {}; 9 | }; 10 | 11 | export default function MediaRenderer({}) { 12 | return ( 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | ); 22 | } 23 | 24 | const sharedStyleValues = { 25 | mediaRenderingStyleSwitchContainerHeight: 54, 26 | }; 27 | 28 | const Wrapper = styled.div` 29 | width: 100%; 30 | height: 100%; 31 | `; 32 | 33 | const MediaRenderingStyleSwitchContainer = styled.div` 34 | width: 100%; 35 | height: ${sharedStyleValues.mediaRenderingStyleSwitchContainerHeight}px; 36 | display: flex; 37 | flex-direction: row; 38 | justify-content: start; 39 | align-items: center; 40 | box-sizing: border-box; 41 | padding-left: 20px; 42 | padding-right: 20px; 43 | `; 44 | 45 | const MediaRenderingContainer = styled.div` 46 | width: 100%; 47 | height: calc(100% - ${sharedStyleValues.mediaRenderingStyleSwitchContainerHeight}px); 48 | `; -------------------------------------------------------------------------------- /react_client/component/feature/chat/media/MediaConstraintCheckableSwitch.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useSelector } from "react-redux"; 3 | import styled from "styled-components"; 4 | 5 | import { selectIsCalling } from "store/mediaChatSlice"; 6 | import MediaConstraintCheckBox from "./MediaConstraintCheckBox"; 7 | import MediaConstraintSwitch from "./MediaConstraintSwitch"; 8 | 9 | export const MediaConstraintCheckableSwitchPropsBuilder = ({}) => { 10 | return {}; 11 | }; 12 | 13 | export default function MediaConstraintCheckableSwitch({}) { 14 | const isCalling = useSelector(selectIsCalling); 15 | const borderColor = isCalling ? "#C4C4C4" : "rgb(33, 150, 243)"; 16 | return ( 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | ); 26 | } 27 | 28 | const Wrapper = styled.div` 29 | display: flex; 30 | align-items: center; 31 | width: 202px; 32 | height: 55px; 33 | border: 1px solid ${(props) => props.borderColor}; 34 | border-radius: 10px; 35 | box-sizing: border-box; 36 | margin-left: 5px; 37 | margin-right: 5px; 38 | `; 39 | 40 | const MediaConstraintCheckBoxContainer = styled.div` 41 | margin-left: 18px; 42 | `; 43 | 44 | const MediaConstraintSwitchContainer = styled.div` 45 | margin-left: 9px; 46 | margin-right: 9px; 47 | `; 48 | -------------------------------------------------------------------------------- /react_client/util/time-since.js: -------------------------------------------------------------------------------- 1 | import * as localizableEnum from "constant/enum/localizable"; 2 | 3 | function timeSince(timestamp, withLocalizedStrings) { 4 | const localizedYearsAgoText = withLocalizedStrings[localizableEnum.key.YEARS_AGO]; 5 | const localizedMonthsAgoText = withLocalizedStrings[localizableEnum.key.MONTHS_AGO] 6 | const localizedDaysAgoText = withLocalizedStrings[localizableEnum.key.DAYS_AGO] 7 | const localizedHoursAgoText = withLocalizedStrings[localizableEnum.key.HOURS_AGO] 8 | const localizedMinutesAgoText = withLocalizedStrings[localizableEnum.key.MINUTES_AGO] 9 | const localizedSecondsAgoText = withLocalizedStrings[localizableEnum.key.SECONDS_AGO] 10 | 11 | let seconds = Math.floor((new Date() - timestamp) / 1000); 12 | 13 | let interval = seconds / 31536000; 14 | 15 | if (interval > 1) { 16 | return Math.floor(interval) + ` ${localizedYearsAgoText}`; 17 | } 18 | interval = seconds / 2592000; 19 | if (interval > 1) { 20 | return Math.floor(interval) + ` ${localizedMonthsAgoText}`; 21 | } 22 | interval = seconds / 86400; 23 | if (interval > 1) { 24 | return Math.floor(interval) + ` ${localizedDaysAgoText}`; 25 | } 26 | interval = seconds / 3600; 27 | if (interval > 1) { 28 | return Math.floor(interval) + ` ${localizedHoursAgoText}`; 29 | } 30 | interval = seconds / 60; 31 | if (interval > 1) { 32 | return Math.floor(interval) + ` ${localizedMinutesAgoText}`; 33 | } 34 | return Math.floor(seconds) + ` ${localizedSecondsAgoText}`; 35 | } 36 | 37 | export { timeSince }; 38 | -------------------------------------------------------------------------------- /express_server/controllers/authorController.js: -------------------------------------------------------------------------------- 1 | // const mongoose = require('mongoose'); 2 | // const Author = require('../models/author'); 3 | 4 | // GET author information by author id 5 | // exports.authorDetail = (req, res, next) => { 6 | // const mongooseId = mongoose.Types.ObjectId(req.params.id); 7 | // Author 8 | // .findById(mongooseId) 9 | // .exec() 10 | // .then( 11 | // (author) => { 12 | // res.json({ 13 | // id: author._id, 14 | // firstName: author.first_name, 15 | // familyName: author.family_name, 16 | // dateOfBirth: author.date_of_birth, 17 | // }); 18 | // }, 19 | // (error) => { 20 | // next(error, null); 21 | // } 22 | // ); 23 | 24 | // }; 25 | 26 | // GET author information list, sorted by `family_name` in ascending order 27 | // exports.authorList = (req, res, next) => { 28 | // Author 29 | // .find() 30 | // .sort({ family_name: 1 }) 31 | // .exec() 32 | // .then( 33 | // (author_list) => { 34 | // res.json({ 35 | // author_list: author_list.map(author => { 36 | // const info = { 37 | // id: author._id, 38 | // firstName: author.first_name, 39 | // familyName: author.family_name, 40 | // dateOfBirth: author.date_of_birth, 41 | // } 42 | // return info 43 | // }) 44 | // }); 45 | // }, 46 | // (error) => { 47 | // return next(error); 48 | // } 49 | // ); 50 | // }; 51 | 52 | -------------------------------------------------------------------------------- /react_client/component/feature/membership/MembershipRenderer.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import styled from "styled-components"; 3 | 4 | import MemberOverview from "./MemberOverview"; 5 | import MemberDetail from "./MemberDetail"; 6 | 7 | const visualMemberAvatarMarginLeftWhenMouseMovedInside = 0; 8 | const visualMemberAvatarMarginLeftWhenMouseMovedOutside = -5; 9 | const memberDetailDisplayWhenMouseMovedInside = "block"; 10 | const memberDetailDisplayWhenMouseMovedOutside = "none"; 11 | 12 | export default function MembershipRenderer({}) { 13 | const [detailDisplay, setDetailDisplay] = React.useState(memberDetailDisplayWhenMouseMovedOutside); 14 | const [visualMemberAvatarMarginLeft, setVisualMemberAvatarMarginLeft] = React.useState( 15 | visualMemberAvatarMarginLeftWhenMouseMovedOutside 16 | ); 17 | 18 | const showDetail = () => { 19 | setDetailDisplay(memberDetailDisplayWhenMouseMovedInside); 20 | setVisualMemberAvatarMarginLeft(visualMemberAvatarMarginLeftWhenMouseMovedInside); 21 | }; 22 | 23 | const hideDetail = () => { 24 | setDetailDisplay(memberDetailDisplayWhenMouseMovedOutside); 25 | setVisualMemberAvatarMarginLeft(visualMemberAvatarMarginLeftWhenMouseMovedOutside); 26 | }; 27 | 28 | return ( 29 | 33 | 34 | 35 | 36 | 37 | 38 | ); 39 | } 40 | 41 | const OverviewContainer = styled.div` 42 | width: 100%; 43 | height: 100%; 44 | `; 45 | 46 | const Wrapper = styled.div` 47 | width: 100%; 48 | height: 100%; 49 | `; 50 | -------------------------------------------------------------------------------- /express_server/models/author.js: -------------------------------------------------------------------------------- 1 | // const mongoose = require('mongoose'); 2 | // const moment = require('moment'); 3 | // const Schema = mongoose.Schema; 4 | 5 | // const AuthorSchema = new Schema( 6 | // { 7 | // first_name: { type: String, required: true, max: 100 }, 8 | // family_name: { type: String, required: true, max: 100 }, 9 | // date_of_birth: { type: Date }, 10 | // date_of_death: { type: Date }, 11 | // } 12 | // ); 13 | 14 | // // 虚拟属性'name':表示作者全名 15 | // AuthorSchema 16 | // .virtual('name') 17 | // .get(function () { 18 | // return this.family_name + ', ' + this.first_name; 19 | // }); 20 | 21 | // // 虚拟属性'lifespan':作者寿命 22 | // AuthorSchema 23 | // .virtual('lifespan') 24 | // .get(function () { 25 | // return (this.date_of_death.getYear() - this.date_of_birth.getYear()).toString(); 26 | // }); 27 | 28 | // // 虚拟属性'url':作者 URL 29 | // AuthorSchema 30 | // .virtual('url') 31 | // .get(function () { 32 | // return '/catalog/author/' + this._id; 33 | // }); 34 | 35 | // AuthorSchema 36 | // .virtual('date_of_birth_plain_text_formatted') 37 | // .get(function () { 38 | // return this.date_of_birth ? moment(this.date_of_birth).format('MMMM Do, YYYY') : '' 39 | // }); 40 | 41 | // AuthorSchema 42 | // .virtual('date_of_death_plain_text_formatted') 43 | // .get(function () { 44 | // return this.date_of_death ? moment(this.date_of_death).format('MMMM Do, YYYY') : '' 45 | // }); 46 | 47 | // AuthorSchema 48 | // .virtual('date_of_birth_input_control_formatted') 49 | // .get(function () { 50 | // return this.date_of_birth ? moment(this.date_of_birth).format('YYYY-MM-DD') : ''; 51 | // }) 52 | 53 | // AuthorSchema 54 | // .virtual('date_of_death_input_control_formatted') 55 | // .get(function () { 56 | // return this.date_of_death ? moment(this.date_of_death).format('YYYY-MM-DD') : ''; 57 | // }) 58 | 59 | // 导出 Author 模型 60 | // module.exports = mongoose.model('Author', AuthorSchema); 61 | -------------------------------------------------------------------------------- /react_client/component/feature/membership/MemberDetail.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useSelector } from "react-redux"; 3 | import styled from "styled-components"; 4 | 5 | import { selectAllMembers } from "store/membershipSlice"; 6 | 7 | export default function MemberDetail({ display }) { 8 | const allMembers = useSelector(selectAllMembers); 9 | return ( 10 | 11 | 12 | {Object.entries(allMembers).map(([id, { name }]) => { 13 | return {name}; 14 | })} 15 | 16 | 17 | ); 18 | } 19 | 20 | const sharedStyleValues = { 21 | dropdownOptionHorizontalMargin: 5, 22 | dropdownOptionVerticalMargin: 0, 23 | }; 24 | 25 | const DetailListWrapper = styled.ul` 26 | display: ${(props) => props.display}; 27 | box-sizing: border-box; 28 | width: 100px; 29 | padding: 0; 30 | padding-top: 3px; 31 | padding-bottom: 3px; 32 | margin: 0; 33 | border: 1.5px solid rgb(120, 144, 156); 34 | border-radius: 10px; 35 | position: absolute; 36 | top: -1px; 37 | left: -5px; 38 | background-color: rgb(255, 255, 255); 39 | `; 40 | 41 | const DetailListItemWrapper = styled.li` 42 | box-sizing: border-box; 43 | height: 30px; 44 | list-style-type: none; 45 | color: rgb(120, 144, 156); 46 | display: flex; 47 | align-items: center; 48 | justify-content: center; 49 | width: calc(100% - ${sharedStyleValues.dropdownOptionHorizontalMargin * 2}px); 50 | border-radius: 10px; 51 | margin-left: ${sharedStyleValues.dropdownOptionHorizontalMargin}px; 52 | margin-right: ${sharedStyleValues.dropdownOptionHorizontalMargin}px; 53 | margin-top: ${sharedStyleValues.dropdownOptionVerticalMargin}px; 54 | margin-bottom: ${sharedStyleValues.dropdownOptionVerticalMargin}px; 55 | `; 56 | 57 | const Wrapper = styled.div` 58 | position: relative; 59 | display: ${(props) => props.display}; 60 | z-index: 2; 61 | `; 62 | -------------------------------------------------------------------------------- /react_client/context/global-context.js: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { LocalizationContext, LocalizationContextProvider } from "context/localization-context"; 4 | import { 5 | MediaRenderingContext, 6 | MediaRenderingContextProvider, 7 | } from "context/media-rendering-context"; 8 | import { MessageContext, MessageContextProvider } from "context/message-context"; 9 | 10 | const GlobalContext = React.createContext(); 11 | GlobalContext.displayName = "GlobalContext"; 12 | 13 | function ContextProviderComposer({ children }) { 14 | return ( 15 | 16 | 17 | 18 | {children} 19 | 20 | 21 | 22 | ); 23 | } 24 | 25 | function ChildrenWrapper({ children }) { 26 | const localizationContextValue = React.useContext(LocalizationContext); 27 | const mediaRenderingContextValue = React.useContext(MediaRenderingContext); 28 | const messageContextValue = React.useContext(MessageContext); 29 | 30 | const resetGlobalContext = () => { 31 | if (typeof localizationContextValue.resetLocalizationContext === "function") { 32 | localizationContextValue.resetLocalizationContext(); 33 | } 34 | if (typeof mediaRenderingContextValue.resetMediaRenderingContext === "function") { 35 | mediaRenderingContextValue.resetMediaRenderingContext(); 36 | } 37 | if (typeof messageContextValue.resetMessageContext === "function") { 38 | messageContextValue.resetMessageContext(); 39 | } 40 | } 41 | 42 | const contextValue = { 43 | ...localizationContextValue, 44 | ...mediaRenderingContextValue, 45 | ...messageContextValue, 46 | 47 | resetGlobalContext, 48 | }; 49 | 50 | return {children}; 51 | } 52 | 53 | export { ContextProviderComposer as GlobalContextProvider, GlobalContext }; 54 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webrtc-group-chat-demo", 3 | "version": "1.0.0", 4 | "description": "A web application to realize P2P features including video calling, screen sharing, text messaging and file transceiving with low lantency", 5 | "main": "index.jsx", 6 | "scripts": { 7 | "start": "npm run express & npm run dev", 8 | "express": "nodemon ./express_server/server.js", 9 | "dev": "webpack serve --mode=development --config=./webpack/webpack.config.js", 10 | "build": "webpack --mode=production --config=./webpack/webpack.config.js", 11 | "analyze": "webpack --mode=production --env analyzer --config=./webpack/webpack.config.js" 12 | }, 13 | "keywords": [ 14 | "webrtc" 15 | ], 16 | "author": "MingDongShenSen", 17 | "license": "ISC", 18 | "dependencies": { 19 | "@babel/core": "^7.21.0", 20 | "@babel/preset-env": "^7.20.2", 21 | "@babel/preset-react": "^7.18.6", 22 | "@pmmmwh/react-refresh-webpack-plugin": "^0.5.7", 23 | "@reduxjs/toolkit": "^1.9.0", 24 | "axios": "^0.27.2", 25 | "babel-loader": "^9.1.2", 26 | "babel-plugin-styled-components": "^2.0.7", 27 | "body-parser": "^1.20.1", 28 | "chalk": "^4.1.2", 29 | "core-js": "^3.29.1", 30 | "css-loader": "^6.7.1", 31 | "dotenv": "^16.0.3", 32 | "express": "^4.18.2", 33 | "express-session": "^1.17.3", 34 | "html-webpack-plugin": "^5.5.0", 35 | "mongoose": "^6.8.1", 36 | "morgan": "^1.10.0", 37 | "nodemon": "^2.0.20", 38 | "react": "18.0", 39 | "react-dom": "18.0", 40 | "react-loader-spinner": "^5.3.4", 41 | "react-redux": "^8.0.5", 42 | "react-refresh": "^0.13.0", 43 | "react-router": "^6.4.3", 44 | "react-router-dom": "^6.8.2", 45 | "style-loader": "^3.3.1", 46 | "styled-components": "^5.3.6", 47 | "uuid": "^9.0.0", 48 | "webpack": "^5.72.1", 49 | "webpack-bundle-analyzer": "^4.7.0", 50 | "webpack-cli": "^4.9.2", 51 | "webpack-dev-server": "^4.9.0", 52 | "webrtc-group-chat-client": "^0.0.1-beta.3", 53 | "ws": "^8.11.0" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /react_client/component/feature/chat/media/CallingSwitch.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import styled from "styled-components"; 3 | import { useDispatch, useSelector } from "react-redux"; 4 | 5 | import SingleTabSwitch, { 6 | singleTabSwitchOptionBuilder, 7 | singleTabSwitchPropsBuilder, 8 | } from "../../../generic/switch/SingleTabSwitch"; 9 | import { startCalling, hangUpCalling, selectIsCalling } from "store/mediaChatSlice"; 10 | import startCallingUrl from "resource/image/start_calling_3x.png"; 11 | import hangUpCallingUrl from "resource/image/hang_up_calling_3x.png"; 12 | 13 | export const CallingSwitchPropsBuilder = ({}) => { 14 | return {}; 15 | }; 16 | 17 | export default function CallingSwitch({}) { 18 | const dispatch = useDispatch(); 19 | const isCalling = useSelector(selectIsCalling); 20 | 21 | const startCallingOption = singleTabSwitchOptionBuilder({ 22 | switchOptionBorderColor: "rgb(0, 150, 136)", 23 | switchOptionBackgroundColor: "rgba(255, 255, 255, 0)", 24 | switchOptionBackgroundImageUrl: startCallingUrl, 25 | switchOptionBackgroundImageSize: "contain", 26 | switchOptionOnClick: () => { 27 | dispatch(startCalling()); 28 | }, 29 | switchOptionSelected: !isCalling, 30 | }); 31 | const hangUpCallingOption = singleTabSwitchOptionBuilder({ 32 | switchOptionBorderColor: "rgb(244, 67, 54)", 33 | switchOptionBackgroundColor: "rgba(255, 255, 255, 0)", 34 | switchOptionBackgroundImageUrl: hangUpCallingUrl, 35 | switchOptionBackgroundImageSize: "contain", 36 | switchOptionOnClick: () => { 37 | dispatch(hangUpCalling()); 38 | }, 39 | switchOptionSelected: isCalling, 40 | }); 41 | 42 | return ( 43 | 44 | 51 | 52 | ); 53 | } 54 | 55 | const Wrapper = styled.div` 56 | width: 590px; 57 | height: 55px; 58 | margin-left: 5px; 59 | margin-right: 5px; 60 | `; 61 | -------------------------------------------------------------------------------- /react_client/store/membershipSlice.js: -------------------------------------------------------------------------------- 1 | import { createSelector, createSlice } from "@reduxjs/toolkit"; 2 | import { 3 | selectAuthenticated, 4 | selectAuthenticatedUserId, 5 | selectAuthenticatedUserName, 6 | } from "./authSlice"; 7 | 8 | const initialState = { 9 | peersInfo: {}, 10 | }; 11 | 12 | export const membershipSlice = createSlice({ 13 | name: "membership", 14 | initialState, 15 | reducers: { 16 | updatePeersInfo: { 17 | reducer(sliceState, action) { 18 | sliceState.peersInfo = action.payload; 19 | }, 20 | }, 21 | reset: { 22 | reducer(sliceState, action) { 23 | return initialState; 24 | }, 25 | }, 26 | }, 27 | }); 28 | 29 | /* Reducer */ 30 | 31 | export default membershipSlice.reducer; 32 | 33 | /* Action Creator */ 34 | 35 | export const { updatePeersInfo, reset } = membershipSlice.actions; 36 | 37 | /* Selector */ 38 | 39 | export const selectMembership = (state) => { 40 | return state.membership; 41 | }; 42 | 43 | export const selectAllMembers = createSelector( 44 | selectMembership, 45 | selectAuthenticatedUserId, 46 | selectAuthenticatedUserName, 47 | ({ peersInfo }, authenticatedUserId, authenticatedUserName) => { 48 | return {[authenticatedUserId]: { name: authenticatedUserName }, ...peersInfo }; 49 | } 50 | ); 51 | 52 | export const selectAllMembersOverview = createSelector( 53 | selectAllMembers, 54 | (allMembers) => { 55 | const allMembersOverview = { 56 | ...allMembers, 57 | } 58 | 59 | Object.keys(allMembersOverview).forEach((id) => { 60 | let initialLetterOfName = "?"; 61 | const content = allMembersOverview[id]; 62 | 63 | if (content && typeof content.name === "string" && content.name.length > 0) { 64 | initialLetterOfName = content.name.slice(0, 1).toUpperCase(); 65 | } 66 | 67 | allMembersOverview[id] = initialLetterOfName; 68 | }) 69 | return allMembersOverview; 70 | } 71 | ); 72 | 73 | export const selectAllMembersCount = createSelector( 74 | selectMembership, 75 | selectAuthenticated, 76 | ({ peersInfo }, authenticated) => { 77 | const peersCount = Object.keys(peersInfo).length; 78 | return peersCount + (authenticated ? 1 : 0); 79 | } 80 | ); 81 | -------------------------------------------------------------------------------- /react_client/component/feature/navigation/GoBackNavigator.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useDispatch } from "react-redux"; 3 | import styled from "styled-components"; 4 | 5 | import { leaveRoom, updateJoinedRoomId } from "store/roomSlice"; 6 | import goBackImageUrl from "resource/image/go_back_3x.png"; 7 | import { GlobalContext } from "context/global-context"; 8 | import { reset as resetTextChatSlice } from "store/textChatSlice"; 9 | import { reset as resetMediaChatSlice } from "store/mediaChatSlice"; 10 | 11 | export default function GoBackNavigator() { 12 | const { resetMediaRenderingContext, resetMessageContext } = React.useContext(GlobalContext); 13 | return ( 14 | 18 | ); 19 | } 20 | 21 | const MemorizedGoBackNavigator = React.memo(GoBackNavigatorToMemo, arePropsEqual); 22 | 23 | function GoBackNavigatorToMemo({ resetMediaRenderingContext, resetMessageContext }) { 24 | const dispatch = useDispatch(); 25 | const handleRoomLeaved = () => { 26 | // media 27 | dispatch(resetMediaChatSlice()); 28 | resetMediaRenderingContext(); 29 | 30 | // message 31 | dispatch(resetTextChatSlice()); 32 | resetMessageContext(); 33 | 34 | // room 35 | dispatch(updateJoinedRoomId({ roomId: "", roomName: "" })); 36 | dispatch(leaveRoom()); 37 | }; 38 | 39 | return ; 40 | } 41 | 42 | const arePropsEqual = (prevProps, nextProps) => { 43 | const isResetMediaRenderingContextEqual = Object.is( 44 | prevProps.resetMediaRenderingContext, 45 | nextProps.resetMediaRenderingContext 46 | ); 47 | const isResetMessageContextEqual = Object.is( 48 | prevProps.resetMessageContext, 49 | nextProps.resetMessageContext 50 | ); 51 | return isResetMediaRenderingContextEqual && isResetMessageContextEqual; 52 | }; 53 | 54 | const Wrapper = styled.button` 55 | width: 100%; 56 | height: 100%; 57 | display: flex; 58 | justify-content: center; 59 | align-items: center; 60 | background-image: url(${goBackImageUrl}); 61 | background-color: transparent; 62 | border-color: transparent; 63 | background-repeat: no-repeat; 64 | background-position: center; 65 | background-size: calc(100% / 3); 66 | `; 67 | -------------------------------------------------------------------------------- /react_client/component/feature/membership/MemberOverview.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import styled from "styled-components"; 3 | import { useSelector } from "react-redux"; 4 | 5 | import { selectAllMembersCount, selectAllMembersOverview } from "store/membershipSlice"; 6 | 7 | const visualAvatarsCount = 4; 8 | 9 | export default function MemberOverview({ visualAvatarMarginLeft }) { 10 | const allMembersOverview = useSelector(selectAllMembersOverview); 11 | const allMembersCount = useSelector(selectAllMembersCount); 12 | 13 | return ( 14 | 15 | {Object.entries(allMembersOverview).map(([id, initialLetterOfName], index) => { 16 | if (index > visualAvatarsCount - 1 && index === allMembersCount - 1) { 17 | const hiddenAvatarsCount = allMembersCount - visualAvatarsCount; 18 | return {`+${hiddenAvatarsCount}`}; 19 | } else if (index <= visualAvatarsCount - 1) { 20 | const marginLeft = index !== 0 ? visualAvatarMarginLeft : 0; 21 | return ( 22 | 27 | {initialLetterOfName} 28 | 29 | ); 30 | } else { 31 | return; 32 | } 33 | })} 34 | 35 | ); 36 | } 37 | 38 | const AvatarWrapper = styled.div` 39 | box-sizing: border-box; 40 | height: 100%; 41 | aspect-ratio: 1 / 1; 42 | border: 1px solid rgb(36, 41, 47); 43 | border-radius: 50%; 44 | background-color: rgb(255, 255, 255); 45 | font-size: 18px; 46 | font-weight: 500; 47 | color: rgb(120, 144, 156); 48 | text-align: center; 49 | 50 | display: flex; 51 | align-items: center; 52 | justify-content: center; 53 | `; 54 | 55 | const VistualAvatarWrapper = styled(AvatarWrapper)` 56 | margin-left: ${(props) => props.marginLeft}px; 57 | z-index: ${(props) => props.zIndex}; 58 | `; 59 | 60 | const HiddenAvatarWrapper = styled(AvatarWrapper)` 61 | margin-left: 3px; 62 | `; 63 | 64 | const Wrapper = styled.div` 65 | width: 100%; 66 | height: calc(100% - 10px); 67 | display: flex; 68 | flex-direction: row; 69 | justify-content: start; 70 | 71 | padding-top: 5px; 72 | padding-bottom: 5px; 73 | `; 74 | -------------------------------------------------------------------------------- /react_client/component/feature/chat/media/MediaAudioEnablingSwitch.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import styled from "styled-components"; 3 | import { useDispatch, useSelector } from "react-redux"; 4 | 5 | import SingleTabSwitch, { 6 | singleTabSwitchOptionBuilder, 7 | singleTabSwitchPropsBuilder, 8 | } from "../../../generic/switch/SingleTabSwitch"; 9 | import { selectAudioRelated, toggleAudioEnabling } from "store/mediaChatSlice"; 10 | import enableAudioUrl from "resource/image/enable_audio_3x.png"; 11 | import disableAudioUrl from "resource/image/disable_audio_3x.png"; 12 | import audioEnablingDisabledUrl from "resource/image/audio_enabling_disabled_3x.png"; 13 | 14 | export const MediaAudioEnablingSwitchPropsBuilder = ({}) => { 15 | return {}; 16 | }; 17 | 18 | export default function MediaAudioEnablingSwitch({}) { 19 | const dispatch = useDispatch(); 20 | const { isAudioEnablingAvaliable, isAudioEnabled } = useSelector(selectAudioRelated); 21 | 22 | const enableAudioOption = singleTabSwitchOptionBuilder({ 23 | switchOptionBorderColor: "rgb(0, 150, 136)", 24 | switchOptionBackgroundColor: "rgb(0, 150, 136)", 25 | switchOptionBackgroundImageUrl: enableAudioUrl, 26 | switchOptionBackgroundImageSize: "24px auto", 27 | switchOptionOnClick: () => { 28 | dispatch(toggleAudioEnabling()); 29 | }, 30 | switchOptionSelected: !isAudioEnabled, 31 | }); 32 | const disableAudioOption = singleTabSwitchOptionBuilder({ 33 | switchOptionBorderColor: "rgb(244, 67, 54)", 34 | switchOptionBackgroundColor: "rgb(244, 67, 54)", 35 | switchOptionBackgroundImageUrl: disableAudioUrl, 36 | switchOptionBackgroundImageSize: "24px auto", 37 | switchOptionOnClick: () => { 38 | dispatch(toggleAudioEnabling()); 39 | }, 40 | switchOptionSelected: isAudioEnabled, 41 | }); 42 | 43 | return ( 44 | 45 | 54 | 55 | ); 56 | } 57 | 58 | const Wrapper = styled.div` 59 | width: 55px; 60 | height: 55px; 61 | margin-left: 5px; 62 | margin-right: 5px; 63 | `; -------------------------------------------------------------------------------- /react_client/component/feature/chat/media/MediaVideoEnablingSwitch.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import styled from "styled-components"; 3 | import { useDispatch, useSelector } from "react-redux"; 4 | 5 | import SingleTabSwitch, { 6 | singleTabSwitchOptionBuilder, 7 | singleTabSwitchPropsBuilder, 8 | } from "../../../generic/switch/SingleTabSwitch"; 9 | import { selectVideoRelated, toggleVideoEnabling } from "store/mediaChatSlice"; 10 | import enableVideoUrl from "resource/image/enable_video_3x.png"; 11 | import disableVideoUrl from "resource/image/disable_video_3x.png"; 12 | import videoEnablingDisabledUrl from "resource/image/video_enabling_disabled_3x.png"; 13 | 14 | export const MediaVideoEnablingSwitchPropsBuilder = ({}) => { 15 | return {}; 16 | }; 17 | 18 | export default function MediaVideoEnablingSwitch({}) { 19 | const dispatch = useDispatch(); 20 | const { isVideoEnablingAvaliable, isVideoEnabled } = useSelector(selectVideoRelated); 21 | 22 | const enableVideoOption = singleTabSwitchOptionBuilder({ 23 | switchOptionBorderColor: "rgb(0, 150, 136)", 24 | switchOptionBackgroundColor: "rgb(0, 150, 136)", 25 | switchOptionBackgroundImageUrl: enableVideoUrl, 26 | switchOptionBackgroundImageSize: "24px auto", 27 | switchOptionOnClick: () => { 28 | dispatch(toggleVideoEnabling()); 29 | }, 30 | switchOptionSelected: !isVideoEnabled, 31 | }); 32 | const disableVideoOption = singleTabSwitchOptionBuilder({ 33 | switchOptionBorderColor: "rgb(244, 67, 54)", 34 | switchOptionBackgroundColor: "rgb(244, 67, 54)", 35 | switchOptionBackgroundImageUrl: disableVideoUrl, 36 | switchOptionBackgroundImageSize: "24px auto", 37 | switchOptionOnClick: () => { 38 | dispatch(toggleVideoEnabling()); 39 | }, 40 | switchOptionSelected: isVideoEnabled, 41 | }); 42 | 43 | return ( 44 | 45 | 54 | 55 | ); 56 | } 57 | 58 | const Wrapper = styled.div` 59 | width: 55px; 60 | height: 55px; 61 | margin-left: 5px; 62 | margin-right: 5px; 63 | `; -------------------------------------------------------------------------------- /react_client/constant/enum/localizable.js: -------------------------------------------------------------------------------- 1 | export const type = Object.freeze({ 2 | ENGLISH: "English", 3 | CHINESE: "Chinese", 4 | }); 5 | 6 | export const key = Object.freeze({ 7 | /** 8 | * Localization 9 | */ 10 | LOCALIZATION_SELECTED_TEXT_KEY: "LOCALIZATION_SELECTED_TEXT_KEY", 11 | LOCALIZATION_SELECTED_TEXT_VALUE: "LOCALIZATION_SELECTED_TEXT_VALUE", 12 | LOCALIZATION_ENGLISH_ITEM_TEXT: "LOCALIZATION_ENGLISH_ITEM_TEXT", 13 | LOCALIZATION_CHINESE_ITEM_TEXT: "LOCALIZATION_CHINESE_ITEM_TEXT", 14 | 15 | /** 16 | * Sign in 17 | */ 18 | SIGN_IN_TITLE: "SIGN_IN_TITLE", 19 | SIGN_IN_TITLE_DESC: "SIGN_IN_TITLE_DESC", 20 | SIGN_IN_INPUT_PLACEHOLDER: "SIGN_IN_INPUT_PLACEHOLDER", 21 | SIGN_IN_COMFIRM: "SIGN_IN_COMFIRM", 22 | /** 23 | * Room list 24 | */ 25 | ROOM_LIST_CREATE_NEW_ROOM_TITLE: "ROOM_LIST_CREATE_NEW_ROOM_TITLE", 26 | ROOM_LIST_CREATE_NEW_ROOM_INPUT_PLACEHOLDER: "ROOM_LIST_CREATE_NEW_ROOM_INPUT_PLACEHOLDER", 27 | ROOM_LIST_CREATE_NEW_ROOM_COMFIRM: "ROOM_LIST_CREATE_NEW_ROOM_COMFIRM", 28 | ROOM_LIST_JOIN_ROOM: "ROOM_LIST_JOIN_ROOM", 29 | /** 30 | * Navigation 31 | */ 32 | NAVIGATION_ROOM_LIST_TITLE: "NAVIGATION_ROOM_LIST_TITLE", 33 | NAVIGATION_CREATE_NEW_ROOM: "NAVIGATION_CREATE_NEW_ROOM", 34 | NAVIGATION_WELCOME: "NAVIGATION_WELCOME", 35 | NAVIGATION_SIGN_OUT: "NAVIGATION_SIGN_OUT", 36 | /** 37 | * Chat room 38 | */ 39 | CHAT_ROOM_MEDIA_CONSTRAINT_CAMERA: "CHAT_ROOM_MEDIA_CONSTRAINT_CAMERA", 40 | CHAT_ROOM_MEDIA_CONSTRAINT_SCREEN: "CHAT_ROOM_MEDIA_CONSTRAINT_SCREEN", 41 | CHAT_ROOM_MESSAGE_TYPE_TEXT: "CHAT_ROOM_MESSAGE_TYPE_TEXT", 42 | CHAT_ROOM_MESSAGE_TYPE_FILE: "CHAT_ROOM_MESSAGE_TYPE_FILE", 43 | CHAT_ROOM_MESSAGE_TEXT_INPUT_PLACEHOLDER: "CHAT_ROOM_MESSAGE_TEXT_INPUT_PLACEHOLDER", 44 | CHAT_ROOM_MESSAGE_FILE_INPUT_PLACEHOLDER_IDLE: "CHAT_ROOM_MESSAGE_FILE_INPUT_PLACEHOLDER_IDLE", 45 | CHAT_ROOM_MESSAGE_FILE_INPUT_PLACEHOLDER_FILE: "CHAT_ROOM_MESSAGE_FILE_INPUT_PLACEHOLDER_FILE", 46 | CHAT_ROOM_MESSAGE_FILE_INPUT_PLACEHOLDER_FILE_PLURAL: 47 | "CHAT_ROOM_MESSAGE_FILE_INPUT_PLACEHOLDER_FILE_PLURAL", 48 | CHAT_ROOM_MESSAGE_FILE_INPUT_PLACEHOLDER_ADDED: "CHAT_ROOM_MESSAGE_FILE_INPUT_PLACEHOLDER_ADDED", 49 | /** 50 | * Time description 51 | */ 52 | YEARS_AGO: "YEARS_AGO", 53 | MONTHS_AGO: "MONTHS_AGO", 54 | DAYS_AGO: "DAYS_AGO", 55 | HOURS_AGO: "HOURS_AGO", 56 | MINUTES_AGO: "MINUTES_AGO", 57 | SECONDS_AGO: "SECONDS_AGO", 58 | }); -------------------------------------------------------------------------------- /react_client/component/feature/require_auth/RequireAuth.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useDispatch, useSelector } from "react-redux"; 3 | import { Navigate, Outlet } from "react-router-dom"; 4 | import styled from "styled-components"; 5 | 6 | import { requestToSignout, selectAuthenticated, selectAuthLoadingStatus } from "store/authSlice"; 7 | import NavigationBar from "../navigation/NavigationBar"; 8 | import useBeforeunload from "hook/use-beforeunload"; 9 | import Loading from "component/generic/loading/Loading"; 10 | import * as loadingStatusEnum from "constant/enum/loading-status"; 11 | import { selectRoomLoadingStatus } from "store/roomSlice"; 12 | 13 | export default function RequireAuth({ children, redirectTo }) { 14 | const dispatch = useDispatch(); 15 | const authenticated = useSelector(selectAuthenticated); 16 | const authLoadingStatus = useSelector(selectAuthLoadingStatus); 17 | const roomLoadingStatus = useSelector(selectRoomLoadingStatus); 18 | const isLoading = 19 | authLoadingStatus === loadingStatusEnum.status.LOADING || 20 | roomLoadingStatus === loadingStatusEnum.status.LOADING; 21 | 22 | useBeforeunload(() => { 23 | dispatch(requestToSignout()); 24 | }); 25 | 26 | if (!authenticated) { 27 | // Redirect them to the /login page, but save the current location they were 28 | // trying to go to when they were redirected. This allows us to send them 29 | // along to that page after they login, which is a nicer user experience 30 | // than dropping them off on the home page. 31 | 32 | return ( 33 | 37 | ); 38 | } 39 | 40 | return ( 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | {isLoading && } 49 | 50 | ); 51 | } 52 | 53 | const sharedStyleValues = { 54 | navigationContainerHeight: 60, 55 | }; 56 | 57 | const Wrapper = styled.div` 58 | width: 100%; 59 | height: 100%; 60 | `; 61 | 62 | const NavigationContainer = styled.nav` 63 | width: 100%; 64 | height: ${sharedStyleValues.navigationContainerHeight}px; 65 | `; 66 | 67 | const OutletContainer = styled.div` 68 | box-sizing: border-box; 69 | width: 100%; 70 | height: calc(100% - ${sharedStyleValues.navigationContainerHeight}px); 71 | background-color: rgb(255, 255, 255); ; 72 | `; 73 | -------------------------------------------------------------------------------- /react_client/component/feature/navigation/NewRoomNavigator.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useDispatch, useSelector } from "react-redux"; 3 | import styled from "styled-components"; 4 | 5 | import { 6 | toggleNewRoomPopupVisibility, 7 | selectHasJoinedRoom, 8 | selectJoinedRoomName, 9 | } from "store/roomSlice"; 10 | import * as localizableEnum from "constant/enum/localizable"; 11 | import { GlobalContext } from "context/global-context"; 12 | 13 | export default function NewRoomNavigator() { 14 | const { localizedStrings } = React.useContext(GlobalContext); 15 | return ; 16 | } 17 | 18 | const MemorizedNewRoomNavigator = React.memo(NewRoomNavigatorToMemo, arePropsEqual); 19 | 20 | function NewRoomNavigatorToMemo({ localizedStrings }) { 21 | const dispatch = useDispatch(); 22 | 23 | const hasJoinedRoom = useSelector(selectHasJoinedRoom); 24 | const joinedRoomName = useSelector(selectJoinedRoomName); 25 | 26 | const handleNewRoomPopupVisibilityToggled = () => { 27 | dispatch(toggleNewRoomPopupVisibility()); 28 | }; 29 | 30 | const title = hasJoinedRoom 31 | ? joinedRoomName 32 | : localizedStrings[localizableEnum.key.NAVIGATION_ROOM_LIST_TITLE]; 33 | const buttonVisibility = hasJoinedRoom ? "hidden" : "visible"; 34 | 35 | return ( 36 | 37 | {title} 38 | 44 | 45 | ); 46 | } 47 | 48 | const arePropsEqual = (prevProps, nextProps) => { 49 | return Object.is(prevProps.localizedStrings, nextProps.localizedStrings); 50 | }; 51 | 52 | const Wrapper = styled.div` 53 | width: 100%; 54 | height: 100%; 55 | display: flex; 56 | justify-content: start; 57 | align-items: center; 58 | flex-direction: row; 59 | `; 60 | 61 | const Title = styled.h4` 62 | color: rgb(255, 255, 255); 63 | font-size: 24px; 64 | font-weight: bold; 65 | margin: 0; 66 | margin-right: 20px; 67 | `; 68 | 69 | const Button = styled.button` 70 | flex: 0 0 60px; 71 | border: 1px solid rgb(255, 255, 255); 72 | border-radius: 10px; 73 | color: rgb(255, 255, 255); 74 | text-align: center; 75 | font-size: 16px; 76 | font-weight: bold; 77 | background-color: transparent; 78 | display: inline-block; 79 | width: 60px; 80 | height: 30px; 81 | visibility: ${(props) => props.visibility}; 82 | margin-top: 2px; 83 | `; 84 | -------------------------------------------------------------------------------- /react_client/component/feature/navigation/SignoutNavigator.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useDispatch } from "react-redux"; 3 | import styled from "styled-components"; 4 | 5 | import { requestToSignout } from "store/authSlice"; 6 | import * as localizableEnum from "constant/enum/localizable"; 7 | import { GlobalContext } from "context/global-context"; 8 | import { reset as resetTextChatSlice } from "store/textChatSlice"; 9 | import { reset as resetMediaChatSlice } from "store/mediaChatSlice"; 10 | import { reset as resetRoomSlice } from "store/roomSlice"; 11 | 12 | export default function SignoutNavigator() { 13 | const { localizedStrings, resetMediaRenderingContext, resetMessageContext } = 14 | React.useContext(GlobalContext); 15 | 16 | return ( 17 | 22 | ); 23 | } 24 | 25 | const MemorizedSignoutNavigator = React.memo(SignoutNavigatorToMemo, arePropsEqual); 26 | 27 | function SignoutNavigatorToMemo({ 28 | localizedStrings, 29 | resetMediaRenderingContext, 30 | resetMessageContext, 31 | }) { 32 | const dispatch = useDispatch(); 33 | 34 | const handleSignoutClicked = () => { 35 | // media 36 | dispatch(resetMediaChatSlice()); 37 | resetMediaRenderingContext(); 38 | 39 | // message 40 | dispatch(resetTextChatSlice()); 41 | resetMessageContext(); 42 | 43 | // roomList 44 | dispatch(resetRoomSlice()); 45 | 46 | // auth 47 | dispatch(requestToSignout()); 48 | }; 49 | 50 | return ( 51 | 52 | 53 | {localizedStrings[localizableEnum.key.NAVIGATION_SIGN_OUT]} 54 | 55 | 56 | ); 57 | } 58 | 59 | const arePropsEqual = (prevProps, nextProps) => { 60 | const isLocalizedStringEqual = Object.is(prevProps.localizedStrings, nextProps.localizedStrings); 61 | const isResetMediaRenderingContextEqual = Object.is( 62 | prevProps.resetMediaRenderingContext, 63 | nextProps.resetMediaRenderingContext 64 | ); 65 | const isResetMessageContextEqual = Object.is( 66 | prevProps.resetMessageContext, 67 | nextProps.resetMessageContext 68 | ); 69 | return isLocalizedStringEqual && isResetMediaRenderingContextEqual && isResetMessageContextEqual; 70 | }; 71 | 72 | const SignoutNavigatorWrapper = styled.div` 73 | width: 100%; 74 | height: 100%; 75 | `; 76 | 77 | const SignoutNavigatorButton = styled.button` 78 | width: 100%; 79 | height: 100%; 80 | 81 | box-sizing: border-box; 82 | border: 1px solid #ffffff; 83 | border-radius: 10px; 84 | color: rgb(255, 255, 255); 85 | text-align: center; 86 | font-size: 14px; 87 | background-color: transparent; 88 | `; 89 | -------------------------------------------------------------------------------- /react_client/component/feature/chat/media/MediaConstraintSwitch.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useDispatch, useSelector } from "react-redux"; 3 | import styled from "styled-components"; 4 | 5 | import MultiTabSwitch, { 6 | multiTabSwitchTabBuilder, 7 | multiTabSwitchPropsBuilder, 8 | } from "../../../generic/switch/MultiTabSwitch"; 9 | import { 10 | selectEnableVideoCallingInput, 11 | selectIsCalling, 12 | selectVideoCallingInputType, 13 | updateVideoCallingInputType, 14 | } from "store/mediaChatSlice"; 15 | import * as mediaChatEnum from "constant/enum/media-chat" 16 | import * as localizableEnum from "constant/enum/localizable"; 17 | import { GlobalContext } from "context/global-context"; 18 | 19 | export const MediaConstraintSwitchPropsBuilder = ({}) => { 20 | return {}; 21 | }; 22 | 23 | export default function MediaConstraintSwitch({}) { 24 | const { localizedStrings } = React.useContext(GlobalContext); 25 | return ; 26 | } 27 | 28 | const MemorizedMediaConstraintSwitch = React.memo(MediaConstraintSwitchToMemo, arePropsEqual); 29 | 30 | function MediaConstraintSwitchToMemo({ localizedStrings }) { 31 | const dispatch = useDispatch(); 32 | const isCalling = useSelector(selectIsCalling); 33 | const enableVideoCallingInput = useSelector(selectEnableVideoCallingInput); 34 | const videoCallingInputType = useSelector(selectVideoCallingInputType); 35 | 36 | const switchEnabled = !isCalling && enableVideoCallingInput; 37 | const cameraTab = multiTabSwitchTabBuilder({ 38 | switchTabName: localizedStrings[localizableEnum.key.CHAT_ROOM_MEDIA_CONSTRAINT_CAMERA], 39 | switchTabOnClick: () => { 40 | dispatch( 41 | updateVideoCallingInputType(mediaChatEnum.videoCallingInputType.VIDEO_CALLING_INPUT_TYPE_CAMERA) 42 | ); 43 | }, 44 | switchTabSelected: 45 | videoCallingInputType === mediaChatEnum.videoCallingInputType.VIDEO_CALLING_INPUT_TYPE_CAMERA, 46 | }); 47 | const screenTab = multiTabSwitchTabBuilder({ 48 | switchTabName: localizedStrings[localizableEnum.key.CHAT_ROOM_MEDIA_CONSTRAINT_SCREEN], 49 | switchTabOnClick: () => { 50 | dispatch( 51 | updateVideoCallingInputType(mediaChatEnum.videoCallingInputType.VIDEO_CALLING_INPUT_TYPE_SCREEN) 52 | ); 53 | }, 54 | switchTabSelected: 55 | videoCallingInputType === mediaChatEnum.videoCallingInputType.VIDEO_CALLING_INPUT_TYPE_SCREEN, 56 | }); 57 | 58 | return ( 59 | 60 | 66 | 67 | ); 68 | } 69 | 70 | const arePropsEqual = (prevProps, nextProps) => { 71 | return Object.is(prevProps.localizedStrings, nextProps.localizedStrings); 72 | }; 73 | 74 | const Wrapper = styled.div` 75 | width: 146px; 76 | height: 40px; 77 | `; -------------------------------------------------------------------------------- /express_server/controllers/authenticationController.js: -------------------------------------------------------------------------------- 1 | const chalk = require("chalk"); 2 | const { v4: uuidv4 } = require("uuid"); 3 | const sessionController = require("./sessionController"); 4 | const websocketMap = sessionController.websocketMap; 5 | const authenticatedUserIds = sessionController.authenticatedUserIds; 6 | const signaling = require("../signaling/signaling"); 7 | const sendSignalThroughResponse = signaling.sendThroughResponse; 8 | const signalTypeEnum = signaling.typeEnum; 9 | const websocketController = require("./websocketController"); 10 | 11 | exports.handleLogin = (req, res, next) => { 12 | // regenerate the session, which is good practice to help 13 | // guard against forms of session fixation 14 | req.session.regenerate(function (err) { 15 | if (err) next(err); 16 | 17 | // store user information in session, typically a user id 18 | const userId = uuidv4(); 19 | const userName = req.body.userName; 20 | req.session.userId = userId; 21 | req.session.username = userName; 22 | authenticatedUserIds.add(userId); 23 | 24 | // save the session before redirection to ensure page 25 | // load does not happen before session is saved 26 | req.session.save(function (err) { 27 | if (err) return next(err); 28 | sendSignalThroughResponse(res, signalTypeEnum.LOG_IN_SUCCESS, { 29 | userName: userName, 30 | userId: userId, 31 | }); 32 | }); 33 | }); 34 | }; 35 | 36 | exports.handleLogout = (req, res, next) => { 37 | const sessionUserId = req.session.userId; 38 | const sessionUserName = req.session.username; 39 | 40 | console.log( 41 | `[${chalk.green`HTTP`}] before logout action been executed, avaliable session are [${chalk.yellow`...`}]` 42 | ); 43 | for (let userId of Array.from(websocketMap.keys())) { 44 | console.log(`[${chalk.yellow`${userId}`}]`); 45 | } 46 | 47 | // TODO: 48 | // 49 | // Priority Level: Low 50 | // 51 | // this 'session.destroy' method may be used incorrectly, 52 | // because 'req.session' regards each browser(eg: chrome, firefox ...) as one unique user, 53 | // as a result, when you log out inside a browser, no matter how many tabs you has opened inside that browser, 54 | // 'req.session' will think that they(tabs) log out 55 | // 56 | 57 | req.session.destroy(function () { 58 | if (sessionUserId && sessionUserId.length > 0) { 59 | authenticatedUserIds.delete(sessionUserId); 60 | 61 | if (websocketMap.has(sessionUserId)) { 62 | const ws = websocketMap.get(sessionUserId); 63 | websocketController.handleLeaveRoom(ws, sessionUserId); 64 | 65 | console.log( 66 | `[${chalk.green`WebSocket`}] will perform ${chalk.green`an active connection close`} to a user of a name(${chalk.green`${ 67 | typeof sessionUserName === "string" ? sessionUserName : "unknown" 68 | }`})` 69 | ); 70 | } 71 | } 72 | 73 | sendSignalThroughResponse(res, signalTypeEnum.LOG_OUT_SUCCESS); 74 | }); 75 | }; 76 | -------------------------------------------------------------------------------- /react_client/index.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { createRoot } from "react-dom/client"; 3 | import { Provider as ReduxProvider } from "react-redux"; 4 | import { RouterProvider, createBrowserRouter, redirect } from "react-router-dom"; 5 | 6 | import store from "store/store"; 7 | import "./index.css"; 8 | import { GlobalContextProvider } from "context/global-context"; 9 | import Loading from "component/generic/loading/Loading"; 10 | import ErrorPage from "component/generic/error/ErrorPage"; 11 | 12 | const Signin = React.lazy(() => 13 | import(/* webpackChunkName: "sign_in_component" */ "component/feature/sign_in/Signin") 14 | ); 15 | const RequireAuth = React.lazy(() => 16 | import( 17 | /* webpackChunkName: "require_auth_component" */ "component/feature/require_auth/RequireAuth" 18 | ) 19 | ); 20 | const RoomList = React.lazy(() => 21 | import(/* webpackChunkName: "room_list_component" */ "component/feature/room_list/RoomList") 22 | ); 23 | const ChatRoom = React.lazy(() => 24 | import(/* webpackChunkName: "chat_room_component" */ "component/feature/chat/ChatRoom") 25 | ); 26 | 27 | /** 28 | * Displaying the current environment ('development' or 'production') 29 | */ 30 | 31 | console.debug(`[In ${process.env.NODE_ENV} mode]`); 32 | 33 | /** 34 | * The root component to render in the application 35 | */ 36 | 37 | function App() { 38 | const router = createBrowserRouter([ 39 | { 40 | path: "/signin", 41 | element: ( 42 | }> 43 | 44 | 45 | ), 46 | errorElement: , 47 | }, 48 | { 49 | element: ( 50 | }> 51 | 52 | 53 | ), 54 | errorElement: , 55 | children: [ 56 | { 57 | path: "/room-list", 58 | element: ( 59 | }> 60 | 61 | 62 | ), 63 | }, 64 | { 65 | path: "/chat-room", 66 | element: ( 67 | }> 68 | 69 | 70 | ), 71 | }, 72 | ], 73 | }, 74 | { 75 | path: "*", 76 | loader: () => { 77 | throw redirect("/signin"); 78 | }, 79 | errorElement: , 80 | }, 81 | ]); 82 | 83 | return ( 84 | 85 | 86 | 87 | 88 | 89 | ); 90 | } 91 | 92 | // Renderring this react root component into DOM 93 | // as a child element of a div with the id of 'root' 94 | const container = document.getElementById("root"); 95 | const root = createRoot(container); 96 | // ReactDOM.render(, container) 97 | root.render(); 98 | -------------------------------------------------------------------------------- /react_client/component/feature/chat/ChatRoom.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import styled from "styled-components"; 3 | import { Navigate } from "react-router-dom"; 4 | import { useSelector } from "react-redux"; 5 | 6 | import { selectHasJoinedRoom } from "store/roomSlice"; 7 | import MediaController from "./media/MediaController"; 8 | import MediaRenderer from "./media/MediaRenderer"; 9 | import MessageTypeSwitch from "./message/MessageTypeSwitch"; 10 | import MessageBox from "./message/MessageBox"; 11 | import MessageSender from "./message/MessageSender"; 12 | 13 | export default function ChatRoom() { 14 | const hasJoinedRoom = useSelector(selectHasJoinedRoom); 15 | 16 | if (!hasJoinedRoom) { 17 | return ; 18 | } 19 | 20 | return ( 21 | 22 | {/* media chat */} 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | {/* message chat */} 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | ); 46 | } 47 | 48 | const sharedStyleValues = { 49 | mediaControllerContainerHeight: 116, 50 | messageContainerWidth: 328, 51 | messageTypeSwitchContainerHeight: 60, 52 | messageSenderContainerHeight: 120, 53 | }; 54 | 55 | const Wrapper = styled.div` 56 | width: 100%; 57 | height: 100%; 58 | display: flex; 59 | flex-direction: row; 60 | background-color: rgb(250, 250, 250); 61 | `; 62 | 63 | const MediaContainer = styled.div` 64 | flex: 0 0 calc(100% - ${sharedStyleValues.messageContainerWidth}px); 65 | height: 100%; 66 | `; 67 | 68 | const MediaRendererContainer = styled.div` 69 | width: 100%; 70 | height: calc(100% - ${sharedStyleValues.mediaControllerContainerHeight}px); 71 | `; 72 | 73 | const MediaControllerContainer = styled.div` 74 | width: 100%; 75 | height: ${sharedStyleValues.mediaControllerContainerHeight}px; 76 | box-sizing: border-box; 77 | border-top: 1px solid #c4c4c4; 78 | display: flex; 79 | justify-content: end; 80 | flex-direction: column; 81 | `; 82 | 83 | const MessageContainer = styled.div` 84 | flex: 0 0 ${sharedStyleValues.messageContainerWidth}px; 85 | height: 100%; 86 | `; 87 | 88 | const MessageTypeSwitchContainer = styled.div` 89 | height: ${sharedStyleValues.messageTypeSwitchContainerHeight}px; 90 | `; 91 | 92 | const MessageBoxContainer = styled.div` 93 | height: calc( 94 | 100% - 95 | ${sharedStyleValues.messageTypeSwitchContainerHeight + 96 | sharedStyleValues.mediaControllerContainerHeight}px 97 | ); 98 | `; 99 | 100 | const MessageSenderContainer = styled.div` 101 | height: ${sharedStyleValues.messageSenderContainerHeight}px; 102 | `; 103 | 104 | -------------------------------------------------------------------------------- /express_server/server.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config() 2 | 3 | // const createError = require('http-errors'); 4 | // const cookieParser = require('cookie-parser'); 5 | const path = require("path"); 6 | const fs = require("fs"); 7 | const express = require("express"); 8 | const app = express(); 9 | 10 | const server = require("http").createServer(app); 11 | 12 | const logger = require("morgan"); 13 | const bodyParser = require("body-parser"); 14 | const apiRouter = require("./routers/apiRouter"); 15 | const mongoDBController = require("./controllers/mongoDBController"); 16 | const websocketController = require("./controllers/websocketController"); 17 | const sessionController = require("./controllers/sessionController"); 18 | const sessionParser = sessionController.sessionParser; 19 | 20 | const openMongDBConnection = false; 21 | 22 | /** 23 | * ??? middleware setup 24 | */ 25 | 26 | app.use(logger("dev")); 27 | 28 | /** 29 | * sessionParser middleware setup 30 | */ 31 | 32 | app.use(sessionParser); 33 | 34 | /** 35 | * bodyParser middleware setup 36 | */ 37 | 38 | app.use(bodyParser.json()); 39 | 40 | app.use(bodyParser.urlencoded({ extended: true })); 41 | 42 | /** 43 | * static files serving middleware setup 44 | */ 45 | 46 | // the production build folder 47 | app.use(express.static(path.join(process.cwd(), "build"))); 48 | 49 | // the folder that provides files for the html template file 50 | app.use(express.static(path.join(process.cwd(), "public"))); 51 | 52 | /** 53 | * routes setup to send html 54 | */ 55 | 56 | // middleware to test if authenticated 57 | // function isAuthenticated (req, res, next) { 58 | // if (req.session.userId) next() 59 | // else next('route') 60 | // } 61 | app.get("/signin", function (req, res) { 62 | res.sendFile(path.join(process.cwd(), "build", "index.html")); 63 | }); 64 | // app.get("/signin", function (req, res) { 65 | // res.status(403).send("

403 Forbidden

"); 66 | // }); 67 | 68 | app.get("/", function (req, res) { 69 | res.redirect("/signin"); 70 | }); 71 | 72 | app.get("/room-list", function (req, res) { 73 | res.redirect("/signin"); 74 | }); 75 | 76 | app.get("/chat-room", function (req, res) { 77 | res.redirect("/signin"); 78 | }); 79 | 80 | /** 81 | * api route setup to send data 82 | */ 83 | 84 | app.use("/api", apiRouter); 85 | 86 | /** 87 | * error handling middleware setup 88 | */ 89 | 90 | app.use(function (req, res, next) { 91 | next(new Error("Something Broke!")); 92 | }); 93 | 94 | app.use((err, req, res, next) => { 95 | console.error(err.stack); 96 | res.status(400).send(err.message); 97 | }); 98 | 99 | /** 100 | * Create a WebSocket server completely detached from the HTTP server 101 | * 102 | * note: the user who hasn't logged in cannot cannot open the websocket connection 103 | */ 104 | 105 | server.on("upgrade", websocketController.handleUpgrade); 106 | 107 | /** 108 | * start server listening & mongoose connection setup 109 | */ 110 | 111 | server.listen(process.env.EXPRESS_SERVER_PORT, () => { 112 | console.log(`express server run on PORT(${process.env.EXPRESS_SERVER_PORT})`); 113 | 114 | if (openMongDBConnection) { 115 | mongoDBController.connectMongDB(); 116 | } 117 | }); 118 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # webrtc-group-chat-demo 2 | 3 | A web application to realize P2P features including video calling, screen sharing, text messaging and file transceiving with low lantency 4 | 5 | ![alt text](https://github.com/myan0020/webrtc-group-chat-demo/blob/master/screenshots/chat_text.png?raw=true) 6 | 7 | ![alt text](https://github.com/myan0020/webrtc-group-chat-demo/blob/master/screenshots/chat_file.png?raw=true) 8 | 9 | ## Prerequisites: 10 | 11 | A secure context, avaliable TURN server urls or a private TURN server 12 | 13 | ## For development: 14 | 15 | **Step 1**: clone this project into your local machine, move into it and install all dependencies; 16 | ``` 17 | $ git clone https://github.com/myan0020/webrtc-group-chat-demo.git 18 | $ cd webrtc-group-chat-demo 19 | $ npm install 20 | ``` 21 | 22 | **Step 2**: start a webpack dev server to serve web pages and enable react fast refreshing, and start another server to enable data fetching plus WebRTC peer connection establishment related signaling; 23 | ``` 24 | $ npm start 25 | ``` 26 | 27 | ## For production: 28 | 29 | To make use of any WebRTC features, you need a reverse proxy (eg: nginx) for the express server in this demo that enables HTTPS and a TURN server to establish each peer connection. 30 | 31 | That sounds complicated! 32 | 33 | Luckily, if you know how to use docker, everything will be easier. Here are important steps to deploy the project. 34 | 35 | **Important Step 1**: make sure the nginx configuration file in your production environment maintains a consistent configuration with the information in this project's "docker-compose.yml" file; 36 | 37 | **Important Step 2**: make sure the COTURN configuration file in your production environment also maintains a consistent 38 | configuration with the information in this project's "docker-compose.yml" file; 39 | ``` 40 | # 41 | # Note: If the default realm is not specified, then realm falls back to the host domain name. 42 | # If the domain name string is empty, or set to '(None)', then it is initialized as an empty string. 43 | # 44 | realm=81.68.228.106:3478 45 | 46 | # Enable verbose logging 47 | verbose 48 | 49 | # Enable long-term credential mechanism 50 | lt-cred-mech 51 | 52 | fingerprint 53 | 54 | # Turn OFF the CLI support. 55 | # By default it is always ON. 56 | # See also options cli-ip and cli-port. 57 | # 58 | no-cli 59 | 60 | # Log file path 61 | log-file=/var/log/turnserver/turn.log 62 | 63 | # Enable full ISO-8601 timestamp in all logs. 64 | new-log-timestamp 65 | 66 | # This flag means that no log file rollover will be used, and the log file 67 | # name will be constructed as-is, without PID and date appendage. 68 | # This option can be used, for example, together with the logrotate tool. 69 | # 70 | simple-log 71 | 72 | # Specify the user for the TURN authentification 73 | user=mingdongshensen:12345 74 | ``` 75 | **Important Step 3**: clone this project into your production environment, and move into it; 76 | ``` 77 | $ git clone https://github.com/myan0020/webrtc-group-chat-demo.git 78 | $ cd webrtc-group-chat-demo 79 | ``` 80 | 81 | **Important Step 4**: build a docker image for this project; 82 | ``` 83 | $ docker build -t webrtc-group-chat-demo . 84 | ``` 85 | **Important Step 5**: use docker compose to run a multi-container app in detached mode; 86 | ``` 87 | docker compose -f docker-compose.yml up -d 88 | ``` 89 | 90 | ## Authors 91 | MingDongShenSen -------------------------------------------------------------------------------- /react_client/context/message-context.js: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useSelector } from "react-redux"; 3 | 4 | import { selectAllTextMessages } from "store/textChatSlice"; 5 | import { FileMessageContext, FileMessageContextProvider } from "context/file-message-context"; 6 | import * as messageChatEnum from "constant/enum/message-chat"; 7 | 8 | const MessageContext = React.createContext(); 9 | MessageContext.displayName = "MessageContext"; 10 | 11 | function MessageContextProviderWrapper({ children }) { 12 | return ( 13 | 14 | {children} 15 | 16 | ); 17 | } 18 | 19 | function MessageContextProviderContent({ children }) { 20 | const [visibleMessageType, setVisibleMessageType] = React.useState( 21 | messageChatEnum.type.MESSAGE_TYPE_TEXT 22 | ); 23 | const { 24 | messageContainer: fileMessageContainer, 25 | unreadMessageCount: unreadFileMessageCount, 26 | isSendingStatusSending: isFileSendingStatusSending, 27 | 28 | readAllMessage: readAllFileMessage, 29 | inputFiles, 30 | updateInputFiles, 31 | sendFiles, 32 | cancelAllFileSending, 33 | clearAllFileInput, 34 | clearAllFileBuffersReceived, 35 | clearAllFileReceived, 36 | 37 | resetFileMessageContext, 38 | } = React.useContext(FileMessageContext); 39 | const textMessageContainer = useSelector(selectAllTextMessages); 40 | 41 | const textMessageList = []; 42 | if (textMessageContainer) { 43 | for (let message of Object.values(textMessageContainer)) { 44 | textMessageList.push({ 45 | ...message, 46 | type: messageChatEnum.type.MESSAGE_TYPE_TEXT, 47 | }); 48 | } 49 | } 50 | const orderedTextMessageList = Object.values(textMessageList).sort((a, b) => { 51 | return a.timestamp - b.timestamp; 52 | }); 53 | 54 | const fileMessageList = []; 55 | if (fileMessageContainer) { 56 | for (let message of Object.values(fileMessageContainer)) { 57 | fileMessageList.push({ 58 | ...message, 59 | type: messageChatEnum.type.MESSAGE_TYPE_FILE, 60 | }); 61 | } 62 | } 63 | const orderedFileMessageList = Object.values(fileMessageList).sort((a, b) => { 64 | return a.timestamp - b.timestamp; 65 | }); 66 | 67 | const resetMessageContext = () => { 68 | if (typeof resetFileMessageContext === "function") { 69 | resetFileMessageContext(); 70 | } 71 | setVisibleMessageType(messageChatEnum.type.MESSAGE_TYPE_TEXT); 72 | }; 73 | 74 | const contextValue = { 75 | visibleMessageType, 76 | updateVisibleMessageType: setVisibleMessageType, 77 | 78 | orderedTextMessageList, 79 | 80 | orderedFileMessageList, 81 | isFileSendingStatusSending, 82 | unreadFileMessageCount, 83 | readAllFileMessage, 84 | inputFiles, 85 | updateInputFiles, 86 | sendFiles, 87 | cancelAllFileSending, 88 | clearAllFileInput, 89 | clearAllFileBuffersReceived, 90 | clearAllFileReceived, 91 | 92 | resetFileMessageContext, 93 | resetMessageContext, 94 | }; 95 | 96 | return {children}; 97 | } 98 | 99 | export { MessageContextProviderWrapper as MessageContextProvider, MessageContext }; 100 | -------------------------------------------------------------------------------- /react_client/component/feature/chat/media/MediaVideoVolumeController.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import styled from "styled-components"; 3 | 4 | import volumeUnmutedIconUrl from "resource/image/sound_volume_unmuted_3x.png"; 5 | import volumeMutedIconUrl from "resource/image/sound_volume_muted_3x.png"; 6 | 7 | const minVolumeMultipler = 0; 8 | const defaultVolumeMultipler = 1; 9 | const maxVolumeMultipler = 10; 10 | const volumeMultiplerStep = 0.1; 11 | 12 | export default function MediaVideoVolumeController({ audioProcessor }) { 13 | const [volumeMultipler, setVolumeMultipler] = React.useState(audioProcessor.volumeMultipler); 14 | 15 | const volumeIconUrl = 16 | volumeMultipler <= minVolumeMultipler + volumeMultiplerStep 17 | ? volumeMutedIconUrl 18 | : volumeUnmutedIconUrl; 19 | 20 | const handleVolumnMultiplierChange = (e) => { 21 | if (!audioProcessor) { 22 | return; 23 | } 24 | audioProcessor.volumeMultipler = e.target.value; 25 | setVolumeMultipler(e.target.value); 26 | }; 27 | 28 | const handleVolumnMultiplierChangeToFixValue = (e) => { 29 | if (!audioProcessor) { 30 | return; 31 | } 32 | const newVolumnMultiplier = 33 | volumeMultipler === minVolumeMultipler ? defaultVolumeMultipler : minVolumeMultipler; 34 | audioProcessor.volumeMultipler = newVolumnMultiplier; 35 | setVolumeMultipler(newVolumnMultiplier); 36 | }; 37 | 38 | return ( 39 | 40 | 44 | 52 | 53 | ); 54 | } 55 | 56 | const Wrapper = styled.div` 57 | width: 100%; 58 | height: 100%; 59 | display: flex; 60 | align-items: center; 61 | flex-direction: row; 62 | `; 63 | 64 | const VolumeIconWrapper = styled.button` 65 | flex: 0 0 content; 66 | height: 100%; 67 | aspect-ratio: 1; 68 | background-image: url(${(props) => props.volumeIconUrl}); 69 | background-position: center; 70 | background-repeat: no-repeat; 71 | background-size: contain; 72 | background-color: transparent; 73 | border-color: transparent; 74 | opacity: 0.4; 75 | 76 | &:hover { 77 | opacity: 1; 78 | } 79 | `; 80 | 81 | const VolumeMultiplierInput = styled.input` 82 | flex: 1 1 0; 83 | height: 30%; 84 | width: 50px; 85 | border-radius: 5px; 86 | 87 | -webkit-appearance: none; 88 | background: #d3d3d3; 89 | outline: none; 90 | opacity: 0.4; 91 | -webkit-transition: 0.2s; 92 | transition: opacity 0.2s; 93 | 94 | cursor: pointer; 95 | 96 | &:hover { 97 | opacity: 1; 98 | } 99 | 100 | &::-webkit-slider-thumb { 101 | -webkit-appearance: none; 102 | appearance: none; 103 | background: #04aa6d; 104 | cursor: pointer; 105 | width: 16px; 106 | height: 16px; 107 | border-radius: 8px; 108 | } 109 | 110 | &::-moz-range-thumb { 111 | background: #04aa6d; 112 | cursor: pointer; 113 | width: 16px; 114 | height: 16px; 115 | border-radius: 8px; 116 | } 117 | `; 118 | -------------------------------------------------------------------------------- /express_server/signaling/signaling.js: -------------------------------------------------------------------------------- 1 | const chalk = require("chalk"); 2 | 3 | const typeEnum = { 4 | // HTTP // 5 | // 6 | // auth 7 | LOG_IN_SUCCESS: 1, 8 | LOG_OUT_SUCCESS: 2, 9 | 10 | // WebSocket // 11 | // 12 | // heartbeat 13 | PING: 3, 14 | PONG: 4, 15 | // 16 | // chat room 17 | GET_ROOMS: 5, 18 | CREATE_ROOM: 6, 19 | UPDATE_ROOMS: 7, 20 | JOIN_ROOM: 8, 21 | JOIN_ROOM_SUCCESS: 9, 22 | LEAVE_ROOM: 10, 23 | LEAVE_ROOM_SUCCESS: 11, 24 | // 25 | // WebRTC connection 26 | WEBRTC_NEW_PEER_ARIVAL: 12, 27 | WEBRTC_NEW_PEER_LEAVE: 13, 28 | WEBRTC_NEW_PASSTHROUGH: 14, 29 | }; 30 | 31 | const createMessage = (selectedType, payload) => { 32 | const typeValueSet = new Set(Object.values(typeEnum)); 33 | if (!typeValueSet.has(selectedType)) { 34 | console.log(chalk.red`'createMessage' has received a wrong 'selectedType'( ${selectedType} )`); 35 | return null; 36 | } 37 | 38 | const message = {}; 39 | message.type = selectedType; 40 | message.payload = payload; 41 | 42 | return message; 43 | }; 44 | 45 | const createSerializedMessage = (selectedType, payload) => { 46 | return JSON.stringify(createMessage(selectedType, payload)); 47 | }; 48 | 49 | const findTypeNameByTypeValue = (typeValue) => { 50 | for (let typeName in typeEnum) if (typeEnum[typeName] === typeValue) return typeName; 51 | return undefined; 52 | }; 53 | 54 | exports.typeEnum = typeEnum; 55 | 56 | exports.findTypeNameByTypeValue = findTypeNameByTypeValue; 57 | 58 | exports.sendThroughResponse = (res, selectedType, payload) => { 59 | if (!res) { 60 | console.log(chalk.red`Response cannot be sent because 'res' param is Null`); 61 | return; 62 | } 63 | 64 | const message = createMessage(selectedType, payload); 65 | if (!message) { 66 | console.log(chalk.red`Response cannot be sent because the outgoing message is Null`); 67 | return; 68 | } 69 | 70 | const selectedTypeName = findTypeNameByTypeValue(selectedType); 71 | if (!selectedTypeName || selectedTypeName.length === 0) { 72 | console.log( 73 | chalk.red`[HTTP] the log to show signal type name cannot be printed because 'selectedType'( pointing to ${selectedTypeName} ) param is invalid` 74 | ); 75 | } else { 76 | console.log( 77 | `[${chalk.green`HTTP`}] ${chalk.green`${selectedTypeName}`} signal msg respond ${chalk.blue`to`} a user` 78 | ); 79 | } 80 | 81 | res.send(message); 82 | }; 83 | 84 | exports.sendThroughWebsocket = (websocket, selectedType, payload) => { 85 | if (!websocket) { 86 | console.log( 87 | chalk.red`[WebSocket] msg of type(${selectedType}) and of payload(${JSON.stringify( 88 | payload 89 | )}) cannot be sent because 'websocket' param is Null` 90 | ); 91 | return; 92 | } 93 | 94 | const selectedTypeName = findTypeNameByTypeValue(selectedType); 95 | if (!selectedTypeName || selectedTypeName.length === 0) { 96 | console.log( 97 | chalk.red`[WebSocket] the log to show signal type name cannot be printed because 'selectedType'( pointing to ${selectedTypeName} ) param is invalid` 98 | ); 99 | } else { 100 | console.log( 101 | `[${chalk.green`WebSocket`}] ${chalk.green`${selectedTypeName}`} signal msg ${chalk.blue`to`} a user of a name(${chalk.green`${ 102 | websocket.username ? websocket.username : "unknown" 103 | }`})` 104 | ); 105 | } 106 | 107 | websocket.send(createSerializedMessage(selectedType, payload)); 108 | }; 109 | -------------------------------------------------------------------------------- /react_client/component/feature/localization/LocalizationSwitch.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import styled from "styled-components"; 3 | 4 | import * as localizableEnum from "constant/enum/localizable"; 5 | import DropdownSwitch, { 6 | dropdownSwitchOptionBuilder, 7 | dropdownSwitchPropsBuilder, 8 | } from "../../generic/switch/DropdownSwitch"; 9 | import { GlobalContext } from "context/global-context"; 10 | 11 | export default function LocalizationSwitch({ 12 | iconImageUrl, 13 | selectedTextColor, 14 | isSelectedTextKeyVisible, 15 | }) { 16 | const { localizedStrings, changeLocalization } = React.useContext(GlobalContext); 17 | return ( 18 | 25 | ); 26 | } 27 | 28 | const MemorizedLocalizationSwitch = React.memo(LocalizationSwitchToMemo, arePropsEqual); 29 | 30 | function LocalizationSwitchToMemo({ 31 | iconImageUrl, 32 | selectedTextColor, 33 | isSelectedTextKeyVisible, 34 | 35 | localizedStrings, 36 | changeLocalization, 37 | }) { 38 | const EnglishOption = dropdownSwitchOptionBuilder({ 39 | dropdownOptionName: localizedStrings[localizableEnum.key.LOCALIZATION_ENGLISH_ITEM_TEXT], 40 | dropdownOptionSelected: true, 41 | dropdownOptionOnClick: () => { 42 | changeLocalization(localizableEnum.type.ENGLISH); 43 | }, 44 | }); 45 | const ChineseOption = dropdownSwitchOptionBuilder({ 46 | dropdownOptionName: localizedStrings[localizableEnum.key.LOCALIZATION_CHINESE_ITEM_TEXT], 47 | dropdownOptionSelected: false, 48 | dropdownOptionOnClick: () => { 49 | changeLocalization(localizableEnum.type.CHINESE); 50 | }, 51 | }); 52 | 53 | return ( 54 | 55 | 68 | 69 | ); 70 | } 71 | 72 | const arePropsEqual = (prevProps, nextProps) => { 73 | const isIconImageUrlEqual = Object.is(prevProps.iconImageUrl, nextProps.iconImageUrl); 74 | const isSelectedTextColorEqual = Object.is( 75 | prevProps.selectedTextColor, 76 | nextProps.selectedTextColor 77 | ); 78 | const isIsSelectedTextKeyVisibleEqual = Object.is( 79 | prevProps.isSelectedTextKeyVisible, 80 | nextProps.isSelectedTextKeyVisible 81 | ); 82 | const isLocalizedStringEqual = Object.is(prevProps.localizedStrings, nextProps.localizedStrings); 83 | const isChangeLocalizationEqual = Object.is( 84 | prevProps.changeLocalization, 85 | nextProps.changeLocalization 86 | ); 87 | return ( 88 | isIconImageUrlEqual && 89 | isSelectedTextColorEqual && 90 | isIsSelectedTextKeyVisibleEqual && 91 | isLocalizedStringEqual && 92 | isChangeLocalizationEqual 93 | ); 94 | }; 95 | 96 | const Wrapper = styled.div` 97 | width: 100%; 98 | height: 100%; 99 | `; -------------------------------------------------------------------------------- /react_client/component/feature/chat/media/MediaRenderingStyleSwitch.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import styled from "styled-components"; 3 | 4 | import MultiTabSwitch, { 5 | multiTabSwitchTabBuilder, 6 | multiTabSwitchPropsBuilder, 7 | } from "../../../generic/switch/MultiTabSwitch"; 8 | import presentationEnabledUrl from "resource/image/presentation_enabled_3x.png"; 9 | import presentationDisabledUrl from "resource/image/presentation_disabled_3x.png"; 10 | import equalityEnabledUrl from "resource/image/equality_enabled_3x.png"; 11 | import equalityDisabledUrl from "resource/image/equality_disabled_3x.png"; 12 | import { GlobalContext } from "context/global-context"; 13 | import * as mediaChatEnum from "constant/enum/media-chat"; 14 | 15 | export default function MediaRenderingStyleSwitch({}) { 16 | const { updateMediaAccessibilityType, mediaAccessibilityType } = React.useContext(GlobalContext); 17 | return ( 18 | 22 | ); 23 | } 24 | 25 | const MemorizedMediaRenderingStyleSwitch = React.memo( 26 | MediaRenderingStyleSwitchToMemo, 27 | arePropsEqual 28 | ); 29 | 30 | function MediaRenderingStyleSwitchToMemo({ updateMediaAccessibilityType, mediaAccessibilityType }) { 31 | const presentationStyleTab = multiTabSwitchTabBuilder({ 32 | switchTabName: "", 33 | switchTabBorderRadius: 20, 34 | switchTabSelectedBackgroundImageUrl: presentationEnabledUrl, 35 | switchTabSelectedBackgroundImageSize: "30px 20px", 36 | switchTabUnselectedBackgroundImageUrl: presentationDisabledUrl, 37 | switchTabUnselectedBackgroundImageSize: "30px 20px", 38 | switchTabOnClick: () => { 39 | updateMediaAccessibilityType( 40 | mediaChatEnum.mediaAccessibilityType.MEDIA_ACCESSIBILITY_TYPE_PRESENTATION 41 | ); 42 | }, 43 | switchTabSelected: 44 | mediaAccessibilityType === 45 | mediaChatEnum.mediaAccessibilityType.MEDIA_ACCESSIBILITY_TYPE_PRESENTATION, 46 | }); 47 | const equalityStyleTab = multiTabSwitchTabBuilder({ 48 | switchTabName: "", 49 | switchTabBorderRadius: 20, 50 | switchTabSelectedBackgroundImageUrl: equalityEnabledUrl, 51 | switchTabSelectedBackgroundImageSize: "30px 20px", 52 | switchTabUnselectedBackgroundImageUrl: equalityDisabledUrl, 53 | switchTabUnselectedBackgroundImageSize: "30px 20px", 54 | switchTabOnClick: () => { 55 | updateMediaAccessibilityType( 56 | mediaChatEnum.mediaAccessibilityType.MEDIA_ACCESSIBILITY_TYPE_EQUALITY 57 | ); 58 | }, 59 | switchTabSelected: 60 | mediaAccessibilityType === 61 | mediaChatEnum.mediaAccessibilityType.MEDIA_ACCESSIBILITY_TYPE_EQUALITY, 62 | }); 63 | 64 | return ( 65 | 66 | 74 | 75 | ); 76 | } 77 | 78 | const arePropsEqual = (prevProps, nextProps) => { 79 | const isUpdateMediaAccessibilityTypeEqual = Object.is( 80 | prevProps.updateMediaAccessibilityType, 81 | nextProps.updateMediaAccessibilityType 82 | ); 83 | const isMediaAccessibilityTypeEqual = Object.is( 84 | prevProps.mediaAccessibilityType, 85 | nextProps.mediaAccessibilityType 86 | ); 87 | return isUpdateMediaAccessibilityTypeEqual && isMediaAccessibilityTypeEqual; 88 | }; 89 | 90 | const Wrapper = styled.div` 91 | width: 80px; 92 | height: 40px; 93 | `; 94 | -------------------------------------------------------------------------------- /react_client/store/textChatSlice.js: -------------------------------------------------------------------------------- 1 | import { createAsyncThunk, createSelector, createSlice } from "@reduxjs/toolkit"; 2 | import GroupChatService from "webrtc-group-chat-client"; 3 | 4 | import { selectAuth } from "./authSlice"; 5 | import * as loadingStatusEnum from "constant/enum/loading-status"; 6 | 7 | const initialState = { 8 | loadingStatus: loadingStatusEnum.status.IDLE, 9 | textMessages: {}, 10 | }; 11 | 12 | export const textChatSlice = createSlice({ 13 | name: "textChat", 14 | initialState, 15 | reducers: { 16 | addTextMessage: { 17 | reducer(sliceState, action) { 18 | Object.values(sliceState.textMessages).forEach((textMessage) => { 19 | textMessage.isNew = !textMessage.isRead; 20 | }); 21 | 22 | sliceState.textMessages[action.payload.id] = action.payload; 23 | }, 24 | }, 25 | readAllTextMessages: { 26 | reducer(sliceState, action) { 27 | Object.values(sliceState.textMessages).forEach((textMessage) => { 28 | textMessage.isRead = true; 29 | }); 30 | }, 31 | }, 32 | reset: { 33 | reducer(sliceState, action) { 34 | return initialState; 35 | }, 36 | }, 37 | }, 38 | }); 39 | 40 | /* Thunk action creator */ 41 | 42 | export const sendTextMessage = createAsyncThunk( 43 | "textChat/sendTextMessage", 44 | async (text, thunkAPI) => { 45 | GroupChatService.sendChatMessageToAllPeer(text); 46 | 47 | const { authenticatedUserId, authenticatedUserName } = selectAuth(thunkAPI.getState()); 48 | const timestamp = (new Date()).getTime(); 49 | const id = `${authenticatedUserId}-${timestamp}`; 50 | const textMessage = { 51 | id, 52 | timestamp, 53 | userId: authenticatedUserId, 54 | userName: authenticatedUserName, 55 | isLocalSender: true, 56 | text: text, 57 | isRead: true, 58 | isNew: false, 59 | }; 60 | 61 | thunkAPI.dispatch(addTextMessage(textMessage)); 62 | } 63 | ); 64 | 65 | export const receiveTextMessage = createAsyncThunk( 66 | "textChat/receiveTextMessage", 67 | async (message, thunkAPI) => { 68 | const defaultMessage = { 69 | userId: "unknown user id", 70 | userName: "unknown user name", 71 | text: "unknown message", 72 | }; 73 | if (message && typeof message.peerId === "string") { 74 | defaultMessage.userId = message.peerId; 75 | } 76 | if (message && typeof message.peerName === "string") { 77 | defaultMessage.userName = message.peerName; 78 | } 79 | if (message && typeof message.text === "string") { 80 | defaultMessage.text = message.text; 81 | } 82 | 83 | const timestamp = (new Date()).getTime(); 84 | const id = `${defaultMessage.userId}-${timestamp}`; 85 | const textMessage = { 86 | id, 87 | timestamp, 88 | userId: defaultMessage.userId, 89 | userName: defaultMessage.userName, 90 | isLocalSender: false, 91 | text: defaultMessage.text, 92 | isRead: false, 93 | isNew: true, 94 | }; 95 | 96 | thunkAPI.dispatch(addTextMessage(textMessage)); 97 | } 98 | ); 99 | 100 | /* Reducer */ 101 | 102 | export default textChatSlice.reducer; 103 | 104 | /* Action Creator */ 105 | 106 | export const { addTextMessage, readAllTextMessages, reset } = textChatSlice.actions; 107 | 108 | /* Selector */ 109 | 110 | export const selectAllTextMessages = (state) => state.textChat.textMessages; 111 | 112 | export const selectUnreadTextMessageCount = createSelector( 113 | selectAllTextMessages, 114 | (textMessages) => { 115 | return Object.values(textMessages).filter(textMessage => !textMessage.isRead).length; 116 | } 117 | ); 118 | -------------------------------------------------------------------------------- /react_client/store/store.js: -------------------------------------------------------------------------------- 1 | import { combineReducers, configureStore } from "@reduxjs/toolkit"; 2 | import GroupChatService from "webrtc-group-chat-client"; 3 | 4 | import authReducer from "./authSlice"; 5 | import roomReducer, { 6 | updateJoinedRoomId, 7 | updateRoomList, 8 | updateRoomLoadingStatus, 9 | } from "./roomSlice"; 10 | import mediaChatReducer, { 11 | updateIsCalling, 12 | updateAudioEnablingAvaliable, 13 | updateAudioEnabling, 14 | updateAudioMutingAvaliable, 15 | updateAudioMuting, 16 | updateVideoEnablingAvaliable, 17 | updateVideoEnabling, 18 | updateVideoMutingAvaliable, 19 | updateVideoMuting, 20 | } from "./mediaChatSlice"; 21 | import textChatReducer, { receiveTextMessage } from "./textChatSlice"; 22 | import membershipReducer, { updatePeersInfo as updateMembershipPeersInfo } from "./membershipSlice"; 23 | import * as loadingStatusEnum from "constant/enum/loading-status"; 24 | 25 | const combinedReducer = combineReducers({ 26 | auth: authReducer, 27 | room: roomReducer, 28 | mediaChat: mediaChatReducer, 29 | textChat: textChatReducer, 30 | membership: membershipReducer, 31 | }); 32 | 33 | const store = configureStore({ 34 | reducer: (state, action) => { 35 | if (action.type === "RESET") { 36 | state = undefined; 37 | } 38 | return combinedReducer(state, action); 39 | }, 40 | }); 41 | 42 | /** 43 | * GroupChatService preparation 44 | */ 45 | 46 | const iceServerUserName = env.TURN_SERVER_USER_NAME; 47 | const iceServerCredential = env.TURN_SERVER_CREDENTIAL; 48 | const iceServerUrls = JSON.parse(env.TURN_SERVER_URLS); 49 | 50 | GroupChatService.peerConnectionConfig = { 51 | iceServers: [ 52 | { 53 | username: iceServerUserName, 54 | credential: iceServerCredential, 55 | urls: iceServerUrls, 56 | }, 57 | ], 58 | }; 59 | 60 | GroupChatService.onRoomsInfoUpdated((payload) => { 61 | const rooms = payload.rooms; 62 | if (rooms) { 63 | store.dispatch(updateRoomList(rooms)); 64 | } 65 | }); 66 | 67 | GroupChatService.onJoinRoomInSuccess((payload) => { 68 | const roomId = payload.roomId; 69 | const roomName = payload.roomName; 70 | if (roomId.length > 0 && roomName.length > 0) { 71 | store.dispatch(updateJoinedRoomId({ roomId, roomName })); 72 | store.dispatch(updateRoomLoadingStatus(loadingStatusEnum.status.IDLE)); 73 | } 74 | }); 75 | 76 | GroupChatService.onLeaveRoomInSuccess((payload) => { 77 | store.dispatch(updateJoinedRoomId({ roomId: "", roomName: "" })); 78 | store.dispatch(updateRoomLoadingStatus(loadingStatusEnum.status.IDLE)); 79 | }); 80 | 81 | GroupChatService.onWebRTCCallingStateChanged((isCalling) => { 82 | store.dispatch(updateIsCalling(isCalling)); 83 | }); 84 | 85 | GroupChatService.onLocalAudioEnableAvaliableChanged((avaliable) => { 86 | store.dispatch(updateAudioEnablingAvaliable(avaliable)); 87 | store.dispatch(updateAudioEnabling(GroupChatService.localMicEnabled)); 88 | }); 89 | 90 | GroupChatService.onLocalAudioMuteAvaliableChanged((avaliable) => { 91 | store.dispatch(updateAudioMutingAvaliable(avaliable)); 92 | store.dispatch(updateAudioMuting(GroupChatService.localMicMuted)); 93 | }); 94 | 95 | GroupChatService.onLocalVideoEnableAvaliableChanged((avaliable) => { 96 | store.dispatch(updateVideoEnablingAvaliable(avaliable)); 97 | store.dispatch(updateVideoEnabling(GroupChatService.localCameraEnabled)); 98 | }); 99 | 100 | GroupChatService.onLocalVideoMuteAvaliableChanged((avaliable) => { 101 | store.dispatch(updateVideoMutingAvaliable(avaliable)); 102 | store.dispatch(updateVideoMuting(GroupChatService.localCameraMuted)); 103 | }); 104 | 105 | GroupChatService.onChatMessageReceived((message) => { 106 | store.dispatch(receiveTextMessage(message)); 107 | }); 108 | 109 | GroupChatService.onPeersInfoChanged((peersInfo) => { 110 | store.dispatch(updateMembershipPeersInfo(peersInfo)); 111 | }); 112 | 113 | export default store; 114 | -------------------------------------------------------------------------------- /react_client/store/roomSlice.js: -------------------------------------------------------------------------------- 1 | import { createAsyncThunk, createSelector, createSlice } from "@reduxjs/toolkit"; 2 | import axios from "axios"; 3 | import GroupChatService from "webrtc-group-chat-client"; 4 | 5 | import * as loadingStatusEnum from "constant/enum/loading-status"; 6 | 7 | const initialState = { 8 | roomList: {}, 9 | joinedRoomId: "", 10 | joinedRoomName: "", 11 | isNewRoomPopupVisible: false, 12 | loadingStatus: loadingStatusEnum.status.IDLE, 13 | }; 14 | 15 | export const roomSlice = createSlice({ 16 | name: "room", 17 | initialState, 18 | reducers: { 19 | updateRoomList: { 20 | reducer(sliceState, action) { 21 | sliceState.roomList = action.payload; 22 | }, 23 | }, 24 | toggleNewRoomPopupVisibility: { 25 | reducer(sliceState, action) { 26 | sliceState.isNewRoomPopupVisible = !sliceState.isNewRoomPopupVisible; 27 | }, 28 | }, 29 | updateJoinedRoomId: { 30 | reducer(sliceState, action) { 31 | sliceState.joinedRoomId = action.payload.roomId; 32 | sliceState.joinedRoomName = action.payload.roomName; 33 | }, 34 | }, 35 | updateRoomLoadingStatus: { 36 | reducer(sliceState, action) { 37 | sliceState.loadingStatus = action.payload; 38 | }, 39 | }, 40 | reset: { 41 | reducer(sliceState, action) { 42 | return initialState; 43 | }, 44 | }, 45 | }, 46 | extraReducers: (builder) => { 47 | builder 48 | .addCase(fetchInitialRoomList.pending, (sliceState, action) => { 49 | sliceState.loadingStatus = loadingStatusEnum.status.LOADING; 50 | }) 51 | .addCase(fetchInitialRoomList.fulfilled, (sliceState, action) => { 52 | sliceState.loadingStatus = loadingStatusEnum.status.IDLE; 53 | if (action.payload.responseStatus !== 200) { 54 | return; 55 | } 56 | sliceState.roomList = action.payload.roomList; 57 | }); 58 | }, 59 | }); 60 | 61 | /* Thunk action creator */ 62 | 63 | export const fetchInitialRoomList = createAsyncThunk("room/fetchInitialRoomList", async () => { 64 | const config = { 65 | url: "/api/rooms", 66 | method: "GET", 67 | }; 68 | const response = await axios(config); 69 | if (!response || !response.data || !response.data.payload) { 70 | return; 71 | } 72 | return { 73 | responseStatus: response.status, 74 | roomList: response.data.payload.rooms, 75 | }; 76 | }); 77 | 78 | export const createRoom = createAsyncThunk("room/createRoom", async (roomName) => { 79 | if (!roomName || roomName.length === 0) return; 80 | GroupChatService.createNewRoom(roomName); 81 | }); 82 | 83 | export const joinRoom = createAsyncThunk("room/joinRoom", async (roomId, thunkAPI) => { 84 | if (!roomId || roomId.length === 0) return; 85 | thunkAPI.dispatch(updateRoomLoadingStatus(loadingStatusEnum.status.LOADING)); 86 | GroupChatService.joinRoom(roomId); 87 | }); 88 | 89 | export const leaveRoom = createAsyncThunk("room/leaveRoom", async (_, thunkAPI) => { 90 | thunkAPI.dispatch(updateRoomLoadingStatus(loadingStatusEnum.status.LOADING)); 91 | GroupChatService.leaveRoom(); 92 | }); 93 | 94 | /* Reducer */ 95 | 96 | export default roomSlice.reducer; 97 | 98 | /* Action Creator */ 99 | 100 | export const { 101 | updateRoomList, 102 | toggleNewRoomPopupVisibility, 103 | updateJoinedRoomId, 104 | updateRoomLoadingStatus, 105 | reset, 106 | } = roomSlice.actions; 107 | 108 | /* Selector */ 109 | 110 | export const selectRoom = (state) => { 111 | return state.room; 112 | }; 113 | 114 | export const selectHasJoinedRoom = createSelector(selectRoom, (room) => { 115 | return room.joinedRoomId.length > 0; 116 | }); 117 | 118 | export const selectJoinedRoomName = createSelector(selectRoom, (room) => { 119 | return room.joinedRoomName; 120 | }); 121 | 122 | export const selectRoomList = createSelector(selectRoom, (room) => { 123 | return room.roomList; 124 | }); 125 | 126 | export const selectNewRoomPopupVisible = createSelector(selectRoom, (room) => { 127 | return room.isNewRoomPopupVisible; 128 | }); 129 | 130 | export const selectRoomLoadingStatus = createSelector(selectRoom, (room) => { 131 | return room.loadingStatus; 132 | }); 133 | -------------------------------------------------------------------------------- /react_client/store/authSlice.js: -------------------------------------------------------------------------------- 1 | import { createAsyncThunk, createSelector, createSlice } from "@reduxjs/toolkit"; 2 | import axios from "axios"; 3 | import GroupChatService from "webrtc-group-chat-client"; 4 | 5 | import { fetchInitialRoomList } from "./roomSlice"; 6 | import * as loadingStatusEnum from "constant/enum/loading-status"; 7 | 8 | const initialState = { 9 | authenticated: false, 10 | authenticatedUserName: "", 11 | authenticatedUserId: "", 12 | loadingStatus: loadingStatusEnum.status.IDLE, 13 | }; 14 | 15 | export const authSlice = createSlice({ 16 | name: "auth", 17 | initialState, 18 | reducers: { 19 | reset: { 20 | reducer(sliceState, action) { 21 | return initialState; 22 | }, 23 | }, 24 | }, 25 | extraReducers: (builder) => { 26 | builder 27 | .addCase(requestToSignin.pending, (sliceState, action) => { 28 | sliceState.loadingStatus = loadingStatusEnum.status.LOADING; 29 | }) 30 | .addCase(requestToSignin.fulfilled, (sliceState, action) => { 31 | sliceState.loadingStatus = loadingStatusEnum.status.IDLE; 32 | if (!action.payload) { 33 | return; 34 | } 35 | sliceState.authenticated = true; 36 | sliceState.authenticatedUserName = action.payload.userName; 37 | sliceState.authenticatedUserId = action.payload.userId; 38 | }) 39 | .addCase(requestToSignout.pending, (sliceState, action) => { 40 | sliceState.loadingStatus = loadingStatusEnum.status.LOADING; 41 | }) 42 | .addCase(requestToSignout.fulfilled, (sliceState, action) => { 43 | sliceState.loadingStatus = loadingStatusEnum.status.IDLE; 44 | if (!action.payload) { 45 | return; 46 | } 47 | 48 | sliceState.authenticated = false; 49 | sliceState.authenticatedUserName = ""; 50 | sliceState.authenticatedUserId = ""; 51 | }); 52 | }, 53 | }); 54 | 55 | /* Thunk action creator */ 56 | 57 | export const requestToSignin = createAsyncThunk( 58 | "auth/requestToSignin", 59 | async (userName, thunkAPI) => { 60 | if (typeof userName !== "string" || userName.length === 0) { 61 | return; 62 | } 63 | const config = { 64 | url: "/api/login", 65 | method: "POST", 66 | data: { 67 | userName: userName ? userName : "unknownUserName", 68 | }, 69 | }; 70 | const response = await axios(config); 71 | if (response.status !== 200) { 72 | return; 73 | } 74 | 75 | // side effects 76 | thunkAPI.dispatch(fetchInitialRoomList()); 77 | 78 | let url; 79 | if (process.env.NODE_ENV === "production") { 80 | url = `wss://${location.hostname}`; 81 | } else { 82 | url = `ws://${location.hostname}:${env.EXPRESS_SERVER_PORT}`; 83 | } 84 | GroupChatService.connect(url); 85 | 86 | return { 87 | responseStatus: response.status, 88 | userName: response.data.payload.userName, 89 | userId: response.data.payload.userId, 90 | }; 91 | } 92 | ); 93 | 94 | export const requestToSignout = createAsyncThunk("auth/requestToSignout", async (_, thunkAPI) => { 95 | const config = { 96 | url: "/api/logout", 97 | method: "POST", 98 | }; 99 | const response = await axios(config); 100 | if (response.status !== 200) { 101 | return; 102 | } 103 | 104 | // side effects 105 | GroupChatService.disconnect(); 106 | 107 | return { 108 | responseStatus: response.status, 109 | }; 110 | }); 111 | 112 | /* Reducer */ 113 | 114 | export default authSlice.reducer; 115 | 116 | /* Action Creator */ 117 | 118 | export const { reset } = authSlice.actions; 119 | 120 | /* Selector */ 121 | 122 | export const selectAuth = (state) => state.auth; 123 | 124 | export const selectAuthenticated = createSelector(selectAuth, (auth) => { 125 | return auth.authenticated; 126 | }); 127 | 128 | export const selectAuthenticatedUserId = createSelector(selectAuth, (auth) => { 129 | return auth.authenticatedUserId; 130 | }); 131 | 132 | export const selectAuthenticatedUserName = createSelector(selectAuth, (auth) => { 133 | return auth.authenticatedUserName; 134 | }); 135 | 136 | export const selectAuthLoadingStatus = createSelector(selectAuth, (auth) => { 137 | return auth.loadingStatus; 138 | }); 139 | -------------------------------------------------------------------------------- /react_client/component/feature/chat/media/MediaVideo.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import styled from "styled-components"; 3 | 4 | import MediaUserTag from "./MediaUserTag"; 5 | import MediaVideoRenderer from "./MediaVideoRenderer"; 6 | import MediaVideoController from "./MediaVideoController"; 7 | import MediaAudioRenderer from "./MediaAudioRenderer"; 8 | 9 | export default function MediaVideo(props) { 10 | // required props 11 | const userId = props.userId; 12 | const userName = props.userName; 13 | const forceAudioOutputUnavaliable = props.forceAudioOutputUnavaliable; 14 | const forceVideoUnpresentable = props.forceVideoUnpresentable; 15 | const videoStream = props.videoStream; 16 | const isVideoCancellable = props.isVideoCancellable; 17 | // nullable props 18 | const isAudioSourceAvaliable = props.isAudioSourceAvaliable; 19 | const audioProcessor = props.audioProcessor; 20 | 21 | const [isMediaControllerVisible, setIsMediaControllerVisible] = React.useState(false); 22 | 23 | const isAudioGainNodeAvaliable = audioProcessor && audioProcessor.audioGainNode ? true : false; 24 | const isVideoSourceAvaliable = 25 | videoStream instanceof MediaStream && videoStream.getTracks().length > 0; 26 | const isUserTagAvaliable = isAudioSourceAvaliable || isVideoSourceAvaliable; 27 | const isAudioControlAvaliable = 28 | !forceAudioOutputUnavaliable && isAudioSourceAvaliable && isAudioGainNodeAvaliable; 29 | const isVideoControlAvaliable = 30 | isVideoSourceAvaliable && (!forceVideoUnpresentable || isVideoCancellable); 31 | const isMediaControlAvaliable = isAudioControlAvaliable || isVideoControlAvaliable; 32 | 33 | const handleMouseOverMediaVideoControllerContainer = () => { 34 | setIsMediaControllerVisible(true); 35 | }; 36 | const handleMouseLeaveMediaVideoControllerContainer = () => { 37 | setIsMediaControllerVisible(false); 38 | }; 39 | 40 | return ( 41 | 45 | {isAudioSourceAvaliable && ( 46 | 47 | 51 | 52 | )} 53 | {isVideoSourceAvaliable && ( 54 | 55 | 56 | 57 | )} 58 | {isMediaControlAvaliable && isMediaControllerVisible && ( 59 | 60 | 68 | 69 | )} 70 | {isUserTagAvaliable && ( 71 | 72 | 73 | 74 | )} 75 | 76 | ); 77 | } 78 | 79 | const Wrapper = styled.div` 80 | box-sizing: border-box; 81 | border-width: 3px; 82 | width: 100%; 83 | height: 100%; 84 | position: relative; 85 | border: 3px solid rgba(250, 250, 250); 86 | border-radius: 10px; 87 | background-color: rgb(236, 239, 241); 88 | `; 89 | 90 | const MediaAudioRendererContainer = styled.div` 91 | width: 100%; 92 | height: 100%; 93 | top: 0; 94 | left: 0; 95 | position: absolute; 96 | `; 97 | 98 | const MediaVideoRendererContainer = styled.div` 99 | display: block; 100 | width: 100%; 101 | height: 100%; 102 | top: 0; 103 | left: 0; 104 | position: absolute; 105 | background-color: rgb(236, 239, 241); 106 | `; 107 | 108 | const MediaUserTagContainer = styled.div` 109 | position: absolute; 110 | top: 5px; 111 | left: 5px; 112 | max-width: calc(100% - 2 * 5px); 113 | height: 25px; 114 | border-radius: 10px; 115 | border-color: transparent; 116 | overflow: hidden; 117 | `; 118 | 119 | const MediaVideoControllerContainer = styled.div` 120 | width: 100%; 121 | height: 100%; 122 | top: 0; 123 | left: 0; 124 | position: absolute; 125 | `; 126 | -------------------------------------------------------------------------------- /react_client/component/feature/chat/media/MediaAudioRenderer.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import styled from "styled-components"; 3 | 4 | const audioAnalyserNodeFFTSize = 512; 5 | const audioCanvasFillColor = "rgba(255, 255, 255, 0)"; 6 | const audioCanvasStrokeColor = "rgb(0, 150, 136)"; 7 | 8 | export default function MediaAudioRenderer(props) { 9 | const isAudioSourceAvaliable = props.isAudioSourceAvaliable; 10 | const audioProcessor = props.audioProcessor; 11 | 12 | const [audioCanvasSize, setAudioCanvasSize] = React.useState({ width: 300, height: 150 }); 13 | 14 | const audioCanvasRef = React.useRef(); 15 | 16 | const audioCanvasSizeRef = React.useRef(); 17 | audioCanvasSizeRef.current = audioCanvasSize; 18 | 19 | const isAudioAnalyserAvaliableRef = React.useRef(); 20 | const isAudioAnalyserAvaliable = 21 | isAudioSourceAvaliable && audioProcessor && audioProcessor.audioAnalyserNode ? true : false; 22 | isAudioAnalyserAvaliableRef.current = isAudioAnalyserAvaliable; 23 | 24 | React.useEffect(() => { 25 | if (!isAudioAnalyserAvaliableRef.current) { 26 | return; 27 | } 28 | 29 | const audioAnalyserNode = audioProcessor.audioAnalyserNode; 30 | const audioCanvas = audioCanvasRef.current; 31 | 32 | if ( 33 | audioCanvasSizeRef.current.width !== audioCanvas.clientWidth || 34 | audioCanvasSizeRef.current.height !== audioCanvas.clientHeight 35 | ) { 36 | setAudioCanvasSize({ width: audioCanvas.clientWidth, height: audioCanvas.clientHeight }); 37 | return; 38 | } 39 | 40 | const audioCanvasContext = audioCanvas.getContext("2d"); 41 | const audioAnimationIDRef = drawAudioWaveformAnimation(audioAnalyserNode, audioCanvasContext); 42 | 43 | return () => { 44 | if (typeof audioAnimationIDRef.current === undefined) { 45 | return; 46 | } 47 | cancelAnimationFrame(audioAnimationIDRef.current); 48 | }; 49 | }, [isAudioAnalyserAvaliableRef.current, audioCanvasSize]); 50 | 51 | const playAudioIfPossible = (audioDOM) => { 52 | if (!audioDOM) return; 53 | if (audioProcessor && audioProcessor.playWithAudioDOMLoaded) { 54 | audioProcessor.playWithAudioDOMLoaded(audioDOM); 55 | } 56 | }; 57 | 58 | return ( 59 | 60 | 72 | ); 73 | } 74 | 75 | function drawAudioWaveformAnimation(audioAnalyserNode, audioCanvasContext) { 76 | const animationIDRef = {}; 77 | 78 | audioAnalyserNode.fftSize = audioAnalyserNodeFFTSize; 79 | const bufferLength = audioAnalyserNode.fftSize; 80 | 81 | // We can use Float32Array instead of Uint8Array if we want higher precision 82 | // const dataArray = new Float32Array(bufferLength); 83 | const dataArray = new Uint8Array(bufferLength); 84 | 85 | const draw = function () { 86 | animationIDRef.current = requestAnimationFrame(draw); 87 | 88 | audioAnalyserNode.getByteTimeDomainData(dataArray); 89 | 90 | const WIDTH = audioCanvasContext.canvas.width; 91 | const HEIGHT = audioCanvasContext.canvas.height; 92 | 93 | audioCanvasContext.clearRect(0, 0, WIDTH, HEIGHT); 94 | audioCanvasContext.fillStyle = audioCanvasFillColor; 95 | audioCanvasContext.fillRect(0, 0, WIDTH, HEIGHT); 96 | audioCanvasContext.lineWidth = 1; 97 | audioCanvasContext.strokeStyle = audioCanvasStrokeColor; 98 | audioCanvasContext.beginPath(); 99 | 100 | const sliceWidth = (WIDTH * 1.0) / bufferLength; 101 | let x = 0; 102 | 103 | for (let i = 0; i < bufferLength; i++) { 104 | // 128 (bit) = 1 (byte) / 2 105 | let v = dataArray[i] / 128.0; 106 | let y = (v * HEIGHT) / 2; 107 | 108 | if (i === 0) { 109 | audioCanvasContext.moveTo(x, y); 110 | } else { 111 | audioCanvasContext.lineTo(x, y); 112 | } 113 | 114 | x += sliceWidth; 115 | } 116 | 117 | audioCanvasContext.lineTo(WIDTH, HEIGHT / 2); 118 | audioCanvasContext.stroke(); 119 | }; 120 | 121 | draw(); 122 | return animationIDRef; 123 | } 124 | 125 | const Wrapper = styled.div` 126 | width: 100%; 127 | height: 100%; 128 | `; 129 | 130 | const Audio = styled.audio``; 131 | 132 | const AudioCanvas = styled.canvas` 133 | width: 100%; 134 | height: 100%; 135 | `; 136 | -------------------------------------------------------------------------------- /webpack/webpack.common.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const HtmlWebpackPlugin = require("html-webpack-plugin"); 3 | const webpack = require("webpack"); 4 | const ReactRefreshWebpackPlugin = require("@pmmmwh/react-refresh-webpack-plugin"); 5 | const BundleAnalyzerPlugin = require("webpack-bundle-analyzer").BundleAnalyzerPlugin; 6 | 7 | require("dotenv").config(); 8 | 9 | module.exports = (env, args) => { 10 | const isEnvDevelopment = args.mode === "development"; 11 | return { 12 | target: "web", // can be omitted as default is 'web' 13 | entry: { 14 | index: "./react_client/index.jsx", 15 | }, 16 | output: { 17 | filename: "js/[name]_[contenthash]_bundle.js", // [entry name ('index')]_bundle.js 18 | path: path.resolve(process.cwd(), "build"), // webpack process should always be executed in the root project directory 19 | publicPath: "", 20 | clean: true, 21 | }, 22 | cache: { 23 | type: 'filesystem', 24 | profile: true, 25 | }, 26 | resolve: { 27 | extensions: [".jsx", "..."], 28 | // aiming to shorten so long module path names when importing these modules inside a << different type >> of module 29 | // so, all folders directly under "react_client" folder should collect modules with different types 30 | // eg: importing a React context module (at "./react_client/context/") into a React feature component module (at "./react_client/component/") 31 | // 32 | alias: { 33 | component: path.resolve(process.cwd(), "./react_client/component/"), 34 | context: path.resolve(process.cwd(), "./react_client/context/"), 35 | store: path.resolve(process.cwd(), "./react_client/store/"), 36 | util: path.resolve(process.cwd(), "./react_client/util/"), 37 | service: path.resolve(process.cwd(), "./react_client/service/"), 38 | resource: path.resolve(process.cwd(), "./react_client/resource/"), 39 | hook: path.resolve(process.cwd(), "./react_client/hook/"), 40 | constant: path.resolve(process.cwd(), "./react_client/constant/"), 41 | }, 42 | }, 43 | plugins: [ 44 | isEnvDevelopment && new ReactRefreshWebpackPlugin(), 45 | new HtmlWebpackPlugin({ 46 | title: "webrtc-group-chat-demo", 47 | template: path.resolve(process.cwd(), "./react_client/index.html"), 48 | filename: "index.html", 49 | }), 50 | new webpack.DefinePlugin({ 51 | // ... any other global vars 52 | env: JSON.stringify(process.env), 53 | }), 54 | env.analyzer && new BundleAnalyzerPlugin(), 55 | new webpack.DefinePlugin({ 56 | "process.env.NODE_ENV": isEnvDevelopment 57 | ? JSON.stringify("development") 58 | : JSON.stringify("production"), 59 | }), 60 | ].filter(Boolean), 61 | module: { 62 | rules: [ 63 | { 64 | test: /\.jsx?$/, 65 | exclude: /node_modules/, 66 | loader: "babel-loader", 67 | options: { 68 | plugins: [isEnvDevelopment && require.resolve("react-refresh/babel")].filter(Boolean), 69 | cacheDirectory: true, 70 | }, 71 | }, 72 | { 73 | // local styles should use modular css 74 | test: /\.css$/i, 75 | exclude: /node_modules|react_client\/index.css/, 76 | include: /react_client\/component/, 77 | use: [ 78 | { 79 | loader: "style-loader", 80 | }, 81 | { 82 | loader: "css-loader", 83 | options: { 84 | modules: { 85 | localIdentName: "[name]_[local]_[hash:base64]", 86 | }, 87 | }, 88 | }, 89 | ], 90 | }, 91 | { 92 | // global styles should not use modular css 93 | test: /\.css$/i, 94 | exclude: /node_modules/, 95 | include: /react_client\/index.css/, 96 | use: [ 97 | { 98 | loader: "style-loader", 99 | }, 100 | { 101 | loader: "css-loader", 102 | }, 103 | ], 104 | }, 105 | { 106 | test: /\.(png|svg|jpg|jpeg|gif)$/i, 107 | type: "asset/resource", 108 | include: /react_client\/resource/, 109 | generator: { 110 | filename: "images/[hash].[ext]", 111 | }, 112 | }, 113 | { 114 | test: /\.(woff|woff2|eot|ttf|otf)$/i, 115 | type: "asset/resource", 116 | generator: { 117 | filename: "fonts/[hash].[ext]", 118 | }, 119 | }, 120 | ], 121 | }, 122 | }; 123 | }; 124 | -------------------------------------------------------------------------------- /react_client/component/feature/navigation/NavigationBar.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useSelector } from "react-redux"; 3 | import styled from "styled-components"; 4 | 5 | import { selectAuthenticatedUserName } from "store/authSlice"; 6 | import GoBackNavigator from "./GoBackNavigator"; 7 | import NewRoomNavigator from "./NewRoomNavigator"; 8 | import SignoutNavigator from "./SignoutNavigator"; 9 | import * as localizableEnum from "constant/enum/localizable"; 10 | import LocalizationSwitch from "../localization/LocalizationSwitch"; 11 | import globalWhiteImageUrl from "resource/image/gobal_white_3x.png"; 12 | import { GlobalContext } from "context/global-context"; 13 | import MembershipRenderer from "../membership/MembershipRenderer"; 14 | import { selectHasJoinedRoom } from "store/roomSlice"; 15 | 16 | export default function NavigationBar() { 17 | const { localizedStrings } = React.useContext(GlobalContext); 18 | const authenticatedUserName = useSelector(selectAuthenticatedUserName); 19 | return ( 20 | 24 | ); 25 | } 26 | 27 | const MemorizedNavigationBar = React.memo(NavigationBarToMemo, arePropsEqual); 28 | 29 | function NavigationBarToMemo({ localizedStrings, authenticatedUserName }) { 30 | const hasJoinedRoom = useSelector(selectHasJoinedRoom); 31 | const leftContainerVisibility = !hasJoinedRoom ? "hidden" : "visible"; 32 | 33 | const welcomeUserMessage = `${ 34 | localizedStrings[localizableEnum.key.NAVIGATION_WELCOME] 35 | }, ${authenticatedUserName}`; 36 | 37 | return ( 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | {hasJoinedRoom && ( 47 | 48 | 49 | 50 | )} 51 | 52 | {welcomeUserMessage} 53 | 54 | 55 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | ); 66 | } 67 | 68 | const arePropsEqual = (prevProps, nextProps) => { 69 | const isLocalizedStringEqual = Object.is(prevProps.localizedStrings, nextProps.localizedStrings); 70 | const isAuthenticatedUserNameEqual = Object.is( 71 | prevProps.authenticatedUserName, 72 | nextProps.authenticatedUserName 73 | ); 74 | return isLocalizedStringEqual && isAuthenticatedUserNameEqual; 75 | }; 76 | 77 | const sharedStyleValues = { 78 | rightContainerInnerHorizontalMargin: 8, 79 | welcomeUserWrapperMarginRight: 25, 80 | }; 81 | 82 | const Wrapper = styled.div` 83 | width: 100%; 84 | height: 100%; 85 | background-color: rgb(36, 41, 47); 86 | display: flex; 87 | flex-direction: row; 88 | align-items: center; 89 | `; 90 | 91 | const LeftContainer = styled.div` 92 | flex: 0 0 49px; 93 | height: 100%; 94 | margin-right: 15px; 95 | visibility: ${(props) => props.visibility}; 96 | `; 97 | 98 | const MiddleContainer = styled.div` 99 | flex: 1 0 400px; 100 | height: 100%; 101 | margin-left: 15px; 102 | `; 103 | 104 | const RightContainer = styled.div` 105 | flex: 1 0 250px; 106 | height: 100%; 107 | 108 | display: flex; 109 | flex-direction: row; 110 | justify-content: end; 111 | align-items: center; 112 | `; 113 | 114 | const MembershipRendererContainer = styled.div` 115 | flex: 0 0 134px; 116 | height: 40px; 117 | margin-left: ${sharedStyleValues.rightContainerInnerHorizontalMargin}px; 118 | margin-right: ${sharedStyleValues.rightContainerInnerHorizontalMargin}px; 119 | box-sizing: border-box; 120 | `; 121 | 122 | const WelcomeUserWrapper = styled.div` 123 | flex: 0 0 content; 124 | text-align: end; 125 | color: rgb(255, 255, 255); 126 | margin-left: ${sharedStyleValues.rightContainerInnerHorizontalMargin}px; 127 | margin-right: ${sharedStyleValues.welcomeUserWrapperMarginRight}px; 128 | `; 129 | 130 | const LocalizationSwitchContainer = styled.div` 131 | flex: 0 0 80px; 132 | height: 35px; 133 | margin-left: ${sharedStyleValues.rightContainerInnerHorizontalMargin}px; 134 | margin-right: ${sharedStyleValues.rightContainerInnerHorizontalMargin}px; 135 | box-sizing: border-box; 136 | `; 137 | 138 | const SignoutNavigatorContainer = styled.div` 139 | flex: 0 0 80px; 140 | box-sizing: border-box; 141 | height: 34px; 142 | 143 | margin-left: ${sharedStyleValues.rightContainerInnerHorizontalMargin}px; 144 | margin-right: ${sharedStyleValues.rightContainerInnerHorizontalMargin * 2}px; 145 | `; 146 | -------------------------------------------------------------------------------- /react_client/constant/string/localizable-strings.js: -------------------------------------------------------------------------------- 1 | import * as localizableEnum from "../enum/localizable"; 2 | 3 | export const localizableStrings = Object.freeze({ 4 | [localizableEnum.type.ENGLISH]: { 5 | /** 6 | * Localization 7 | */ 8 | [localizableEnum.key.LOCALIZATION_SELECTED_TEXT_KEY]: "Language", 9 | [localizableEnum.key.LOCALIZATION_SELECTED_TEXT_VALUE]: "EN", 10 | [localizableEnum.key.LOCALIZATION_ENGLISH_ITEM_TEXT]: "English", 11 | [localizableEnum.key.LOCALIZATION_CHINESE_ITEM_TEXT]: "中文", 12 | /** 13 | * Sign in 14 | */ 15 | [localizableEnum.key.SIGN_IN_TITLE]: "WebRTC Group Chat", 16 | [localizableEnum.key.SIGN_IN_TITLE_DESC]: "Make P2P Group Chatting possible", 17 | [localizableEnum.key.SIGN_IN_INPUT_PLACEHOLDER]: "Enter your username ...", 18 | [localizableEnum.key.SIGN_IN_COMFIRM]: "Sign in", 19 | /** 20 | * Room list 21 | */ 22 | [localizableEnum.key.ROOM_LIST_CREATE_NEW_ROOM_TITLE]: "Create New Room", 23 | [localizableEnum.key.ROOM_LIST_CREATE_NEW_ROOM_INPUT_PLACEHOLDER]: 24 | "Enter your new room name ...", 25 | [localizableEnum.key.ROOM_LIST_CREATE_NEW_ROOM_COMFIRM]: "Confirm", 26 | [localizableEnum.key.ROOM_LIST_JOIN_ROOM]: "Join", 27 | /** 28 | * Navigation 29 | */ 30 | [localizableEnum.key.NAVIGATION_ROOM_LIST_TITLE]: "Avaliable Chat Rooms", 31 | [localizableEnum.key.NAVIGATION_CREATE_NEW_ROOM]: "New", 32 | [localizableEnum.key.NAVIGATION_WELCOME]: "Hi", 33 | [localizableEnum.key.NAVIGATION_SIGN_OUT]: "Sign out", 34 | /** 35 | * Chat room 36 | */ 37 | [localizableEnum.key.CHAT_ROOM_MEDIA_CONSTRAINT_CAMERA]: "Camera", 38 | [localizableEnum.key.CHAT_ROOM_MEDIA_CONSTRAINT_SCREEN]: "Screen", 39 | [localizableEnum.key.CHAT_ROOM_MESSAGE_TYPE_TEXT]: "Text Message", 40 | [localizableEnum.key.CHAT_ROOM_MESSAGE_TYPE_FILE]: "File Message", 41 | [localizableEnum.key.CHAT_ROOM_MESSAGE_TEXT_INPUT_PLACEHOLDER]: "Write text here ...", 42 | [localizableEnum.key.CHAT_ROOM_MESSAGE_FILE_INPUT_PLACEHOLDER_IDLE]: 43 | "Click here to add files", 44 | [localizableEnum.key.CHAT_ROOM_MESSAGE_FILE_INPUT_PLACEHOLDER_FILE]: "file", 45 | [localizableEnum.key.CHAT_ROOM_MESSAGE_FILE_INPUT_PLACEHOLDER_FILE_PLURAL]: "s", 46 | [localizableEnum.key.CHAT_ROOM_MESSAGE_FILE_INPUT_PLACEHOLDER_ADDED]: "added", 47 | /** 48 | * Time description 49 | */ 50 | [localizableEnum.key.YEARS_AGO]: "years ago", 51 | [localizableEnum.key.MONTHS_AGO]: "months ago", 52 | [localizableEnum.key.DAYS_AGO]: "days ago", 53 | [localizableEnum.key.HOURS_AGO]: "hours ago", 54 | [localizableEnum.key.MINUTES_AGO]: "minutes ago", 55 | [localizableEnum.key.SECONDS_AGO]: "seconds ago", 56 | }, 57 | [localizableEnum.type.CHINESE]: { 58 | /** 59 | * 本地化 60 | */ 61 | [localizableEnum.key.LOCALIZATION_SELECTED_TEXT_KEY]: "语言", 62 | [localizableEnum.key.LOCALIZATION_SELECTED_TEXT_VALUE]: "中文", 63 | [localizableEnum.key.LOCALIZATION_ENGLISH_ITEM_TEXT]: "English", 64 | [localizableEnum.key.LOCALIZATION_CHINESE_ITEM_TEXT]: "中文", 65 | /** 66 | * 登陆 67 | */ 68 | [localizableEnum.key.SIGN_IN_TITLE]: "WebRTC聊天室", 69 | [localizableEnum.key.SIGN_IN_TITLE_DESC]: "让去中心化群聊成为可能", 70 | [localizableEnum.key.SIGN_IN_INPUT_PLACEHOLDER]: "请输入用户名 ...", 71 | [localizableEnum.key.SIGN_IN_COMFIRM]: "登陆", 72 | /** 73 | * 房间列表 74 | */ 75 | [localizableEnum.key.ROOM_LIST_CREATE_NEW_ROOM_TITLE]: "新建房间", 76 | [localizableEnum.key.ROOM_LIST_CREATE_NEW_ROOM_INPUT_PLACEHOLDER]: "请输入房间名 ...", 77 | [localizableEnum.key.ROOM_LIST_CREATE_NEW_ROOM_COMFIRM]: "确定", 78 | [localizableEnum.key.ROOM_LIST_JOIN_ROOM]: "加入", 79 | /** 80 | * 导航栏 81 | */ 82 | [localizableEnum.key.NAVIGATION_ROOM_LIST_TITLE]: "房间列表", 83 | [localizableEnum.key.NAVIGATION_CREATE_NEW_ROOM]: "新建", 84 | [localizableEnum.key.NAVIGATION_WELCOME]: "你好", 85 | [localizableEnum.key.NAVIGATION_SIGN_OUT]: "注销", 86 | /** 87 | * 聊天室 88 | */ 89 | [localizableEnum.key.CHAT_ROOM_MEDIA_CONSTRAINT_CAMERA]: "摄像头", 90 | [localizableEnum.key.CHAT_ROOM_MEDIA_CONSTRAINT_SCREEN]: "屏幕录制", 91 | [localizableEnum.key.CHAT_ROOM_MESSAGE_TYPE_TEXT]: "文字消息", 92 | [localizableEnum.key.CHAT_ROOM_MESSAGE_TYPE_FILE]: "文件消息", 93 | [localizableEnum.key.CHAT_ROOM_MESSAGE_TEXT_INPUT_PLACEHOLDER]: "请输入文字消息 ...", 94 | [localizableEnum.key.CHAT_ROOM_MESSAGE_FILE_INPUT_PLACEHOLDER_IDLE]: "点击此处添加文件", 95 | [localizableEnum.key.CHAT_ROOM_MESSAGE_FILE_INPUT_PLACEHOLDER_FILE]: "个文件", 96 | [localizableEnum.key.CHAT_ROOM_MESSAGE_FILE_INPUT_PLACEHOLDER_FILE_PLURAL]: "", 97 | [localizableEnum.key.CHAT_ROOM_MESSAGE_FILE_INPUT_PLACEHOLDER_ADDED]: "", 98 | /** 99 | * 时间描述 100 | */ 101 | [localizableEnum.key.YEARS_AGO]: "年前", 102 | [localizableEnum.key.MONTHS_AGO]: "月前", 103 | [localizableEnum.key.DAYS_AGO]: "天前", 104 | [localizableEnum.key.HOURS_AGO]: "小时前", 105 | [localizableEnum.key.MINUTES_AGO]: "分钟前", 106 | [localizableEnum.key.SECONDS_AGO]: "秒前", 107 | }, 108 | }); 109 | -------------------------------------------------------------------------------- /react_client/component/feature/chat/message/TextMessage.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import styled from "styled-components"; 3 | 4 | import { timeSince } from "util/time-since"; 5 | 6 | export const textMessagePropsBuilder = (isParentVisible, messageItem, localizedStrings) => { 7 | const defaultMessage = { 8 | id: "unknown id", 9 | userId: "unknown user id", 10 | userName: "unknown user name", 11 | time: "Mar 18th, 1993", 12 | isLocalSender: false, 13 | content: "unknown content", 14 | 15 | titleFlexJustifyContent: "start", 16 | userNameVisibility: isParentVisible ? "visible" : "hidden", 17 | contentTopLeftBorderRadius: 10, 18 | contentTopRightBorderRadius: 10, 19 | alignTextToRight: true, 20 | }; 21 | if (!messageItem) { 22 | return defaultMessage; 23 | } 24 | if (typeof messageItem.id === "string" && messageItem.id.length > 0) { 25 | defaultMessage.id = messageItem.id; 26 | } 27 | if (typeof messageItem.userId === "string" && messageItem.userId.length > 0) { 28 | defaultMessage.userId = messageItem.userId; 29 | } 30 | if (typeof messageItem.userName === "string" && messageItem.userName.length > 0) { 31 | defaultMessage.userName = messageItem.userName; 32 | } 33 | if (typeof messageItem.timestamp === "number") { 34 | defaultMessage.time = timeSince(messageItem.timestamp, localizedStrings); 35 | } 36 | if (typeof messageItem.isLocalSender === "boolean") { 37 | defaultMessage.userNameVisibility = messageItem.isLocalSender 38 | ? "hidden" 39 | : isParentVisible 40 | ? "visible" 41 | : "hidden"; 42 | defaultMessage.contentTopLeftBorderRadius = messageItem.isLocalSender 43 | ? sharedStyleValues.noneSenderSideContentBorderRadius 44 | : sharedStyleValues.senderSideContentBorderRadius; 45 | defaultMessage.contentTopRightBorderRadius = messageItem.isLocalSender 46 | ? sharedStyleValues.senderSideContentBorderRadius 47 | : sharedStyleValues.noneSenderSideContentBorderRadius; 48 | defaultMessage.alignTextToRight = messageItem.isLocalSender; 49 | } 50 | if (typeof messageItem.text === "string") { 51 | defaultMessage.content = messageItem.text; 52 | } 53 | return defaultMessage; 54 | }; 55 | 56 | function TextMessage({ 57 | id, 58 | userId, 59 | userName, 60 | time, 61 | userNameVisibility, 62 | contentTopLeftBorderRadius, 63 | contentTopRightBorderRadius, 64 | alignTextToRight, 65 | content, 66 | }, ref) { 67 | return ( 68 | 69 | 70 | {userName} 71 | {time} 72 | 73 | 74 | 79 | {content} 80 | 81 | 82 | 83 | ); 84 | } 85 | 86 | const sharedStyleValues = { 87 | contentHorizontalMargin: 10, 88 | senderSideContentBorderRadius: 0, 89 | noneSenderSideContentBorderRadius: 10, 90 | }; 91 | 92 | const Wrapper = styled.div` 93 | box-sizing: border-box; 94 | padding-left: 8px; 95 | padding-right: 8px; 96 | padding-top: 10px; 97 | padding-bottom: 10px; 98 | `; 99 | 100 | const TitleWrapper = styled.div` 101 | height: 20px; 102 | display: flex; 103 | flex-direction: row; 104 | justify-content: space-between; 105 | align-items: end; 106 | `; 107 | 108 | const UserNameWrapper = styled.div` 109 | visibility: ${(props) => props.visibility}; 110 | font-size: 14px; 111 | color: rgb(0, 150, 136); 112 | margin-left: 5px; 113 | margin-right: 15px; 114 | line-height: 1; 115 | `; 116 | 117 | const TimestampWrapper = styled.div` 118 | font-size: 12px; 119 | color: rgba(128, 128, 128, 0.5); 120 | line-height: 1; 121 | margin-right: 15px; 122 | `; 123 | 124 | const ContentWrapper = styled.div` 125 | box-sizing: border-box; 126 | max-width: calc(100% - ${2 * sharedStyleValues.contentHorizontalMargin}px); 127 | margin-top: 10px; 128 | margin-left: ${sharedStyleValues.contentHorizontalMargin}px; 129 | margin-right: ${sharedStyleValues.contentHorizontalMargin}px; 130 | word-break: break-word; 131 | `; 132 | 133 | const TextContentWrapper = styled.div` 134 | display: inline-block; 135 | ${(props) => 136 | props.alignTextToRight && "position: relative; left: 100%; transform: translateX(-100%);"} 137 | padding: 8px; 138 | border-color: transparent; 139 | background-color: rgb(255, 255, 255); 140 | color: rgb(128, 128, 128); 141 | border-radius: ${(props) => props.topLeftBorderRadius}px 142 | ${(props) => props.topRightBorderRadius}px 143 | ${sharedStyleValues.noneSenderSideContentBorderRadius}px 144 | ${sharedStyleValues.noneSenderSideContentBorderRadius}px; 145 | font-size: 10px; 146 | line-height: 1.5; 147 | box-sizing: border-box; 148 | `; 149 | 150 | export default React.forwardRef(TextMessage); 151 | -------------------------------------------------------------------------------- /react_client/component/generic/checkbox/CheckBox.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import styled from "styled-components"; 3 | 4 | import checkMarkUrl from "resource/image/check_mark_3x.png"; 5 | 6 | export const checkBoxPropsBuilder = ({ 7 | initialEnabled, 8 | initalChecked, 9 | 10 | boxBorderRadius, 11 | boxBorderWidth, 12 | boxBorderColor, 13 | boxBackgroundColor, 14 | onBoxClick, 15 | 16 | boxCheckMarkBackgroundColor, 17 | boxCheckMarkColor, 18 | boxCheckMarkImageUrl, 19 | boxCheckMarkImageSize, 20 | 21 | boxCheckMarkSizePercentage, 22 | }) => { 23 | const enabled = typeof initialEnabled === "boolean" ? initialEnabled : true; 24 | const checked = typeof initalChecked === "boolean" ? initalChecked : true; 25 | 26 | const borderRadius = typeof boxBorderRadius === "number" ? boxBorderRadius : 5; 27 | const borderWidth = typeof boxBorderWidth === "number" ? boxBorderWidth : 1; 28 | 29 | let borderColor = "rgba(196, 196, 196, 0.5)"; 30 | let backgroundColor = "rgb(255, 255, 255)"; 31 | if (enabled) { 32 | borderColor = typeof boxBorderColor === "string" ? boxBorderColor : "rgb(33, 150, 243)"; 33 | backgroundColor = 34 | typeof boxBackgroundColor === "string" ? boxBackgroundColor : "rgb(255, 255, 255)"; 35 | } 36 | 37 | const onClick = typeof onBoxClick === "function" ? onBoxClick : null; 38 | 39 | let checkMarkBackgroundColor = "rgba(196, 196, 196, 0.5)"; 40 | let checkMarkColor = "rgb(255, 255, 255)"; 41 | if (enabled) { 42 | checkMarkBackgroundColor = 43 | typeof boxCheckMarkBackgroundColor === "string" 44 | ? boxCheckMarkBackgroundColor 45 | : "rgb(33, 150, 243)"; 46 | checkMarkColor = 47 | typeof boxCheckMarkColor === "string" ? boxCheckMarkColor : "rgb(255, 255, 255)"; 48 | } 49 | 50 | const checkMarkImageUrl = 51 | typeof boxCheckMarkImageUrl === "string" ? boxCheckMarkImageUrl : checkMarkUrl; 52 | 53 | const checkMarkImageSize = 54 | typeof boxCheckMarkImageSize === "string" ? boxCheckMarkImageSize : "contain"; 55 | 56 | const markSizePercentage = 57 | typeof boxCheckMarkSizePercentage === "number" && 58 | boxCheckMarkSizePercentage < 1 && 59 | boxCheckMarkSizePercentage > 0 60 | ? boxCheckMarkSizePercentage 61 | : 15 / 20; 62 | 63 | return { 64 | initialEnabled: enabled, 65 | initalChecked: checked, 66 | 67 | boxBorderRadius: borderRadius, 68 | boxBorderWidth: borderWidth, 69 | boxBorderColor: borderColor, 70 | boxBackgroundColor: backgroundColor, 71 | onBoxClick: onClick, 72 | 73 | boxCheckMarkBackgroundColor: checkMarkBackgroundColor, 74 | boxCheckMarkColor: checkMarkColor, 75 | boxCheckMarkImageUrl: checkMarkImageUrl, 76 | boxCheckMarkImageSize: checkMarkImageSize, 77 | 78 | boxCheckMarkSizePercentage: markSizePercentage, 79 | }; 80 | }; 81 | 82 | export default function CheckBox({ 83 | initialEnabled, 84 | initalChecked, 85 | 86 | boxBorderRadius, 87 | boxBorderWidth, 88 | boxBorderColor, 89 | boxBackgroundColor, 90 | onBoxClick, 91 | 92 | boxCheckMarkBackgroundColor, 93 | boxCheckMarkColor, 94 | boxCheckMarkImageUrl, 95 | boxCheckMarkImageSize, 96 | 97 | boxCheckMarkSizePercentage, 98 | }) { 99 | const [checked, setChecked] = React.useState(initalChecked); 100 | 101 | const handleBoxClick = () => { 102 | if (!initialEnabled) { 103 | return; 104 | } 105 | if (onBoxClick) { 106 | onBoxClick(!checked); 107 | } 108 | setChecked(!checked); 109 | }; 110 | 111 | const checkMarkVisibility = checked ? "visible" : "hidden"; 112 | 113 | return ( 114 | 123 | 134 | 135 | ); 136 | } 137 | 138 | const Wrapper = styled.div` 139 | box-sizing: border-box; 140 | width: 100%; 141 | height: 100%; 142 | border: ${(props) => props.borderWidth}px solid ${(props) => props.borderColor}; 143 | border-radius: ${(props) => props.borderRadius}px; 144 | background-color: ${(props) => props.backgroundColor}; 145 | &:hover, 146 | &:active { 147 | opacity: ${(props) => (props.enabled ? 0.5 : 1)}; 148 | } 149 | `; 150 | 151 | const CheckMarkWrapper = styled.div` 152 | visibility: ${(props) => props.visiblility}; 153 | width: 100%; 154 | height: 100%; 155 | background-color: ${(props) => props.checkMarkBackgroundColor}; 156 | background-image: url(${(props) => props.checkMarkImageUrl}); 157 | background-position: center; 158 | background-repeat: no-repeat; 159 | background-size: ${(props) => props.checkMarkImageSize}; 160 | `; 161 | -------------------------------------------------------------------------------- /react_client/component/feature/chat/media/MediaVideoController.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import styled from "styled-components"; 3 | 4 | import { GlobalContext } from "context/global-context"; 5 | import MediaVideoVolumeController from "./MediaVideoVolumeController"; 6 | import updatePresenterImageUrl from "resource/image/present_video_3x.png"; 7 | import cancelImageUrl from "resource/image/cancel_media_presenting_3x.png"; 8 | 9 | export default function MediaVideoController(props) { 10 | const userId = props.userId; 11 | 12 | const isAudioControlAvaliable = props.isAudioControlAvaliable; 13 | const audioProcessor = props.audioProcessor; 14 | 15 | const forceVideoUnpresentable = props.forceVideoUnpresentable; 16 | const isVideoSourceAvaliable = props.isVideoSourceAvaliable; 17 | const isVideoCancellable = props.isVideoCancellable; 18 | 19 | const { updatePresenterId } = React.useContext(GlobalContext); 20 | 21 | return ( 22 | 34 | ); 35 | } 36 | 37 | const MemorizedMediaVideoController = React.memo(MediaVideoControllerToMemo, arePropsEqual); 38 | 39 | function MediaVideoControllerToMemo(props) { 40 | const userId = props.userId; 41 | 42 | const isAudioControlAvaliable = props.isAudioControlAvaliable; 43 | const audioProcessor = props.audioProcessor; 44 | 45 | const forceVideoUnpresentable = props.forceVideoUnpresentable; 46 | const isVideoSourceAvaliable = props.isVideoSourceAvaliable; 47 | const isVideoCancellable = props.isVideoCancellable; 48 | 49 | const updatePresenterId = props.updatePresenterId; 50 | 51 | const isVideoPresentable = forceVideoUnpresentable ? false : isVideoSourceAvaliable; 52 | 53 | const handleUpdatePresenterClick = () => { 54 | updatePresenterId(userId); 55 | }; 56 | const handleCancelClick = () => { 57 | updatePresenterId(undefined); 58 | }; 59 | 60 | return ( 61 | 62 | 66 | 70 | 71 | {isAudioControlAvaliable && ( 72 | 73 | )} 74 | 75 | 76 | ); 77 | } 78 | 79 | const arePropsEqual = (prevProps, nextProps) => { 80 | const isUserIdEqual = Object.is(prevProps.userId, nextProps.userId); 81 | 82 | const isIsAudioControlAvaliableEqual = Object.is( 83 | prevProps.isAudioControlAvaliable, 84 | nextProps.isAudioControlAvaliable 85 | ); 86 | const isAudioProcessorEqual = Object.is(prevProps.audioProcessor, nextProps.audioProcessor); 87 | 88 | const isForceVideoUnpresentableEqual = Object.is( 89 | prevProps.forceVideoUnpresentable, 90 | nextProps.forceVideoUnpresentable 91 | ); 92 | const isIsVideoSourceAvaliableEqual = Object.is(prevProps.isVideoSourceAvaliable, nextProps.isVideoSourceAvaliable); 93 | const isIsVideoCancellableEqual = Object.is( 94 | prevProps.isVideoCancellable, 95 | nextProps.isVideoCancellable 96 | ); 97 | 98 | const isUpdatePresenterIdEqual = Object.is( 99 | prevProps.updatePresenterId, 100 | nextProps.updatePresenterId 101 | ); 102 | 103 | return ( 104 | isUserIdEqual && 105 | isIsAudioControlAvaliableEqual && 106 | isAudioProcessorEqual && 107 | isForceVideoUnpresentableEqual && 108 | isIsVideoSourceAvaliableEqual && 109 | isIsVideoCancellableEqual && 110 | isUpdatePresenterIdEqual 111 | ); 112 | }; 113 | 114 | const Wrapper = styled.div` 115 | width: 100%; 116 | height: 100%; 117 | background-color: rgba(0, 0, 0, 0.5); 118 | display: flex; 119 | align-items: center; 120 | justify-content: center; 121 | `; 122 | 123 | const UpdatePresenterButton = styled.button` 124 | width: 50%; 125 | height: 40%; 126 | visibility: ${(props) => props.visibility}; 127 | background-image: url(${updatePresenterImageUrl}); 128 | background-position: center; 129 | background-repeat: no-repeat; 130 | background-size: contain; 131 | background-color: transparent; 132 | border-color: transparent; 133 | 134 | opacity: 0.4; 135 | 136 | &:hover { 137 | opacity: 1; 138 | } 139 | `; 140 | 141 | const CancelButton = styled.button` 142 | visibility: ${(props) => props.visibility}; 143 | position: absolute; 144 | top: 5px; 145 | right: 5px; 146 | width: 40px; 147 | height: 40px; 148 | border-color: transparent; 149 | border-radius: 20px; 150 | background-color: rgba(0, 0, 0, 0.7); 151 | background-image: url(${cancelImageUrl}); 152 | background-position: center; 153 | background-repeat: no-repeat; 154 | background-size: contain; 155 | 156 | opacity: 0.4; 157 | 158 | &:hover { 159 | opacity: 1; 160 | } 161 | `; 162 | 163 | const MediaVideoVolumeControllerContainer = styled.div` 164 | position: absolute; 165 | bottom: 5%; 166 | right: 10%; 167 | width: 80%; 168 | height: 28px; 169 | `; 170 | -------------------------------------------------------------------------------- /react_client/context/media-rendering-context.js: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useSelector } from "react-redux"; 3 | import GroupChatService from "webrtc-group-chat-client"; 4 | 5 | import { selectAuthenticatedUserId, selectAuthenticatedUserName } from "store/authSlice"; 6 | import * as mediaChatEnum from "constant/enum/media-chat"; 7 | 8 | const MediaRenderingContext = React.createContext(); 9 | MediaRenderingContext.displayName = "MediaRenderingContext"; 10 | 11 | const numberOfInitialVisibleMediaMembers = 4; 12 | 13 | function MediaRenderingContextProvider({ children }) { 14 | const authenticatedUserId = useSelector(selectAuthenticatedUserId); 15 | const authenticatedUserName = useSelector(selectAuthenticatedUserName); 16 | const [localMediaContext, setLocalMediaContext] = React.useState(); 17 | const [peerMediaContextMap, setPeerMediaContextMap] = React.useState(); 18 | const [mediaAccessibilityType, setMediaAccessibilityType] = React.useState( 19 | mediaChatEnum.mediaAccessibilityType.MEDIA_ACCESSIBILITY_TYPE_PRESENTATION 20 | ); 21 | const [presenterId, setPresenterId] = React.useState(); 22 | 23 | const mediaAccessibilityTypeRef = React.useRef(mediaAccessibilityType); 24 | mediaAccessibilityTypeRef.current = mediaAccessibilityType; 25 | 26 | React.useEffect(() => { 27 | GroupChatService.onLocalMediaContextChanged((localMediaContext) => { 28 | setLocalMediaContext(localMediaContext); 29 | }); 30 | GroupChatService.onPeerMediaContextMapChanged((peerMediaContextMap) => { 31 | console.debug( 32 | `onPeerMediaContextMapChanged called with peer stream map size ${ 33 | peerMediaContextMap ? peerMediaContextMap.map.size : "unknown" 34 | }` 35 | ); 36 | setPeerMediaContextMap(peerMediaContextMap); 37 | }); 38 | }, []); 39 | 40 | // config media rendering data source list 41 | 42 | let localVideoStream; 43 | if (localMediaContext && localMediaContext.videoTrack) { 44 | localVideoStream = new MediaStream([localMediaContext.videoTrack]); 45 | } 46 | 47 | let localAudioProcessor; 48 | if (localMediaContext && localMediaContext.audioProcessor) { 49 | localAudioProcessor = localMediaContext.audioProcessor; 50 | } 51 | 52 | const mediaRenderingDataSourceList = [ 53 | { 54 | userId: authenticatedUserId, 55 | userName: authenticatedUserName, 56 | isAudioSourceAvaliable: localMediaContext && localMediaContext.audioTrack ? true : false, 57 | audioProcessor: localAudioProcessor, 58 | videoStream: localVideoStream, 59 | }, 60 | ]; 61 | 62 | if (peerMediaContextMap && peerMediaContextMap.map.size > 0) { 63 | Array.from(peerMediaContextMap.map.entries()).forEach(([peerId, peerMediaContext]) => { 64 | const peerName = GroupChatService.getPeerNameById(peerId); 65 | if (typeof peerName === undefined) { 66 | return; 67 | } 68 | let videoStream; 69 | if (peerMediaContext.videoTrack) { 70 | videoStream = new MediaStream([peerMediaContext.videoTrack]); 71 | } 72 | mediaRenderingDataSourceList.push({ 73 | userId: peerId, 74 | userName: peerName, 75 | isAudioSourceAvaliable: peerMediaContext && peerMediaContext.audioTrack ? true : false, 76 | audioProcessor: peerMediaContext.audioProcessor, 77 | videoStream: videoStream, 78 | }); 79 | }); 80 | } 81 | 82 | if (mediaRenderingDataSourceList.length < numberOfInitialVisibleMediaMembers) { 83 | const numberOfRestVisibleMediaMembers = 84 | numberOfInitialVisibleMediaMembers - mediaRenderingDataSourceList.length; 85 | for (let index = 0; index < numberOfRestVisibleMediaMembers; index++) { 86 | const emptyMediaRenderingDataSource = {}; 87 | mediaRenderingDataSourceList.push(emptyMediaRenderingDataSource); 88 | } 89 | } 90 | 91 | // config presenter's media rendering data source 92 | 93 | let mediaRenderingDataSourceForPresenter; 94 | if (typeof presenterId === "string" && presenterId.length > 0) { 95 | mediaRenderingDataSourceForPresenter = mediaRenderingDataSourceList.find( 96 | (mediaRenderingDataSource) => mediaRenderingDataSource.userId === presenterId 97 | ); 98 | } 99 | 100 | const updatePresenterId = (presenterId) => { 101 | if ( 102 | mediaAccessibilityType === 103 | mediaChatEnum.mediaAccessibilityType.MEDIA_ACCESSIBILITY_TYPE_PRESENTATION 104 | ) { 105 | setPresenterId(presenterId); 106 | } 107 | }; 108 | 109 | const updateMediaAccessibilityType = (toMediaAccessibilityType) => { 110 | if (mediaAccessibilityTypeRef.current !== toMediaAccessibilityType) { 111 | setMediaAccessibilityType(toMediaAccessibilityType); 112 | } 113 | }; 114 | 115 | const resetMediaRenderingContext = () => { 116 | setLocalMediaContext(null); 117 | setPeerMediaContextMap(null); 118 | setMediaAccessibilityType( 119 | mediaChatEnum.mediaAccessibilityType.MEDIA_ACCESSIBILITY_TYPE_PRESENTATION 120 | ); 121 | setPresenterId(undefined); 122 | }; 123 | 124 | const contextValue = { 125 | numberOfInitialVisibleMediaMembers: numberOfInitialVisibleMediaMembers, 126 | mediaRenderingDataSourceList, 127 | mediaRenderingDataSourceForPresenter: { ...mediaRenderingDataSourceForPresenter }, 128 | updatePresenterId, 129 | mediaAccessibilityType, 130 | updateMediaAccessibilityType, 131 | 132 | resetMediaRenderingContext, 133 | }; 134 | return ( 135 | {children} 136 | ); 137 | } 138 | 139 | export { MediaRenderingContextProvider, MediaRenderingContext }; 140 | -------------------------------------------------------------------------------- /react_client/component/feature/chat/message/MessageTypeSwitch.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import styled from "styled-components"; 3 | import { useSelector } from "react-redux"; 4 | 5 | import * as messageChatEnum from "constant/enum/message-chat"; 6 | import * as localizableEnum from "constant/enum/localizable"; 7 | import MultiTabSwitch, { 8 | multiTabSwitchPropsBuilder, 9 | multiTabSwitchTabBuilder, 10 | } from "../../../generic/switch/MultiTabSwitch"; 11 | import badgeBackgroundImageUrl from "resource/image/badge_3x.png"; 12 | import { GlobalContext } from "context/global-context"; 13 | import { selectUnreadTextMessageCount } from "store/textChatSlice"; 14 | 15 | export const MessageTypeSwitchPropsBuilder = ({}) => { 16 | return {}; 17 | }; 18 | 19 | export default function MessageTypeSwitch({}) { 20 | const { 21 | localizedStrings, 22 | visibleMessageType, 23 | updateVisibleMessageType, 24 | unreadFileMessageCount, 25 | } = React.useContext(GlobalContext); 26 | const unreadTextMessageCount = useSelector(selectUnreadTextMessageCount); 27 | 28 | let textMessageTabSelected = true; 29 | let fileMessageTabSelected = false; 30 | if (visibleMessageType === messageChatEnum.type.MESSAGE_TYPE_TEXT) { 31 | textMessageTabSelected = true; 32 | fileMessageTabSelected = false; 33 | } else if (visibleMessageType === messageChatEnum.type.MESSAGE_TYPE_FILE) { 34 | textMessageTabSelected = false; 35 | fileMessageTabSelected = true; 36 | } 37 | 38 | const textMessageTabBadgeText = unreadTextMessageCount > 0 ? `${unreadTextMessageCount}` : ""; 39 | const fileMessageTabBadgeText = unreadFileMessageCount > 0 ? `${unreadFileMessageCount}` : ""; 40 | 41 | return ( 42 | 50 | ); 51 | } 52 | 53 | const MemorizedMessageTypeSwitch = React.memo(MessageTypeSwitchToMemo, arePropsEqual); 54 | 55 | function MessageTypeSwitchToMemo({ 56 | localizedStrings, 57 | updateVisibleMessageType, 58 | textMessageTabSelected, 59 | textMessageTabBadgeText, 60 | fileMessageTabSelected, 61 | fileMessageTabBadgeText, 62 | }) { 63 | const textMessageTab = multiTabSwitchTabBuilder({ 64 | switchTabName: localizedStrings[localizableEnum.key.CHAT_ROOM_MESSAGE_TYPE_TEXT], 65 | switchTabBorderRadius: 5, 66 | switchTabBadgeText: textMessageTabBadgeText, 67 | switchTabBadgeBackgroundImageUrl: badgeBackgroundImageUrl, 68 | switchTabOnClick: () => { 69 | updateVisibleMessageType(messageChatEnum.type.MESSAGE_TYPE_TEXT); 70 | }, 71 | switchTabSelected: textMessageTabSelected, 72 | }); 73 | const fileMessageTab = multiTabSwitchTabBuilder({ 74 | switchTabName: localizedStrings[localizableEnum.key.CHAT_ROOM_MESSAGE_TYPE_FILE], 75 | switchTabBorderRadius: 5, 76 | switchTabBadgeText: fileMessageTabBadgeText, 77 | switchTabBadgeBackgroundImageUrl: badgeBackgroundImageUrl, 78 | switchTabOnClick: () => { 79 | updateVisibleMessageType(messageChatEnum.type.MESSAGE_TYPE_FILE); 80 | }, 81 | switchTabSelected: fileMessageTabSelected, 82 | }); 83 | 84 | return ( 85 | 86 | 87 | 100 | 101 | 102 | ); 103 | } 104 | 105 | const arePropsEqual = (prevProps, nextProps) => { 106 | const isLocalizedStringEqual = Object.is(prevProps.localizedStrings, nextProps.localizedStrings); 107 | const isUpdateVisibleMessageTypeEqual = Object.is( 108 | prevProps.updateVisibleMessageType, 109 | nextProps.updateVisibleMessageType 110 | ); 111 | const isTextMessageTabSelectedEqual = Object.is( 112 | prevProps.textMessageTabSelected, 113 | nextProps.textMessageTabSelected 114 | ); 115 | const isTextMessageTabBadgeTextEqual = Object.is( 116 | prevProps.textMessageTabBadgeText, 117 | nextProps.textMessageTabBadgeText 118 | ); 119 | const isFileMessageTabSelectedEqual = Object.is( 120 | prevProps.fileMessageTabSelected, 121 | nextProps.fileMessageTabSelected 122 | ); 123 | const isFileMessageTabBadgeTextEqual = Object.is( 124 | prevProps.fileMessageTabBadgeText, 125 | nextProps.fileMessageTabBadgeText 126 | ); 127 | return ( 128 | isLocalizedStringEqual && 129 | isUpdateVisibleMessageTypeEqual && 130 | isTextMessageTabSelectedEqual && 131 | isTextMessageTabBadgeTextEqual && 132 | isFileMessageTabSelectedEqual && 133 | isFileMessageTabBadgeTextEqual 134 | ); 135 | }; 136 | 137 | const sharedStyleValues = { 138 | switchPaddingTop: 10, 139 | switchPaddingBottom: 10, 140 | }; 141 | 142 | const Wrapper = styled.div` 143 | width: 100%; 144 | height: 100%; 145 | `; 146 | 147 | const SwitchContainer = styled.div` 148 | width: 100%; 149 | height: calc( 150 | 100% - ${sharedStyleValues.switchPaddingTop}px - ${sharedStyleValues.switchPaddingBottom}px 151 | ); 152 | padding-top: ${sharedStyleValues.switchPaddingTop}px; 153 | padding-bottom: ${sharedStyleValues.switchPaddingBottom}px; 154 | `; -------------------------------------------------------------------------------- /react_client/component/feature/chat/message/MessageBox.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import styled from "styled-components"; 3 | import { useDispatch, useSelector } from "react-redux"; 4 | 5 | import TextMessage, { textMessagePropsBuilder } from "./TextMessage"; 6 | import FileMessage, { fileMessagePropsBuilder } from "./FileMessage"; 7 | import * as messageChatEnum from "constant/enum/message-chat"; 8 | import { GlobalContext } from "context/global-context"; 9 | import { readAllTextMessages, selectUnreadTextMessageCount } from "store/textChatSlice"; 10 | 11 | const autoScrollingThredhold = 400; 12 | const autoScrollToBottomIfNecessary = (scrollableContainer, autoScrollingThreshold) => { 13 | if ( 14 | !(scrollableContainer instanceof HTMLElement) || 15 | typeof autoScrollingThreshold !== "number" || 16 | autoScrollingThreshold < 0 17 | ) { 18 | return; 19 | } 20 | const scrollDiff = 21 | scrollableContainer.scrollHeight - 22 | scrollableContainer.scrollTop - 23 | scrollableContainer.clientHeight; 24 | const isNecessary = scrollDiff < autoScrollingThreshold; 25 | if (isNecessary) { 26 | scrollableContainer.scrollTop = scrollableContainer.scrollHeight; 27 | } 28 | }; 29 | 30 | export default function MessageBox({}) { 31 | const { 32 | localizedStrings, 33 | visibleMessageType, 34 | orderedTextMessageList, 35 | orderedFileMessageList, 36 | readAllFileMessage, 37 | } = React.useContext(GlobalContext); 38 | return ( 39 | 46 | ); 47 | } 48 | 49 | const MemorizedMessageBox = React.memo(MessageBoxToMemo, arePropsEqual); 50 | 51 | function MessageBoxToMemo({ 52 | localizedStrings, 53 | visibleMessageType, 54 | orderedTextMessageList, 55 | orderedFileMessageList, 56 | readAllFileMessage, 57 | }) { 58 | const dispatch = useDispatch(); 59 | const unreadTextMessageCount = useSelector(selectUnreadTextMessageCount); 60 | const textBoxWrapperRef = React.useRef(null); 61 | const fileBoxWrapperRef = React.useRef(null); 62 | 63 | React.useEffect(() => { 64 | if (visibleMessageType === messageChatEnum.type.MESSAGE_TYPE_TEXT) { 65 | if (unreadTextMessageCount === 0) { 66 | return; 67 | } 68 | dispatch(readAllTextMessages()); 69 | } else if (visibleMessageType === messageChatEnum.type.MESSAGE_TYPE_FILE) { 70 | readAllFileMessage(); 71 | } 72 | }); 73 | 74 | React.useEffect(() => { 75 | if (textBoxWrapperRef.current) { 76 | autoScrollToBottomIfNecessary(textBoxWrapperRef.current, autoScrollingThredhold); 77 | } 78 | if (fileBoxWrapperRef.current) { 79 | autoScrollToBottomIfNecessary(fileBoxWrapperRef.current, autoScrollingThredhold); 80 | } 81 | }); 82 | 83 | let isTextMessageVisible = true; 84 | let isFileMessageVisible = false; 85 | let textMessageVisibility = "visible"; 86 | let fileMessageVisibility = "hidden"; 87 | if (visibleMessageType === messageChatEnum.type.MESSAGE_TYPE_TEXT) { 88 | isTextMessageVisible = true; 89 | isFileMessageVisible = false; 90 | textMessageVisibility = "visible"; 91 | fileMessageVisibility = "hidden"; 92 | } else if (visibleMessageType === messageChatEnum.type.MESSAGE_TYPE_FILE) { 93 | isTextMessageVisible = false; 94 | isFileMessageVisible = true; 95 | textMessageVisibility = "hidden"; 96 | fileMessageVisibility = "visible"; 97 | } 98 | 99 | return ( 100 | 101 | 105 | {orderedTextMessageList.map((messageItem, index) => { 106 | if (messageItem.type === messageChatEnum.type.MESSAGE_TYPE_TEXT) { 107 | return ( 108 | 112 | ); 113 | } 114 | 115 | return `Unexpected component to render: ${messageItem.id}`; 116 | })} 117 | 118 | 122 | {orderedFileMessageList.map((messageItem, index) => { 123 | if (messageItem.type === messageChatEnum.type.MESSAGE_TYPE_FILE) { 124 | return ( 125 | 129 | ); 130 | } 131 | 132 | return `Unexpected component to render: ${messageItem.id}`; 133 | })} 134 | 135 | 136 | ); 137 | } 138 | 139 | const arePropsEqual = (prevProps, nextProps) => { 140 | const isLocalizedStringEqual = Object.is(prevProps.localizedStrings, nextProps.localizedStrings); 141 | const isVisibleMessageTypeEqual = Object.is( 142 | prevProps.visibleMessageType, 143 | nextProps.visibleMessageType 144 | ); 145 | const isOrderedTextMessageListEqual = Object.is( 146 | prevProps.orderedTextMessageList, 147 | nextProps.orderedTextMessageList 148 | ); 149 | const isOrderedFileMessageListEqual = Object.is( 150 | prevProps.orderedFileMessageList, 151 | nextProps.orderedFileMessageList 152 | ); 153 | const isReadAllFileMessageEqual = Object.is( 154 | prevProps.readAllFileMessage, 155 | nextProps.readAllFileMessage 156 | ); 157 | return ( 158 | isLocalizedStringEqual && 159 | isVisibleMessageTypeEqual && 160 | isOrderedTextMessageListEqual && 161 | isOrderedFileMessageListEqual && 162 | isReadAllFileMessageEqual 163 | ); 164 | }; 165 | 166 | const sharedStyleValues = { 167 | // autoScrollingThredhold: 300, 168 | }; 169 | 170 | const Wrapper = styled.div` 171 | width: 100%; 172 | height: 100%; 173 | box-sizing: border-box; 174 | position: relative; 175 | `; 176 | 177 | const TextMessageWrapper = styled.div` 178 | width: 100%; 179 | height: 100%; 180 | box-sizing: border-box; 181 | overflow: auto; 182 | visibility: ${(props) => props.visibility}; 183 | position: absolute; 184 | `; 185 | 186 | const FileMessageWrapper = styled.div` 187 | width: 100%; 188 | height: 100%; 189 | box-sizing: border-box; 190 | overflow: auto; 191 | visibility: ${(props) => props.visibility}; 192 | position: absolute; 193 | `; -------------------------------------------------------------------------------- /react_client/component/feature/sign_in/Signin.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import styled from "styled-components"; 3 | import { useDispatch, useSelector } from "react-redux"; 4 | import { Navigate } from "react-router-dom"; 5 | 6 | import { requestToSignin, selectAuthenticated, selectAuthLoadingStatus } from "store/authSlice"; 7 | import * as localizableEnum from "constant/enum/localizable"; 8 | import LocalizationSwitch from "../localization/LocalizationSwitch"; 9 | import globalGreyImageUrl from "resource/image/global_grey_3x.png"; 10 | import { GlobalContext } from "context/global-context"; 11 | import Loading from "component/generic/loading/Loading"; 12 | import * as loadingStatusEnum from "constant/enum/loading-status"; 13 | 14 | const sharedStyleValues = { 15 | formInputVerticalMargin: 40, 16 | loadingContentWidth: 100, 17 | loadingContentHeight: 100, 18 | }; 19 | 20 | export default function Signin() { 21 | const dispatch = useDispatch(); 22 | 23 | const { localizedStrings } = React.useContext(GlobalContext); 24 | const authenticated = useSelector(selectAuthenticated); 25 | const [inputUserName, setInputUserName] = React.useState(""); 26 | 27 | const onInputNewUserNameChange = (e) => { 28 | setInputUserName(e.target.value); 29 | }; 30 | const onSigninClick = (e) => { 31 | e.preventDefault(); 32 | dispatch(requestToSignin(inputUserName)); 33 | }; 34 | const onKeyDown = (e) => { 35 | if (e.key !== "Enter") return; 36 | if (inputUserName.length === 0) return; 37 | onSigninClick(e); 38 | }; 39 | 40 | if (!authenticated) { 41 | return ( 42 | 49 | ); 50 | } 51 | 52 | return ; 53 | } 54 | 55 | const MemorizedSignin = React.memo(SigninToMemo, arePropsEqual); 56 | 57 | function SigninToMemo({ 58 | localizedStrings, 59 | inputUserName, 60 | onInputNewUserNameChange, 61 | onKeyDown, 62 | onSigninClick, 63 | }) { 64 | const authLoadingStatus = useSelector(selectAuthLoadingStatus); 65 | 66 | return ( 67 | 68 | 69 | 70 | {localizedStrings[localizableEnum.key.SIGN_IN_TITLE]} 71 | 72 | {localizedStrings[localizableEnum.key.SIGN_IN_TITLE_DESC]} 73 | 74 | 75 | 76 | 77 | 84 | 85 | 89 | {localizedStrings[localizableEnum.key.SIGN_IN_COMFIRM]} 90 | 91 | 92 | 97 | 98 | 99 | 100 | {authLoadingStatus === loadingStatusEnum.status.LOADING && } 101 | 102 | ); 103 | } 104 | 105 | const arePropsEqual = (prevProps, nextProps) => { 106 | const isLocalizedStringEqual = Object.is(prevProps.localizedStrings, nextProps.localizedStrings); 107 | const isInputUserNameEqual = Object.is(prevProps.inputUserName, nextProps.inputUserName); 108 | return isLocalizedStringEqual && isInputUserNameEqual; 109 | }; 110 | 111 | const Wrapper = styled.div` 112 | width: 100%; 113 | height: 100%; 114 | background: radial-gradient(at 50% 20%, rgb(255, 255, 255) 70%, rgb(196, 196, 196) 90%); 115 | `; 116 | 117 | const ContentWrapper = styled.div` 118 | position: relative; 119 | top: 26%; 120 | margin-left: 50px; 121 | margin-right: 117px; 122 | height: calc(300 / 800 * 100%); 123 | display: flex; 124 | flex-direction: row; 125 | justify-content: center; 126 | gap: calc(120 / 1280 * 100%); 127 | `; 128 | 129 | const HeadingWrapper = styled.div` 130 | flex-basis: 522px; 131 | `; 132 | 133 | const Heading = styled.h1` 134 | text-align: center; 135 | font-size: 48px; 136 | font-weight: bolder; 137 | `; 138 | 139 | const HeadingDescription = styled.p` 140 | text-align: center; 141 | font-size: 28px; 142 | font-weight: normal; 143 | color: #808080; 144 | `; 145 | 146 | const LoadingWrapper = styled.div` 147 | position: fixed; 148 | left: 0; 149 | top: 0; 150 | width: 100%; 151 | height: 100%; 152 | background-color: rgba(0, 0, 0, 0.3); 153 | z-index: 1; 154 | `; 155 | 156 | const LoadingContentWrapper = styled.div` 157 | position: relative; 158 | top: 50%; 159 | left: 50%; 160 | transform: translate(-50%, -100%); 161 | width: ${sharedStyleValues.loadingContentWidth}px; 162 | height: ${sharedStyleValues.loadingContentHeight}px; 163 | `; 164 | 165 | const FormWrapper = styled.form` 166 | flex-basis: 404px; 167 | display: flex; 168 | flex-direction: column; 169 | `; 170 | 171 | const FormInputWrapper = styled.div` 172 | padding: 10px; 173 | border: 1px solid #c4c4c4; 174 | border-radius: 10px; 175 | padding-left: 12px; 176 | border-width: 1px; 177 | margin-top: ${sharedStyleValues.formInputVerticalMargin}px; 178 | margin-bottom: ${sharedStyleValues.formInputVerticalMargin}px; 179 | `; 180 | 181 | const FormInput = styled.input` 182 | width: 100%; 183 | height: 100%; 184 | box-sizing: border-box; 185 | border-color: transparent; 186 | font-size: 28px; 187 | color: #808080; 188 | &::placeholder { 189 | /* Chrome, Firefox, Opera, Safari 10.1+ */ 190 | color: #c4c4c4; 191 | font-size: 28px; 192 | font-weight: normal; 193 | opacity: 1; /* Firefox */ 194 | } 195 | &:focus { 196 | outline: none; 197 | } 198 | `; 199 | 200 | const FormButton = styled.button` 201 | background-color: #52c41a; 202 | color: white; 203 | font-size: 28px; 204 | font-weight: bold; 205 | height: 54px; 206 | 207 | border-radius: 10px; 208 | border-width: 1px; 209 | border-color: aliceblue; 210 | `; 211 | 212 | const FormLanguageSwitchContainer = styled.div` 213 | width: 200px; 214 | height: 40px; 215 | margin-top: 15px; 216 | font-size: 20px; 217 | `; 218 | -------------------------------------------------------------------------------- /react_client/component/generic/switch/DropdownSwitch.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import styled from "styled-components"; 3 | 4 | export const dropdownSwitchOptionBuilder = ({ 5 | dropdownOptionName, 6 | dropdownOptionSelected, 7 | dropdownOptionOnClick, 8 | }) => { 9 | const name = typeof dropdownOptionName === "string" ? dropdownOptionName : "unknown option"; 10 | const selected = typeof dropdownOptionSelected == "boolean" ? dropdownOptionSelected : false; 11 | const onClick = typeof dropdownOptionOnClick === "function" ? dropdownOptionOnClick : null; 12 | return { 13 | name, 14 | selected, 15 | onClick, 16 | }; 17 | }; 18 | 19 | export const dropdownSwitchPropsBuilder = ({ 20 | dropdownSwitchIconImageUrl, 21 | dropdownSwitchIconImageWidth, 22 | dropdownSwitchSelectedOptionTextKey, 23 | dropdownSwitchSelectedOptionTextValue, 24 | dropdownSwitchSelectedOptionTextColor, 25 | dropdownSwitchSelectedTextKeyVisible, 26 | dropdownSwitchOptions, 27 | }) => { 28 | const iconImageUrl = 29 | typeof dropdownSwitchIconImageUrl === "string" ? dropdownSwitchIconImageUrl : ""; 30 | const iconImageWidth = 31 | typeof dropdownSwitchIconImageWidth === "number" && dropdownSwitchIconImageWidth > 0 32 | ? dropdownSwitchIconImageWidth 33 | : 0; 34 | const selectedOptionTextKey = 35 | typeof dropdownSwitchSelectedOptionTextKey === "string" 36 | ? dropdownSwitchSelectedOptionTextKey 37 | : ""; 38 | const selectedOptionTextValue = 39 | typeof dropdownSwitchSelectedOptionTextValue === "string" 40 | ? dropdownSwitchSelectedOptionTextValue 41 | : ""; 42 | const selectedOptionTextColor = 43 | typeof dropdownSwitchSelectedOptionTextColor === "string" 44 | ? dropdownSwitchSelectedOptionTextColor 45 | : "rgb(255, 255, 255)"; 46 | const isSelectedOptionTextKeyVisible = 47 | typeof dropdownSwitchSelectedTextKeyVisible === "boolean" 48 | ? dropdownSwitchSelectedTextKeyVisible 49 | : true; 50 | const options = 51 | dropdownSwitchOptions && 52 | dropdownSwitchOptions instanceof Array && 53 | dropdownSwitchOptions.length >= 1 54 | ? dropdownSwitchOptions 55 | : [{ name: "Option1", onClick: null, selected: true }]; 56 | 57 | return { 58 | iconImageUrl, 59 | iconImageWidth, 60 | selectedOptionTextKey, 61 | selectedOptionTextValue, 62 | selectedOptionTextColor, 63 | isSelectedOptionTextKeyVisible, 64 | options, 65 | }; 66 | }; 67 | 68 | export default function DropdownSwitch({ 69 | iconImageUrl, 70 | iconImageWidth, 71 | selectedOptionTextKey, 72 | selectedOptionTextValue, 73 | selectedOptionTextColor, 74 | isSelectedOptionTextKeyVisible, 75 | options, 76 | }) { 77 | const [dropdownOptionsDisplay, setDropdownOptionsDisplay] = React.useState("none"); 78 | 79 | const toggleDropdownOptionsDisplay = () => { 80 | if (dropdownOptionsDisplay === "none") { 81 | setDropdownOptionsDisplay("block"); 82 | return; 83 | } 84 | setDropdownOptionsDisplay("none"); 85 | }; 86 | 87 | return ( 88 | 89 | 90 | 94 | 95 | {`${ 96 | isSelectedOptionTextKeyVisible ? `${selectedOptionTextKey}: ` : "" 97 | }${selectedOptionTextValue}`} 98 | 99 | 100 | 101 | 102 | {options.map((option) => { 103 | const handleDropdownOptionClick = () => { 104 | if (option.onClick) { 105 | option.onClick(); 106 | } 107 | }; 108 | return ( 109 | 113 | {option.name} 114 | 115 | ); 116 | })} 117 | 118 | 119 | ); 120 | } 121 | 122 | const sharedStyleValues = { 123 | dropdownOptionHorizontalMargin: 5, 124 | dropdownOptionVerticalMargin: 5, 125 | }; 126 | 127 | const Wrapper = styled.div` 128 | box-sizing: border-box; 129 | width: 100%; 130 | height: 100%; 131 | 132 | position: relative; 133 | `; 134 | 135 | const SelectedOptionWrapper = styled.div` 136 | box-sizing: border-box; 137 | max-width: 100%; 138 | height: 100%; 139 | 140 | display: flex; 141 | flex-direction: row; 142 | justify-content: start; 143 | align-items: center; 144 | 145 | &:hover, 146 | &:active { 147 | opacity: 0.5; 148 | } 149 | `; 150 | 151 | const SelectedOptionIconWrapper = styled.div` 152 | box-sizing: border-box; 153 | flex: 0 0 ${(props) => props.width}px; 154 | height: ${(props) => props.width}px; 155 | 156 | background-image: url(${(props) => props.backgroundImageUrl}); 157 | background-position: center; 158 | background-repeat: no-repeat; 159 | background-size: contain; 160 | `; 161 | 162 | const SelectedOptionTextWrapper = styled.div` 163 | box-sizing: border-box; 164 | flex: 0 0 content; 165 | height: 100%; 166 | margin-left: 3px; 167 | 168 | display: flex; 169 | align-items: center; 170 | color: ${(props) => props.color}; 171 | `; 172 | 173 | const DropdownBackgroundWrapper = styled.div` 174 | display: ${(props) => props.display}; 175 | position: fixed; 176 | left: 0; 177 | top: 0; 178 | width: 100%; 179 | height: 100%; 180 | 181 | z-index: 1; 182 | `; 183 | 184 | const DropdownContentWrapper = styled.ul` 185 | display: ${(props) => props.display}; 186 | 187 | box-sizing: border-box; 188 | width: 100px; 189 | padding: 0; 190 | padding-top: 3px; 191 | padding-bottom: 3px; 192 | margin: 0; 193 | border: 1.5px solid #808080; 194 | border-radius: 10px; 195 | 196 | position: absolute; 197 | top: 120%; 198 | left: -5px; 199 | z-index: 1; 200 | 201 | background-color: rgb(36, 41, 47); 202 | `; 203 | 204 | const DropdownOptionWrapper = styled.li` 205 | box-sizing: border-box; 206 | height: 50px; 207 | list-style-type: none; 208 | color: rgb(255, 255, 255); 209 | display: flex; 210 | align-items: center; 211 | justify-content: center; 212 | width: calc(100% - ${sharedStyleValues.dropdownOptionHorizontalMargin * 2}px); 213 | border-radius: 10px; 214 | margin-left: ${sharedStyleValues.dropdownOptionHorizontalMargin}px; 215 | margin-right: ${sharedStyleValues.dropdownOptionHorizontalMargin}px; 216 | margin-top: ${sharedStyleValues.dropdownOptionVerticalMargin}px; 217 | margin-bottom: ${sharedStyleValues.dropdownOptionVerticalMargin}px; 218 | 219 | &:hover { 220 | background-color: rgba(255, 255, 255, 0.2); 221 | } 222 | `; -------------------------------------------------------------------------------- /react_client/component/generic/switch/SingleTabSwitch.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import styled from "styled-components"; 3 | 4 | export const singleTabSwitchOptionBuilder = ({ 5 | switchOptionBorderColor, 6 | switchOptionBackgroundColor, 7 | switchOptionBackgroundImageUrl, 8 | switchOptionBackgroundImageSize, 9 | switchOptionOnClick, 10 | switchOptionSelected, 11 | }) => { 12 | const borderColor = 13 | typeof switchOptionBorderColor === "string" ? switchOptionBorderColor : "#C4C4C4"; 14 | const backgroundColor = 15 | typeof switchOptionBackgroundColor === "string" ? switchOptionBackgroundColor : "#F44336"; 16 | const backgroundImageUrl = 17 | typeof switchOptionBackgroundImageUrl === "string" ? switchOptionBackgroundImageUrl : undefined; 18 | const backgroundImageSize = 19 | typeof switchOptionBackgroundImageSize === "string" 20 | ? switchOptionBackgroundImageSize 21 | : "contain"; 22 | const onClick = typeof switchOptionOnClick === "function" ? switchOptionOnClick : null; 23 | const selected = typeof switchOptionSelected === "boolean" ? switchOptionSelected : false; 24 | 25 | return { 26 | borderColor, 27 | backgroundColor, 28 | backgroundImageUrl, 29 | backgroundImageSize, 30 | onClick, 31 | selected, 32 | }; 33 | }; 34 | 35 | export const singleTabSwitchPropsBuilder = ({ 36 | switchEnabled, 37 | switchBorderRadius, 38 | switchBackgroundImageUrl, 39 | switchBackgroundImageSize, 40 | switchOneOption, 41 | switchAnotherOption, 42 | }) => { 43 | const enabled = typeof switchEnabled === "boolean" ? switchEnabled : true; 44 | const borderRadius = typeof switchBorderRadius === "number" ? switchBorderRadius : 10; 45 | const backgroundImageUrl = 46 | typeof switchBackgroundImageUrl === "string" ? switchBackgroundImageUrl : undefined; 47 | const backgroundImageSize = 48 | typeof switchBackgroundImageSize === "string" ? switchBackgroundImageSize : "contain"; 49 | 50 | const numberOfOptions = (switchOneOption ? 1 : 0) + (switchAnotherOption ? 1 : 0); 51 | let options; 52 | if (numberOfOptions === 2) { 53 | options = [switchOneOption, switchAnotherOption]; 54 | } else { 55 | console.error( 56 | `SingleTabSwitch: invalid options count of ${numberOfOptions}, should be only "2"` 57 | ); 58 | options = [ 59 | { backgroundColor: "#F44336", backgroundImageUrl: undefined, onClick: null, selected: true }, 60 | { backgroundColor: "#009688", backgroundImageUrl: undefined, onClick: null, selected: false }, 61 | ]; 62 | } 63 | 64 | const oneOptionIndex = 0; 65 | const anotherOptionIndex = 1; 66 | let selectedIndex = oneOptionIndex; 67 | options.forEach((option, index) => { 68 | if (index > 2) { 69 | return; 70 | } 71 | if (option.selected) { 72 | selectedIndex = index; 73 | return; 74 | } 75 | }); 76 | 77 | const switchOneOptionDisplay = 78 | !switchEnabled || oneOptionIndex !== selectedIndex ? "none" : "block"; 79 | const switchAnotherOptionDisplay = 80 | !switchEnabled || anotherOptionIndex !== selectedIndex ? "none" : "block"; 81 | 82 | options[oneOptionIndex].display = switchOneOptionDisplay; 83 | options[anotherOptionIndex].display = switchAnotherOptionDisplay; 84 | 85 | const borderColor = enabled ? options[selectedIndex].borderColor : "#C4C4C4"; 86 | 87 | return { 88 | switchEnabled: enabled, 89 | switchBorderRadius: borderRadius, 90 | switchBorderColor: borderColor, 91 | switchBackgroundImageUrl: backgroundImageUrl, 92 | switchBackgroundImageSize: backgroundImageSize, 93 | switchOptions: options, 94 | }; 95 | }; 96 | 97 | export default function SingleTabSwitch({ 98 | switchEnabled, 99 | switchBorderRadius, 100 | switchBorderColor, 101 | switchBackgroundImageUrl, 102 | switchBackgroundImageSize, 103 | switchOptions, 104 | }) { 105 | const oneOptionIndex = 0; 106 | const oneOptionDisplay = switchOptions[oneOptionIndex].display; 107 | const oneOptionBorderColor = switchOptions[oneOptionIndex].borderColor; 108 | const oneOptionBackgroundColor = switchOptions[oneOptionIndex].backgroundColor; 109 | const oneOptionBackgroundImageUrl = switchOptions[oneOptionIndex].backgroundImageUrl; 110 | const oneOptionBackgroundImageSize = switchOptions[oneOptionIndex].backgroundImageSize; 111 | 112 | const anotherOptionIndex = 1; 113 | const anotherOptionDisplay = switchOptions[anotherOptionIndex].display; 114 | const anotherOptionBorderColor = switchOptions[anotherOptionIndex].borderColor; 115 | const anotherOptionBackgroundColor = switchOptions[anotherOptionIndex].backgroundColor; 116 | const anotherOptionBackgroundImageUrl = switchOptions[anotherOptionIndex].backgroundImageUrl; 117 | const anotherOptionBackgroundImageSize = switchOptions[anotherOptionIndex].backgroundImageSize; 118 | 119 | const handleOptionSelected = (oldSelectedOptionIndex) => { 120 | if (!switchEnabled) { 121 | return; 122 | } 123 | if (switchOptions[oldSelectedOptionIndex].onClick) { 124 | switchOptions[oldSelectedOptionIndex].onClick(); 125 | } 126 | }; 127 | 128 | return ( 129 | 136 | { 143 | handleOptionSelected(oneOptionIndex); 144 | }} 145 | /> 146 | { 153 | handleOptionSelected(anotherOptionIndex); 154 | }} 155 | /> 156 | 157 | ); 158 | } 159 | 160 | const Wrapper = styled.div` 161 | background-color: rgba(0, 0, 0, 0); 162 | ${(props) => 163 | typeof props.backgroundImageUrl === "string" && 164 | `background-image: url(${props.backgroundImageUrl});`} 165 | background-position: center; 166 | background-repeat: no-repeat; 167 | background-size: ${(props) => props.backgroundImageSize}; 168 | border-style: solid; 169 | border-radius: ${(props) => props.borderRadius}px; 170 | border-width: 1px; 171 | border-color: ${(props) => props.borderColor}; 172 | width: 100%; 173 | height: 100%; 174 | box-sizing: border-box; 175 | overflow: hidden; 176 | &:hover, 177 | &:active { 178 | opacity: ${(props) => (props.enabled ? 0.5 : 1)}; 179 | } 180 | `; 181 | 182 | const OptionWrapper = styled.div` 183 | display: ${(props) => props.display}; 184 | border-width: 0px; 185 | border-color: transparent; 186 | border-style: solid; 187 | background-color: ${(props) => props.backgroundColor}; 188 | ${(props) => 189 | typeof props.backgroundImageUrl === "string" && 190 | `background-image: url(${props.backgroundImageUrl});`} 191 | background-position: center; 192 | background-repeat: no-repeat; 193 | background-size: ${(props) => props.backgroundImageSize}; 194 | width: 100%; 195 | height: 100%; 196 | `; -------------------------------------------------------------------------------- /react_client/component/feature/chat/media/MediaMultiVideoRenderer.jsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import styled from "styled-components"; 3 | 4 | import MediaVideo from "./MediaVideo"; 5 | import { GlobalContext } from "context/global-context"; 6 | import * as mediaChatEnum from "constant/enum/media-chat"; 7 | 8 | export default function MediaMultiVideoRenderer({}) { 9 | const { 10 | numberOfInitialVisibleMediaMembers, 11 | mediaRenderingDataSourceList, 12 | mediaRenderingDataSourceForPresenter, 13 | mediaAccessibilityType, 14 | } = React.useContext(GlobalContext); 15 | 16 | return ( 17 | 23 | ); 24 | } 25 | 26 | const MemorizedMediaMultiVideoRenderer = React.memo(MediaMultiVideoRendererToMemo, arePropsEqual); 27 | 28 | function MediaMultiVideoRendererToMemo({ 29 | numberOfInitialVisibleMediaMembers, 30 | mediaRenderingDataSourceList, 31 | mediaRenderingDataSourceForPresenter, 32 | mediaAccessibilityType, 33 | }) { 34 | let shouldDisplayForEquality; 35 | let shouldDisplayForPresentation; 36 | 37 | if ( 38 | mediaAccessibilityType === 39 | mediaChatEnum.mediaAccessibilityType.MEDIA_ACCESSIBILITY_TYPE_EQUALITY 40 | ) { 41 | shouldDisplayForEquality = true; 42 | shouldDisplayForPresentation = false; 43 | } else if ( 44 | mediaAccessibilityType === 45 | mediaChatEnum.mediaAccessibilityType.MEDIA_ACCESSIBILITY_TYPE_PRESENTATION 46 | ) { 47 | shouldDisplayForEquality = false; 48 | shouldDisplayForPresentation = true; 49 | } 50 | 51 | return ( 52 | 53 | {shouldDisplayForPresentation && ( 54 | 55 | 65 | 66 | )} 67 | 68 | {shouldDisplayForEquality && ( 69 | 70 | {mediaRenderingDataSourceList.map((mediaRenderingDataSource, index) => { 71 | const forceAudioOutputUnavaliable = index === 0; 72 | 73 | return ( 74 | 78 | 89 | 90 | ); 91 | })} 92 | 93 | )} 94 | {shouldDisplayForPresentation && ( 95 | 98 | {mediaRenderingDataSourceList.map((mediaRenderingDataSource, index) => { 99 | const forceAudioOutputUnavaliable = index === 0; 100 | 101 | return ( 102 | 106 | 117 | 118 | ); 119 | })} 120 | 121 | )} 122 | 123 | ); 124 | } 125 | 126 | const arePropsEqual = (prevProps, nextProps) => { 127 | const isNumberOfInitialVisibleMediaMembersEqual = Object.is( 128 | prevProps.numberOfInitialVisibleMediaMembers, 129 | nextProps.numberOfInitialVisibleMediaMembers 130 | ); 131 | const isMediaRenderingDataSourceListEqual = Object.is( 132 | prevProps.mediaRenderingDataSourceList, 133 | nextProps.mediaRenderingDataSourceList 134 | ); 135 | const isMediaRenderingDataSourceForPresenterEqual = Object.is( 136 | prevProps.mediaRenderingDataSourceForPresenter, 137 | nextProps.mediaRenderingDataSourceForPresenter 138 | ); 139 | const isMediaAccessibilityTypeEqual = Object.is( 140 | prevProps.mediaAccessibilityType, 141 | nextProps.mediaAccessibilityType 142 | ); 143 | return ( 144 | isNumberOfInitialVisibleMediaMembersEqual && 145 | isMediaRenderingDataSourceListEqual && 146 | isMediaRenderingDataSourceForPresenterEqual && 147 | isMediaAccessibilityTypeEqual 148 | ); 149 | }; 150 | 151 | const sharedStyleValues = { 152 | bottomSpaceHeight: 14, 153 | numberOfRowsForEqualityType: 2, 154 | }; 155 | 156 | const Wrapper = styled.div` 157 | width: 100%; 158 | height: calc(100% - ${sharedStyleValues.bottomSpaceHeight}px); 159 | display: flex; 160 | flex-direction: row; 161 | `; 162 | 163 | const PresenterVideoContainer = styled.div` 164 | display: block; 165 | flex: 1 0 0%; 166 | height: 100%; 167 | `; 168 | 169 | const PresentationTypeMembersContainer = styled.div` 170 | display: block; 171 | flex: 0 0 content; 172 | aspect-ratio: 1 / ${(props) => props.numberOfInitialVisibleMembers}; 173 | height: 100%; 174 | overflow-y: auto; 175 | `; 176 | 177 | const PresentationTypeMemberContainer = styled.div` 178 | width: 100%; 179 | height: calc(100% / ${(props) => props.numberOfInitialVisibleMembers}); 180 | `; 181 | 182 | const EqualityTypeMembersContainer = styled.div` 183 | display: flex; 184 | flex-direction: row; 185 | flex-wrap: wrap; 186 | flex: 0 0 100%; 187 | height: 100%; 188 | overflow-y: auto; 189 | `; 190 | 191 | const EqualityTypeMemberContainer = styled.div` 192 | height: calc(100% / ${sharedStyleValues.numberOfRowsForEqualityType}); 193 | flex: 0 0 194 | calc( 195 | 100% / 196 | ${(props) => 197 | Math.floor( 198 | props.numberOfInitialVisibleMembers / sharedStyleValues.numberOfRowsForEqualityType 199 | )} 200 | ); 201 | `; 202 | -------------------------------------------------------------------------------- /react_client/context/file-message-context.js: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useSelector } from "react-redux"; 3 | import GroupChatService, { SendingSliceName, ReceivingSliceName } from "webrtc-group-chat-client"; 4 | 5 | import { selectAuthenticated, selectAuthenticatedUserName } from "store/authSlice"; 6 | 7 | const FileMessageContext = React.createContext(); 8 | FileMessageContext.displayName = "FileMessageContext"; 9 | 10 | const fileMessageContainerBuilder = ( 11 | authenticatedUserId, 12 | authenticatedUserName, 13 | isLocalSender, 14 | oldFileMessageContainer, 15 | newTransceivingRelatedData 16 | ) => { 17 | const newFileMessageContainer = { ...oldFileMessageContainer }; 18 | 19 | if (isLocalSender) { 20 | const newSendingFileHashToAllSlices = newTransceivingRelatedData; 21 | 22 | Object.entries(newSendingFileHashToAllSlices).forEach(([fileHash, newAllSlices]) => { 23 | const id = `${authenticatedUserId}-${fileHash}`; 24 | 25 | const oldFileMessage = oldFileMessageContainer ? oldFileMessageContainer[id] : null; 26 | 27 | let newFileMessage; 28 | 29 | let newFileProgress = newAllSlices[SendingSliceName.SENDING_MIN_PROGRESS]; 30 | if (typeof newFileProgress !== "number") { 31 | newFileProgress = 0; 32 | } 33 | 34 | if (!oldFileMessage) { 35 | newFileMessage = {}; 36 | newFileMessage.id = `${authenticatedUserId}-${fileHash}`; 37 | newFileMessage.userId = authenticatedUserId; 38 | newFileMessage.userName = authenticatedUserName; 39 | newFileMessage.fileHash = fileHash; 40 | newFileMessage.timestamp = Date.parse(new Date()); 41 | newFileMessage.isLocalSender = true; 42 | newFileMessage.fileSendingCanceller = () => { 43 | GroupChatService.cancelFileSendingToAllPeer(fileHash); 44 | // .cancelFileSendingToAllPeer(fileHash); 45 | }; 46 | 47 | const newFileMetaData = { 48 | ...newAllSlices[SendingSliceName.SENDING_META_DATA], 49 | }; 50 | newFileMessage.fileName = newFileMetaData.name; 51 | newFileMessage.fileSize = newFileMetaData.size; 52 | 53 | newFileMessage.isRead = true; 54 | newFileMessage.isNew = false; 55 | } else { 56 | newFileMessage = oldFileMessage; 57 | } 58 | newFileMessage.fileProgress = newFileProgress; 59 | newFileMessageContainer[id] = newFileMessage; 60 | }); 61 | 62 | return newFileMessageContainer; 63 | } 64 | 65 | const newReceivingPeerMapOfHashToAllSlices = newTransceivingRelatedData; 66 | 67 | newReceivingPeerMapOfHashToAllSlices.forEach((fileHashToAllSlices, peerId) => { 68 | Object.entries(fileHashToAllSlices).forEach(([fileHash, receivingAllSlices]) => { 69 | const id = `${peerId}-${fileHash}`; 70 | const oldFileMessage = oldFileMessageContainer ? oldFileMessageContainer[id] : null; 71 | 72 | let newFileMessage; 73 | 74 | let newFileProgress = receivingAllSlices[ReceivingSliceName.RECEIVING_PROGRESS]; 75 | if (typeof newFileProgress !== "number") { 76 | newFileProgress = 0; 77 | } 78 | 79 | if (!oldFileMessage) { 80 | newFileMessage = {}; 81 | newFileMessage.id = `${peerId}-${fileHash}`; 82 | newFileMessage.userId = peerId; 83 | newFileMessage.userName = GroupChatService.getPeerNameById(peerId); 84 | newFileMessage.fileHash = fileHash; 85 | newFileMessage.timestamp = Date.parse(new Date()); 86 | newFileMessage.isLocalSender = false; 87 | 88 | const newFileMetaData = { 89 | ...receivingAllSlices[ReceivingSliceName.RECEIVING_META_DATA], 90 | }; 91 | newFileMessage.fileName = newFileMetaData.name; 92 | newFileMessage.fileSize = newFileMetaData.size; 93 | 94 | newFileMessage.isRead = false; 95 | newFileMessage.isNew = true; 96 | } else { 97 | newFileMessage = oldFileMessage; 98 | newFileMessage.isNew = !oldFileMessage.isRead; 99 | } 100 | 101 | newFileMessage.fileProgress = newFileProgress; 102 | newFileMessage.fileExporter = receivingAllSlices[ReceivingSliceName.RECEIVING_FILE_EXPORTER]; 103 | newFileMessageContainer[id] = newFileMessage; 104 | }); 105 | }); 106 | 107 | return newFileMessageContainer; 108 | }; 109 | 110 | function FileMessageContextProvider({ children }) { 111 | const [inputFiles, setInputFiles] = React.useState(null); 112 | const [isSendingStatusSending, setIsSendingStatusSending] = React.useState(false); 113 | const [messageContainer, setMessageContainer] = React.useState(null); 114 | const authenticatedUserId = useSelector(selectAuthenticated); 115 | const authenticatedUserName = useSelector(selectAuthenticatedUserName); 116 | 117 | const messageContainerRef = React.useRef(messageContainer); 118 | messageContainerRef.current = messageContainer; 119 | 120 | React.useEffect(() => { 121 | GroupChatService.onFileSendingRelatedDataChanged( 122 | (sendingRelatedDataProxy, isSendingStatusSending) => { 123 | if (isSendingStatusSending !== undefined) { 124 | setIsSendingStatusSending(isSendingStatusSending); 125 | } 126 | if (sendingRelatedDataProxy && sendingRelatedDataProxy.fileHashToAllSlices) { 127 | const newMessageContainer = fileMessageContainerBuilder( 128 | authenticatedUserId, 129 | authenticatedUserName, 130 | true, 131 | messageContainerRef.current, 132 | sendingRelatedDataProxy.fileHashToAllSlices 133 | ); 134 | setMessageContainer(newMessageContainer); 135 | } 136 | } 137 | ); 138 | GroupChatService.onFileReceivingRelatedDataChanged((receivingRelatedDataProxy) => { 139 | if (receivingRelatedDataProxy && receivingRelatedDataProxy.peerMapOfHashToAllSlices) { 140 | const newMessageContainer = fileMessageContainerBuilder( 141 | authenticatedUserId, 142 | authenticatedUserName, 143 | false, 144 | messageContainerRef.current, 145 | receivingRelatedDataProxy.peerMapOfHashToAllSlices 146 | ); 147 | setMessageContainer(newMessageContainer); 148 | } 149 | }); 150 | }, []); 151 | 152 | let unreadMessageCount = 0; 153 | if (messageContainer) { 154 | unreadMessageCount = Object.values(messageContainer).filter( 155 | (message) => !message.isRead 156 | ).length; 157 | } 158 | 159 | // config 160 | const readAllMessage = () => { 161 | if (unreadMessageCount === 0) { 162 | return; 163 | } 164 | 165 | const newMessageContainer = { ...messageContainerRef.current }; 166 | Object.keys(messageContainerRef.current).forEach((id) => { 167 | const newMessage = { ...newMessageContainer[id] }; 168 | newMessage.isRead = true; 169 | newMessageContainer[id] = newMessage; 170 | }); 171 | 172 | setMessageContainer(newMessageContainer); 173 | }; 174 | const updateInputFiles = (files) => { 175 | setInputFiles(files); 176 | }; 177 | const sendFiles = () => { 178 | if (inputFiles) { 179 | GroupChatService.sendFileToAllPeer(inputFiles); 180 | } 181 | }; 182 | const cancelAllFileSending = () => { 183 | GroupChatService.cancelAllFileSending(); 184 | }; 185 | const clearAllFileBuffersReceived = () => { 186 | GroupChatService.clearAllFileBuffersReceived(); 187 | }; 188 | const clearAllFileReceived = () => { 189 | GroupChatService.clearAllFilesReceived(); 190 | }; 191 | const resetFileMessageContext = () => { 192 | setInputFiles(null); 193 | setIsSendingStatusSending(false); 194 | setMessageContainer(null); 195 | }; 196 | 197 | const contextValue = { 198 | messageContainer, 199 | unreadMessageCount, 200 | isSendingStatusSending, 201 | 202 | readAllMessage, 203 | inputFiles, 204 | updateInputFiles, 205 | sendFiles, 206 | cancelAllFileSending, 207 | clearAllFileBuffersReceived, 208 | clearAllFileReceived, 209 | 210 | resetFileMessageContext, 211 | }; 212 | 213 | return {children}; 214 | } 215 | 216 | export { FileMessageContextProvider, FileMessageContext }; 217 | --------------------------------------------------------------------------------