├── .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 | 
6 |
7 | 
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 |
--------------------------------------------------------------------------------