├── chat ├── __init__.py ├── auth.py ├── config.py ├── app.py ├── demo_data.py ├── socketio_signals.py ├── utils.py └── routes.py ├── Procfile ├── client ├── .gitignore ├── build │ ├── robots.txt │ ├── avatars │ │ ├── 0.jpg │ │ ├── 1.jpg │ │ ├── 2.jpg │ │ ├── 3.jpg │ │ ├── 4.jpg │ │ ├── 5.jpg │ │ ├── 6.jpg │ │ ├── 7.jpg │ │ ├── 8.jpg │ │ ├── 9.jpg │ │ ├── 10.jpg │ │ ├── 11.jpg │ │ └── 12.jpg │ ├── favicon.ico │ ├── welcome-back.png │ ├── asset-manifest.json │ ├── static │ │ ├── js │ │ │ ├── runtime-main.c012fedc.js │ │ │ ├── 2.a950a4d1.chunk.js.LICENSE.txt │ │ │ ├── runtime-main.c012fedc.js.map │ │ │ └── main.e15e5a37.chunk.js │ │ └── css │ │ │ ├── main.c68ddd7d.chunk.css │ │ │ └── main.c68ddd7d.chunk.css.map │ └── index.html ├── public │ ├── robots.txt │ ├── favicon.ico │ ├── avatars │ │ ├── 0.jpg │ │ ├── 1.jpg │ │ ├── 10.jpg │ │ ├── 11.jpg │ │ ├── 12.jpg │ │ ├── 2.jpg │ │ ├── 3.jpg │ │ ├── 4.jpg │ │ ├── 5.jpg │ │ ├── 6.jpg │ │ ├── 7.jpg │ │ ├── 8.jpg │ │ └── 9.jpg │ ├── welcome-back.png │ └── index.html ├── README.md ├── src │ ├── components │ │ ├── LoadingScreen.jsx │ │ ├── Chat │ │ │ ├── components │ │ │ │ ├── MessageList │ │ │ │ │ ├── components │ │ │ │ │ │ ├── InfoMessage.jsx │ │ │ │ │ │ ├── NoMessages.jsx │ │ │ │ │ │ ├── MessagesLoading.jsx │ │ │ │ │ │ ├── ClockIcon.jsx │ │ │ │ │ │ ├── ReceiverMessage.jsx │ │ │ │ │ │ └── SenderMessage.jsx │ │ │ │ │ └── index.jsx │ │ │ │ ├── OnlineIndicator.jsx │ │ │ │ ├── ChatList │ │ │ │ │ ├── components │ │ │ │ │ │ ├── ChatListItem │ │ │ │ │ │ │ ├── style.css │ │ │ │ │ │ │ └── index.jsx │ │ │ │ │ │ ├── AvatarImage.jsx │ │ │ │ │ │ ├── ChatIcon.jsx │ │ │ │ │ │ └── Footer.jsx │ │ │ │ │ └── index.jsx │ │ │ │ └── TypingArea.jsx │ │ │ ├── index.jsx │ │ │ └── use-chat-handlers.js │ │ ├── Login │ │ │ ├── style.css │ │ │ └── index.jsx │ │ ├── Navbar.jsx │ │ └── Logo.jsx │ ├── index.jsx │ ├── utils.js │ ├── styles │ │ ├── style-overrides.css │ │ ├── style.css │ │ └── font-face.css │ ├── api.js │ ├── state.js │ ├── App.jsx │ └── hooks.js └── package.json ├── .flake8 ├── repo.json ├── requirements.txt ├── docs ├── YTThumbnail.png ├── screenshot000.png └── screenshot001.png ├── images └── app_preview_image.png ├── .editorconfig ├── app.py ├── Dockerfile ├── app.json ├── LICENSE ├── marketplace.json ├── .gitignore └── README.md /chat/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: gunicorn --worker-class eventlet -w 1 app:app -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | !build 3 | .eslintcache -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = 3 | F401, 4 | F403, 5 | F405, 6 | max-line-length = 120 7 | -------------------------------------------------------------------------------- /client/build/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /client/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /repo.json: -------------------------------------------------------------------------------- 1 | { 2 | "github": "https://github.com/redis-developer/basic-redis-chat-app-demo-python" 3 | } 4 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/redis-developer/basic-redis-chat-app-demo-python/master/requirements.txt -------------------------------------------------------------------------------- /docs/YTThumbnail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/redis-developer/basic-redis-chat-app-demo-python/master/docs/YTThumbnail.png -------------------------------------------------------------------------------- /docs/screenshot000.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/redis-developer/basic-redis-chat-app-demo-python/master/docs/screenshot000.png -------------------------------------------------------------------------------- /docs/screenshot001.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/redis-developer/basic-redis-chat-app-demo-python/master/docs/screenshot001.png -------------------------------------------------------------------------------- /client/build/avatars/0.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/redis-developer/basic-redis-chat-app-demo-python/master/client/build/avatars/0.jpg -------------------------------------------------------------------------------- /client/build/avatars/1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/redis-developer/basic-redis-chat-app-demo-python/master/client/build/avatars/1.jpg -------------------------------------------------------------------------------- /client/build/avatars/2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/redis-developer/basic-redis-chat-app-demo-python/master/client/build/avatars/2.jpg -------------------------------------------------------------------------------- /client/build/avatars/3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/redis-developer/basic-redis-chat-app-demo-python/master/client/build/avatars/3.jpg -------------------------------------------------------------------------------- /client/build/avatars/4.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/redis-developer/basic-redis-chat-app-demo-python/master/client/build/avatars/4.jpg -------------------------------------------------------------------------------- /client/build/avatars/5.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/redis-developer/basic-redis-chat-app-demo-python/master/client/build/avatars/5.jpg -------------------------------------------------------------------------------- /client/build/avatars/6.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/redis-developer/basic-redis-chat-app-demo-python/master/client/build/avatars/6.jpg -------------------------------------------------------------------------------- /client/build/avatars/7.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/redis-developer/basic-redis-chat-app-demo-python/master/client/build/avatars/7.jpg -------------------------------------------------------------------------------- /client/build/avatars/8.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/redis-developer/basic-redis-chat-app-demo-python/master/client/build/avatars/8.jpg -------------------------------------------------------------------------------- /client/build/avatars/9.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/redis-developer/basic-redis-chat-app-demo-python/master/client/build/avatars/9.jpg -------------------------------------------------------------------------------- /client/build/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/redis-developer/basic-redis-chat-app-demo-python/master/client/build/favicon.ico -------------------------------------------------------------------------------- /client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/redis-developer/basic-redis-chat-app-demo-python/master/client/public/favicon.ico -------------------------------------------------------------------------------- /client/build/avatars/10.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/redis-developer/basic-redis-chat-app-demo-python/master/client/build/avatars/10.jpg -------------------------------------------------------------------------------- /client/build/avatars/11.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/redis-developer/basic-redis-chat-app-demo-python/master/client/build/avatars/11.jpg -------------------------------------------------------------------------------- /client/build/avatars/12.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/redis-developer/basic-redis-chat-app-demo-python/master/client/build/avatars/12.jpg -------------------------------------------------------------------------------- /client/public/avatars/0.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/redis-developer/basic-redis-chat-app-demo-python/master/client/public/avatars/0.jpg -------------------------------------------------------------------------------- /client/public/avatars/1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/redis-developer/basic-redis-chat-app-demo-python/master/client/public/avatars/1.jpg -------------------------------------------------------------------------------- /client/public/avatars/10.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/redis-developer/basic-redis-chat-app-demo-python/master/client/public/avatars/10.jpg -------------------------------------------------------------------------------- /client/public/avatars/11.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/redis-developer/basic-redis-chat-app-demo-python/master/client/public/avatars/11.jpg -------------------------------------------------------------------------------- /client/public/avatars/12.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/redis-developer/basic-redis-chat-app-demo-python/master/client/public/avatars/12.jpg -------------------------------------------------------------------------------- /client/public/avatars/2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/redis-developer/basic-redis-chat-app-demo-python/master/client/public/avatars/2.jpg -------------------------------------------------------------------------------- /client/public/avatars/3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/redis-developer/basic-redis-chat-app-demo-python/master/client/public/avatars/3.jpg -------------------------------------------------------------------------------- /client/public/avatars/4.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/redis-developer/basic-redis-chat-app-demo-python/master/client/public/avatars/4.jpg -------------------------------------------------------------------------------- /client/public/avatars/5.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/redis-developer/basic-redis-chat-app-demo-python/master/client/public/avatars/5.jpg -------------------------------------------------------------------------------- /client/public/avatars/6.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/redis-developer/basic-redis-chat-app-demo-python/master/client/public/avatars/6.jpg -------------------------------------------------------------------------------- /client/public/avatars/7.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/redis-developer/basic-redis-chat-app-demo-python/master/client/public/avatars/7.jpg -------------------------------------------------------------------------------- /client/public/avatars/8.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/redis-developer/basic-redis-chat-app-demo-python/master/client/public/avatars/8.jpg -------------------------------------------------------------------------------- /client/public/avatars/9.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/redis-developer/basic-redis-chat-app-demo-python/master/client/public/avatars/9.jpg -------------------------------------------------------------------------------- /images/app_preview_image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/redis-developer/basic-redis-chat-app-demo-python/master/images/app_preview_image.png -------------------------------------------------------------------------------- /client/build/welcome-back.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/redis-developer/basic-redis-chat-app-demo-python/master/client/build/welcome-back.png -------------------------------------------------------------------------------- /client/public/welcome-back.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/redis-developer/basic-redis-chat-app-demo-python/master/client/public/welcome-back.png -------------------------------------------------------------------------------- /client/README.md: -------------------------------------------------------------------------------- 1 | # client 2 | 3 | ## Project setup 4 | 5 | ``` 6 | yarn install 7 | ``` 8 | 9 | ### Compiles and hot-reloads for development 10 | 11 | ``` 12 | yarn start 13 | ``` 14 | 15 | ### Compiles and minifies for production 16 | 17 | ``` 18 | yarn build 19 | ``` 20 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [Makefile] 4 | indent_style = tab 5 | 6 | [*.{html,py,js}] 7 | charset = utf-8 8 | 9 | [*.js] 10 | indent_style = space 11 | indent_size = 2 12 | 13 | [*.html] 14 | indent_style = space 15 | indent_size = 4 16 | 17 | [*.py] 18 | indent_style = space 19 | indent_size = 4 20 | -------------------------------------------------------------------------------- /client/src/components/LoadingScreen.jsx: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import React from "react"; 3 | 4 | export function LoadingScreen() { 5 | return ( 6 |
7 |
8 | 9 |
10 |
11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /client/src/index.jsx: -------------------------------------------------------------------------------- 1 | import "bootstrap/dist/css/bootstrap.min.css"; 2 | import "./styles/style-overrides.css"; 3 | import "./styles/style.css"; 4 | import "./styles/font-face.css"; 5 | 6 | import React from "react"; 7 | import ReactDOM from "react-dom"; 8 | 9 | import App from "./App"; 10 | 11 | ReactDOM.render(, document.getElementById("root")); 12 | -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | from chat.app import app, run_app # noqa 2 | 3 | 4 | if __name__ == "__main__": 5 | # monkey patch is "required to force the message queue package to use coroutine friendly functions and classes" 6 | # check flask-socketio docs https://flask-socketio.readthedocs.io/en/latest/ 7 | import eventlet 8 | 9 | eventlet.monkey_patch() 10 | run_app() 11 | -------------------------------------------------------------------------------- /client/src/components/Chat/components/MessageList/components/InfoMessage.jsx: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | const InfoMessage = ({ message }) => { 3 | return ( 4 |

8 | {message} 9 |

10 | ); 11 | }; 12 | 13 | export default InfoMessage; 14 | -------------------------------------------------------------------------------- /client/src/components/Chat/components/OnlineIndicator.jsx: -------------------------------------------------------------------------------- 1 | const OnlineIndicator = ({ online, hide = false, width = 8, height = 8 }) => { 2 | return ( 3 |
9 | ); 10 | }; 11 | 12 | export default OnlineIndicator; 13 | -------------------------------------------------------------------------------- /chat/auth.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | 3 | from flask import jsonify, session 4 | 5 | 6 | def auth_middleware(f): 7 | """This decorator will filter out unauthorized connections.""" 8 | 9 | @wraps(f) 10 | def __auth_middleware(*args, **kwargs): 11 | if not session["user"]: 12 | return jsonify(None), 403 13 | return f(*args, **kwargs) 14 | 15 | return __auth_middleware 16 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Use Python37 2 | FROM python:3.7 3 | # Copy requirements.txt to the docker image and install packages 4 | COPY requirements.txt / 5 | RUN pip install -r requirements.txt 6 | # Set the WORKDIR to be the folder 7 | COPY . /app 8 | # Expose port 8080 9 | EXPOSE 8080 10 | ENV PORT 8080 11 | WORKDIR /app 12 | # Use gunicorn as the entrypoint 13 | CMD exec gunicorn --bind :$PORT --worker-class eventlet -w 1 app:app 14 | -------------------------------------------------------------------------------- /client/src/components/Chat/components/MessageList/components/NoMessages.jsx: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import React from "react"; 3 | import { CardText } from "react-bootstrap-icons"; 4 | 5 | const NoMessages = () => { 6 | return ( 7 |
8 | 9 |

No messages

10 |
11 | ); 12 | }; 13 | 14 | export default NoMessages; 15 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Python Flask Redis chat", 3 | "repository": "https://github.com/redis-developer/basic-redis-chat-app-demo-python", 4 | "logo": "https://redis.io/images/redis-white.png", 5 | "addons": ["rediscloud:30"], 6 | "env": { 7 | "REDIS_ENDPOINT_URL": { 8 | "description": "A Redis cloud endpoint URL.", 9 | "required": true 10 | }, 11 | "REDIS_PASSWORD": { 12 | "description": "A Redis password.", 13 | "required": true 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /client/src/components/Chat/components/MessageList/components/MessagesLoading.jsx: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import React from "react"; 3 | 4 | const MessagesLoading = () => { 5 | return ( 6 |
7 |
8 | 9 |
10 |
11 | ); 12 | }; 13 | 14 | export default MessagesLoading; 15 | -------------------------------------------------------------------------------- /client/src/components/Chat/components/ChatList/components/ChatListItem/style.css: -------------------------------------------------------------------------------- 1 | .chat-list-item { 2 | cursor: pointer; 3 | padding: 14px 16px; 4 | } 5 | .mdi-circle:before { 6 | content: "󰝥"; 7 | } 8 | 9 | .mdi-set, 10 | .mdi:before { 11 | display: inline-block; 12 | font: normal normal normal 24px/1 Material Design Icons; 13 | font-size: inherit; 14 | text-rendering: auto; 15 | line-height: inherit; 16 | -webkit-font-smoothing: antialiased; 17 | -moz-osx-font-smoothing: grayscale; 18 | } 19 | -------------------------------------------------------------------------------- /client/src/components/Chat/components/MessageList/components/ClockIcon.jsx: -------------------------------------------------------------------------------- 1 | const ClockIcon = () => ( 2 | 12 | ); 13 | 14 | export default ClockIcon; 15 | -------------------------------------------------------------------------------- /client/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | Python Flask Redis chat 13 | 14 | 15 | 16 |
17 | 18 | 19 | -------------------------------------------------------------------------------- /chat/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import redis 4 | from werkzeug.utils import import_string 5 | 6 | 7 | class Config(object): 8 | # Parse redis environment variables. 9 | redis_endpoint_url = os.environ.get("REDIS_ENDPOINT_URL", "127.0.0.1:6379") 10 | REDIS_HOST, REDIS_PORT = tuple(redis_endpoint_url.split(":")) 11 | REDIS_PASSWORD = os.environ.get("REDIS_PASSWORD", None) 12 | SECRET_KEY = os.environ.get("SECRET_KEY", "Optional default value") 13 | SESSION_TYPE = "redis" 14 | redis_client = redis.Redis( 15 | host=REDIS_HOST, port=REDIS_PORT, password=REDIS_PASSWORD 16 | ) 17 | SESSION_REDIS = redis_client 18 | # TODO: Auth... 19 | 20 | 21 | class ConfigDev(Config): 22 | # DEBUG = True 23 | pass 24 | 25 | 26 | class ConfigProd(Config): 27 | pass 28 | 29 | 30 | def get_config() -> Config: 31 | return import_string(os.environ.get("CHAT_CONFIG", "chat.config.ConfigDev")) 32 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "axios": "^0.21.1", 7 | "bootstrap": "^4.5.3", 8 | "jdenticon": "^3.1.0", 9 | "moment": "^2.29.1", 10 | "react": "^17.0.1", 11 | "react-bootstrap": "^1.4.0", 12 | "react-bootstrap-icons": "^1.1.0", 13 | "react-dom": "^17.0.1", 14 | "react-scripts": "4.0.1", 15 | "socket.io-client": "^3.0.4" 16 | }, 17 | "scripts": { 18 | "start": "react-scripts start", 19 | "build": "react-scripts build" 20 | }, 21 | "eslintConfig": { 22 | "extends": [ 23 | "react-app", 24 | "react-app/jest" 25 | ] 26 | }, 27 | "proxy": "http://localhost:8000", 28 | "browserslist": { 29 | "production": [ 30 | ">0.2%", 31 | "not dead", 32 | "not op_mini all" 33 | ], 34 | "development": [ 35 | "last 1 chrome version", 36 | "last 1 firefox version", 37 | "last 1 safari version" 38 | ] 39 | }, 40 | "devDependencies": { 41 | "@types/socket.io-client": "^1.4.34" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Redis Developer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /client/src/components/Chat/components/TypingArea.jsx: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | const TypingArea = ({ message, setMessage, onSubmit }) => ( 3 |
4 |
5 |
6 |
7 | setMessage(e.target.value)} 10 | type="text" 11 | placeholder="Enter Message..." 12 | className="form-control chat-input" 13 | /> 14 | {/**/} 15 |
16 |
17 |
18 | 27 |
28 |
29 |
30 | ); 31 | 32 | export default TypingArea; 33 | -------------------------------------------------------------------------------- /client/build/asset-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": { 3 | "main.css": "/static/css/main.c68ddd7d.chunk.css", 4 | "main.js": "/static/js/main.e15e5a37.chunk.js", 5 | "main.js.map": "/static/js/main.e15e5a37.chunk.js.map", 6 | "runtime-main.js": "/static/js/runtime-main.c012fedc.js", 7 | "runtime-main.js.map": "/static/js/runtime-main.c012fedc.js.map", 8 | "static/css/2.150d169a.chunk.css": "/static/css/2.150d169a.chunk.css", 9 | "static/js/2.a950a4d1.chunk.js": "/static/js/2.a950a4d1.chunk.js", 10 | "static/js/2.a950a4d1.chunk.js.map": "/static/js/2.a950a4d1.chunk.js.map", 11 | "index.html": "/index.html", 12 | "static/css/2.150d169a.chunk.css.map": "/static/css/2.150d169a.chunk.css.map", 13 | "static/css/main.c68ddd7d.chunk.css.map": "/static/css/main.c68ddd7d.chunk.css.map", 14 | "static/js/2.a950a4d1.chunk.js.LICENSE.txt": "/static/js/2.a950a4d1.chunk.js.LICENSE.txt" 15 | }, 16 | "entrypoints": [ 17 | "static/js/runtime-main.c012fedc.js", 18 | "static/css/2.150d169a.chunk.css", 19 | "static/js/2.a950a4d1.chunk.js", 20 | "static/css/main.c68ddd7d.chunk.css", 21 | "static/js/main.e15e5a37.chunk.js" 22 | ] 23 | } -------------------------------------------------------------------------------- /client/src/components/Chat/components/MessageList/components/ReceiverMessage.jsx: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import moment from "moment"; 3 | import React from "react"; 4 | import ClockIcon from "./ClockIcon"; 5 | 6 | const ReceiverMessage = ({ 7 | username = "user", 8 | message = "Lorem ipsum dolor...", 9 | date, 10 | }) => ( 11 |
12 |
13 |
14 |
18 |
19 |
25 | {username} 26 |
27 |

{message}

28 |

29 | {moment.unix(date).format("LT")}{" "} 30 |

31 |
32 |
33 |
34 |
35 | ); 36 | export default ReceiverMessage; 37 | -------------------------------------------------------------------------------- /client/src/components/Chat/components/ChatList/components/AvatarImage.jsx: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import React, { useMemo } from "react"; 3 | import { getAvatarByUserAndRoomId } from "../../../../../utils"; 4 | import ChatIcon from "./ChatIcon"; 5 | 6 | const AvatarImage = ({ name, id }) => { 7 | const url = useMemo(() => { 8 | const av = getAvatarByUserAndRoomId("" + id); 9 | if (name === "Mary") { 10 | return `${process.env.PUBLIC_URL}/avatars/0.jpg`; 11 | } else if (name === "Pablo") { 12 | return `${process.env.PUBLIC_URL}/avatars/2.jpg`; 13 | } else if (name === "Joe") { 14 | return `${process.env.PUBLIC_URL}/avatars/9.jpg`; 15 | } else if (name === "Alex") { 16 | return `${process.env.PUBLIC_URL}/avatars/8.jpg`; 17 | } 18 | return av; 19 | }, [id, name]); 20 | 21 | return ( 22 | <> 23 | {name !== "General" ? ( 24 | {name} 30 | ) : ( 31 |
32 | 33 |
34 | )} 35 | 36 | ); 37 | }; 38 | 39 | export default AvatarImage; 40 | -------------------------------------------------------------------------------- /client/src/components/Chat/components/ChatList/components/ChatIcon.jsx: -------------------------------------------------------------------------------- 1 | const ChatIcon = () => ( 2 | 9 | 10 | 15 | 16 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | ); 31 | 32 | export default ChatIcon; 33 | -------------------------------------------------------------------------------- /client/build/static/js/runtime-main.c012fedc.js: -------------------------------------------------------------------------------- 1 | !function(e){function t(t){for(var n,l,i=t[0],f=t[1],a=t[2],p=0,s=[];p { 7 | const processedRooms = useMemo(() => { 8 | const roomsList = Object.values(rooms); 9 | const main = roomsList.filter((x) => x.id === "0"); 10 | let other = roomsList.filter((x) => x.id !== "0"); 11 | other = other.sort( 12 | (a, b) => +a.id.split(":").pop() - +b.id.split(":").pop() 13 | ); 14 | return [...(main ? main : []), ...other]; 15 | }, [rooms]); 16 | return ( 17 | <> 18 |
19 |
20 |

Chats

21 |
22 |
23 |
24 | {processedRooms.map((room) => ( 25 | 28 | dispatch({ type: "set current room", payload: room.id }) 29 | } 30 | active={currentRoom === room.id} 31 | room={room} 32 | /> 33 | ))} 34 |
35 |
36 |
37 |
38 | 39 | ); 40 | }; 41 | 42 | export default ChatList; 43 | -------------------------------------------------------------------------------- /client/src/components/Chat/components/MessageList/components/SenderMessage.jsx: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import moment from "moment"; 3 | import React from "react"; 4 | import ClockIcon from "./ClockIcon"; 5 | import OnlineIndicator from "../../OnlineIndicator"; 6 | 7 | const SenderMessage = ({ 8 | user, 9 | message = "Lorem ipsum dolor...", 10 | date, 11 | onUserClicked, 12 | }) => ( 13 |
14 |
15 |
19 |
20 | {user && ( 21 |
22 |
30 | {user.username} 31 |
32 | 33 |
34 | )} 35 |

{message}

36 |

37 | {moment.unix(date).format("LT")}{" "} 38 |

39 |
40 |
41 |
42 |
43 |
44 | ); 45 | 46 | export default SenderMessage; 47 | -------------------------------------------------------------------------------- /client/build/static/js/2.a950a4d1.chunk.js.LICENSE.txt: -------------------------------------------------------------------------------- 1 | /* 2 | object-assign 3 | (c) Sindre Sorhus 4 | @license MIT 5 | */ 6 | 7 | /*! 8 | Copyright (c) 2017 Jed Watson. 9 | Licensed under the MIT License (MIT), see 10 | http://jedwatson.github.io/classnames 11 | */ 12 | 13 | /*! 14 | * The buffer module from node.js, for the browser. 15 | * 16 | * @author Feross Aboukhadijeh 17 | * @license MIT 18 | */ 19 | 20 | /*! ieee754. BSD-3-Clause License. Feross Aboukhadijeh */ 21 | 22 | /** @license React v0.20.1 23 | * scheduler.production.min.js 24 | * 25 | * Copyright (c) Facebook, Inc. and its affiliates. 26 | * 27 | * This source code is licensed under the MIT license found in the 28 | * LICENSE file in the root directory of this source tree. 29 | */ 30 | 31 | /** @license React v17.0.1 32 | * react-dom.production.min.js 33 | * 34 | * Copyright (c) Facebook, Inc. and its affiliates. 35 | * 36 | * This source code is licensed under the MIT license found in the 37 | * LICENSE file in the root directory of this source tree. 38 | */ 39 | 40 | /** @license React v17.0.1 41 | * react-jsx-runtime.production.min.js 42 | * 43 | * Copyright (c) Facebook, Inc. and its affiliates. 44 | * 45 | * This source code is licensed under the MIT license found in the 46 | * LICENSE file in the root directory of this source tree. 47 | */ 48 | 49 | /** @license React v17.0.1 50 | * react.production.min.js 51 | * 52 | * Copyright (c) Facebook, Inc. and its affiliates. 53 | * 54 | * This source code is licensed under the MIT license found in the 55 | * LICENSE file in the root directory of this source tree. 56 | */ 57 | 58 | //! moment.js 59 | -------------------------------------------------------------------------------- /client/src/components/Chat/components/ChatList/components/Footer.jsx: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import React from "react"; 4 | import { Power } from "react-bootstrap-icons"; 5 | import OnlineIndicator from "../../OnlineIndicator"; 6 | import AvatarImage from "./AvatarImage"; 7 | 8 | const Footer = ({ user, onLogOut }) => ( 9 |
13 | {true ? ( 14 | <> 15 | 16 | 17 | 18 | ) : ( 19 | <> 20 | 21 | 22 | 23 | )} 24 |
25 | ); 26 | 27 | const LogoutButton = ({ onLogOut, col = 5, noinfo = false }) => ( 28 |
33 | Log out 34 |
35 | ); 36 | 37 | const UserInfo = ({ user, col = 7, noinfo = false }) => ( 38 |
43 |
44 | 45 |
46 | {!noinfo && ( 47 |
48 |
{user.username}
49 |
50 | 51 |

Active

52 |
53 |
54 | )} 55 |
56 | ); 57 | 58 | export default Footer; 59 | -------------------------------------------------------------------------------- /marketplace.json: -------------------------------------------------------------------------------- 1 | { 2 | "app_name": "Basic Redis chat app in Python", 3 | "description": "Showcases how to impliment chat app in Python (Flask), Socket.IO and Redis", 4 | "type": "Building Block", 5 | "contributed_by": "Redis", 6 | "repo_url": "https://github.com/redis-developer/basic-redis-chat-app-demo-python", 7 | "preview_image_url": "https://raw.githubusercontent.com/redis-developer/basic-redis-chat-app-demo-python/master/images/app_preview_image.png", 8 | "download_url": "https://github.com/redis-developer/basic-redis-chat-app-demo-python/archive/main.zip", 9 | "hosted_url": "", 10 | "quick_deploy": "true", 11 | "deploy_buttons": [ 12 | { 13 | "heroku": "https://heroku.com/deploy?template=https://github.com/redis-developer/basic-redis-chat-app-demo-python" 14 | }, 15 | { 16 | "Google": "https://deploy.cloud.run/?git_repo=https://github.com/redis-developer/basic-redis-chat-app-demo-python.git" 17 | } 18 | ], 19 | "language": [ 20 | "Python" 21 | ], 22 | "redis_commands": [ 23 | "AUTH", 24 | "INCR", 25 | "DECR", 26 | "HMSET", 27 | "EXISTS", 28 | "HEXISTS", 29 | "SET", 30 | "GET", 31 | "HGETALL", 32 | "ZRANGEBYSCORE", 33 | "ZADD", 34 | "SADD", 35 | "HMGET", 36 | "SISMEMBER", 37 | "SMEMBERS", 38 | "SREM", 39 | "PUBLISH", 40 | "SUBSCRIBE" 41 | ], 42 | "redis_use_cases": [ 43 | "Pub/Sub" 44 | ], 45 | "redis_features": [], 46 | "app_image_urls": [ 47 | "https://raw.githubusercontent.com/redis-developer/basic-redis-chat-app-demo-python/master/docs/screenshot001.png" 48 | ], 49 | "youtube_url": "https://www.youtube.com/watch?v=miK7xDkDXF0", 50 | "special_tags": [], 51 | "verticals": [], 52 | "markdown": "https://raw.githubusercontent.com/redis-developer/basic-redis-chat-app-demo-python/master/README.md" 53 | } -------------------------------------------------------------------------------- /chat/app.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | from flask import Flask 5 | from flask_cors import CORS 6 | from flask_session import Session 7 | from flask_socketio import SocketIO 8 | 9 | from chat import utils 10 | from chat.config import get_config 11 | from chat.socketio_signals import io_connect, io_disconnect, io_join_room, io_on_message 12 | 13 | sess = Session() 14 | app = Flask(__name__, static_url_path="", static_folder="../client/build") 15 | app.config.from_object(get_config()) 16 | CORS(app) 17 | socketio = SocketIO(app, cors_allowed_origins="*") 18 | 19 | 20 | def run_app(): 21 | # Create redis connection etc. 22 | # Here we initialize our database, create demo data (if it's necessary) 23 | # TODO: maybe we need to do it for gunicorn run also? 24 | utils.init_redis() 25 | sess.init_app(app) 26 | 27 | # moved to this method bc it only applies to app.py direct launch 28 | # Get port from the command-line arguments or environment variables 29 | arg = sys.argv[1:] 30 | # TODO: js client is hardcoded to proxy all to 8000 port, maybe change it? 31 | port = int(os.environ.get("PORT", 5000)) 32 | if len(arg) > 0: 33 | try: 34 | port = int(arg[0]) 35 | except ValueError: 36 | pass 37 | 38 | # we need socketio.run() instead of app.run() bc we need to use the eventlet server 39 | socketio.run(app, port=port, debug=True, use_reloader=True) 40 | 41 | 42 | # this was rewritten from decorators so we can move this methods to another file 43 | socketio.on_event("connect", io_connect) 44 | socketio.on_event("disconnect", io_disconnect) 45 | socketio.on_event("room.join", io_join_room) 46 | socketio.on_event("message", io_on_message) 47 | 48 | # routes moved to another file and we need to import it lately 49 | # bc they are using app from this file 50 | from chat import routes # noqa 51 | 52 | application = app 53 | -------------------------------------------------------------------------------- /client/src/utils.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { getUsers } from "./api"; 4 | 5 | /** 6 | * @param {string[]} names 7 | * @param {string} username 8 | */ 9 | export const parseRoomName = (names, username) => { 10 | for (let name of names) { 11 | if (typeof name !== 'string') { 12 | name = name[0]; 13 | } 14 | if (name !== username) { 15 | return name; 16 | } 17 | } 18 | return names[0]; 19 | }; 20 | 21 | /** Get an avatar for a room or a user */ 22 | export const getAvatarByUserAndRoomId = (roomId = "1") => { 23 | const TOTAL_IMAGES = 13; 24 | const seed1 = 654; 25 | const seed2 = 531; 26 | 27 | const uidParsed = +roomId.split(":").pop(); 28 | let roomIdParsed = +roomId.split(":").reverse().pop(); 29 | if (roomIdParsed < 0) { 30 | roomIdParsed += 3555; 31 | } 32 | 33 | const theId = (uidParsed * seed1 + roomIdParsed * seed2) % TOTAL_IMAGES; 34 | 35 | return `${process.env.PUBLIC_URL}/avatars/${theId}.jpg`; 36 | }; 37 | 38 | const jdenticon = require("jdenticon"); 39 | 40 | const avatars = {}; 41 | export const getAvatar = (username) => { 42 | let av = avatars[username]; 43 | if (av === undefined) { 44 | av = 45 | "data:image/svg+xml;base64," + window.btoa(jdenticon.toSvg(username, 50)); 46 | avatars[username] = av; 47 | } 48 | return av; 49 | }; 50 | 51 | export const populateUsersFromLoadedMessages = async (users, dispatch, messages) => { 52 | const userIds = {}; 53 | messages.forEach((message) => { 54 | userIds[message.from] = 1; 55 | }); 56 | 57 | const ids = Object.keys(userIds).filter( 58 | (id) => users[id] === undefined 59 | ); 60 | 61 | if (ids.length !== 0) { 62 | /** We need to fetch users first */ 63 | const newUsers = await getUsers(ids); 64 | dispatch({ 65 | type: "append users", 66 | payload: newUsers, 67 | }); 68 | } 69 | 70 | }; -------------------------------------------------------------------------------- /client/build/index.html: -------------------------------------------------------------------------------- 1 | Python Flask Redis chat
-------------------------------------------------------------------------------- /client/src/styles/style-overrides.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --primary: rgb(85, 110, 230) !important; 3 | --light: #f5f5f8 !important; 4 | --success: rgb(52, 195, 143) !important; 5 | } 6 | 7 | .bg-success { 8 | background-color: var(--success) !important; 9 | } 10 | 11 | .bg-light { 12 | background-color: var(--light) !important; 13 | } 14 | 15 | .bg-gray { 16 | background-color: var(--gray) !important; 17 | } 18 | 19 | .bg-primary { 20 | background-color: var(--primary) !important; 21 | } 22 | 23 | .text-primary { 24 | color: var(--primary) !important; 25 | } 26 | 27 | .list-group-item.active { 28 | background-color: var(--primary) !important; 29 | border-color: var(--primary) !important; 30 | } 31 | 32 | .btn-rounded { 33 | border-radius: 30px !important; 34 | } 35 | 36 | .btn { 37 | display: inline-block; 38 | font-weight: 400; 39 | color: #495057; 40 | text-align: center; 41 | vertical-align: middle; 42 | user-select: none; 43 | background-color: transparent; 44 | border: 1px solid transparent; 45 | border-radius: 30px !important; 46 | padding: 0.47rem 0.75rem; 47 | font-size: 0.8125rem; 48 | line-height: 1.5; 49 | border-radius: 0.25rem; 50 | transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, 51 | border-color 0.15s ease-in-out, -webkit-box-shadow 0.15s ease-in-out; 52 | transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, 53 | border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; 54 | transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, 55 | border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out, 56 | -webkit-box-shadow 0.15s ease-in-out; 57 | } 58 | 59 | .btn-primary { 60 | color: #fff; 61 | background-color: var(--primary); 62 | border-color: var(--primary); 63 | } 64 | 65 | .btn-primary.focus, 66 | .btn-primary:focus, 67 | .btn-primary:hover { 68 | color: #fff; 69 | background-color: #3452e1; 70 | border-color: #2948df; 71 | } 72 | 73 | .font-size-14 { 74 | font-size: 14px !important; 75 | } 76 | 77 | .font-size-11 { 78 | font-size: 11px !important; 79 | } 80 | 81 | .font-size-12 { 82 | font-size: 12px !important; 83 | } 84 | 85 | .font-size-15 { 86 | font-size: 15px !important; 87 | } 88 | 89 | .w-md { 90 | min-width: 110px; 91 | } 92 | -------------------------------------------------------------------------------- /client/src/components/Login/style.css: -------------------------------------------------------------------------------- 1 | .login-form .username-select button { 2 | background-color: transparent !important; 3 | color: inherit; 4 | padding: 7.5px 12px !important; 5 | display: block !important; 6 | border: 1px solid rgb(206, 212, 218) !important; 7 | border-radius: 4px !important; 8 | width: 100% !important; 9 | text-align: left !important; 10 | } 11 | 12 | .login-form .username-select .btn-primary:not(:disabled):not(.disabled).active, 13 | .login-form .username-select .btn-primary:not(:disabled):not(.disabled):active, 14 | .show > .btn-primary.dropdown-toggle { 15 | color: inherit; 16 | } 17 | 18 | .login-form .username-select .dropdown-menu.show.dropdown-menu-right { 19 | transform: translate(0px, 38px) !important; 20 | } 21 | 22 | .username-select-dropdown { 23 | position: relative; 24 | display: flex !important; 25 | align-items: center; 26 | background-color: transparent !important; 27 | color: inherit; 28 | padding: 0 12px !important; 29 | border: 1px solid rgb(206, 212, 218) !important; 30 | border-radius: 4px !important; 31 | width: 100% !important; 32 | text-align: left !important; 33 | height: calc(1.5em + 0.94rem + 2px) !important; 34 | 35 | cursor: pointer; 36 | } 37 | 38 | .username-select-dropdown .username-select-block { 39 | background-color: var(--white); 40 | position: absolute; 41 | top: -1138px; 42 | left: 0; 43 | opacity: 0; 44 | transform: scale(0.5, 0.5); 45 | transform-origin: top left; 46 | transition: opacity 0.2s ease, transform 0.2s ease; 47 | 48 | border: 1px solid rgb(206, 212, 218) !important; 49 | border-radius: 4px !important; 50 | 51 | padding: 8px 0px; 52 | } 53 | 54 | .username-select-dropdown:focus { 55 | outline: none; 56 | border-color: #80bdff !important; 57 | box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25); 58 | } 59 | 60 | .username-select-dropdown .username-select-block.open { 61 | top: 42px; 62 | transform: scale(1, 1); 63 | opacity: 1; 64 | } 65 | 66 | .username-select-row { 67 | display: flex; 68 | width: 100%; 69 | justify-content: space-between; 70 | align-items: center; 71 | } 72 | 73 | .username-select-dropdown .username-select-block .username-select-block-item { 74 | padding: 4px 24px; 75 | } 76 | 77 | .username-select-dropdown 78 | .username-select-block 79 | .username-select-block-item:hover { 80 | background-color: var(--light); 81 | } 82 | -------------------------------------------------------------------------------- /client/src/components/Navbar.jsx: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import React, { useEffect, useState } from "react"; 3 | import { getButtonLinks } from "../api"; 4 | 5 | const Navbar = () => { 6 | /** 7 | * @type {[{ 8 | * heroku?: string; 9 | * google_cloud?: string; 10 | * vercel?: string; 11 | * github?: string; 12 | * }, React.Dispatch]} 13 | */ 14 | const [links, setLinks] = useState(null); 15 | useEffect(() => { 16 | getButtonLinks().then(setLinks); 17 | }, []); 18 | return ( 19 | 29 | ); 30 | }; 31 | 32 | const GithubIcon = ({ link }) => ( 33 | 34 | 42 | 47 | 52 | 53 | 54 | ); 55 | 56 | export default Navbar; 57 | -------------------------------------------------------------------------------- /client/src/components/Logo.jsx: -------------------------------------------------------------------------------- 1 | const Logo = ({ width = 64, height = 64 }) => { 2 | return ( 3 | 10 | 11 | 12 | 16 | 20 | 21 | 22 | 23 | 27 | 28 | 29 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 43 | 44 | 45 | ); 46 | }; 47 | 48 | export default Logo; 49 | -------------------------------------------------------------------------------- /chat/demo_data.py: -------------------------------------------------------------------------------- 1 | from chat import utils 2 | import redis 3 | import math 4 | import json 5 | import random 6 | import time 7 | 8 | demo_users = ["Pablo", "Joe", "Mary", "Alex"] 9 | greetings = ["Hello", "Hi", "Yo", "Hola"] 10 | demo_password = "password123" 11 | messages = [ 12 | "Hello!", 13 | "Hi, How are you? What about our next meeting?", 14 | "Yeah everything is fine", 15 | "Next meeting tomorrow 10.00AM", 16 | "Wow that's great", 17 | ] 18 | 19 | 20 | def math_random(): 21 | return random.uniform(0, 1) 22 | 23 | 24 | def get_greeting(): 25 | return greetings[math.floor(math_random() * len(greetings))] 26 | 27 | 28 | def add_message(room_id, from_id, content, timestamp): 29 | room_key = f"room:{room_id}" 30 | message = { 31 | "from": from_id, 32 | "date": timestamp, 33 | "message": content, 34 | "roomId": room_id, 35 | } 36 | # Now the other user sends the greeting to the user 37 | utils.redis_client.zadd(room_key, {json.dumps(message): int(message["date"])}) 38 | 39 | 40 | def create(): 41 | """Create demo data with the default users""" 42 | # For each name create a user. 43 | users = [] 44 | for demo_user in demo_users: 45 | user = utils.create_user(demo_user, demo_password) 46 | users.append(user) 47 | 48 | rooms = {} 49 | 50 | # Once the demo users were created, for each user send messages to other ones. 51 | for user in users: 52 | other_users = filter(lambda x: x["id"] != user["id"], users) 53 | 54 | for other_user in other_users: 55 | private_room_id = utils.get_private_room_id( 56 | int(user["id"]), int(other_user["id"]) 57 | ) 58 | 59 | if private_room_id not in rooms: 60 | res = utils.create_private_room(user["id"], other_user["id"]) 61 | room = res[0] 62 | rooms[private_room_id] = room 63 | 64 | add_message( 65 | private_room_id, 66 | other_user["id"], 67 | get_greeting(), 68 | time.time() - math_random() * 222, 69 | ) 70 | 71 | def random_user_id(): 72 | return users[math.floor(len(users) * math_random())]["id"] 73 | 74 | for key, message in enumerate(messages): 75 | add_message( 76 | "0", random_user_id(), message, time.time() - ((len(messages) - key) * 200), 77 | ) 78 | -------------------------------------------------------------------------------- /client/src/components/Chat/index.jsx: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import React from "react"; 3 | import ChatList from "./components/ChatList"; 4 | import MessageList from "./components/MessageList"; 5 | import TypingArea from "./components/TypingArea"; 6 | import useChatHandlers from "./use-chat-handlers"; 7 | 8 | /** 9 | * @param {{ 10 | * onLogOut: () => void, 11 | * onMessageSend: (message: string, roomId: string) => void, 12 | * user: import("../../state").UserEntry 13 | * }} props 14 | */ 15 | export default function Chat({ onLogOut, user, onMessageSend }) { 16 | const { 17 | onLoadMoreMessages, 18 | onUserClicked, 19 | message, 20 | setMessage, 21 | rooms, 22 | room, 23 | currentRoom, 24 | dispatch, 25 | messageListElement, 26 | roomId, 27 | messages, 28 | users, 29 | } = useChatHandlers(user); 30 | 31 | return ( 32 |
33 |
34 |
35 | 42 |
43 | {/* Chat Box*/} 44 |
45 |
46 |

{room ? room.name : "Room"}

47 |
48 | 57 | 58 | {/* Typing area */} 59 | { 63 | e.preventDefault(); 64 | onMessageSend(message.trim(), roomId); 65 | setMessage(""); 66 | 67 | messageListElement.current.scrollTop = 68 | messageListElement.current.scrollHeight; 69 | }} 70 | /> 71 |
72 |
73 |
74 | ); 75 | } 76 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 98 | __pypackages__/ 99 | 100 | # Celery stuff 101 | celerybeat-schedule 102 | celerybeat.pid 103 | 104 | # SageMath parsed files 105 | *.sage.py 106 | 107 | # Environments 108 | .env 109 | .venv 110 | env/ 111 | venv/ 112 | ENV/ 113 | env.bak/ 114 | venv.bak/ 115 | 116 | # Spyder project settings 117 | .spyderproject 118 | .spyproject 119 | 120 | # Rope project settings 121 | .ropeproject 122 | 123 | # mkdocs documentation 124 | /site 125 | 126 | # mypy 127 | .mypy_cache/ 128 | .dmypy.json 129 | dmypy.json 130 | 131 | # Pyre type checker 132 | .pyre/ 133 | 134 | # pytype static type analyzer 135 | .pytype/ 136 | 137 | # Cython debug symbols 138 | cython_debug/ 139 | 140 | # IDE 141 | .idea -------------------------------------------------------------------------------- /client/src/api.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | axios.defaults.withCredentials = true; 3 | 4 | const BASE_URL = ''; 5 | 6 | export const MESSAGES_TO_LOAD = 15; 7 | 8 | const url = x => `${BASE_URL}${x}`; 9 | 10 | /** Checks if there's an existing session. */ 11 | export const getMe = () => { 12 | return axios.get(url('/me')) 13 | .then(x => x.data) 14 | .catch(_ => null); 15 | }; 16 | 17 | /** Handle user log in */ 18 | export const login = (username, password) => { 19 | return axios.post(url('/login'), { 20 | username, 21 | password 22 | }).then(x => 23 | x.data 24 | ) 25 | .catch(e => { throw new Error(e.response && e.response.data && e.response.data.message); }); 26 | }; 27 | 28 | export const logOut = () => { 29 | return axios.post(url('/logout')); 30 | }; 31 | 32 | /** 33 | * Function for checking which deployment urls exist. 34 | * 35 | * @returns {Promise<{ 36 | * heroku?: string; 37 | * google_cloud?: string; 38 | * vercel?: string; 39 | * github?: string; 40 | * }>} 41 | */ 42 | export const getButtonLinks = () => { 43 | return axios.get(url('/links')) 44 | .then(x => x.data) 45 | .catch(_ => null); 46 | }; 47 | 48 | /** This was used to get a random login name (for demo purposes). */ 49 | export const getRandomName = () => { 50 | return axios.get(url('/randomname')).then(x => x.data); 51 | }; 52 | 53 | /** 54 | * Load messages 55 | * 56 | * @param {string} id room id 57 | * @param {number} offset 58 | * @param {number} size 59 | */ 60 | export const getMessages = (id, 61 | offset = 0, 62 | size = MESSAGES_TO_LOAD 63 | ) => { 64 | return axios.get(url(`/room/${id}/messages`), { 65 | params: { 66 | offset, 67 | size 68 | } 69 | }) 70 | .then(x => x.data.reverse()); 71 | }; 72 | 73 | /** 74 | * @returns {Promise<{ name: string, id: string, messages: Array }>} 75 | */ 76 | export const getPreloadedRoom = async () => { 77 | return axios.get(url(`/room/0/preload`)).then(x => x.data); 78 | }; 79 | 80 | /** 81 | * Fetch users by requested ids 82 | * @param {Array} ids 83 | */ 84 | export const getUsers = (ids) => { 85 | return axios.get(url(`/users`), { params: { ids } }).then(x => x.data); 86 | }; 87 | 88 | /** Fetch users which are online */ 89 | export const getOnlineUsers = () => { 90 | return axios.get(url(`/users/online`)).then(x => x.data); 91 | }; 92 | 93 | /** This one is called on a private messages room created. */ 94 | export const addRoom = async (user1, user2) => { 95 | return axios.post(url(`/room`), { user1, user2 }).then(x => x.data); 96 | }; 97 | 98 | /** 99 | * @returns {Promise>} 100 | */ 101 | export const getRooms = async (userId) => { 102 | return axios.get(url(`/rooms/${userId}`)).then(x => x.data); 103 | }; 104 | 105 | export const getEventSource = () => new EventSource(url('/stream')); 106 | -------------------------------------------------------------------------------- /client/src/components/Chat/components/MessageList/index.jsx: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import React from "react"; 3 | import { MESSAGES_TO_LOAD } from "../../../../api"; 4 | import InfoMessage from "./components/InfoMessage"; 5 | import MessagesLoading from "./components/MessagesLoading"; 6 | import NoMessages from "./components/NoMessages"; 7 | import ReceiverMessage from "./components/ReceiverMessage"; 8 | import SenderMessage from "./components/SenderMessage"; 9 | 10 | const MessageList = ({ 11 | messageListElement, 12 | messages, 13 | room, 14 | onLoadMoreMessages, 15 | user, 16 | onUserClicked, 17 | users, 18 | }) => ( 19 |
23 | {messages === undefined ? ( 24 | 25 | ) : messages.length === 0 ? ( 26 | 27 | ) : ( 28 | <> 29 | )} 30 |
31 | {messages && messages.length !== 0 && ( 32 | <> 33 | {room.offset && room.offset >= MESSAGES_TO_LOAD ? ( 34 |
35 |
38 |
39 | 49 |
50 |
53 |
54 | ) : ( 55 | <> 56 | )} 57 | {messages.map((message, x) => { 58 | const key = message.message + message.date + message.from + x; 59 | if (message.from === "info") { 60 | return ; 61 | } 62 | if (+message.from !== +user.id) { 63 | return ( 64 | onUserClicked(message.from)} 66 | key={key} 67 | message={message.message} 68 | date={message.date} 69 | user={users[message.from]} 70 | /> 71 | ); 72 | } 73 | return ( 74 | 82 | ); 83 | })} 84 | 85 | )} 86 |
87 |
88 | ); 89 | export default MessageList; 90 | -------------------------------------------------------------------------------- /chat/socketio_signals.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from flask import session 4 | from flask_socketio import emit, join_room 5 | 6 | from chat import utils 7 | 8 | 9 | def publish(name, message, broadcast=False, room=None): 10 | """If the messages' origin is the same sever, use socket.io for sending, otherwise: pub/sub""" 11 | if room: 12 | emit(name, message, room=room, broadcast=True) 13 | else: 14 | emit(name, message, broadcast=broadcast) 15 | # Here is an additional publish for the redis pub/sub 16 | outgoing = {"serverId": utils.SERVER_ID, "type": name, "data": message} 17 | utils.redis_client.publish("MESSAGES", json.dumps(outgoing)) 18 | 19 | 20 | def io_connect(): 21 | """Handle socket.io connection, check if the session is attached""" 22 | # it's better to use get method for dict-like objects, it provides helpful setting of default value 23 | user = session.get("user", None) 24 | if not user: 25 | return 26 | 27 | user_id = user.get("id", None) 28 | utils.redis_client.sadd("online_users", user_id) 29 | 30 | msg = dict(user) 31 | msg["online"] = True 32 | 33 | publish("user.connected", msg, broadcast=True) 34 | 35 | 36 | def io_disconnect(): 37 | user = session.get("user", None) 38 | if user: 39 | utils.redis_client.srem("online_users", user["id"]) 40 | msg = dict(user) 41 | msg["online"] = False 42 | publish("user.disconnected", msg, broadcast=True) 43 | 44 | 45 | def io_join_room(id_room): 46 | join_room(id_room) 47 | 48 | 49 | def io_on_message(message): 50 | """Handle incoming message, make sure it's send to the correct room.""" 51 | 52 | def escape(htmlstring): 53 | """Clean up html from the incoming string""" 54 | escapes = {'"': """, "'": "'", "<": "<", ">": ">"} 55 | # This is done first to prevent escaping other escapes. 56 | htmlstring = htmlstring.replace("&", "&") 57 | for seq, esc in escapes.items(): 58 | htmlstring = htmlstring.replace(seq, esc) 59 | return htmlstring 60 | 61 | # Make sure nothing illegal is sent here. 62 | message["message"] = escape(message["message"]) 63 | # The user might be set as offline if he tried to access the chat from another tab, pinging by message 64 | # resets the user online status 65 | utils.redis_client.sadd("online_users", message["from"]) 66 | # We've got a new message. Store it in db, then send back to the room. */ 67 | message_string = json.dumps(message) 68 | room_id = message["roomId"] 69 | room_key = f"room:{room_id}" 70 | 71 | is_private = not bool(utils.redis_client.exists(f"{room_key}:name")) 72 | room_has_messages = bool(utils.redis_client.exists(room_key)) 73 | 74 | if is_private and not room_has_messages: 75 | ids = room_id.split(":") 76 | msg = { 77 | "id": room_id, 78 | "names": [ 79 | utils.hmget(f"user:{ids[0]}", "username"), 80 | utils.hmget(f"user:{ids[1]}", "username"), 81 | ], 82 | } 83 | publish("show.room", msg, broadcast=True) 84 | utils.redis_client.zadd(room_key, {message_string: int(message["date"])}) 85 | 86 | if is_private: 87 | publish("message", message, room=room_id) 88 | else: 89 | publish("message", message, broadcast=True) 90 | -------------------------------------------------------------------------------- /chat/utils.py: -------------------------------------------------------------------------------- 1 | import json 2 | import math 3 | import random 4 | 5 | import bcrypt 6 | 7 | from chat import demo_data 8 | from chat.config import get_config 9 | 10 | SERVER_ID = random.uniform(0, 322321) 11 | 12 | redis_client = get_config().redis_client 13 | 14 | 15 | def make_username_key(username): 16 | return f"username:{username}" 17 | 18 | 19 | def create_user(username, password): 20 | username_key = make_username_key(username) 21 | # Create a user 22 | hashed_password = bcrypt.hashpw(str(password).encode("utf-8"), bcrypt.gensalt(10)) 23 | next_id = redis_client.incr("total_users") 24 | user_key = f"user:{next_id}" 25 | redis_client.set(username_key, user_key) 26 | redis_client.hmset(user_key, {"username": username, "password": hashed_password}) 27 | 28 | redis_client.sadd(f"user:{next_id}:rooms", "0") 29 | 30 | return {"id": next_id, "username": username} 31 | 32 | 33 | def get_messages(room_id=0, offset=0, size=50): 34 | """Check if room with id exists; fetch messages limited by size""" 35 | room_key = f"room:{room_id}" 36 | room_exists = redis_client.exists(room_key) 37 | if not room_exists: 38 | return [] 39 | else: 40 | values = redis_client.zrevrange(room_key, offset, offset + size) 41 | return list(map(lambda x: json.loads(x.decode("utf-8")), values)) 42 | 43 | 44 | def hmget(key, key2): 45 | """Wrapper around hmget to unpack bytes from hmget""" 46 | result = redis_client.hmget(key, key2) 47 | return list(map(lambda x: x.decode("utf-8"), result)) 48 | 49 | 50 | def get_private_room_id(user1, user2): 51 | if math.isnan(user1) or math.isnan(user2) or user1 == user2: 52 | return None 53 | min_user_id = user2 if user1 > user2 else user1 54 | max_user_id = user1 if user1 > user2 else user2 55 | return f"{min_user_id}:{max_user_id}" 56 | 57 | 58 | def create_private_room(user1, user2): 59 | """Create a private room and add users to it""" 60 | room_id = get_private_room_id(user1, user2) 61 | if not room_id: 62 | return None, True 63 | 64 | # Add rooms to those users 65 | redis_client.sadd(f"user:{user1}:rooms", room_id) 66 | redis_client.sadd(f"user:{user2}:rooms", room_id) 67 | 68 | return ( 69 | { 70 | "id": room_id, 71 | "names": [ 72 | hmget(f"user:{user1}", "username"), 73 | hmget(f"user:{user2}", "username"), 74 | ], 75 | }, 76 | False, 77 | ) 78 | 79 | 80 | def init_redis(): 81 | # We store a counter for the total users and increment it on each register 82 | total_users_exist = redis_client.exists("total_users") 83 | if not total_users_exist: 84 | # This counter is used for the id 85 | redis_client.set("total_users", 0) 86 | # Some rooms have pre-defined names. When the clients attempts to fetch a room, an additional lookup 87 | # is handled to resolve the name. 88 | # Rooms with private messages don't have a name 89 | redis_client.set(f"room:0:name", "General") 90 | 91 | demo_data.create() 92 | 93 | # We use event stream for pub sub. A client connects to the stream endpoint and listens for the messages 94 | 95 | 96 | def event_stream(): 97 | """Handle message formatting, etc.""" 98 | pubsub = redis_client.pubsub(ignore_subscribe_messages=True) 99 | pubsub.subscribe("MESSAGES") 100 | for message in pubsub.listen(): 101 | message_parsed = json.loads(message["data"]) 102 | if message_parsed["serverId"] == SERVER_ID: 103 | continue 104 | 105 | data = "data: %s\n\n" % json.dumps( 106 | {"type": message_parsed["type"], "data": message_parsed["data"],} 107 | ) 108 | yield data -------------------------------------------------------------------------------- /client/src/components/Chat/use-chat-handlers.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import { useCallback } from "react"; 3 | import { useEffect, useState, useRef } from "react"; 4 | import { addRoom, getMessages } from "../../api"; 5 | import { useAppState } from "../../state"; 6 | import { parseRoomName, populateUsersFromLoadedMessages } from "../../utils"; 7 | 8 | /** Lifecycle hooks with callbacks for the Chat component */ 9 | const useChatHandlers = (/** @type {import("../../state").UserEntry} */ user) => { 10 | const [state, dispatch] = useAppState(); 11 | const messageListElement = useRef(null); 12 | 13 | /** @type {import("../../state").Room} */ 14 | const room = state.rooms[state.currentRoom]; 15 | const roomId = room?.id; 16 | const messages = room?.messages; 17 | 18 | const [message, setMessage] = useState(""); 19 | 20 | const scrollToTop = useCallback(() => { 21 | setTimeout(() => { 22 | if (messageListElement.current) { 23 | messageListElement.current.scrollTop = 0; 24 | } 25 | }, 0); 26 | }, []); 27 | 28 | const scrollToBottom = useCallback(() => { 29 | if (messageListElement.current) { 30 | messageListElement.current.scrollTo({ 31 | top: messageListElement.current.scrollHeight, 32 | }); 33 | } 34 | }, []); 35 | 36 | useEffect(() => { 37 | scrollToBottom(); 38 | }, [messages, scrollToBottom]); 39 | 40 | const onFetchMessages = useCallback( 41 | (offset = 0, prepend = false) => { 42 | getMessages(roomId, offset).then(async (messages) => { 43 | /** We've got messages but it's possible we might not have the cached user entires which correspond to those messages */ 44 | await populateUsersFromLoadedMessages(state.users, dispatch, messages); 45 | 46 | dispatch({ 47 | type: prepend ? "prepend messages" : "set messages", 48 | payload: { id: roomId, messages: messages }, 49 | }); 50 | if (prepend) { 51 | setTimeout(() => { 52 | scrollToTop(); 53 | }, 10); 54 | } else { 55 | scrollToBottom(); 56 | } 57 | }); 58 | }, 59 | [dispatch, roomId, scrollToBottom, scrollToTop, state.users] 60 | ); 61 | 62 | useEffect(() => { 63 | if (roomId === undefined) { 64 | return; 65 | } 66 | if (messages === undefined) { 67 | /** Fetch logic goes here */ 68 | onFetchMessages(); 69 | } 70 | }, [ 71 | messages, 72 | dispatch, 73 | roomId, 74 | state.users, 75 | state, 76 | scrollToBottom, 77 | onFetchMessages, 78 | ]); 79 | 80 | useEffect(() => { 81 | if (messageListElement.current) { 82 | scrollToBottom(); 83 | } 84 | }, [scrollToBottom, roomId]); 85 | 86 | const onUserClicked = async (userId) => { 87 | /** Check if room exists. */ 88 | const targetUser = state.users[userId]; 89 | let roomId = targetUser.room; 90 | if (roomId === undefined) { 91 | // @ts-ignore 92 | const room = await addRoom(userId, user.id); 93 | roomId = room.id; 94 | /** We need to set this room id to user. */ 95 | dispatch({ type: "set user", payload: { ...targetUser, room: roomId } }); 96 | /** Then a new room should be added to the store. */ 97 | dispatch({ 98 | type: "add room", 99 | // @ts-ignore 100 | payload: { id: roomId, name: parseRoomName(room.names, user.username) }, 101 | }); 102 | } 103 | /** Then a room should be changed */ 104 | dispatch({ type: "set current room", payload: roomId }); 105 | }; 106 | 107 | const onLoadMoreMessages = useCallback(() => { 108 | onFetchMessages(room.offset, true); 109 | }, [onFetchMessages, room]); 110 | 111 | return { 112 | onLoadMoreMessages, 113 | onUserClicked, 114 | message, 115 | setMessage, 116 | dispatch, 117 | room, 118 | rooms: state.rooms, 119 | currentRoom: state.currentRoom, 120 | messageListElement, 121 | roomId, 122 | users: state.users, 123 | messages, 124 | }; 125 | }; 126 | export default useChatHandlers; -------------------------------------------------------------------------------- /client/src/components/Chat/components/ChatList/components/ChatListItem/index.jsx: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import "./style.css"; 3 | import React, { useMemo } from "react"; 4 | import { useAppState } from "../../../../../../state"; 5 | import moment from "moment"; 6 | import { useEffect } from "react"; 7 | import { getMessages } from "../../../../../../api"; 8 | import AvatarImage from "../AvatarImage"; 9 | import OnlineIndicator from "../../../OnlineIndicator"; 10 | 11 | /** 12 | * @param {{ active: boolean; room: import('../../../../../../state').Room; onClick: () => void; }} props 13 | */ 14 | const ChatListItem = ({ room, active = false, onClick }) => { 15 | const { online, name, lastMessage, userId } = useChatListItemHandlers(room); 16 | return ( 17 |
23 |
24 | 25 |
26 |
27 | 28 |
29 |
30 |
{name}
31 | {lastMessage && ( 32 |

{lastMessage.message}

33 | )} 34 |
35 | {lastMessage && ( 36 |
37 | {moment.unix(lastMessage.date).format("LT")} 38 |
39 | )} 40 |
41 | ); 42 | }; 43 | 44 | const useChatListItemHandlers = ( 45 | /** @type {import("../../../../../../state").Room} */ room 46 | ) => { 47 | const { id, name } = room; 48 | const [state] = useAppState(); 49 | 50 | /** Here we want to associate the room with a user by its name (since it's unique). */ 51 | const [isUser, online, userId] = useMemo(() => { 52 | try { 53 | let pseudoUserId = Math.abs(parseInt(id.split(":").reverse().pop())); 54 | const isUser = pseudoUserId > 0; 55 | const usersFiltered = Object.entries(state.users) 56 | .filter(([, user]) => user.username === name) 57 | .map(([, user]) => user); 58 | let online = false; 59 | if (usersFiltered.length > 0) { 60 | online = usersFiltered[0].online; 61 | pseudoUserId = +usersFiltered[0].id; 62 | } 63 | return [isUser, online, pseudoUserId]; 64 | } catch (_) { 65 | return [false, false, "0"]; 66 | } 67 | }, [id, name, state.users]); 68 | 69 | const lastMessage = useLastMessage(room); 70 | 71 | return { 72 | isUser, 73 | online, 74 | userId, 75 | name: room.name, 76 | lastMessage, 77 | }; 78 | }; 79 | 80 | const useLastMessage = ( 81 | /** @type {import("../../../../../../state").Room} */ room 82 | ) => { 83 | const [, dispatch] = useAppState(); 84 | const { lastMessage } = room; 85 | useEffect(() => { 86 | if (lastMessage === undefined) { 87 | /** need to fetch it */ 88 | if (room.messages === undefined) { 89 | getMessages(room.id, 0, 1).then((messages) => { 90 | let message = null; 91 | if (messages.length !== 0) { 92 | message = messages.pop(); 93 | } 94 | dispatch({ 95 | type: "set last message", 96 | payload: { id: room.id, lastMessage: message }, 97 | }); 98 | }); 99 | } else if (room.messages.length === 0) { 100 | dispatch({ 101 | type: "set last message", 102 | payload: { id: room.id, lastMessage: null }, 103 | }); 104 | } else { 105 | dispatch({ 106 | type: "set last message", 107 | payload: { 108 | id: room.id, 109 | lastMessage: room.messages[room.messages.length - 1], 110 | }, 111 | }); 112 | } 113 | } 114 | }, [lastMessage, dispatch, room]); 115 | 116 | return lastMessage; 117 | }; 118 | 119 | export default ChatListItem; 120 | -------------------------------------------------------------------------------- /client/src/state.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import { createContext, useContext, useReducer } from "react"; 3 | 4 | /** 5 | * @typedef {{ 6 | * from: string 7 | * date: number 8 | * message: string 9 | * roomId?: string 10 | * }} Message 11 | * 12 | * @typedef {{ 13 | * name: string; 14 | * id: string; 15 | * messages?: Message[] 16 | * connected?: boolean; 17 | * offset?: number; 18 | * forUserId?: null | number | string 19 | * lastMessage?: Message | null 20 | * }} Room 21 | * 22 | * @typedef {{ 23 | * username: string; 24 | * id: string; 25 | * online?: boolean; 26 | * room?: string; 27 | * }} UserEntry 28 | * 29 | * @typedef {{ 30 | * currentRoom: string; 31 | * rooms: {[id: string]: Room}; 32 | * users: {[id: string]: UserEntry} 33 | * }} State 34 | * 35 | * @param {State} state 36 | * @param {{type: string; payload: any}} action 37 | * @returns {State} 38 | */ 39 | const reducer = (state, action) => { 40 | switch (action.type) { 41 | case "clear": 42 | return { currentRoom: "0", rooms: {}, users: {} }; 43 | case "set user": { 44 | return { 45 | ...state, 46 | users: { ...state.users, [action.payload.id]: action.payload }, 47 | }; 48 | } 49 | case "make user online": { 50 | return { 51 | ...state, 52 | users: { 53 | ...state.users, 54 | [action.payload]: { ...state.users[action.payload], online: true }, 55 | }, 56 | }; 57 | } 58 | case "append users": { 59 | return { ...state, users: { ...state.users, ...action.payload } }; 60 | } 61 | case "set messages": { 62 | return { 63 | ...state, 64 | rooms: { 65 | ...state.rooms, 66 | [action.payload.id]: { 67 | ...state.rooms[action.payload.id], 68 | messages: action.payload.messages, 69 | offset: action.payload.messages.length, 70 | }, 71 | }, 72 | }; 73 | } 74 | case "prepend messages": { 75 | const messages = [ 76 | ...action.payload.messages, 77 | ...state.rooms[action.payload.id].messages, 78 | ]; 79 | return { 80 | ...state, 81 | rooms: { 82 | ...state.rooms, 83 | [action.payload.id]: { 84 | ...state.rooms[action.payload.id], 85 | messages, 86 | offset: messages.length, 87 | }, 88 | }, 89 | }; 90 | } 91 | case "append message": 92 | if (state.rooms[action.payload.id] === undefined) { 93 | return state; 94 | } 95 | return { 96 | ...state, 97 | rooms: { 98 | ...state.rooms, 99 | [action.payload.id]: { 100 | ...state.rooms[action.payload.id], 101 | lastMessage: action.payload.message, 102 | messages: state.rooms[action.payload.id].messages 103 | ? [ 104 | ...state.rooms[action.payload.id].messages, 105 | action.payload.message, 106 | ] 107 | : undefined, 108 | }, 109 | }, 110 | }; 111 | case 'set last message': 112 | return { ...state, rooms: { ...state.rooms, [action.payload.id]: { ...state.rooms[action.payload.id], lastMessage: action.payload.lastMessage } } }; 113 | case "set current room": 114 | return { ...state, currentRoom: action.payload }; 115 | case "add room": 116 | return { 117 | ...state, 118 | rooms: { ...state.rooms, [action.payload.id]: action.payload }, 119 | }; 120 | case "set rooms": { 121 | /** @type {Room[]} */ 122 | const newRooms = action.payload; 123 | const rooms = { ...state.rooms }; 124 | newRooms.forEach((room) => { 125 | rooms[room.id] = { 126 | ...room, 127 | messages: rooms[room.id] && rooms[room.id].messages, 128 | }; 129 | }); 130 | return { ...state, rooms }; 131 | } 132 | default: 133 | return state; 134 | } 135 | }; 136 | 137 | /** @type {State} */ 138 | const initialState = { 139 | currentRoom: "main", 140 | rooms: {}, 141 | users: {}, 142 | }; 143 | 144 | const useAppStateContext = () => { 145 | return useReducer(reducer, initialState); 146 | }; 147 | 148 | // @ts-ignore 149 | export const AppContext = createContext(); 150 | 151 | /** 152 | * @returns {[ 153 | * State, 154 | * React.Dispatch<{ 155 | * type: string; 156 | * payload: any; 157 | * }> 158 | * ]} 159 | */ 160 | export const useAppState = () => { 161 | const [state, dispatch] = useContext(AppContext); 162 | return [state, dispatch]; 163 | }; 164 | 165 | export default useAppStateContext; -------------------------------------------------------------------------------- /client/src/styles/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: "Poppins", Arial, Helvetica, sans-serif; 3 | font-size: 13px; 4 | color: #495057; 5 | } 6 | 7 | .navbar { 8 | box-shadow: rgba(18, 38, 63, 0.03) 0px 12px 24px 0px; 9 | } 10 | 11 | .navbar-brand { 12 | font-size: 16px; 13 | } 14 | 15 | .chats-title { 16 | padding-left: 14px; 17 | } 18 | 19 | .login-page { 20 | display: flex; 21 | align-items: center; 22 | flex-direction: column; 23 | justify-content: center; 24 | padding-bottom: 190px; 25 | height: 100vh; 26 | } 27 | 28 | .form-signin { 29 | width: 100%; 30 | max-width: 330px; 31 | padding: 15px; 32 | margin: 0 auto; 33 | } 34 | 35 | .text-small { 36 | font-size: 0.9rem; 37 | } 38 | 39 | .messages-box, 40 | .chat-box { 41 | /* height: 510px; */ 42 | width: 100%; 43 | } 44 | 45 | .chat-box-wrapper { 46 | flex: 1; 47 | overflow-y: scroll; 48 | } 49 | 50 | .rounded-lg { 51 | border-radius: 0.5rem; 52 | } 53 | 54 | input::placeholder { 55 | font-size: 0.9rem; 56 | color: #999; 57 | } 58 | 59 | .centered-box { 60 | width: 100%; 61 | height: 100vh; 62 | display: flex; 63 | align-items: center; 64 | justify-content: center; 65 | } 66 | 67 | .login-error-anchor { 68 | position: relative; 69 | } 70 | 71 | .toast-box { 72 | text-align: left; 73 | margin-top: 30px; 74 | position: absolute; 75 | width: 100%; 76 | top: 0; 77 | display: flex; 78 | flex-direction: row; 79 | justify-content: center; 80 | } 81 | 82 | .full-height { 83 | height: 100vh; 84 | flex-direction: column; 85 | display: flex; 86 | } 87 | 88 | .full-height .container { 89 | flex: 1; 90 | } 91 | 92 | .container .row { 93 | height: 100%; 94 | } 95 | 96 | .flex-column { 97 | display: flex; 98 | flex-direction: column; 99 | } 100 | 101 | .bg-white.flex-column { 102 | height: 100%; 103 | } 104 | 105 | .flex { 106 | flex: 1; 107 | } 108 | 109 | .logout-button { 110 | cursor: pointer; 111 | display: flex; 112 | flex-direction: row; 113 | align-items: center; 114 | padding: 15px 20px; 115 | } 116 | 117 | .logout-button svg { 118 | margin-right: 15px; 119 | } 120 | 121 | .no-messages { 122 | opacity: 0.5; 123 | height: 100%; 124 | width: 100%; 125 | } 126 | 127 | .avatar-box { 128 | width: 50px; 129 | height: 50px; 130 | object-fit: cover; 131 | object-position: 50%; 132 | overflow: hidden; 133 | border-radius: 4px; 134 | cursor: pointer; 135 | } 136 | 137 | .user-link { 138 | cursor: pointer; 139 | } 140 | 141 | .user-link:hover { 142 | text-decoration: underline; 143 | } 144 | 145 | .online-indicator { 146 | width: 14px; 147 | height: 14px; 148 | border: 2px solid white; 149 | bottom: -7px; 150 | right: -7px; 151 | background-color: #4df573; 152 | } 153 | 154 | .online-indicator.selected { 155 | border: none; 156 | width: 12px; 157 | height: 12px; 158 | bottom: -5px; 159 | right: -5px; 160 | } 161 | 162 | .online-indicator.offline { 163 | background-color: #bbb; 164 | } 165 | 166 | span.pseudo-link { 167 | font-size: 14px; 168 | text-decoration: underline; 169 | color: var(--blue); 170 | cursor: pointer; 171 | } 172 | 173 | span.pseudo-link:hover { 174 | text-decoration: none; 175 | } 176 | 177 | .list-group-item { 178 | cursor: pointer; 179 | height: 70px; 180 | box-sizing: border-box; 181 | transition: background-color 0.1s ease-out; 182 | } 183 | 184 | .chat-icon { 185 | width: 45px; 186 | height: 45px; 187 | border-radius: 4px; 188 | background-color: #eee; 189 | } 190 | 191 | .chat-icon.active { 192 | background-color: var(--blue); 193 | } 194 | 195 | .chats-title { 196 | font-size: 15px; 197 | } 198 | 199 | .chat-body { 200 | border-radius: 10px !important; 201 | } 202 | 203 | .chat-list-container { 204 | height: 100%; 205 | } 206 | 207 | .chat-input { 208 | border-radius: 30px !important; 209 | background-color: #eff2f7 !important; 210 | border-color: #eff2f7 !important; 211 | padding-right: 120px; 212 | } 213 | 214 | .form-control::placeholder { 215 | font-size: 13px; 216 | } 217 | 218 | .form-control { 219 | display: block; 220 | width: 100%; 221 | height: calc(1.5em + 0.94rem + 2px); 222 | padding: 7.5px 12px; 223 | font-size: 13px; 224 | font-weight: 400; 225 | line-height: 1.5; 226 | color: #495057; 227 | background-color: #fff; 228 | background-clip: padding-box; 229 | border: 1px solid #ced4da; 230 | -webkit-transition: border-color 0.15s ease-in-out, 231 | -webkit-box-shadow 0.15s ease-in-out; 232 | transition: border-color 0.15s ease-in-out, 233 | -webkit-box-shadow 0.15s ease-in-out; 234 | transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; 235 | transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out, 236 | -webkit-box-shadow 0.15s ease-in-out; 237 | } 238 | 239 | .rounded-button { 240 | border-radius: 30px; 241 | background-color: var(--light); 242 | } 243 | -------------------------------------------------------------------------------- /chat/routes.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | 4 | import bcrypt 5 | from flask import Response, jsonify, request, session 6 | 7 | from chat import utils 8 | from chat.app import app 9 | from chat.auth import auth_middleware 10 | 11 | 12 | @app.route("/stream") 13 | def stream(): 14 | return Response(utils.event_stream(), mimetype="text/event-stream") 15 | 16 | 17 | # Return our SPA application. 18 | @app.route("/", defaults={"path": ""}) 19 | @app.route("/") 20 | def catch_all(path): 21 | return app.send_static_file("index.html") 22 | 23 | 24 | # This check if the session contains the valid user credentials 25 | @app.route("/me") 26 | def get_me(): 27 | user = session.get("user", None) 28 | return jsonify(user) 29 | 30 | 31 | @app.route("/links") 32 | def get_links(): 33 | """Returns JSON with available deploy links""" 34 | # Return github link to the repo 35 | repo = open(os.path.join(app.root_path, "../repo.json")) 36 | data = json.load(repo) 37 | return jsonify(data) 38 | 39 | 40 | @app.route("/login", methods=["POST"]) 41 | def login(): 42 | """For now, just simulate session behavior""" 43 | # TODO 44 | data = request.get_json() 45 | username = data["username"] 46 | password = data["password"] 47 | 48 | username_key = utils.make_username_key(username) 49 | user_exists = utils.redis_client.exists(username_key) 50 | if not user_exists: 51 | new_user = utils.create_user(username, password) 52 | session["user"] = new_user 53 | else: 54 | user_key = utils.redis_client.get(username_key).decode("utf-8") 55 | data = utils.redis_client.hgetall(user_key) 56 | if ( 57 | bcrypt.hashpw(password.encode("utf-8"), data[b"password"]) 58 | == data[b"password"] 59 | ): 60 | user = {"id": user_key.split(":")[-1], "username": username} 61 | session["user"] = user 62 | return user, 200 63 | 64 | return jsonify({"message": "Invalid username or password"}), 404 65 | 66 | 67 | @app.route("/logout", methods=["POST"]) 68 | @auth_middleware 69 | def logout(): 70 | session["user"] = None 71 | return jsonify(None), 200 72 | 73 | 74 | @app.route("/users/online") 75 | @auth_middleware 76 | def get_online_users(): 77 | online_ids = map( 78 | lambda x: x.decode("utf-8"), utils.redis_client.smembers("online_users") 79 | ) 80 | users = {} 81 | for online_id in online_ids: 82 | user = utils.redis_client.hgetall(f"user:{online_id}") 83 | users[online_id] = { 84 | "id": online_id, 85 | "username": user.get(b"username", "").decode("utf-8"), 86 | "online": True, 87 | } 88 | return jsonify(users), 200 89 | 90 | 91 | @app.route("/rooms/") 92 | @auth_middleware 93 | def get_rooms_for_user_id(user_id=0): 94 | """Get rooms for the selected user.""" 95 | # We got the room ids 96 | room_ids = list( 97 | map( 98 | lambda x: x.decode("utf-8"), 99 | list(utils.redis_client.smembers(f"user:{user_id}:rooms")), 100 | ) 101 | ) 102 | rooms = [] 103 | 104 | for room_id in room_ids: 105 | name = utils.redis_client.get(f"room:{room_id}:name") 106 | 107 | # It's a room without a name, likey the one with private messages 108 | if not name: 109 | room_exists = utils.redis_client.exists(f"room:{room_id}") 110 | if not room_exists: 111 | continue 112 | 113 | user_ids = room_id.split(":") 114 | if len(user_ids) != 2: 115 | return jsonify(None), 400 116 | 117 | rooms.append( 118 | { 119 | "id": room_id, 120 | "names": [ 121 | utils.hmget(f"user:{user_ids[0]}", "username"), 122 | utils.hmget(f"user:{user_ids[1]}", "username"), 123 | ], 124 | } 125 | ) 126 | else: 127 | rooms.append({"id": room_id, "names": [name.decode("utf-8")]}) 128 | return jsonify(rooms), 200 129 | 130 | 131 | @app.route("/room//messages") 132 | @auth_middleware 133 | def get_messages_for_selected_room(room_id="0"): 134 | offset = request.args.get("offset") 135 | size = request.args.get("size") 136 | 137 | try: 138 | messages = utils.get_messages(room_id, int(offset), int(size)) 139 | return jsonify(messages) 140 | except: 141 | return jsonify(None), 400 142 | 143 | 144 | @app.route("/users") 145 | def get_user_info_from_ids(): 146 | ids = request.args.getlist("ids[]") 147 | if ids: 148 | users = {} 149 | for id in ids: 150 | user = utils.redis_client.hgetall(f"user:{id}") 151 | is_member = utils.redis_client.sismember("online_users", id) 152 | users[id] = { 153 | "id": id, 154 | "username": user[b"username"].decode("utf-8"), 155 | "online": bool(is_member), 156 | } 157 | return jsonify(users) 158 | return jsonify(None), 404 159 | -------------------------------------------------------------------------------- /client/src/App.jsx: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import React, { useEffect, useCallback } from "react"; 3 | import Login from "./components/Login"; 4 | import Chat from "./components/Chat"; 5 | import { getOnlineUsers, getRooms } from "./api"; 6 | import useAppStateContext, { AppContext } from "./state"; 7 | import moment from "moment"; 8 | import { parseRoomName } from "./utils"; 9 | import { LoadingScreen } from "./components/LoadingScreen"; 10 | import Navbar from "./components/Navbar"; 11 | import { useSocket, useUser } from "./hooks"; 12 | 13 | const App = () => { 14 | const { 15 | loading, 16 | user, 17 | state, 18 | dispatch, 19 | onLogIn, 20 | onMessageSend, 21 | onLogOut, 22 | } = useAppHandlers(); 23 | 24 | if (loading) { 25 | return ; 26 | } 27 | 28 | const showLogin = !user; 29 | 30 | return ( 31 | 32 |
38 | 39 | {showLogin ? ( 40 | 41 | ) : ( 42 | 43 | )} 44 |
45 |
46 | ); 47 | }; 48 | 49 | const useAppHandlers = () => { 50 | const [state, dispatch] = useAppStateContext(); 51 | const onUserLoaded = useCallback( 52 | (user) => { 53 | if (user !== null) { 54 | if (!state.users[user.id]) { 55 | dispatch({ type: "set user", payload: { ...user, online: true } }); 56 | } 57 | } 58 | }, 59 | [dispatch, state.users] 60 | ); 61 | 62 | const { user, onLogIn, onLogOut, loading } = useUser(onUserLoaded, dispatch); 63 | const [socket, connected] = useSocket(user, dispatch); 64 | 65 | /** Socket joins specific rooms once they are added */ 66 | useEffect(() => { 67 | if (user === null) { 68 | /** We are logged out */ 69 | /** But it's necessary to pre-populate the main room, so the user won't wait for messages once he's logged in */ 70 | return; 71 | } 72 | if (connected) { 73 | /** 74 | * The socket needs to be joined to the newly added rooms 75 | * on an active connection. 76 | */ 77 | const newRooms = []; 78 | Object.keys(state.rooms).forEach((roomId) => { 79 | const room = state.rooms[roomId]; 80 | if (room.connected) { 81 | return; 82 | } 83 | newRooms.push({ ...room, connected: true }); 84 | socket.emit("room.join", room.id); 85 | }); 86 | if (newRooms.length !== 0) { 87 | dispatch({ type: "set rooms", payload: newRooms }); 88 | } 89 | } else { 90 | /** 91 | * It's necessary to set disconnected flags on rooms 92 | * once the client is not connected 93 | */ 94 | const newRooms = []; 95 | Object.keys(state.rooms).forEach((roomId) => { 96 | const room = state.rooms[roomId]; 97 | if (!room.connected) { 98 | return; 99 | } 100 | newRooms.push({ ...room, connected: false }); 101 | }); 102 | /** Only update the state if it's only necessary */ 103 | if (newRooms.length !== 0) { 104 | dispatch({ type: "set rooms", payload: newRooms }); 105 | } 106 | } 107 | }, [user, connected, dispatch, socket, state.rooms, state.users]); 108 | 109 | /** Populate default rooms when user is not null */ 110 | useEffect(() => { 111 | /** @ts-ignore */ 112 | if (Object.values(state.rooms).length === 0 && user !== null) { 113 | /** First of all fetch online users. */ 114 | getOnlineUsers().then((users) => { 115 | dispatch({ 116 | type: "append users", 117 | payload: users, 118 | }); 119 | }); 120 | /** Then get rooms. */ 121 | getRooms(user.id).then((rooms) => { 122 | const payload = []; 123 | rooms.forEach(({ id, names }) => { 124 | payload.push({ id, name: parseRoomName(names, user.username) }); 125 | }); 126 | /** Here we also can populate the state with default chat rooms */ 127 | dispatch({ 128 | type: "set rooms", 129 | payload, 130 | }); 131 | dispatch({ type: "set current room", payload: "0" }); 132 | }); 133 | } 134 | }, [dispatch, state.rooms, user]); 135 | 136 | const onMessageSend = useCallback( 137 | (message, roomId) => { 138 | if (typeof message !== "string" || message.trim().length === 0) { 139 | return; 140 | } 141 | if (!socket) { 142 | /** Normally there shouldn't be such case. */ 143 | console.error("Couldn't send message"); 144 | } 145 | socket.emit("message", { 146 | roomId: roomId, 147 | message, 148 | from: user.id, 149 | date: moment(new Date()).unix(), 150 | }); 151 | }, 152 | [user, socket] 153 | ); 154 | 155 | return { 156 | loading, 157 | user, 158 | state, 159 | dispatch, 160 | onLogIn, 161 | onMessageSend, 162 | onLogOut, 163 | }; 164 | }; 165 | 166 | export default App; 167 | -------------------------------------------------------------------------------- /client/src/hooks.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import { useEffect, useRef, useState } from "react"; 3 | import { getEventSource, getMe, login, logOut } from "./api"; 4 | import io from "socket.io-client"; 5 | import { parseRoomName } from "./utils"; 6 | 7 | /** 8 | * @param {import('./state').UserEntry} newUser 9 | */ 10 | const updateUser = (newUser, dispatch, infoMessage) => { 11 | dispatch({ type: "set user", payload: newUser }); 12 | if (infoMessage !== undefined) { 13 | dispatch({ 14 | type: "append message", 15 | payload: { 16 | id: "0", 17 | message: { 18 | /** Date isn't shown in the info message, so we only need a unique value */ 19 | date: Math.random() * 10000, 20 | from: "info", 21 | message: infoMessage, 22 | }, 23 | }, 24 | }); 25 | } 26 | }; 27 | 28 | const onShowRoom = (room, username, dispatch) => dispatch({ 29 | type: "add room", 30 | payload: { 31 | id: room.id, 32 | name: parseRoomName(room.names, username), 33 | }, 34 | }); 35 | 36 | const onMessage = (message, dispatch) => { 37 | /** Set user online */ 38 | dispatch({ 39 | type: "make user online", 40 | payload: message.from, 41 | }); 42 | dispatch({ 43 | type: "append message", 44 | payload: { id: message.roomId === undefined ? "0" : message.roomId, message }, 45 | }); 46 | }; 47 | 48 | /** @returns {[SocketIOClient.Socket, boolean]} */ 49 | const useSocket = (user, dispatch) => { 50 | const [connected, setConnected] = useState(false); 51 | /** @type {React.MutableRefObject} */ 52 | const socketRef = useRef(null); 53 | const eventSourceRef = useRef(null); 54 | const socket = socketRef.current; 55 | 56 | /** First of all it's necessary to handle the socket io connection */ 57 | useEffect(() => { 58 | if (user === null) { 59 | if (socket !== null) { 60 | socket.disconnect(); 61 | } 62 | setConnected(false); 63 | if (eventSourceRef.current !== null) { 64 | eventSourceRef.current.close(); 65 | eventSourceRef.current = null; 66 | } 67 | } else { 68 | 69 | if (eventSourceRef.current === null) { 70 | eventSourceRef.current = getEventSource(); 71 | /** Handle non socket.io messages */ 72 | eventSourceRef.current.onmessage = function (e) { 73 | const { type, data } = JSON.parse(e.data); 74 | switch (type) { 75 | case "user.connected": updateUser(data, dispatch, `${data.username} connected`); 76 | break; 77 | case "user.disconnected": updateUser(data, dispatch, `${data.username} left`); 78 | break; 79 | case "show.room": onShowRoom(data, user.username, dispatch); 80 | break; 81 | case 'message': onMessage(data, dispatch); 82 | break; 83 | default: 84 | break; 85 | } 86 | }; 87 | } 88 | 89 | if (socket !== null) { 90 | socket.connect(); 91 | } else { 92 | socketRef.current = io(); 93 | } 94 | setConnected(true); 95 | } 96 | }, [user, socket, dispatch]); 97 | 98 | /** 99 | * Once we are sure the socket io object is initialized 100 | * Add event listeners. 101 | */ 102 | useEffect(() => { 103 | if (connected && user) { 104 | socket.on("user.connected", (newUser) => updateUser(newUser, dispatch, `${newUser.username} connected`)); 105 | socket.on("user.disconnected", (newUser) => updateUser(newUser, dispatch, `${newUser.username} left`)); 106 | socket.on("show.room", (room) => { 107 | onShowRoom(room, user.username, dispatch); 108 | }); 109 | socket.on("message", (message) => { 110 | onMessage(message, dispatch); 111 | }); 112 | } else { 113 | /** If there was a log out, we need to clear existing listeners on an active socket connection */ 114 | if (socket) { 115 | socket.off("user.connected"); 116 | socket.off("user.disconnected"); 117 | socket.off("user.room"); 118 | socket.off("message"); 119 | } 120 | } 121 | }, [connected, user, dispatch, socket]); 122 | 123 | return [socket, connected]; 124 | }; 125 | 126 | /** User management hook. */ 127 | const useUser = (onUserLoaded = (user) => { }, dispatch) => { 128 | const [loading, setLoading] = useState(true); 129 | /** @type {[import('./state.js').UserEntry | null, React.Dispatch]} */ 130 | const [user, setUser] = useState(null); 131 | /** Callback used in log in form. */ 132 | const onLogIn = ( 133 | username = "", 134 | password = "", 135 | onError = (val = null) => { }, 136 | onLoading = (loading = false) => { } 137 | ) => { 138 | onError(null); 139 | onLoading(true); 140 | login(username, password) 141 | .then((x) => { 142 | setUser(x); 143 | }) 144 | .catch((e) => onError(e.message)) 145 | .finally(() => onLoading(false)); 146 | }; 147 | 148 | /** Log out form */ 149 | const onLogOut = async () => { 150 | logOut().then(() => { 151 | setUser(null); 152 | /** This will clear the store, to completely re-initialize an app on the next login. */ 153 | dispatch({ type: "clear" }); 154 | setLoading(true); 155 | }); 156 | }; 157 | 158 | /** Runs once when the component is mounted to check if there's user stored in cookies */ 159 | useEffect(() => { 160 | if (!loading) { 161 | return; 162 | } 163 | getMe().then((user) => { 164 | setUser(user); 165 | setLoading(false); 166 | onUserLoaded(user); 167 | }); 168 | }, [onUserLoaded, loading]); 169 | 170 | return { user, onLogIn, onLogOut, loading }; 171 | }; 172 | 173 | export { 174 | updateUser, 175 | useSocket, 176 | useUser 177 | }; -------------------------------------------------------------------------------- /client/src/styles/font-face.css: -------------------------------------------------------------------------------- 1 | /* devanagari */ 2 | @font-face { 3 | font-family: "Poppins"; 4 | font-style: normal; 5 | font-weight: 300; 6 | font-display: swap; 7 | src: url(https://fonts.gstatic.com/s/poppins/v15/pxiByp8kv8JHgFVrLDz8Z11lFd2JQEl8qw.woff2) 8 | format("woff2"); 9 | unicode-range: U+0900-097F, U+1CD0-1CF6, U+1CF8-1CF9, U+200C-200D, U+20A8, 10 | U+20B9, U+25CC, U+A830-A839, U+A8E0-A8FB; 11 | } 12 | /* latin-ext */ 13 | @font-face { 14 | font-family: "Poppins"; 15 | font-style: normal; 16 | font-weight: 300; 17 | font-display: swap; 18 | src: url(https://fonts.gstatic.com/s/poppins/v15/pxiByp8kv8JHgFVrLDz8Z1JlFd2JQEl8qw.woff2) 19 | format("woff2"); 20 | unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, 21 | U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; 22 | } 23 | /* latin */ 24 | @font-face { 25 | font-family: "Poppins"; 26 | font-style: normal; 27 | font-weight: 300; 28 | font-display: swap; 29 | src: url(https://fonts.gstatic.com/s/poppins/v15/pxiByp8kv8JHgFVrLDz8Z1xlFd2JQEk.woff2) 30 | format("woff2"); 31 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, 32 | U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, 33 | U+FEFF, U+FFFD; 34 | } 35 | /* devanagari */ 36 | @font-face { 37 | font-family: "Poppins"; 38 | font-style: normal; 39 | font-weight: 400; 40 | font-display: swap; 41 | src: url(https://fonts.gstatic.com/s/poppins/v15/pxiEyp8kv8JHgFVrJJbecnFHGPezSQ.woff2) 42 | format("woff2"); 43 | unicode-range: U+0900-097F, U+1CD0-1CF6, U+1CF8-1CF9, U+200C-200D, U+20A8, 44 | U+20B9, U+25CC, U+A830-A839, U+A8E0-A8FB; 45 | } 46 | /* latin-ext */ 47 | @font-face { 48 | font-family: "Poppins"; 49 | font-style: normal; 50 | font-weight: 400; 51 | font-display: swap; 52 | src: url(https://fonts.gstatic.com/s/poppins/v15/pxiEyp8kv8JHgFVrJJnecnFHGPezSQ.woff2) 53 | format("woff2"); 54 | unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, 55 | U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; 56 | } 57 | /* latin */ 58 | @font-face { 59 | font-family: "Poppins"; 60 | font-style: normal; 61 | font-weight: 400; 62 | font-display: swap; 63 | src: url(https://fonts.gstatic.com/s/poppins/v15/pxiEyp8kv8JHgFVrJJfecnFHGPc.woff2) 64 | format("woff2"); 65 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, 66 | U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, 67 | U+FEFF, U+FFFD; 68 | } 69 | /* devanagari */ 70 | @font-face { 71 | font-family: "Poppins"; 72 | font-style: normal; 73 | font-weight: 500; 74 | font-display: swap; 75 | src: url(https://fonts.gstatic.com/s/poppins/v15/pxiByp8kv8JHgFVrLGT9Z11lFd2JQEl8qw.woff2) 76 | format("woff2"); 77 | unicode-range: U+0900-097F, U+1CD0-1CF6, U+1CF8-1CF9, U+200C-200D, U+20A8, 78 | U+20B9, U+25CC, U+A830-A839, U+A8E0-A8FB; 79 | } 80 | /* latin-ext */ 81 | @font-face { 82 | font-family: "Poppins"; 83 | font-style: normal; 84 | font-weight: 500; 85 | font-display: swap; 86 | src: url(https://fonts.gstatic.com/s/poppins/v15/pxiByp8kv8JHgFVrLGT9Z1JlFd2JQEl8qw.woff2) 87 | format("woff2"); 88 | unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, 89 | U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; 90 | } 91 | /* latin */ 92 | @font-face { 93 | font-family: "Poppins"; 94 | font-style: normal; 95 | font-weight: 500; 96 | font-display: swap; 97 | src: url(https://fonts.gstatic.com/s/poppins/v15/pxiByp8kv8JHgFVrLGT9Z1xlFd2JQEk.woff2) 98 | format("woff2"); 99 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, 100 | U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, 101 | U+FEFF, U+FFFD; 102 | } 103 | /* devanagari */ 104 | @font-face { 105 | font-family: "Poppins"; 106 | font-style: normal; 107 | font-weight: 600; 108 | font-display: swap; 109 | src: url(https://fonts.gstatic.com/s/poppins/v15/pxiByp8kv8JHgFVrLEj6Z11lFd2JQEl8qw.woff2) 110 | format("woff2"); 111 | unicode-range: U+0900-097F, U+1CD0-1CF6, U+1CF8-1CF9, U+200C-200D, U+20A8, 112 | U+20B9, U+25CC, U+A830-A839, U+A8E0-A8FB; 113 | } 114 | /* latin-ext */ 115 | @font-face { 116 | font-family: "Poppins"; 117 | font-style: normal; 118 | font-weight: 600; 119 | font-display: swap; 120 | src: url(https://fonts.gstatic.com/s/poppins/v15/pxiByp8kv8JHgFVrLEj6Z1JlFd2JQEl8qw.woff2) 121 | format("woff2"); 122 | unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, 123 | U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; 124 | } 125 | /* latin */ 126 | @font-face { 127 | font-family: "Poppins"; 128 | font-style: normal; 129 | font-weight: 600; 130 | font-display: swap; 131 | src: url(https://fonts.gstatic.com/s/poppins/v15/pxiByp8kv8JHgFVrLEj6Z1xlFd2JQEk.woff2) 132 | format("woff2"); 133 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, 134 | U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, 135 | U+FEFF, U+FFFD; 136 | } 137 | /* devanagari */ 138 | @font-face { 139 | font-family: "Poppins"; 140 | font-style: normal; 141 | font-weight: 700; 142 | font-display: swap; 143 | src: url(https://fonts.gstatic.com/s/poppins/v15/pxiByp8kv8JHgFVrLCz7Z11lFd2JQEl8qw.woff2) 144 | format("woff2"); 145 | unicode-range: U+0900-097F, U+1CD0-1CF6, U+1CF8-1CF9, U+200C-200D, U+20A8, 146 | U+20B9, U+25CC, U+A830-A839, U+A8E0-A8FB; 147 | } 148 | /* latin-ext */ 149 | @font-face { 150 | font-family: "Poppins"; 151 | font-style: normal; 152 | font-weight: 700; 153 | font-display: swap; 154 | src: url(https://fonts.gstatic.com/s/poppins/v15/pxiByp8kv8JHgFVrLCz7Z1JlFd2JQEl8qw.woff2) 155 | format("woff2"); 156 | unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, 157 | U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; 158 | } 159 | /* latin */ 160 | @font-face { 161 | font-family: "Poppins"; 162 | font-style: normal; 163 | font-weight: 700; 164 | font-display: swap; 165 | src: url(https://fonts.gstatic.com/s/poppins/v15/pxiByp8kv8JHgFVrLCz7Z1xlFd2JQEk.woff2) 166 | format("woff2"); 167 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, 168 | U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, 169 | U+FEFF, U+FFFD; 170 | } 171 | -------------------------------------------------------------------------------- /client/src/components/Login/index.jsx: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import { Toast } from "react-bootstrap"; 3 | import React, { useState, useRef } from "react"; 4 | import Logo from "../Logo"; 5 | import "./style.css"; 6 | import { useEffect } from "react"; 7 | 8 | const DEMO_USERS = ["Pablo", "Joe", "Mary", "Alex"]; 9 | 10 | export default function Login({ onLogIn }) { 11 | const [username, setUsername] = useState( 12 | () => DEMO_USERS[Math.floor(Math.random() * DEMO_USERS.length)] 13 | ); 14 | const [password, setPassword] = useState("password123"); 15 | const [error, setError] = useState(null); 16 | 17 | const onSubmit = async (event) => { 18 | event.preventDefault(); 19 | onLogIn(username, password, setError); 20 | }; 21 | 22 | return ( 23 | <> 24 |
25 |
31 |
32 |
43 |
44 |

Welcome Back !

45 |

Sign in to continue

46 |
47 |
48 | welcome back 53 |
54 |
55 |
59 |
67 | 68 |
69 |
70 |
71 | 72 |
81 | 82 | 83 |
84 | 89 |
90 | 91 | 94 | setPassword(event.target.value)} 97 | type="password" 98 | id="inputPassword" 99 | className="form-control" 100 | placeholder="Password" 101 | required 102 | /> 103 |
104 | 107 |
108 |
109 | setError(null)} 112 | show={error !== null} 113 | delay={3000} 114 | autohide 115 | > 116 | 117 | 122 | Error 123 | 124 | {error} 125 | 126 |
127 |
128 |
129 | 130 |
131 |
132 | 133 | ); 134 | } 135 | 136 | const UsernameSelect = ({ username, setUsername, names = [""] }) => { 137 | const [open, setOpen] = useState(false); 138 | const [width, setWidth] = useState(0); 139 | const ref = useRef(); 140 | /** @ts-ignore */ 141 | const clientRectWidth = ref.current?.getBoundingClientRect().width; 142 | useEffect(() => { 143 | /** @ts-ignore */ 144 | setWidth(clientRectWidth); 145 | }, [clientRectWidth]); 146 | 147 | /** Click away listener */ 148 | useEffect(() => { 149 | if (open) { 150 | const listener = () => setOpen(false); 151 | document.addEventListener("click", listener); 152 | return () => document.removeEventListener("click", listener); 153 | } 154 | }, [open]); 155 | 156 | /** Make the current div focused */ 157 | useEffect(() => { 158 | if (open) { 159 | /** @ts-ignore */ 160 | ref.current?.focus(); 161 | } 162 | }, [open]); 163 | 164 | return ( 165 |
setOpen((o) => !o)} 170 | > 171 |
172 |
{username}
173 |
174 | 175 | 176 | 177 |
178 |
179 |
183 | {names.map((name) => ( 184 |
setUsername(name)} 188 | > 189 | {name} 190 |
191 | ))} 192 |
193 |
194 | ); 195 | }; 196 | -------------------------------------------------------------------------------- /client/build/static/js/runtime-main.c012fedc.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"sources":["../webpack/bootstrap"],"names":["webpackJsonpCallback","data","moduleId","chunkId","chunkIds","moreModules","executeModules","i","resolves","length","Object","prototype","hasOwnProperty","call","installedChunks","push","modules","parentJsonpFunction","shift","deferredModules","apply","checkDeferredModules","result","deferredModule","fulfilled","j","depId","splice","__webpack_require__","s","installedModules","1","exports","module","l","m","c","d","name","getter","o","defineProperty","enumerable","get","r","Symbol","toStringTag","value","t","mode","__esModule","ns","create","key","bind","n","object","property","p","jsonpArray","this","oldJsonpFunction","slice"],"mappings":"aACE,SAASA,EAAqBC,GAQ7B,IAPA,IAMIC,EAAUC,EANVC,EAAWH,EAAK,GAChBI,EAAcJ,EAAK,GACnBK,EAAiBL,EAAK,GAIHM,EAAI,EAAGC,EAAW,GACpCD,EAAIH,EAASK,OAAQF,IACzBJ,EAAUC,EAASG,GAChBG,OAAOC,UAAUC,eAAeC,KAAKC,EAAiBX,IAAYW,EAAgBX,IACpFK,EAASO,KAAKD,EAAgBX,GAAS,IAExCW,EAAgBX,GAAW,EAE5B,IAAID,KAAYG,EACZK,OAAOC,UAAUC,eAAeC,KAAKR,EAAaH,KACpDc,EAAQd,GAAYG,EAAYH,IAKlC,IAFGe,GAAqBA,EAAoBhB,GAEtCO,EAASC,QACdD,EAASU,OAATV,GAOD,OAHAW,EAAgBJ,KAAKK,MAAMD,EAAiBb,GAAkB,IAGvDe,IAER,SAASA,IAER,IADA,IAAIC,EACIf,EAAI,EAAGA,EAAIY,EAAgBV,OAAQF,IAAK,CAG/C,IAFA,IAAIgB,EAAiBJ,EAAgBZ,GACjCiB,GAAY,EACRC,EAAI,EAAGA,EAAIF,EAAed,OAAQgB,IAAK,CAC9C,IAAIC,EAAQH,EAAeE,GACG,IAA3BX,EAAgBY,KAAcF,GAAY,GAE3CA,IACFL,EAAgBQ,OAAOpB,IAAK,GAC5Be,EAASM,EAAoBA,EAAoBC,EAAIN,EAAe,KAItE,OAAOD,EAIR,IAAIQ,EAAmB,GAKnBhB,EAAkB,CACrBiB,EAAG,GAGAZ,EAAkB,GAGtB,SAASS,EAAoB1B,GAG5B,GAAG4B,EAAiB5B,GACnB,OAAO4B,EAAiB5B,GAAU8B,QAGnC,IAAIC,EAASH,EAAiB5B,GAAY,CACzCK,EAAGL,EACHgC,GAAG,EACHF,QAAS,IAUV,OANAhB,EAAQd,GAAUW,KAAKoB,EAAOD,QAASC,EAAQA,EAAOD,QAASJ,GAG/DK,EAAOC,GAAI,EAGJD,EAAOD,QAKfJ,EAAoBO,EAAInB,EAGxBY,EAAoBQ,EAAIN,EAGxBF,EAAoBS,EAAI,SAASL,EAASM,EAAMC,GAC3CX,EAAoBY,EAAER,EAASM,IAClC5B,OAAO+B,eAAeT,EAASM,EAAM,CAAEI,YAAY,EAAMC,IAAKJ,KAKhEX,EAAoBgB,EAAI,SAASZ,GACX,qBAAXa,QAA0BA,OAAOC,aAC1CpC,OAAO+B,eAAeT,EAASa,OAAOC,YAAa,CAAEC,MAAO,WAE7DrC,OAAO+B,eAAeT,EAAS,aAAc,CAAEe,OAAO,KAQvDnB,EAAoBoB,EAAI,SAASD,EAAOE,GAEvC,GADU,EAAPA,IAAUF,EAAQnB,EAAoBmB,IAC/B,EAAPE,EAAU,OAAOF,EACpB,GAAW,EAAPE,GAA8B,kBAAVF,GAAsBA,GAASA,EAAMG,WAAY,OAAOH,EAChF,IAAII,EAAKzC,OAAO0C,OAAO,MAGvB,GAFAxB,EAAoBgB,EAAEO,GACtBzC,OAAO+B,eAAeU,EAAI,UAAW,CAAET,YAAY,EAAMK,MAAOA,IACtD,EAAPE,GAA4B,iBAATF,EAAmB,IAAI,IAAIM,KAAON,EAAOnB,EAAoBS,EAAEc,EAAIE,EAAK,SAASA,GAAO,OAAON,EAAMM,IAAQC,KAAK,KAAMD,IAC9I,OAAOF,GAIRvB,EAAoB2B,EAAI,SAAStB,GAChC,IAAIM,EAASN,GAAUA,EAAOiB,WAC7B,WAAwB,OAAOjB,EAAgB,SAC/C,WAA8B,OAAOA,GAEtC,OADAL,EAAoBS,EAAEE,EAAQ,IAAKA,GAC5BA,GAIRX,EAAoBY,EAAI,SAASgB,EAAQC,GAAY,OAAO/C,OAAOC,UAAUC,eAAeC,KAAK2C,EAAQC,IAGzG7B,EAAoB8B,EAAI,IAExB,IAAIC,EAAaC,KAAyB,mBAAIA,KAAyB,oBAAK,GACxEC,EAAmBF,EAAW5C,KAAKuC,KAAKK,GAC5CA,EAAW5C,KAAOf,EAClB2D,EAAaA,EAAWG,QACxB,IAAI,IAAIvD,EAAI,EAAGA,EAAIoD,EAAWlD,OAAQF,IAAKP,EAAqB2D,EAAWpD,IAC3E,IAAIU,EAAsB4C,EAI1BxC,I","file":"static/js/runtime-main.c012fedc.js","sourcesContent":[" \t// install a JSONP callback for chunk loading\n \tfunction webpackJsonpCallback(data) {\n \t\tvar chunkIds = data[0];\n \t\tvar moreModules = data[1];\n \t\tvar executeModules = data[2];\n\n \t\t// add \"moreModules\" to the modules object,\n \t\t// then flag all \"chunkIds\" as loaded and fire callback\n \t\tvar moduleId, chunkId, i = 0, resolves = [];\n \t\tfor(;i < chunkIds.length; i++) {\n \t\t\tchunkId = chunkIds[i];\n \t\t\tif(Object.prototype.hasOwnProperty.call(installedChunks, chunkId) && installedChunks[chunkId]) {\n \t\t\t\tresolves.push(installedChunks[chunkId][0]);\n \t\t\t}\n \t\t\tinstalledChunks[chunkId] = 0;\n \t\t}\n \t\tfor(moduleId in moreModules) {\n \t\t\tif(Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {\n \t\t\t\tmodules[moduleId] = moreModules[moduleId];\n \t\t\t}\n \t\t}\n \t\tif(parentJsonpFunction) parentJsonpFunction(data);\n\n \t\twhile(resolves.length) {\n \t\t\tresolves.shift()();\n \t\t}\n\n \t\t// add entry modules from loaded chunk to deferred list\n \t\tdeferredModules.push.apply(deferredModules, executeModules || []);\n\n \t\t// run deferred modules when all chunks ready\n \t\treturn checkDeferredModules();\n \t};\n \tfunction checkDeferredModules() {\n \t\tvar result;\n \t\tfor(var i = 0; i < deferredModules.length; i++) {\n \t\t\tvar deferredModule = deferredModules[i];\n \t\t\tvar fulfilled = true;\n \t\t\tfor(var j = 1; j < deferredModule.length; j++) {\n \t\t\t\tvar depId = deferredModule[j];\n \t\t\t\tif(installedChunks[depId] !== 0) fulfilled = false;\n \t\t\t}\n \t\t\tif(fulfilled) {\n \t\t\t\tdeferredModules.splice(i--, 1);\n \t\t\t\tresult = __webpack_require__(__webpack_require__.s = deferredModule[0]);\n \t\t\t}\n \t\t}\n\n \t\treturn result;\n \t}\n\n \t// The module cache\n \tvar installedModules = {};\n\n \t// object to store loaded and loading chunks\n \t// undefined = chunk not loaded, null = chunk preloaded/prefetched\n \t// Promise = chunk loading, 0 = chunk loaded\n \tvar installedChunks = {\n \t\t1: 0\n \t};\n\n \tvar deferredModules = [];\n\n \t// The require function\n \tfunction __webpack_require__(moduleId) {\n\n \t\t// Check if module is in cache\n \t\tif(installedModules[moduleId]) {\n \t\t\treturn installedModules[moduleId].exports;\n \t\t}\n \t\t// Create a new module (and put it into the cache)\n \t\tvar module = installedModules[moduleId] = {\n \t\t\ti: moduleId,\n \t\t\tl: false,\n \t\t\texports: {}\n \t\t};\n\n \t\t// Execute the module function\n \t\tmodules[moduleId].call(module.exports, module, module.exports, __webpack_require__);\n\n \t\t// Flag the module as loaded\n \t\tmodule.l = true;\n\n \t\t// Return the exports of the module\n \t\treturn module.exports;\n \t}\n\n\n \t// expose the modules object (__webpack_modules__)\n \t__webpack_require__.m = modules;\n\n \t// expose the module cache\n \t__webpack_require__.c = installedModules;\n\n \t// define getter function for harmony exports\n \t__webpack_require__.d = function(exports, name, getter) {\n \t\tif(!__webpack_require__.o(exports, name)) {\n \t\t\tObject.defineProperty(exports, name, { enumerable: true, get: getter });\n \t\t}\n \t};\n\n \t// define __esModule on exports\n \t__webpack_require__.r = function(exports) {\n \t\tif(typeof Symbol !== 'undefined' && Symbol.toStringTag) {\n \t\t\tObject.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });\n \t\t}\n \t\tObject.defineProperty(exports, '__esModule', { value: true });\n \t};\n\n \t// create a fake namespace object\n \t// mode & 1: value is a module id, require it\n \t// mode & 2: merge all properties of value into the ns\n \t// mode & 4: return value when already ns object\n \t// mode & 8|1: behave like require\n \t__webpack_require__.t = function(value, mode) {\n \t\tif(mode & 1) value = __webpack_require__(value);\n \t\tif(mode & 8) return value;\n \t\tif((mode & 4) && typeof value === 'object' && value && value.__esModule) return value;\n \t\tvar ns = Object.create(null);\n \t\t__webpack_require__.r(ns);\n \t\tObject.defineProperty(ns, 'default', { enumerable: true, value: value });\n \t\tif(mode & 2 && typeof value != 'string') for(var key in value) __webpack_require__.d(ns, key, function(key) { return value[key]; }.bind(null, key));\n \t\treturn ns;\n \t};\n\n \t// getDefaultExport function for compatibility with non-harmony modules\n \t__webpack_require__.n = function(module) {\n \t\tvar getter = module && module.__esModule ?\n \t\t\tfunction getDefault() { return module['default']; } :\n \t\t\tfunction getModuleExports() { return module; };\n \t\t__webpack_require__.d(getter, 'a', getter);\n \t\treturn getter;\n \t};\n\n \t// Object.prototype.hasOwnProperty.call\n \t__webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); };\n\n \t// __webpack_public_path__\n \t__webpack_require__.p = \"/\";\n\n \tvar jsonpArray = this[\"webpackJsonpclient\"] = this[\"webpackJsonpclient\"] || [];\n \tvar oldJsonpFunction = jsonpArray.push.bind(jsonpArray);\n \tjsonpArray.push = webpackJsonpCallback;\n \tjsonpArray = jsonpArray.slice();\n \tfor(var i = 0; i < jsonpArray.length; i++) webpackJsonpCallback(jsonpArray[i]);\n \tvar parentJsonpFunction = oldJsonpFunction;\n\n\n \t// run deferred modules from other chunks\n \tcheckDeferredModules();\n"],"sourceRoot":""} -------------------------------------------------------------------------------- /client/build/static/css/main.c68ddd7d.chunk.css: -------------------------------------------------------------------------------- 1 | :root{--primary:#556ee6!important;--light:#f5f5f8!important;--success:#34c38f!important}.bg-success{background-color:#34c38f!important;background-color:var(--success)!important}.bg-light{background-color:#f5f5f8!important;background-color:var(--light)!important}.bg-gray{background-color:var(--gray)!important}.bg-primary{background-color:#556ee6!important;background-color:var(--primary)!important}.text-primary{color:#556ee6!important;color:var(--primary)!important}.list-group-item.active{background-color:#556ee6!important;background-color:var(--primary)!important;border-color:#556ee6!important;border-color:var(--primary)!important}.btn-rounded{border-radius:30px!important}.btn{display:inline-block;font-weight:400;color:#495057;text-align:center;vertical-align:middle;-webkit-user-select:none;-ms-user-select:none;user-select:none;background-color:transparent;border:1px solid transparent;border-radius:30px!important;padding:.47rem .75rem;font-size:.8125rem;line-height:1.5;border-radius:.25rem;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}.btn-primary{color:#fff;background-color:#556ee6;background-color:var(--primary);border-color:#556ee6;border-color:var(--primary)}.btn-primary.focus,.btn-primary:focus,.btn-primary:hover{color:#fff;background-color:#3452e1;border-color:#2948df}.font-size-14{font-size:14px!important}.font-size-11{font-size:11px!important}.font-size-12{font-size:12px!important}.font-size-15{font-size:15px!important}.w-md{min-width:110px}body{font-family:"Poppins",Arial,Helvetica,sans-serif;font-size:13px;color:#495057}.navbar{box-shadow:0 12px 24px 0 rgba(18,38,63,.03)}.navbar-brand{font-size:16px}.chats-title{padding-left:14px}.login-page{display:flex;align-items:center;flex-direction:column;justify-content:center;padding-bottom:190px;height:100vh}.form-signin{width:100%;max-width:330px;padding:15px;margin:0 auto}.text-small{font-size:.9rem}.chat-box,.messages-box{width:100%}.chat-box-wrapper{flex:1 1;overflow-y:scroll}.rounded-lg{border-radius:.5rem}input::-webkit-input-placeholder{font-size:.9rem;color:#999}input:-ms-input-placeholder{font-size:.9rem;color:#999}input::placeholder{font-size:.9rem;color:#999}.centered-box{width:100%;height:100vh;display:flex;align-items:center;justify-content:center}.login-error-anchor{position:relative}.toast-box{text-align:left;margin-top:30px;position:absolute;width:100%;top:0;display:flex;flex-direction:row;justify-content:center}.full-height{height:100vh;flex-direction:column;display:flex}.full-height .container{flex:1 1}.container .row{height:100%}.flex-column{display:flex;flex-direction:column}.bg-white.flex-column{height:100%}.flex{flex:1 1}.logout-button{cursor:pointer;display:flex;flex-direction:row;align-items:center;padding:15px 20px}.logout-button svg{margin-right:15px}.no-messages{opacity:.5;height:100%;width:100%}.avatar-box{width:50px;height:50px;object-fit:cover;object-position:50%;overflow:hidden;border-radius:4px}.avatar-box,.user-link{cursor:pointer}.user-link:hover{text-decoration:underline}.online-indicator{width:14px;height:14px;border:2px solid #fff;bottom:-7px;right:-7px;background-color:#4df573}.online-indicator.selected{border:none;width:12px;height:12px;bottom:-5px;right:-5px}.online-indicator.offline{background-color:#bbb}span.pseudo-link{font-size:14px;text-decoration:underline;color:var(--blue);cursor:pointer}span.pseudo-link:hover{text-decoration:none}.list-group-item{cursor:pointer;height:70px;box-sizing:border-box;transition:background-color .1s ease-out}.chat-icon{width:45px;height:45px;border-radius:4px;background-color:#eee}.chat-icon.active{background-color:var(--blue)}.chats-title{font-size:15px}.chat-body{border-radius:10px!important}.chat-list-container{height:100%}.chat-input{border-radius:30px!important;background-color:#eff2f7!important;border-color:#eff2f7!important;padding-right:120px}.form-control::-webkit-input-placeholder{font-size:13px}.form-control:-ms-input-placeholder{font-size:13px}.form-control::placeholder{font-size:13px}.form-control{display:block;width:100%;height:calc(1.5em + .94rem + 2px);padding:7.5px 12px;font-size:13px;font-weight:400;line-height:1.5;color:#495057;background-color:#fff;background-clip:padding-box;border:1px solid #ced4da;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}.rounded-button{border-radius:30px;background-color:var(--light)}@font-face{font-family:"Poppins";font-style:normal;font-weight:300;font-display:swap;src:url(https://fonts.gstatic.com/s/poppins/v15/pxiByp8kv8JHgFVrLDz8Z11lFd2JQEl8qw.woff2) format("woff2");unicode-range:U+0900-097f,U+1cd0-1cf6,U+1cf8-1cf9,U+200c-200d,U+20a8,U+20b9,U+25cc,U+a830-a839,U+a8e0-a8fb}@font-face{font-family:"Poppins";font-style:normal;font-weight:300;font-display:swap;src:url(https://fonts.gstatic.com/s/poppins/v15/pxiByp8kv8JHgFVrLDz8Z1JlFd2JQEl8qw.woff2) format("woff2");unicode-range:U+0100-024f,U+0259,U+1e??,U+2020,U+20a0-20ab,U+20ad-20cf,U+2113,U+2c60-2c7f,U+a720-a7ff}@font-face{font-family:"Poppins";font-style:normal;font-weight:300;font-display:swap;src:url(https://fonts.gstatic.com/s/poppins/v15/pxiByp8kv8JHgFVrLDz8Z1xlFd2JQEk.woff2) format("woff2");unicode-range:U+00??,U+0131,U+0152-0153,U+02bb-02bc,U+02c6,U+02da,U+02dc,U+2000-206f,U+2074,U+20ac,U+2122,U+2191,U+2193,U+2212,U+2215,U+feff,U+fffd}@font-face{font-family:"Poppins";font-style:normal;font-weight:400;font-display:swap;src:url(https://fonts.gstatic.com/s/poppins/v15/pxiEyp8kv8JHgFVrJJbecnFHGPezSQ.woff2) format("woff2");unicode-range:U+0900-097f,U+1cd0-1cf6,U+1cf8-1cf9,U+200c-200d,U+20a8,U+20b9,U+25cc,U+a830-a839,U+a8e0-a8fb}@font-face{font-family:"Poppins";font-style:normal;font-weight:400;font-display:swap;src:url(https://fonts.gstatic.com/s/poppins/v15/pxiEyp8kv8JHgFVrJJnecnFHGPezSQ.woff2) format("woff2");unicode-range:U+0100-024f,U+0259,U+1e??,U+2020,U+20a0-20ab,U+20ad-20cf,U+2113,U+2c60-2c7f,U+a720-a7ff}@font-face{font-family:"Poppins";font-style:normal;font-weight:400;font-display:swap;src:url(https://fonts.gstatic.com/s/poppins/v15/pxiEyp8kv8JHgFVrJJfecnFHGPc.woff2) format("woff2");unicode-range:U+00??,U+0131,U+0152-0153,U+02bb-02bc,U+02c6,U+02da,U+02dc,U+2000-206f,U+2074,U+20ac,U+2122,U+2191,U+2193,U+2212,U+2215,U+feff,U+fffd}@font-face{font-family:"Poppins";font-style:normal;font-weight:500;font-display:swap;src:url(https://fonts.gstatic.com/s/poppins/v15/pxiByp8kv8JHgFVrLGT9Z11lFd2JQEl8qw.woff2) format("woff2");unicode-range:U+0900-097f,U+1cd0-1cf6,U+1cf8-1cf9,U+200c-200d,U+20a8,U+20b9,U+25cc,U+a830-a839,U+a8e0-a8fb}@font-face{font-family:"Poppins";font-style:normal;font-weight:500;font-display:swap;src:url(https://fonts.gstatic.com/s/poppins/v15/pxiByp8kv8JHgFVrLGT9Z1JlFd2JQEl8qw.woff2) format("woff2");unicode-range:U+0100-024f,U+0259,U+1e??,U+2020,U+20a0-20ab,U+20ad-20cf,U+2113,U+2c60-2c7f,U+a720-a7ff}@font-face{font-family:"Poppins";font-style:normal;font-weight:500;font-display:swap;src:url(https://fonts.gstatic.com/s/poppins/v15/pxiByp8kv8JHgFVrLGT9Z1xlFd2JQEk.woff2) format("woff2");unicode-range:U+00??,U+0131,U+0152-0153,U+02bb-02bc,U+02c6,U+02da,U+02dc,U+2000-206f,U+2074,U+20ac,U+2122,U+2191,U+2193,U+2212,U+2215,U+feff,U+fffd}@font-face{font-family:"Poppins";font-style:normal;font-weight:600;font-display:swap;src:url(https://fonts.gstatic.com/s/poppins/v15/pxiByp8kv8JHgFVrLEj6Z11lFd2JQEl8qw.woff2) format("woff2");unicode-range:U+0900-097f,U+1cd0-1cf6,U+1cf8-1cf9,U+200c-200d,U+20a8,U+20b9,U+25cc,U+a830-a839,U+a8e0-a8fb}@font-face{font-family:"Poppins";font-style:normal;font-weight:600;font-display:swap;src:url(https://fonts.gstatic.com/s/poppins/v15/pxiByp8kv8JHgFVrLEj6Z1JlFd2JQEl8qw.woff2) format("woff2");unicode-range:U+0100-024f,U+0259,U+1e??,U+2020,U+20a0-20ab,U+20ad-20cf,U+2113,U+2c60-2c7f,U+a720-a7ff}@font-face{font-family:"Poppins";font-style:normal;font-weight:600;font-display:swap;src:url(https://fonts.gstatic.com/s/poppins/v15/pxiByp8kv8JHgFVrLEj6Z1xlFd2JQEk.woff2) format("woff2");unicode-range:U+00??,U+0131,U+0152-0153,U+02bb-02bc,U+02c6,U+02da,U+02dc,U+2000-206f,U+2074,U+20ac,U+2122,U+2191,U+2193,U+2212,U+2215,U+feff,U+fffd}@font-face{font-family:"Poppins";font-style:normal;font-weight:700;font-display:swap;src:url(https://fonts.gstatic.com/s/poppins/v15/pxiByp8kv8JHgFVrLCz7Z11lFd2JQEl8qw.woff2) format("woff2");unicode-range:U+0900-097f,U+1cd0-1cf6,U+1cf8-1cf9,U+200c-200d,U+20a8,U+20b9,U+25cc,U+a830-a839,U+a8e0-a8fb}@font-face{font-family:"Poppins";font-style:normal;font-weight:700;font-display:swap;src:url(https://fonts.gstatic.com/s/poppins/v15/pxiByp8kv8JHgFVrLCz7Z1JlFd2JQEl8qw.woff2) format("woff2");unicode-range:U+0100-024f,U+0259,U+1e??,U+2020,U+20a0-20ab,U+20ad-20cf,U+2113,U+2c60-2c7f,U+a720-a7ff}@font-face{font-family:"Poppins";font-style:normal;font-weight:700;font-display:swap;src:url(https://fonts.gstatic.com/s/poppins/v15/pxiByp8kv8JHgFVrLCz7Z1xlFd2JQEk.woff2) format("woff2");unicode-range:U+00??,U+0131,U+0152-0153,U+02bb-02bc,U+02c6,U+02da,U+02dc,U+2000-206f,U+2074,U+20ac,U+2122,U+2191,U+2193,U+2212,U+2215,U+feff,U+fffd}.login-form .username-select button{background-color:transparent!important;color:inherit;padding:7.5px 12px!important;display:block!important;border:1px solid #ced4da!important;border-radius:4px!important;width:100%!important;text-align:left!important}.login-form .username-select .btn-primary:not(:disabled):not(.disabled).active,.login-form .username-select .btn-primary:not(:disabled):not(.disabled):active,.show>.btn-primary.dropdown-toggle{color:inherit}.login-form .username-select .dropdown-menu.show.dropdown-menu-right{transform:translateY(38px)!important}.username-select-dropdown{position:relative;display:flex!important;align-items:center;background-color:transparent!important;color:inherit;padding:0 12px!important;border:1px solid #ced4da!important;border-radius:4px!important;width:100%!important;text-align:left!important;height:calc(1.5em + .94rem + 2px)!important;cursor:pointer}.username-select-dropdown .username-select-block{background-color:var(--white);position:absolute;top:-1138px;left:0;opacity:0;transform:scale(.5);transform-origin:top left;transition:opacity .2s ease,transform .2s ease;border:1px solid #ced4da!important;border-radius:4px!important;padding:8px 0}.username-select-dropdown:focus{outline:none;border-color:#80bdff!important;box-shadow:0 0 0 .2rem rgba(0,123,255,.25)}.username-select-dropdown .username-select-block.open{top:42px;transform:scale(1);opacity:1}.username-select-row{display:flex;width:100%;justify-content:space-between;align-items:center}.username-select-dropdown .username-select-block .username-select-block-item{padding:4px 24px}.username-select-dropdown .username-select-block .username-select-block-item:hover{background-color:var(--light)}.chat-list-item{cursor:pointer;padding:14px 16px}.mdi-circle:before{content:"󰝥"}.mdi-set,.mdi:before{display:inline-block;font:normal normal normal 24px/1 Material Design Icons;font-size:inherit;text-rendering:auto;line-height:inherit;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale} 2 | /*# sourceMappingURL=main.c68ddd7d.chunk.css.map */ -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Basic Redis Chat App Demo Python (Flask) 2 | 3 | Showcases how to impliment chat app in Python (Flask), Socket.IO and Redis. This example uses **pub/sub** feature combined with web-sockets for implementing the message communication between client and server. 4 | 5 | 6 | 7 | 8 | # Overview video 9 | 10 | Here's a short video that explains the project and how it uses Redis: 11 | 12 | [![Watch the video on YouTube](https://github.com/redis-developer/basic-redis-chat-app-demo-python/raw/master/docs/YTThumbnail.png)](https://www.youtube.com/watch?v=miK7xDkDXF0) 13 | 14 | ## Technical Stacks 15 | 16 | - Frontend - _React_, _Socket.IO_ 17 | - Backend - _Flask_, _Redis_ 18 | 19 | ## How it works? 20 | 21 | ### Initialization 22 | 23 | For simplicity, a key with **total_users** value is checked: if it does not exist, we fill the Redis database with initial data. 24 | `EXISTS total_users` (checks if the key exists) 25 | 26 | The demo data initialization is handled in multiple steps: 27 | 28 | **Creating of demo users:** 29 | We create a new user id: `INCR total_users`. Then we set a user ID lookup key by user name: **_e.g._** `SET username:nick user:1`. And finally, the rest of the data is written to the hash set: **_e.g._** `HSET user:1 username "nick" password "bcrypt_hashed_password"`. 30 | 31 | Additionally, each user is added to the default "General" room. For handling rooms for each user, we have a set that holds the room ids. Here's an example command of how to add the room: **_e.g._** `SADD user:1:rooms "0"`. 32 | 33 | **Populate private messages between users.** 34 | At first, private rooms are created: if a private room needs to be established, for each user a room id: `room:1:2` is generated, where numbers correspond to the user ids in ascending order. 35 | 36 | **_E.g._** Create a private room between 2 users: `SADD user:1:rooms 1:2` and `SADD user:2:rooms 1:2`. 37 | 38 | Then we add messages to this room by writing to a sorted set: 39 | 40 | **_E.g._** `ZADD room:1:2 1615480369 "{'from': 1, 'date': 1615480369, 'message': 'Hello', 'roomId': '1:2'}"`. 41 | 42 | We use a stringified _JSON_ for keeping the message structure and simplify the implementation details for this demo-app. 43 | 44 | **Populate the "General" room with messages.** Messages are added to the sorted set with id of the "General" room: `room:0` 45 | 46 | ### Registration 47 | 48 | ![How it works](docs/screenshot000.png) 49 | 50 | Redis is used mainly as a database to keep the user/messages data and for sending messages between connected servers. 51 | 52 | #### How the data is stored: 53 | 54 | - The chat data is stored in various keys and various data types. 55 | - User data is stored in a hash set where each user entry contains the next values: 56 | - `username`: unique user name; 57 | - `password`: hashed password 58 | 59 | * User hash set is accessed by key `user:{userId}`. The data for it stored with `HSET key field data`. User id is calculated by incrementing the `total_users`. 60 | 61 | - E.g `INCR total_users` 62 | 63 | * Username is stored as a separate key (`username:{username}`) which returns the userId for quicker access. 64 | - E.g `SET username:Alex 4` 65 | 66 | #### How the data is accessed: 67 | 68 | - **Get User** `HGETALL user:{id}` 69 | 70 | - E.g `HGETALL user:2`, where we get data for the user with id: 2. 71 | 72 | - **Online users:** will return ids of users which are online 73 | - E.g `SMEMBERS online_users` 74 | 75 | #### Code Example: Prepare User Data in Redis HashSet 76 | 77 | ```Python 78 | def create_user(username, password): 79 | username_key = make_username_key(username) 80 | # Create a user 81 | hashed_password = bcrypt.hashpw(str(password).encode("utf-8"), bcrypt.gensalt(10)) 82 | next_id = redis_client.incr("total_users") 83 | user_key = f"user:{next_id}" 84 | redis_client.set(username_key, user_key) 85 | redis_client.hmset(user_key, {"username": username, "password": hashed_password}) 86 | 87 | redis_client.sadd(f"user:{next_id}:rooms", "0") 88 | 89 | return {"id": next_id, "username": username} 90 | ``` 91 | 92 | ### Rooms 93 | 94 | ![How it works](docs/screenshot001.png) 95 | 96 | #### How the data is stored: 97 | 98 | Each user has a set of rooms associated with them. 99 | 100 | **Rooms** are sorted sets which contains messages where score is the timestamp for each message. Each room has a name associated with it. 101 | 102 | - Rooms which user belongs too are stored at `user:{userId}:rooms` as a set of room ids. 103 | 104 | - E.g `SADD user:Alex:rooms 1` 105 | 106 | - Set room name: `SET room:{roomId}:name {name}` 107 | - E.g `SET room:1:name General` 108 | 109 | #### How the data is accessed: 110 | 111 | - **Get room name** `GET room:{roomId}:name`. 112 | 113 | - E. g `GET room:0:name`. This should return "General" 114 | 115 | - **Get room ids of a user:** `SMEMBERS user:{id}:rooms`. 116 | - E. g `SMEMBERS user:2:rooms`. This will return IDs of rooms for user with ID: 2 117 | 118 | #### Code Example: Get all My Rooms 119 | 120 | ```Python 121 | def get_rooms_for_user_id(user_id=0): 122 | """Get rooms for the selected user.""" 123 | # We got the room ids 124 | room_ids = list( 125 | map( 126 | lambda x: x.decode("utf-8"), 127 | list(utils.redis_client.smembers(f"user:{user_id}:rooms")), 128 | ) 129 | ) 130 | rooms = [] 131 | 132 | for room_id in room_ids: 133 | name = utils.redis_client.get(f"room:{room_id}:name") 134 | 135 | # It's a room without a name, likey the one with private messages 136 | if not name: 137 | room_exists = utils.redis_client.exists(f"room:{room_id}") 138 | if not room_exists: 139 | continue 140 | 141 | user_ids = room_id.split(":") 142 | if len(user_ids) != 2: 143 | return jsonify(None), 400 144 | 145 | rooms.append( 146 | { 147 | "id": room_id, 148 | "names": [ 149 | utils.hmget(f"user:{user_ids[0]}", "username"), 150 | utils.hmget(f"user:{user_ids[1]}", "username"), 151 | ], 152 | } 153 | ) 154 | else: 155 | rooms.append({"id": room_id, "names": [name.decode("utf-8")]}) 156 | return jsonify(rooms), 200 157 | ``` 158 | 159 | ### Messages 160 | 161 | #### Pub/sub 162 | 163 | After initialization, a pub/sub subscription is created: `SUBSCRIBE MESSAGES`. At the same time, each server instance will run a listener on a message on this channel to receive real-time updates. 164 | 165 | Again, for simplicity, each message is serialized to **_JSON_**, which we parse and then handle in the same manner, as WebSocket messages. 166 | 167 | Pub/sub allows connecting multiple servers written in different platforms without taking into consideration the implementation detail of each server. 168 | 169 | #### How the data is stored: 170 | 171 | - Messages are stored at `room:{roomId}` key in a sorted set (as mentioned above). They are added with `ZADD room:{roomId} {timestamp} {message}` command. Message is serialized to an app-specific JSON string. 172 | - E.g `ZADD room:0 1617197047 { "From": "2", "Date": 1617197047, "Message": "Hello", "RoomId": "1:2" }` 173 | 174 | #### How the data is accessed: 175 | 176 | - **Get list of messages** `ZREVRANGE room:{roomId} {offset_start} {offset_end}`. 177 | - E.g `ZREVRANGE room:1:2 0 50` will return 50 messages with 0 offsets for the private room between users with IDs 1 and 2. 178 | 179 | #### Code Example: Send Message 180 | 181 | ```Python 182 | # The user might be set as offline if he tried to access the chat from another tab, pinging by message 183 | # resets the user online status 184 | utils.redis_client.sadd("online_users", message["from"]) 185 | # We've got a new message. Store it in db, then send back to the room. */ 186 | message_string = json.dumps(message) 187 | room_id = message["roomId"] 188 | room_key = f"room:{room_id}" 189 | 190 | is_private = not bool(utils.redis_client.exists(f"{room_key}:name")) 191 | room_has_messages = bool(utils.redis_client.exists(room_key)) 192 | 193 | if is_private and not room_has_messages: 194 | ids = room_id.split(":") 195 | msg = { 196 | "id": room_id, 197 | "names": [ 198 | utils.hmget(f"user:{ids[0]}", "username"), 199 | utils.hmget(f"user:{ids[1]}", "username"), 200 | ], 201 | } 202 | publish("show.room", msg, broadcast=True) 203 | utils.redis_client.zadd(room_key, {message_string: int(message["date"])}) 204 | ``` 205 | 206 | ### Session handling 207 | 208 | The chat server works as a basic _REST_ API which involves keeping the session and handling the user state in the chat rooms (besides the WebSocket/real-time part). 209 | 210 | When a WebSocket/real-time server is instantiated, which listens for the next events: 211 | 212 | **Connection**. A new user is connected. At this point, a user ID is captured and saved to the session (which is cached in Redis). Note, that session caching is language/library-specific and it's used here purely for persistence and maintaining the state between server reloads. 213 | 214 | A global set with `online_users` key is used for keeping the online state for each user. So on a new connection, a user ID is written to that set: 215 | 216 | **E.g.** `SADD online_users 1` (We add user with id 1 to the set **online_users**). 217 | 218 | After that, a message is broadcasted to the clients to notify them that a new user is joined the chat. 219 | 220 | **Disconnect**. It works similarly to the connection event, except we need to remove the user for **online_users** set and notify the clients: `SREM online_users 1` (makes user with id 1 offline). 221 | 222 | **Message**. A user sends a message, and it needs to be broadcasted to the other clients. The pub/sub allows us also to broadcast this message to all server instances which are connected to this Redis: 223 | 224 | `PUBLISH message "{'serverId': 4132, 'type':'message', 'data': {'from': 1, 'date': 1615480369, 'message': 'Hello', 'roomId': '1:2'}}"` 225 | 226 | Note we send additional data related to the type of the message and the server id. Server id is used to discard the messages by the server instance which sends them since it is connected to the same `MESSAGES` channel. 227 | 228 | `type` field of the serialized JSON corresponds to the real-time method we use for real-time communication (connect/disconnect/message). 229 | 230 | `data` is method-specific information. In the example above it's related to the new message. 231 | 232 | #### How the data is stored / accessed: 233 | 234 | The session data is stored in Redis by utilizing the [**redis**](https://pypi.org/project/redis/) client module. 235 | 236 | ```Python 237 | class Config(object): 238 | # Parse redis environment variables. 239 | redis_endpoint_url = os.environ.get("REDIS_ENDPOINT_URL", "127.0.0.1:6379") 240 | REDIS_HOST, REDIS_PORT = tuple(redis_endpoint_url.split(":")) 241 | REDIS_PASSWORD = os.environ.get("REDIS_PASSWORD", None) 242 | SECRET_KEY = os.environ.get("SECRET_KEY", "Optional default value") 243 | SESSION_TYPE = "redis" 244 | redis_client = redis.Redis( 245 | host=REDIS_HOST, port=REDIS_PORT, password=REDIS_PASSWORD 246 | ) 247 | SESSION_REDIS = redis_client 248 | ``` 249 | 250 | ```Python 251 | from flask_session import Session 252 | sess = Session() 253 | # ... 254 | sess.init_app(app) 255 | ``` 256 | 257 | ## How to run it locally? 258 | 259 | #### Copy `.env.sample` to create `.env`. And provide the values for environment variables 260 | 261 | - REDIS_ENDPOINT_URI: Redis server URI 262 | - REDIS_PASSWORD: Password to the server 263 | 264 | #### Run frontend 265 | 266 | ```sh 267 | cd client 268 | yarn install 269 | yarn start 270 | ``` 271 | 272 | #### Run backend 273 | 274 | Run with venv: 275 | 276 | ```sh 277 | python app.py 278 | ``` 279 | 280 | ## Try it out 281 | 282 | #### Deploy to Heroku 283 | 284 |

285 | 286 | Deploy to Heorku 287 | 288 |

289 | 290 | #### Deploy to Google Cloud 291 | 292 |

293 | 294 | Run on Google Cloud 295 | 296 |

297 | -------------------------------------------------------------------------------- /client/build/static/css/main.c68ddd7d.chunk.css.map: -------------------------------------------------------------------------------- 1 | {"version":3,"sources":["webpack://src/styles/style-overrides.css","webpack://src/styles/style.css","webpack://src/styles/font-face.css","webpack://src/components/Login/style.css","webpack://src/components/Chat/components/ChatList/components/ChatListItem/style.css"],"names":[],"mappings":"AAAA,MACE,2BAAuC,CACvC,yBAA2B,CAC3B,2BACF,CAEA,YACE,kCAA2C,CAA3C,yCACF,CAEA,UACE,kCAAyC,CAAzC,uCACF,CAEA,SACE,sCACF,CAEA,YACE,kCAA2C,CAA3C,yCACF,CAEA,cACE,uBAAgC,CAAhC,8BACF,CAEA,wBACE,kCAA2C,CAA3C,yCAA2C,CAC3C,8BAAuC,CAAvC,qCACF,CAEA,aACE,4BACF,CAEA,KACE,oBAAqB,CACrB,eAAgB,CAChB,aAAc,CACd,iBAAkB,CAClB,qBAAsB,CACtB,wBAAiB,CAAjB,oBAAiB,CAAjB,gBAAiB,CACjB,4BAA6B,CAC7B,4BAA6B,CAC7B,4BAA8B,CAC9B,qBAAwB,CACxB,kBAAoB,CACpB,eAAgB,CAChB,oBAAsB,CAGtB,6HAKF,CAEA,aACE,UAAW,CACX,wBAAgC,CAAhC,+BAAgC,CAChC,oBAA4B,CAA5B,2BACF,CAEA,yDAGE,UAAW,CACX,wBAAyB,CACzB,oBACF,CAEA,cACE,wBACF,CAEA,cACE,wBACF,CAEA,cACE,wBACF,CAEA,cACE,wBACF,CAEA,MACE,eACF,CC1FA,KACE,gDAAoD,CACpD,cAAe,CACf,aACF,CAEA,QACE,2CACF,CAEA,cACE,cACF,CAEA,aACE,iBACF,CAEA,YACE,YAAa,CACb,kBAAmB,CACnB,qBAAsB,CACtB,sBAAuB,CACvB,oBAAqB,CACrB,YACF,CAEA,aACE,UAAW,CACX,eAAgB,CAChB,YAAa,CACb,aACF,CAEA,YACE,eACF,CAEA,wBAGE,UACF,CAEA,kBACE,QAAO,CACP,iBACF,CAEA,YACE,mBACF,CAEA,iCACE,eAAiB,CACjB,UACF,CAHA,4BACE,eAAiB,CACjB,UACF,CAHA,mBACE,eAAiB,CACjB,UACF,CAEA,cACE,UAAW,CACX,YAAa,CACb,YAAa,CACb,kBAAmB,CACnB,sBACF,CAEA,oBACE,iBACF,CAEA,WACE,eAAgB,CAChB,eAAgB,CAChB,iBAAkB,CAClB,UAAW,CACX,KAAM,CACN,YAAa,CACb,kBAAmB,CACnB,sBACF,CAEA,aACE,YAAa,CACb,qBAAsB,CACtB,YACF,CAEA,wBACE,QACF,CAEA,gBACE,WACF,CAEA,aACE,YAAa,CACb,qBACF,CAEA,sBACE,WACF,CAEA,MACE,QACF,CAEA,eACE,cAAe,CACf,YAAa,CACb,kBAAmB,CACnB,kBAAmB,CACnB,iBACF,CAEA,mBACE,iBACF,CAEA,aACE,UAAY,CACZ,WAAY,CACZ,UACF,CAEA,YACE,UAAW,CACX,WAAY,CACZ,gBAAiB,CACjB,mBAAoB,CACpB,eAAgB,CAChB,iBAEF,CAEA,uBAHE,cAKF,CAEA,iBACE,yBACF,CAEA,kBACE,UAAW,CACX,WAAY,CACZ,qBAAuB,CACvB,WAAY,CACZ,UAAW,CACX,wBACF,CAEA,2BACE,WAAY,CACZ,UAAW,CACX,WAAY,CACZ,WAAY,CACZ,UACF,CAEA,0BACE,qBACF,CAEA,iBACE,cAAe,CACf,yBAA0B,CAC1B,iBAAkB,CAClB,cACF,CAEA,uBACE,oBACF,CAEA,iBACE,cAAe,CACf,WAAY,CACZ,qBAAsB,CACtB,wCACF,CAEA,WACE,UAAW,CACX,WAAY,CACZ,iBAAkB,CAClB,qBACF,CAEA,kBACE,4BACF,CAEA,aACE,cACF,CAEA,WACE,4BACF,CAEA,qBACE,WACF,CAEA,YACE,4BAA8B,CAC9B,kCAAoC,CACpC,8BAAgC,CAChC,mBACF,CAEA,yCACE,cACF,CAFA,oCACE,cACF,CAFA,2BACE,cACF,CAEA,cACE,aAAc,CACd,UAAW,CACX,iCAAmC,CACnC,kBAAmB,CACnB,cAAe,CACf,eAAgB,CAChB,eAAgB,CAChB,aAAc,CACd,qBAAsB,CACtB,2BAA4B,CAC5B,wBAAyB,CAKzB,oEAGF,CAEA,gBACE,kBAAmB,CACnB,6BACF,CChPA,WACE,qBAAsB,CACtB,iBAAkB,CAClB,eAAgB,CAChB,iBAAkB,CAClB,yGACiB,CACjB,0GAEF,CAEA,WACE,qBAAsB,CACtB,iBAAkB,CAClB,eAAgB,CAChB,iBAAkB,CAClB,yGACiB,CACjB,qGAEF,CAEA,WACE,qBAAsB,CACtB,iBAAkB,CAClB,eAAgB,CAChB,iBAAkB,CAClB,sGACiB,CACjB,mJAGF,CAEA,WACE,qBAAsB,CACtB,iBAAkB,CAClB,eAAgB,CAChB,iBAAkB,CAClB,qGACiB,CACjB,0GAEF,CAEA,WACE,qBAAsB,CACtB,iBAAkB,CAClB,eAAgB,CAChB,iBAAkB,CAClB,qGACiB,CACjB,qGAEF,CAEA,WACE,qBAAsB,CACtB,iBAAkB,CAClB,eAAgB,CAChB,iBAAkB,CAClB,kGACiB,CACjB,mJAGF,CAEA,WACE,qBAAsB,CACtB,iBAAkB,CAClB,eAAgB,CAChB,iBAAkB,CAClB,yGACiB,CACjB,0GAEF,CAEA,WACE,qBAAsB,CACtB,iBAAkB,CAClB,eAAgB,CAChB,iBAAkB,CAClB,yGACiB,CACjB,qGAEF,CAEA,WACE,qBAAsB,CACtB,iBAAkB,CAClB,eAAgB,CAChB,iBAAkB,CAClB,sGACiB,CACjB,mJAGF,CAEA,WACE,qBAAsB,CACtB,iBAAkB,CAClB,eAAgB,CAChB,iBAAkB,CAClB,yGACiB,CACjB,0GAEF,CAEA,WACE,qBAAsB,CACtB,iBAAkB,CAClB,eAAgB,CAChB,iBAAkB,CAClB,yGACiB,CACjB,qGAEF,CAEA,WACE,qBAAsB,CACtB,iBAAkB,CAClB,eAAgB,CAChB,iBAAkB,CAClB,sGACiB,CACjB,mJAGF,CAEA,WACE,qBAAsB,CACtB,iBAAkB,CAClB,eAAgB,CAChB,iBAAkB,CAClB,yGACiB,CACjB,0GAEF,CAEA,WACE,qBAAsB,CACtB,iBAAkB,CAClB,eAAgB,CAChB,iBAAkB,CAClB,yGACiB,CACjB,qGAEF,CAEA,WACE,qBAAsB,CACtB,iBAAkB,CAClB,eAAgB,CAChB,iBAAkB,CAClB,sGACiB,CACjB,mJAGF,CCzKA,oCACE,sCAAwC,CACxC,aAAc,CACd,4BAA8B,CAC9B,uBAAyB,CACzB,kCAA+C,CAC/C,2BAA6B,CAC7B,oBAAsB,CACtB,yBACF,CAEA,iMAGE,aACF,CAEA,qEACE,oCACF,CAEA,0BACE,iBAAkB,CAClB,sBAAwB,CACxB,kBAAmB,CACnB,sCAAwC,CACxC,aAAc,CACd,wBAA0B,CAC1B,kCAA+C,CAC/C,2BAA6B,CAC7B,oBAAsB,CACtB,yBAA2B,CAC3B,2CAA8C,CAE9C,cACF,CAEA,iDACE,6BAA8B,CAC9B,iBAAkB,CAClB,WAAY,CACZ,MAAO,CACP,SAAU,CACV,mBAA0B,CAC1B,yBAA0B,CAC1B,8CAAkD,CAElD,kCAA+C,CAC/C,2BAA6B,CAE7B,aACF,CAEA,gCACE,YAAa,CACb,8BAAgC,CAChC,0CACF,CAEA,sDACE,QAAS,CACT,kBAAsB,CACtB,SACF,CAEA,qBACE,YAAa,CACb,UAAW,CACX,6BAA8B,CAC9B,kBACF,CAEA,6EACE,gBACF,CAEA,mFAGE,6BACF,CChFA,gBACE,cAAe,CACf,iBACF,CACA,mBACE,YACF,CAEA,qBAEE,oBAAqB,CACrB,sDAAuD,CACvD,iBAAkB,CAClB,mBAAoB,CACpB,mBAAoB,CACpB,kCAAmC,CACnC,iCACF","file":"main.c68ddd7d.chunk.css","sourcesContent":[":root {\n --primary: rgb(85, 110, 230) !important;\n --light: #f5f5f8 !important;\n --success: rgb(52, 195, 143) !important;\n}\n\n.bg-success {\n background-color: var(--success) !important;\n}\n\n.bg-light {\n background-color: var(--light) !important;\n}\n\n.bg-gray {\n background-color: var(--gray) !important;\n}\n\n.bg-primary {\n background-color: var(--primary) !important;\n}\n\n.text-primary {\n color: var(--primary) !important;\n}\n\n.list-group-item.active {\n background-color: var(--primary) !important;\n border-color: var(--primary) !important;\n}\n\n.btn-rounded {\n border-radius: 30px !important;\n}\n\n.btn {\n display: inline-block;\n font-weight: 400;\n color: #495057;\n text-align: center;\n vertical-align: middle;\n user-select: none;\n background-color: transparent;\n border: 1px solid transparent;\n border-radius: 30px !important;\n padding: 0.47rem 0.75rem;\n font-size: 0.8125rem;\n line-height: 1.5;\n border-radius: 0.25rem;\n transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out,\n border-color 0.15s ease-in-out, -webkit-box-shadow 0.15s ease-in-out;\n transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out,\n border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;\n transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out,\n border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out,\n -webkit-box-shadow 0.15s ease-in-out;\n}\n\n.btn-primary {\n color: #fff;\n background-color: var(--primary);\n border-color: var(--primary);\n}\n\n.btn-primary.focus,\n.btn-primary:focus,\n.btn-primary:hover {\n color: #fff;\n background-color: #3452e1;\n border-color: #2948df;\n}\n\n.font-size-14 {\n font-size: 14px !important;\n}\n\n.font-size-11 {\n font-size: 11px !important;\n}\n\n.font-size-12 {\n font-size: 12px !important;\n}\n\n.font-size-15 {\n font-size: 15px !important;\n}\n\n.w-md {\n min-width: 110px;\n}\n","body {\r\n font-family: \"Poppins\", Arial, Helvetica, sans-serif;\r\n font-size: 13px;\r\n color: #495057;\r\n}\r\n\r\n.navbar {\r\n box-shadow: rgba(18, 38, 63, 0.03) 0px 12px 24px 0px;\r\n}\r\n\r\n.navbar-brand {\r\n font-size: 16px;\r\n}\r\n\r\n.chats-title {\r\n padding-left: 14px;\r\n}\r\n\r\n.login-page {\r\n display: flex;\r\n align-items: center;\r\n flex-direction: column;\r\n justify-content: center;\r\n padding-bottom: 190px;\r\n height: 100vh;\r\n}\r\n\r\n.form-signin {\r\n width: 100%;\r\n max-width: 330px;\r\n padding: 15px;\r\n margin: 0 auto;\r\n}\r\n\r\n.text-small {\r\n font-size: 0.9rem;\r\n}\r\n\r\n.messages-box,\r\n.chat-box {\r\n /* height: 510px; */\r\n width: 100%;\r\n}\r\n\r\n.chat-box-wrapper {\r\n flex: 1;\r\n overflow-y: scroll;\r\n}\r\n\r\n.rounded-lg {\r\n border-radius: 0.5rem;\r\n}\r\n\r\ninput::placeholder {\r\n font-size: 0.9rem;\r\n color: #999;\r\n}\r\n\r\n.centered-box {\r\n width: 100%;\r\n height: 100vh;\r\n display: flex;\r\n align-items: center;\r\n justify-content: center;\r\n}\r\n\r\n.login-error-anchor {\r\n position: relative;\r\n}\r\n\r\n.toast-box {\r\n text-align: left;\r\n margin-top: 30px;\r\n position: absolute;\r\n width: 100%;\r\n top: 0;\r\n display: flex;\r\n flex-direction: row;\r\n justify-content: center;\r\n}\r\n\r\n.full-height {\r\n height: 100vh;\r\n flex-direction: column;\r\n display: flex;\r\n}\r\n\r\n.full-height .container {\r\n flex: 1;\r\n}\r\n\r\n.container .row {\r\n height: 100%;\r\n}\r\n\r\n.flex-column {\r\n display: flex;\r\n flex-direction: column;\r\n}\r\n\r\n.bg-white.flex-column {\r\n height: 100%;\r\n}\r\n\r\n.flex {\r\n flex: 1;\r\n}\r\n\r\n.logout-button {\r\n cursor: pointer;\r\n display: flex;\r\n flex-direction: row;\r\n align-items: center;\r\n padding: 15px 20px;\r\n}\r\n\r\n.logout-button svg {\r\n margin-right: 15px;\r\n}\r\n\r\n.no-messages {\r\n opacity: 0.5;\r\n height: 100%;\r\n width: 100%;\r\n}\r\n\r\n.avatar-box {\r\n width: 50px;\r\n height: 50px;\r\n object-fit: cover;\r\n object-position: 50%;\r\n overflow: hidden;\r\n border-radius: 4px;\r\n cursor: pointer;\r\n}\r\n\r\n.user-link {\r\n cursor: pointer;\r\n}\r\n\r\n.user-link:hover {\r\n text-decoration: underline;\r\n}\r\n\r\n.online-indicator {\r\n width: 14px;\r\n height: 14px;\r\n border: 2px solid white;\r\n bottom: -7px;\r\n right: -7px;\r\n background-color: #4df573;\r\n}\r\n\r\n.online-indicator.selected {\r\n border: none;\r\n width: 12px;\r\n height: 12px;\r\n bottom: -5px;\r\n right: -5px;\r\n}\r\n\r\n.online-indicator.offline {\r\n background-color: #bbb;\r\n}\r\n\r\nspan.pseudo-link {\r\n font-size: 14px;\r\n text-decoration: underline;\r\n color: var(--blue);\r\n cursor: pointer;\r\n}\r\n\r\nspan.pseudo-link:hover {\r\n text-decoration: none;\r\n}\r\n\r\n.list-group-item {\r\n cursor: pointer;\r\n height: 70px;\r\n box-sizing: border-box;\r\n transition: background-color 0.1s ease-out;\r\n}\r\n\r\n.chat-icon {\r\n width: 45px;\r\n height: 45px;\r\n border-radius: 4px;\r\n background-color: #eee;\r\n}\r\n\r\n.chat-icon.active {\r\n background-color: var(--blue);\r\n}\r\n\r\n.chats-title {\r\n font-size: 15px;\r\n}\r\n\r\n.chat-body {\r\n border-radius: 10px !important;\r\n}\r\n\r\n.chat-list-container {\r\n height: 100%;\r\n}\r\n\r\n.chat-input {\r\n border-radius: 30px !important;\r\n background-color: #eff2f7 !important;\r\n border-color: #eff2f7 !important;\r\n padding-right: 120px;\r\n}\r\n\r\n.form-control::placeholder {\r\n font-size: 13px;\r\n}\r\n\r\n.form-control {\r\n display: block;\r\n width: 100%;\r\n height: calc(1.5em + 0.94rem + 2px);\r\n padding: 7.5px 12px;\r\n font-size: 13px;\r\n font-weight: 400;\r\n line-height: 1.5;\r\n color: #495057;\r\n background-color: #fff;\r\n background-clip: padding-box;\r\n border: 1px solid #ced4da;\r\n -webkit-transition: border-color 0.15s ease-in-out,\r\n -webkit-box-shadow 0.15s ease-in-out;\r\n transition: border-color 0.15s ease-in-out,\r\n -webkit-box-shadow 0.15s ease-in-out;\r\n transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;\r\n transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out,\r\n -webkit-box-shadow 0.15s ease-in-out;\r\n}\r\n\r\n.rounded-button {\r\n border-radius: 30px;\r\n background-color: var(--light);\r\n}\r\n","/* devanagari */\n@font-face {\n font-family: \"Poppins\";\n font-style: normal;\n font-weight: 300;\n font-display: swap;\n src: url(https://fonts.gstatic.com/s/poppins/v15/pxiByp8kv8JHgFVrLDz8Z11lFd2JQEl8qw.woff2)\n format(\"woff2\");\n unicode-range: U+0900-097F, U+1CD0-1CF6, U+1CF8-1CF9, U+200C-200D, U+20A8,\n U+20B9, U+25CC, U+A830-A839, U+A8E0-A8FB;\n}\n/* latin-ext */\n@font-face {\n font-family: \"Poppins\";\n font-style: normal;\n font-weight: 300;\n font-display: swap;\n src: url(https://fonts.gstatic.com/s/poppins/v15/pxiByp8kv8JHgFVrLDz8Z1JlFd2JQEl8qw.woff2)\n format(\"woff2\");\n unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB,\n U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;\n}\n/* latin */\n@font-face {\n font-family: \"Poppins\";\n font-style: normal;\n font-weight: 300;\n font-display: swap;\n src: url(https://fonts.gstatic.com/s/poppins/v15/pxiByp8kv8JHgFVrLDz8Z1xlFd2JQEk.woff2)\n format(\"woff2\");\n unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,\n U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,\n U+FEFF, U+FFFD;\n}\n/* devanagari */\n@font-face {\n font-family: \"Poppins\";\n font-style: normal;\n font-weight: 400;\n font-display: swap;\n src: url(https://fonts.gstatic.com/s/poppins/v15/pxiEyp8kv8JHgFVrJJbecnFHGPezSQ.woff2)\n format(\"woff2\");\n unicode-range: U+0900-097F, U+1CD0-1CF6, U+1CF8-1CF9, U+200C-200D, U+20A8,\n U+20B9, U+25CC, U+A830-A839, U+A8E0-A8FB;\n}\n/* latin-ext */\n@font-face {\n font-family: \"Poppins\";\n font-style: normal;\n font-weight: 400;\n font-display: swap;\n src: url(https://fonts.gstatic.com/s/poppins/v15/pxiEyp8kv8JHgFVrJJnecnFHGPezSQ.woff2)\n format(\"woff2\");\n unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB,\n U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;\n}\n/* latin */\n@font-face {\n font-family: \"Poppins\";\n font-style: normal;\n font-weight: 400;\n font-display: swap;\n src: url(https://fonts.gstatic.com/s/poppins/v15/pxiEyp8kv8JHgFVrJJfecnFHGPc.woff2)\n format(\"woff2\");\n unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,\n U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,\n U+FEFF, U+FFFD;\n}\n/* devanagari */\n@font-face {\n font-family: \"Poppins\";\n font-style: normal;\n font-weight: 500;\n font-display: swap;\n src: url(https://fonts.gstatic.com/s/poppins/v15/pxiByp8kv8JHgFVrLGT9Z11lFd2JQEl8qw.woff2)\n format(\"woff2\");\n unicode-range: U+0900-097F, U+1CD0-1CF6, U+1CF8-1CF9, U+200C-200D, U+20A8,\n U+20B9, U+25CC, U+A830-A839, U+A8E0-A8FB;\n}\n/* latin-ext */\n@font-face {\n font-family: \"Poppins\";\n font-style: normal;\n font-weight: 500;\n font-display: swap;\n src: url(https://fonts.gstatic.com/s/poppins/v15/pxiByp8kv8JHgFVrLGT9Z1JlFd2JQEl8qw.woff2)\n format(\"woff2\");\n unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB,\n U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;\n}\n/* latin */\n@font-face {\n font-family: \"Poppins\";\n font-style: normal;\n font-weight: 500;\n font-display: swap;\n src: url(https://fonts.gstatic.com/s/poppins/v15/pxiByp8kv8JHgFVrLGT9Z1xlFd2JQEk.woff2)\n format(\"woff2\");\n unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,\n U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,\n U+FEFF, U+FFFD;\n}\n/* devanagari */\n@font-face {\n font-family: \"Poppins\";\n font-style: normal;\n font-weight: 600;\n font-display: swap;\n src: url(https://fonts.gstatic.com/s/poppins/v15/pxiByp8kv8JHgFVrLEj6Z11lFd2JQEl8qw.woff2)\n format(\"woff2\");\n unicode-range: U+0900-097F, U+1CD0-1CF6, U+1CF8-1CF9, U+200C-200D, U+20A8,\n U+20B9, U+25CC, U+A830-A839, U+A8E0-A8FB;\n}\n/* latin-ext */\n@font-face {\n font-family: \"Poppins\";\n font-style: normal;\n font-weight: 600;\n font-display: swap;\n src: url(https://fonts.gstatic.com/s/poppins/v15/pxiByp8kv8JHgFVrLEj6Z1JlFd2JQEl8qw.woff2)\n format(\"woff2\");\n unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB,\n U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;\n}\n/* latin */\n@font-face {\n font-family: \"Poppins\";\n font-style: normal;\n font-weight: 600;\n font-display: swap;\n src: url(https://fonts.gstatic.com/s/poppins/v15/pxiByp8kv8JHgFVrLEj6Z1xlFd2JQEk.woff2)\n format(\"woff2\");\n unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,\n U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,\n U+FEFF, U+FFFD;\n}\n/* devanagari */\n@font-face {\n font-family: \"Poppins\";\n font-style: normal;\n font-weight: 700;\n font-display: swap;\n src: url(https://fonts.gstatic.com/s/poppins/v15/pxiByp8kv8JHgFVrLCz7Z11lFd2JQEl8qw.woff2)\n format(\"woff2\");\n unicode-range: U+0900-097F, U+1CD0-1CF6, U+1CF8-1CF9, U+200C-200D, U+20A8,\n U+20B9, U+25CC, U+A830-A839, U+A8E0-A8FB;\n}\n/* latin-ext */\n@font-face {\n font-family: \"Poppins\";\n font-style: normal;\n font-weight: 700;\n font-display: swap;\n src: url(https://fonts.gstatic.com/s/poppins/v15/pxiByp8kv8JHgFVrLCz7Z1JlFd2JQEl8qw.woff2)\n format(\"woff2\");\n unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB,\n U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;\n}\n/* latin */\n@font-face {\n font-family: \"Poppins\";\n font-style: normal;\n font-weight: 700;\n font-display: swap;\n src: url(https://fonts.gstatic.com/s/poppins/v15/pxiByp8kv8JHgFVrLCz7Z1xlFd2JQEk.woff2)\n format(\"woff2\");\n unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,\n U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,\n U+FEFF, U+FFFD;\n}\n",".login-form .username-select button {\n background-color: transparent !important;\n color: inherit;\n padding: 7.5px 12px !important;\n display: block !important;\n border: 1px solid rgb(206, 212, 218) !important;\n border-radius: 4px !important;\n width: 100% !important;\n text-align: left !important;\n}\n\n.login-form .username-select .btn-primary:not(:disabled):not(.disabled).active,\n.login-form .username-select .btn-primary:not(:disabled):not(.disabled):active,\n.show > .btn-primary.dropdown-toggle {\n color: inherit;\n}\n\n.login-form .username-select .dropdown-menu.show.dropdown-menu-right {\n transform: translate(0px, 38px) !important;\n}\n\n.username-select-dropdown {\n position: relative;\n display: flex !important;\n align-items: center;\n background-color: transparent !important;\n color: inherit;\n padding: 0 12px !important;\n border: 1px solid rgb(206, 212, 218) !important;\n border-radius: 4px !important;\n width: 100% !important;\n text-align: left !important;\n height: calc(1.5em + 0.94rem + 2px) !important;\n\n cursor: pointer;\n}\n\n.username-select-dropdown .username-select-block {\n background-color: var(--white);\n position: absolute;\n top: -1138px;\n left: 0;\n opacity: 0;\n transform: scale(0.5, 0.5);\n transform-origin: top left;\n transition: opacity 0.2s ease, transform 0.2s ease;\n\n border: 1px solid rgb(206, 212, 218) !important;\n border-radius: 4px !important;\n\n padding: 8px 0px;\n}\n\n.username-select-dropdown:focus {\n outline: none;\n border-color: #80bdff !important;\n box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);\n}\n\n.username-select-dropdown .username-select-block.open {\n top: 42px;\n transform: scale(1, 1);\n opacity: 1;\n}\n\n.username-select-row {\n display: flex;\n width: 100%;\n justify-content: space-between;\n align-items: center;\n}\n\n.username-select-dropdown .username-select-block .username-select-block-item {\n padding: 4px 24px;\n}\n\n.username-select-dropdown\n .username-select-block\n .username-select-block-item:hover {\n background-color: var(--light);\n}\n",".chat-list-item {\n cursor: pointer;\n padding: 14px 16px;\n}\n.mdi-circle:before {\n content: \"󰝥\";\n}\n\n.mdi-set,\n.mdi:before {\n display: inline-block;\n font: normal normal normal 24px/1 Material Design Icons;\n font-size: inherit;\n text-rendering: auto;\n line-height: inherit;\n -webkit-font-smoothing: antialiased;\n -moz-osx-font-smoothing: grayscale;\n}\n"]} -------------------------------------------------------------------------------- /client/build/static/js/main.e15e5a37.chunk.js: -------------------------------------------------------------------------------- 1 | (this.webpackJsonpclient=this.webpackJsonpclient||[]).push([[0],{124:function(e,t,s){"use strict";s.r(t);var n=s(0),a=(s(64),s(65),s(66),s(67),s(1)),c=s(23),r=s.n(c),o=s(2),i=s(3),l=s(4),d=s.n(l),u=s(8),j=s(130),b=function(e){var t=e.width,s=void 0===t?64:t,a=e.height,c=void 0===a?64:a;return Object(n.jsxs)("svg",{xmlns:"http://www.w3.org/2000/svg",xmlnsXlink:"http://www.w3.org/1999/xlink",viewBox:"0 0 32 32",height:c,width:s,children:[Object(n.jsx)("script",{}),Object(n.jsxs)("defs",{children:[Object(n.jsx)("path",{id:"prefix__a",d:"M45.536 38.764c-2.013 1.05-12.44 5.337-14.66 6.494s-3.453 1.146-5.207.308-12.85-5.32-14.85-6.276c-1-.478-1.524-.88-1.524-1.26v-3.813s14.447-3.145 16.78-3.982 3.14-.867 5.126-.14 13.853 2.868 15.814 3.587v3.76c0 .377-.452.8-1.477 1.324z"}),Object(n.jsx)("path",{id:"prefix__b",d:"M45.536 28.733c-2.013 1.05-12.44 5.337-14.66 6.494s-3.453 1.146-5.207.308-12.85-5.32-14.85-6.276-2.04-1.613-.077-2.382l15.332-5.935c2.332-.837 3.14-.867 5.126-.14s12.35 4.853 14.312 5.57 2.037 1.31.024 2.36z"})]}),Object(n.jsxs)("g",{transform:"matrix(.84833 0 0 .84833 -7.884 -9.45)",children:[Object(n.jsx)("use",{fill:"#a41e11",xlinkHref:"#prefix__a"}),Object(n.jsx)("path",{d:"M45.536 34.95c-2.013 1.05-12.44 5.337-14.66 6.494s-3.453 1.146-5.207.308-12.85-5.32-14.85-6.276-2.04-1.613-.077-2.382l15.332-5.936c2.332-.836 3.14-.867 5.126-.14S43.55 31.87 45.51 32.6s2.037 1.31.024 2.36z",fill:"#d82c20"}),Object(n.jsx)("use",{fill:"#a41e11",xlinkHref:"#prefix__a",y:-6.218}),Object(n.jsx)("use",{fill:"#d82c20",xlinkHref:"#prefix__b"}),Object(n.jsx)("path",{d:"M45.536 26.098c-2.013 1.05-12.44 5.337-14.66 6.495s-3.453 1.146-5.207.308-12.85-5.32-14.85-6.276c-1-.478-1.524-.88-1.524-1.26V21.55s14.447-3.145 16.78-3.982 3.14-.867 5.126-.14 13.853 2.868 15.814 3.587v3.76c0 .377-.452.8-1.477 1.324z",fill:"#a41e11"}),Object(n.jsx)("use",{fill:"#d82c20",xlinkHref:"#prefix__b",y:-6.449}),Object(n.jsxs)("g",{fill:"#fff",children:[Object(n.jsx)("path",{d:"M29.096 20.712l-1.182-1.965-3.774-.34 2.816-1.016-.845-1.56 2.636 1.03 2.486-.814-.672 1.612 2.534.95-3.268.34zM22.8 24.624l8.74-1.342-2.64 3.872z"}),Object(n.jsx)("ellipse",{cx:20.444,cy:21.402,rx:4.672,ry:1.811})]}),Object(n.jsx)("path",{d:"M42.132 21.138l-5.17 2.042-.004-4.087z",fill:"#7a0c00"}),Object(n.jsx)("path",{d:"M36.963 23.18l-.56.22-5.166-2.042 5.723-2.264z",fill:"#ad2115"})]})]})},m=(s(72),["Pablo","Joe","Mary","Alex"]);function f(e){var t=e.onLogIn,s=Object(a.useState)((function(){return m[Math.floor(Math.random()*m.length)]})),c=Object(i.a)(s,2),r=c[0],o=c[1],l=Object(a.useState)("password123"),f=Object(i.a)(l,2),O=f[0],p=f[1],x=Object(a.useState)(null),g=Object(i.a)(x,2),v=g[0],y=g[1],N=function(){var e=Object(u.a)(d.a.mark((function e(s){return d.a.wrap((function(e){for(;;)switch(e.prev=e.next){case 0:s.preventDefault(),t(r,O,y);case 2:case"end":return e.stop()}}),e)})));return function(t){return e.apply(this,arguments)}}();return Object(n.jsx)(n.Fragment,{children:Object(n.jsx)("div",{className:"login-form text-center login-page",children:Object(n.jsxs)("div",{className:"rounded",style:{boxShadow:"0 0.75rem 1.5rem rgba(18,38,63,.03)"},children:[Object(n.jsxs)("div",{className:"position-relative",children:[Object(n.jsxs)("div",{className:"row no-gutters align-items-center",style:{maxWidth:400,backgroundColor:"rgba(85, 110, 230, 0.25)",paddingLeft:20,paddingRight:20,borderTopLeftRadius:4,borderTopRightRadius:4},children:[Object(n.jsxs)("div",{className:"col text-primary text-left",children:[Object(n.jsx)("h3",{className:"font-size-15",children:"Welcome Back !"}),Object(n.jsx)("p",{children:"Sign in to continue"})]}),Object(n.jsx)("div",{className:"col align-self-end",children:Object(n.jsx)("img",{alt:"welcome back",style:{maxWidth:"100%"},src:"".concat("","/welcome-back.png")})})]}),Object(n.jsx)("div",{className:"position-absolute",style:{bottom:-36,left:20},children:Object(n.jsx)("div",{style:{backgroundColor:"rgb(239, 242, 247)",width:72,height:72},className:"rounded-circle d-flex align-items-center justify-content-center",children:Object(n.jsx)(b,{width:34,height:34})})})]}),Object(n.jsxs)("form",{className:"bg-white text-left px-4",style:{paddingTop:58,borderBottomLeftRadius:4,borderBottomRightRadius:4},onSubmit:N,children:[Object(n.jsx)("label",{className:"font-size-12",children:"Name"}),Object(n.jsx)("div",{className:"username-select mb-3",children:Object(n.jsx)(h,{username:r,setUsername:o,names:m})}),Object(n.jsx)("label",{htmlFor:"inputPassword",className:"font-size-12",children:"Password"}),Object(n.jsx)("input",{value:O,onChange:function(e){return p(e.target.value)},type:"password",id:"inputPassword",className:"form-control",placeholder:"Password",required:!0}),Object(n.jsx)("div",{style:{height:30}}),Object(n.jsx)("button",{className:"btn btn-lg btn-primary btn-block",type:"submit",children:"Sign in"}),Object(n.jsx)("div",{className:"login-error-anchor",children:Object(n.jsx)("div",{className:"toast-box",children:Object(n.jsxs)(j.a,{style:{minWidth:277},onClose:function(){return y(null)},show:null!==v,delay:3e3,autohide:!0,children:[Object(n.jsxs)(j.a.Header,{children:[Object(n.jsx)("img",{src:"holder.js/20x20?text=%20",className:"rounded mr-2",alt:""}),Object(n.jsx)("strong",{className:"mr-auto",children:"Error"})]}),Object(n.jsx)(j.a.Body,{children:v})]})})}),Object(n.jsx)("div",{style:{height:30}})]})]})})})}var h=function(e){var t,s=e.username,c=e.setUsername,r=e.names,o=void 0===r?[""]:r,l=Object(a.useState)(!1),d=Object(i.a)(l,2),u=d[0],j=d[1],b=Object(a.useState)(0),m=Object(i.a)(b,2),f=m[0],h=m[1],O=Object(a.useRef)(),p=null===(t=O.current)||void 0===t?void 0:t.getBoundingClientRect().width;return Object(a.useEffect)((function(){h(p)}),[p]),Object(a.useEffect)((function(){if(u){var e=function(){return j(!1)};return document.addEventListener("click",e),function(){return document.removeEventListener("click",e)}}}),[u]),Object(a.useEffect)((function(){var e;u&&(null===(e=O.current)||void 0===e||e.focus())}),[u]),Object(n.jsxs)("div",{tabIndex:0,ref:O,className:"username-select-dropdown ".concat(u?"open":""),onClick:function(){return j((function(e){return!e}))},children:[Object(n.jsxs)("div",{className:"username-select-row",children:[Object(n.jsx)("div",{children:s}),Object(n.jsx)("div",{children:Object(n.jsx)("svg",{width:24,height:24,children:Object(n.jsx)("path",{d:"M7 10l5 5 5-5z",fill:"#333"})})})]}),Object(n.jsx)("div",{style:{width:f},className:"username-select-block ".concat(u?"open":""),children:o.map((function(e){return Object(n.jsx)("div",{className:"username-select-block-item",onClick:function(){return c(e)},children:e},e)}))})]})},O=s(19),p=(s(75),s(12)),x=function(e,t){switch(t.type){case"clear":return{currentRoom:"0",rooms:{},users:{}};case"set user":return Object(o.a)(Object(o.a)({},e),{},{users:Object(o.a)(Object(o.a)({},e.users),{},Object(p.a)({},t.payload.id,t.payload))});case"make user online":return Object(o.a)(Object(o.a)({},e),{},{users:Object(o.a)(Object(o.a)({},e.users),{},Object(p.a)({},t.payload,Object(o.a)(Object(o.a)({},e.users[t.payload]),{},{online:!0})))});case"append users":return Object(o.a)(Object(o.a)({},e),{},{users:Object(o.a)(Object(o.a)({},e.users),t.payload)});case"set messages":return Object(o.a)(Object(o.a)({},e),{},{rooms:Object(o.a)(Object(o.a)({},e.rooms),{},Object(p.a)({},t.payload.id,Object(o.a)(Object(o.a)({},e.rooms[t.payload.id]),{},{messages:t.payload.messages,offset:t.payload.messages.length})))});case"prepend messages":var s=[].concat(Object(O.a)(t.payload.messages),Object(O.a)(e.rooms[t.payload.id].messages));return Object(o.a)(Object(o.a)({},e),{},{rooms:Object(o.a)(Object(o.a)({},e.rooms),{},Object(p.a)({},t.payload.id,Object(o.a)(Object(o.a)({},e.rooms[t.payload.id]),{},{messages:s,offset:s.length})))});case"append message":return void 0===e.rooms[t.payload.id]?e:Object(o.a)(Object(o.a)({},e),{},{rooms:Object(o.a)(Object(o.a)({},e.rooms),{},Object(p.a)({},t.payload.id,Object(o.a)(Object(o.a)({},e.rooms[t.payload.id]),{},{lastMessage:t.payload.message,messages:e.rooms[t.payload.id].messages?[].concat(Object(O.a)(e.rooms[t.payload.id].messages),[t.payload.message]):void 0})))});case"set last message":return Object(o.a)(Object(o.a)({},e),{},{rooms:Object(o.a)(Object(o.a)({},e.rooms),{},Object(p.a)({},t.payload.id,Object(o.a)(Object(o.a)({},e.rooms[t.payload.id]),{},{lastMessage:t.payload.lastMessage})))});case"set current room":return Object(o.a)(Object(o.a)({},e),{},{currentRoom:t.payload});case"add room":return Object(o.a)(Object(o.a)({},e),{},{rooms:Object(o.a)(Object(o.a)({},e.rooms),{},Object(p.a)({},t.payload.id,t.payload))});case"set rooms":var n=t.payload,a=Object(o.a)({},e.rooms);return n.forEach((function(e){a[e.id]=Object(o.a)(Object(o.a)({},e),{},{messages:a[e.id]&&a[e.id].messages})})),Object(o.a)(Object(o.a)({},e),{},{rooms:a});default:return e}},g={currentRoom:"main",rooms:{},users:{}},v=Object(a.createContext)(),y=function(){var e=Object(a.useContext)(v),t=Object(i.a)(e,2);return[t[0],t[1]]},N=function(){return Object(a.useReducer)(x,g)},w=s(18),k=s.n(w),M=s(9),C=s.n(M);C.a.defaults.withCredentials=!0;var L=function(e){return"".concat("").concat(e)},z=function(){return C.a.get(L("/me")).then((function(e){return e.data})).catch((function(e){return null}))},S=function(e,t){return C.a.post(L("/login"),{username:e,password:t}).then((function(e){return e.data})).catch((function(e){throw new Error(e.response&&e.response.data&&e.response.data.message)}))},E=function(){return C.a.post(L("/logout"))},_=function(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:0,s=arguments.length>2&&void 0!==arguments[2]?arguments[2]:15;return C.a.get(L("/room/".concat(e,"/messages")),{params:{offset:t,size:s}}).then((function(e){return e.data.reverse()}))},R=function(e){return C.a.get(L("/users"),{params:{ids:e}}).then((function(e){return e.data}))},I=function(){var e=Object(u.a)(d.a.mark((function e(t,s){return d.a.wrap((function(e){for(;;)switch(e.prev=e.next){case 0:return e.abrupt("return",C.a.post(L("/room"),{user1:t,user2:s}).then((function(e){return e.data})));case 1:case"end":return e.stop()}}),e)})));return function(t,s){return e.apply(this,arguments)}}(),B=function(){var e=Object(u.a)(d.a.mark((function e(t){return d.a.wrap((function(e){for(;;)switch(e.prev=e.next){case 0:return e.abrupt("return",C.a.get(L("/rooms/".concat(t))).then((function(e){return e.data})));case 1:case"end":return e.stop()}}),e)})));return function(t){return e.apply(this,arguments)}}(),F=s(59),T=function(e,t){var s,n=Object(F.a)(e);try{for(n.s();!(s=n.n()).done;){var a=s.value;if("string"!==typeof a&&(a=a[0]),a!==t)return a}}catch(c){n.e(c)}finally{n.f()}return e[0]},U=(s(125),function(){var e=Object(u.a)(d.a.mark((function e(t,s,n){var a,c,r;return d.a.wrap((function(e){for(;;)switch(e.prev=e.next){case 0:if(a={},n.forEach((function(e){a[e.from]=1})),0===(c=Object.keys(a).filter((function(e){return void 0===t[e]}))).length){e.next=8;break}return e.next=6,R(c);case 6:r=e.sent,s({type:"append users",payload:r});case 8:case"end":return e.stop()}}),e)})));return function(t,s,n){return e.apply(this,arguments)}}()),H=function(){return Object(n.jsxs)("svg",{width:"32",height:"32",viewBox:"0 0 1651 1651",fill:"none",xmlns:"http://www.w3.org/2000/svg",children:[Object(n.jsx)("rect",{width:"1651",height:"1651",rx:"14",fill:"white"}),Object(n.jsx)("path",{d:"M495.286 1098.96L497.967 1070.86L478.04 1050.88C408.572 981.233 368 891.771 368 795.344C368 585.371 565.306 402 826 402C1086.69 402 1284 585.371 1284 795.344C1284 1005.32 1086.69 1188.69 826 1188.69V1248.69L825.913 1188.69C779.837 1188.75 733.952 1182.77 689.432 1170.9L667.26 1164.98L646.8 1175.37C620.731 1188.61 562.74 1213.98 467.32 1235.35C480.554 1191.83 490.95 1144.39 495.286 1098.96Z",stroke:"url(#paint0_linear)",strokeWidth:"120"}),Object(n.jsx)("defs",{children:Object(n.jsxs)("linearGradient",{id:"paint0_linear",x1:"662.312",y1:"397.956",x2:"416.164",y2:"1678.7",gradientUnits:"userSpaceOnUse",children:[Object(n.jsx)("stop",{stopColor:"#7514FB"}),Object(n.jsx)("stop",{offset:"0.624243",stopColor:"#F26D41"}),Object(n.jsx)("stop",{offset:"1",stopColor:"#F43B4B"})]})})]})},P=function(e){var t=e.name,s=e.id,c=Object(a.useMemo)((function(){var e=function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:"1",t=13,s=654,n=531,a=+e.split(":").pop(),c=+e.split(":").reverse().pop();c<0&&(c+=3555);var r=(a*s+c*n)%t;return"".concat("","/avatars/").concat(r,".jpg")}(""+s);return"Mary"===t?"".concat("","/avatars/0.jpg"):"Pablo"===t?"".concat("","/avatars/2.jpg"):"Joe"===t?"".concat("","/avatars/9.jpg"):"Alex"===t?"".concat("","/avatars/8.jpg"):e}),[s,t]);return Object(n.jsx)(n.Fragment,{children:"General"!==t?Object(n.jsx)("img",{src:c,alt:t,style:{width:32,height:32,objectFit:"cover"},className:"rounded-circle avatar-xs"}):Object(n.jsx)("div",{className:"overflow-hidden rounded-circle",children:Object(n.jsx)(H,{})})})},W=function(e){var t=e.online,s=e.hide,a=void 0!==s&&s,c=e.width,r=void 0===c?8:c,o=e.height,i=void 0===o?8:o;return Object(n.jsx)("div",{className:t?"rounded-circle bg-success":"rounded-circle bg-gray",style:{width:r,height:i,opacity:a?0:1}})},D=function(e){var t=e.id,s=e.name,n=y(),c=Object(i.a)(n,1)[0],r=Object(a.useMemo)((function(){try{var e=Math.abs(parseInt(t.split(":").reverse().pop())),n=e>0,a=Object.entries(c.users).filter((function(e){return Object(i.a)(e,2)[1].username===s})).map((function(e){return Object(i.a)(e,2)[1]})),r=!1;return a.length>0&&(r=a[0].online,e=+a[0].id),[n,r,e]}catch(o){return[!1,!1,"0"]}}),[t,s,c.users]),o=Object(i.a)(r,3),l=o[0],d=o[1],u=o[2],j=J(e);return{isUser:l,online:d,userId:u,name:e.name,lastMessage:j}},J=function(e){var t=y(),s=Object(i.a)(t,2)[1],n=e.lastMessage;return Object(a.useEffect)((function(){void 0===n&&(void 0===e.messages?_(e.id,0,1).then((function(t){var n=null;0!==t.length&&(n=t.pop()),s({type:"set last message",payload:{id:e.id,lastMessage:n}})})):0===e.messages.length?s({type:"set last message",payload:{id:e.id,lastMessage:null}}):s({type:"set last message",payload:{id:e.id,lastMessage:e.messages[e.messages.length-1]}}))}),[n,s,e]),n},A=function(e){var t=e.room,s=e.active,a=void 0!==s&&s,c=e.onClick,r=D(t),o=r.online,i=r.name,l=r.lastMessage,d=r.userId;return Object(n.jsxs)("div",{onClick:c,className:"chat-list-item d-flex align-items-start rounded ".concat(a?"bg-white":""),children:[Object(n.jsx)("div",{className:"align-self-center mr-3",children:Object(n.jsx)(W,{online:o,hide:"0"===t.id})}),Object(n.jsx)("div",{className:"align-self-center mr-3",children:Object(n.jsx)(P,{name:i,id:d})}),Object(n.jsxs)("div",{className:"media-body overflow-hidden",children:[Object(n.jsx)("h5",{className:"text-truncate font-size-14 mb-1",children:i}),l&&Object(n.jsxs)("p",{className:"text-truncate mb-0",children:[" ",l.message," "]})]}),l&&Object(n.jsx)("div",{className:"font-size-11",children:k.a.unix(l.date).format("LT")})]})},V=s(128),q=function(e){var t=e.onLogOut,s=e.col,a=void 0===s?5:s,c=e.noinfo,r=void 0!==c&&c;return Object(n.jsxs)("div",{onClick:t,style:{cursor:"pointer"},className:"col-".concat(a," text-danger ").concat(r?"":"text-right"),children:[Object(n.jsx)(V.a,{})," Log out"]})},G=function(e){var t=e.user,s=e.col,a=void 0===s?7:s,c=e.noinfo,r=void 0!==c&&c;return Object(n.jsxs)("div",{className:"col-".concat(a," d-flex align-items-center ").concat(r?"justify-content-end":""),children:[Object(n.jsx)("div",{className:"align-self-center ".concat(r?"":"mr-3"),children:Object(n.jsx)(P,{name:t.username,id:t.id})}),!r&&Object(n.jsxs)("div",{className:"media-body",children:[Object(n.jsx)("h5",{className:"font-size-14 mt-0 mb-1",children:t.username}),Object(n.jsxs)("div",{className:"d-flex align-items-center",children:[Object(n.jsx)(W,{online:!0}),Object(n.jsx)("p",{className:"ml-2 text-muted mb-0",children:"Active"})]})]})]})},X=function(e){var t=e.user,s=e.onLogOut;return Object(n.jsx)("div",{className:"row no-gutters align-items-center pl-4 pr-2 pb-3",style:{height:"inherit",flex:0,minHeight:50},children:Object(n.jsxs)(n.Fragment,{children:[Object(n.jsx)(G,{user:t,col:8}),Object(n.jsx)(q,{onLogOut:s,col:4})]})})},Z=function(e){var t=e.rooms,s=e.dispatch,c=e.user,r=e.currentRoom,o=e.onLogOut,i=Object(a.useMemo)((function(){var e=Object.values(t),s=e.filter((function(e){return"0"===e.id})),n=e.filter((function(e){return"0"!==e.id}));return n=n.sort((function(e,t){return+e.id.split(":").pop()-+t.id.split(":").pop()})),[].concat(Object(O.a)(s||[]),Object(O.a)(n))}),[t]);return Object(n.jsx)(n.Fragment,{children:Object(n.jsxs)("div",{className:"chat-list-container flex-column d-flex pr-4",children:[Object(n.jsx)("div",{className:"py-2",children:Object(n.jsx)("p",{className:"h5 mb-0 py-1 chats-title",children:"Chats"})}),Object(n.jsx)("div",{className:"messages-box flex flex-1",children:Object(n.jsx)("div",{className:"list-group rounded-0",children:i.map((function(e){return Object(n.jsx)(A,{onClick:function(){return s({type:"set current room",payload:e.id})},active:r===e.id,room:e},e.id)}))})}),Object(n.jsx)(X,{user:c,onLogOut:o})]})})},K=function(e){var t=e.message;return Object(n.jsx)("p",{className:"mb-2 fs-6 fw-light fst-italic text-black-50 text-center",style:{opacity:.8,fontSize:14},children:t})},Q=function(){return Object(n.jsx)("div",{className:"no-messages flex-column d-flex flex-row justify-content-center align-items-center text-muted text-center",children:Object(n.jsx)("div",{className:"spinner-border",role:"status",children:Object(n.jsx)("span",{className:"visually-hidden"})})})},Y=s(129),$=function(){return Object(n.jsxs)("div",{className:"no-messages flex-column d-flex flex-row justify-content-center align-items-center text-muted text-center",children:[Object(n.jsx)(Y.a,{size:96}),Object(n.jsx)("p",{children:"No messages"})]})},ee=function(){return Object(n.jsxs)("svg",{width:12,height:12,className:"prefix__MuiSvgIcon-root prefix__jss80 prefix__MuiSvgIcon-fontSizeLarge",viewBox:"0 0 24 24","aria-hidden":"true",children:[Object(n.jsx)("path",{d:"M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z"}),Object(n.jsx)("path",{d:"M12.5 7H11v6l5.25 3.15.75-1.23-4.5-2.67z"})]})},te=function(e){var t=e.username,s=void 0===t?"user":t,a=e.message,c=void 0===a?"Lorem ipsum dolor...":a,r=e.date;return Object(n.jsxs)("div",{className:"d-flex",children:[Object(n.jsx)("div",{style:{flex:1}}),Object(n.jsx)("div",{style:{width:"50%"},className:"text-right mb-4",children:Object(n.jsx)("div",{className:"conversation-list d-inline-block bg-light px-3 py-2",style:{borderRadius:12},children:Object(n.jsxs)("div",{className:"ctext-wrap",children:[Object(n.jsx)("div",{className:"conversation-name text-left text-primary mb-1",style:{fontWeight:600},children:s}),Object(n.jsx)("p",{className:"text-left",children:c}),Object(n.jsxs)("p",{className:"chat-time mb-0",children:[Object(n.jsx)(ee,{})," ",k.a.unix(r).format("LT")," "]})]})})})]})},se=function(e){var t=e.user,s=e.message,a=void 0===s?"Lorem ipsum dolor...":s,c=e.date,r=e.onUserClicked;return Object(n.jsxs)("div",{className:"d-flex",children:[Object(n.jsx)("div",{style:{width:"50%"},className:"text-left mb-4",children:Object(n.jsx)("div",{className:"conversation-list d-inline-block px-3 py-2",style:{borderRadius:12,backgroundColor:"rgba(85, 110, 230, 0.1)"},children:Object(n.jsxs)("div",{className:"ctext-wrap",children:[t&&Object(n.jsxs)("div",{className:"conversation-name text-primary d-flex align-items-center mb-1",children:[Object(n.jsx)("div",{className:"mr-2",style:{fontWeight:600,cursor:"pointer"},onClick:r,children:t.username}),Object(n.jsx)(W,{width:7,height:7,online:t.online})]}),Object(n.jsx)("p",{className:"text-left",children:a}),Object(n.jsxs)("p",{className:"chat-time mb-0",children:[Object(n.jsx)(ee,{})," ",k.a.unix(c).format("LT")," "]})]})})}),Object(n.jsx)("div",{style:{flex:1}})]})},ne=function(e){var t=e.messageListElement,s=e.messages,a=e.room,c=e.onLoadMoreMessages,r=e.user,o=e.onUserClicked,i=e.users;return Object(n.jsxs)("div",{ref:t,className:"chat-box-wrapper position-relative d-flex",children:[void 0===s?Object(n.jsx)(Q,{}):0===s.length?Object(n.jsx)($,{}):Object(n.jsx)(n.Fragment,{}),Object(n.jsx)("div",{className:"px-4 pt-5 chat-box position-absolute",children:s&&0!==s.length&&Object(n.jsxs)(n.Fragment,{children:[a.offset&&a.offset>=15?Object(n.jsxs)("div",{className:"d-flex flex-row align-items-center mb-4",children:[Object(n.jsx)("div",{style:{height:1,backgroundColor:"#eee",flex:1}}),Object(n.jsx)("div",{className:"mx-3",children:Object(n.jsx)("button",{"aria-haspopup":"true","aria-expanded":"true",type:"button",onClick:c,className:"btn rounded-button btn-secondary nav-btn",id:"__BVID__168__BV_toggle_",children:"Load more"})}),Object(n.jsx)("div",{style:{height:1,backgroundColor:"#eee",flex:1}})]}):Object(n.jsx)(n.Fragment,{}),s.map((function(e,t){var s=e.message+e.date+e.from+t;return"info"===e.from?Object(n.jsx)(K,{message:e.message},s):+e.from!==+r.id?Object(n.jsx)(se,{onUserClicked:function(){return o(e.from)},message:e.message,date:e.date,user:i[e.from]},s):Object(n.jsx)(te,{username:i[e.from]?i[e.from].username:"",message:e.message,date:e.date},s)}))]})})]})},ae=function(e){var t=e.message,s=e.setMessage,a=e.onSubmit;return Object(n.jsx)("div",{className:"p-3 chat-input-section",children:Object(n.jsxs)("form",{className:"row",onSubmit:a,children:[Object(n.jsx)("div",{className:"col",children:Object(n.jsx)("div",{className:"position-relative",children:Object(n.jsx)("input",{value:t,onChange:function(e){return s(e.target.value)},type:"text",placeholder:"Enter Message...",className:"form-control chat-input"})})}),Object(n.jsx)("div",{className:"col-auto",children:Object(n.jsxs)("button",{type:"submit",className:"btn btn-primary btn-rounded chat-send w-md",children:[Object(n.jsx)("span",{className:"d-none d-sm-inline-block mr-2",children:"Send"}),Object(n.jsx)("svg",{width:13,height:13,viewBox:"0 0 24 24",tabIndex:-1,children:Object(n.jsx)("path",{d:"M2.01 21L23 12 2.01 3 2 10l15 2-15 2z",fill:"white"})})]})})]})})},ce=function(e){var t=y(),s=Object(i.a)(t,2),n=s[0],c=s[1],r=Object(a.useRef)(null),l=n.rooms[n.currentRoom],j=null===l||void 0===l?void 0:l.id,b=null===l||void 0===l?void 0:l.messages,m=Object(a.useState)(""),f=Object(i.a)(m,2),h=f[0],O=f[1],p=Object(a.useCallback)((function(){setTimeout((function(){r.current&&(r.current.scrollTop=0)}),0)}),[]),x=Object(a.useCallback)((function(){r.current&&r.current.scrollTo({top:r.current.scrollHeight})}),[]);Object(a.useEffect)((function(){x()}),[b,x]);var g=Object(a.useCallback)((function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:0,t=arguments.length>1&&void 0!==arguments[1]&&arguments[1];_(j,e).then(function(){var e=Object(u.a)(d.a.mark((function e(s){return d.a.wrap((function(e){for(;;)switch(e.prev=e.next){case 0:return e.next=2,U(n.users,c,s);case 2:c({type:t?"prepend messages":"set messages",payload:{id:j,messages:s}}),t?setTimeout((function(){p()}),10):x();case 4:case"end":return e.stop()}}),e)})));return function(t){return e.apply(this,arguments)}}())}),[c,j,x,p,n.users]);Object(a.useEffect)((function(){void 0!==j&&void 0===b&&g()}),[b,c,j,n.users,n,x,g]),Object(a.useEffect)((function(){r.current&&x()}),[x,j]);var v=function(){var t=Object(u.a)(d.a.mark((function t(s){var a,r,i;return d.a.wrap((function(t){for(;;)switch(t.prev=t.next){case 0:if(a=n.users[s],void 0!==(r=a.room)){t.next=9;break}return t.next=5,I(s,e.id);case 5:i=t.sent,r=i.id,c({type:"set user",payload:Object(o.a)(Object(o.a)({},a),{},{room:r})}),c({type:"add room",payload:{id:r,name:T(i.names,e.username)}});case 9:c({type:"set current room",payload:r});case 10:case"end":return t.stop()}}),t)})));return function(e){return t.apply(this,arguments)}}();return{onLoadMoreMessages:Object(a.useCallback)((function(){g(l.offset,!0)}),[g,l]),onUserClicked:v,message:h,setMessage:O,dispatch:c,room:l,rooms:n.rooms,currentRoom:n.currentRoom,messageListElement:r,roomId:j,users:n.users,messages:b}};function re(e){var t=e.onLogOut,s=e.user,a=e.onMessageSend,c=ce(s),r=c.onLoadMoreMessages,o=c.onUserClicked,i=c.message,l=c.setMessage,d=c.rooms,u=c.room,j=c.currentRoom,b=c.dispatch,m=c.messageListElement,f=c.roomId,h=c.messages,O=c.users;return Object(n.jsx)("div",{className:"container py-5 px-4",children:Object(n.jsxs)("div",{className:"chat-body row overflow-hidden shadow bg-light rounded",children:[Object(n.jsx)("div",{className:"col-4 px-0",children:Object(n.jsx)(Z,{user:s,onLogOut:t,rooms:d,currentRoom:j,dispatch:b})}),Object(n.jsxs)("div",{className:"col-8 px-0 flex-column bg-white rounded-lg",children:[Object(n.jsx)("div",{className:"px-4 py-4",style:{borderBottom:"1px solid #eee"},children:Object(n.jsx)("h2",{className:"font-size-15 mb-0",children:u?u.name:"Room"})}),Object(n.jsx)(ne,{messageListElement:m,messages:h,room:u,onLoadMoreMessages:r,user:s,onUserClicked:o,users:O}),Object(n.jsx)(ae,{message:i,setMessage:l,onSubmit:function(e){e.preventDefault(),a(i.trim(),f),l(""),m.current.scrollTop=m.current.scrollHeight}})]})]})})}function oe(){return Object(n.jsx)("div",{className:"centered-box",children:Object(n.jsx)("div",{className:"spinner-border",role:"status",children:Object(n.jsx)("span",{className:"visually-hidden"})})})}var ie=function(e){var t=e.link;return Object(n.jsx)("a",{href:t,title:"Github",children:Object(n.jsxs)("svg",{width:24,height:24,viewBox:"0 0 64 64","aria-labelledby":"title","aria-describedby":"desc",role:"img",children:[Object(n.jsx)("path",{"data-name":"layer2",d:"M32 0a32.021 32.021 0 0 0-10.1 62.4c1.6.3 2.2-.7 2.2-1.5v-6c-8.9 1.9-10.8-3.8-10.8-3.8-1.5-3.7-3.6-4.7-3.6-4.7-2.9-2 .2-1.9.2-1.9 3.2.2 4.9 3.3 4.9 3.3 2.9 4.9 7.5 3.5 9.3 2.7a6.93 6.93 0 0 1 2-4.3c-7.1-.8-14.6-3.6-14.6-15.8a12.27 12.27 0 0 1 3.3-8.6 11.965 11.965 0 0 1 .3-8.5s2.7-.9 8.8 3.3a30.873 30.873 0 0 1 8-1.1 30.292 30.292 0 0 1 8 1.1c6.1-4.1 8.8-3.3 8.8-3.3a11.965 11.965 0 0 1 .3 8.5 12.1 12.1 0 0 1 3.3 8.6c0 12.3-7.5 15-14.6 15.8a7.746 7.746 0 0 1 2.2 5.9v8.8c0 .9.6 1.8 2.2 1.5A32.021 32.021 0 0 0 32 0z",fill:"#595F70"}),Object(n.jsx)("path",{"data-name":"layer1",d:"M12.1 45.9c-.1.2-.3.2-.5.1s-.4-.3-.3-.5.3-.2.6-.1c.2.2.3.4.2.5zm1.3 1.5a.589.589 0 0 1-.8-.8.631.631 0 0 1 .7.1.494.494 0 0 1 .1.7zm1.3 1.8a.585.585 0 0 1-.7-.3.6.6 0 0 1 0-.8.585.585 0 0 1 .7.3c.2.3.2.7 0 .8zm1.7 1.8c-.2.2-.5.1-.8-.1-.3-.3-.4-.6-.2-.8a.619.619 0 0 1 .8.1.554.554 0 0 1 .2.8zm2.4 1c-.1.3-.4.4-.8.3s-.6-.4-.5-.7.4-.4.8-.3c.3.2.6.5.5.7zm2.6.2c0 .3-.3.5-.7.5s-.7-.2-.7-.5.3-.5.7-.5c.4.1.7.3.7.5zm2.4-.4q0 .45-.6.6a.691.691 0 0 1-.8-.3q0-.45.6-.6c.5-.1.8.1.8.3z",fill:"#595F70"})]})})},le=function(){var e=Object(a.useState)(null),t=Object(i.a)(e,2),s=t[0],c=t[1];return Object(a.useEffect)((function(){C.a.get(L("/links")).then((function(e){return e.data})).catch((function(e){return null})).then(c)}),[]),Object(n.jsxs)("nav",{className:"navbar navbar-expand-lg navbar-light bg-white",children:[Object(n.jsx)("span",{className:"navbar-brand",children:"Redis chat demo"}),null!==s?Object(n.jsx)("span",{className:"navbar-text",children:s.github&&Object(n.jsx)(ie,{link:s.github})}):Object(n.jsx)(n.Fragment,{})]})},de=s(60),ue=s.n(de),je=function(e,t,s){t({type:"set user",payload:e}),void 0!==s&&t({type:"append message",payload:{id:"0",message:{date:1e4*Math.random(),from:"info",message:s}}})},be=function(e,t,s){return s({type:"add room",payload:{id:e.id,name:T(e.names,t)}})},me=function(e,t){t({type:"make user online",payload:e.from}),t({type:"append message",payload:{id:void 0===e.roomId?"0":e.roomId,message:e}})},fe=function(e,t){var s=Object(a.useState)(!1),n=Object(i.a)(s,2),c=n[0],r=n[1],o=Object(a.useRef)(null),l=Object(a.useRef)(null),d=o.current;return Object(a.useEffect)((function(){null===e?(null!==d&&d.disconnect(),r(!1),null!==l.current&&(l.current.close(),l.current=null)):(null===l.current&&(l.current=new EventSource(L("/stream")),l.current.onmessage=function(s){var n=JSON.parse(s.data),a=n.type,c=n.data;switch(a){case"user.connected":je(c,t,"".concat(c.username," connected"));break;case"user.disconnected":je(c,t,"".concat(c.username," left"));break;case"show.room":be(c,e.username,t);break;case"message":me(c,t)}}),null!==d?d.connect():o.current=ue()(),r(!0))}),[e,d,t]),Object(a.useEffect)((function(){c&&e?(d.on("user.connected",(function(e){return je(e,t,"".concat(e.username," connected"))})),d.on("user.disconnected",(function(e){return je(e,t,"".concat(e.username," left"))})),d.on("show.room",(function(s){be(s,e.username,t)})),d.on("message",(function(e){me(e,t)}))):d&&(d.off("user.connected"),d.off("user.disconnected"),d.off("user.room"),d.off("message"))}),[c,e,t,d]),[d,c]},he=function(){var e=N(),t=Object(i.a)(e,2),s=t[0],n=t[1],c=function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:function(e){},t=arguments.length>1?arguments[1]:void 0,s=Object(a.useState)(!0),n=Object(i.a)(s,2),c=n[0],r=n[1],o=Object(a.useState)(null),l=Object(i.a)(o,2),j=l[0],b=l[1],m=function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:"",t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"",s=arguments.length>2&&void 0!==arguments[2]?arguments[2]:function(){},n=arguments.length>3&&void 0!==arguments[3]?arguments[3]:function(){};s(null),n(!0),S(e,t).then((function(e){b(e)})).catch((function(e){return s(e.message)})).finally((function(){return n(!1)}))},f=function(){var e=Object(u.a)(d.a.mark((function e(){return d.a.wrap((function(e){for(;;)switch(e.prev=e.next){case 0:E().then((function(){b(null),t({type:"clear"}),r(!0)}));case 1:case"end":return e.stop()}}),e)})));return function(){return e.apply(this,arguments)}}();return Object(a.useEffect)((function(){c&&z().then((function(t){b(t),r(!1),e(t)}))}),[e,c]),{user:j,onLogIn:m,onLogOut:f,loading:c}}(Object(a.useCallback)((function(e){null!==e&&(s.users[e.id]||n({type:"set user",payload:Object(o.a)(Object(o.a)({},e),{},{online:!0})}))}),[n,s.users]),n),r=c.user,l=c.onLogIn,j=c.onLogOut,b=c.loading,m=fe(r,n),f=Object(i.a)(m,2),h=f[0],O=f[1];Object(a.useEffect)((function(){if(null!==r)if(O){var e=[];Object.keys(s.rooms).forEach((function(t){var n=s.rooms[t];n.connected||(e.push(Object(o.a)(Object(o.a)({},n),{},{connected:!0})),h.emit("room.join",n.id))})),0!==e.length&&n({type:"set rooms",payload:e})}else{var t=[];Object.keys(s.rooms).forEach((function(e){var n=s.rooms[e];n.connected&&t.push(Object(o.a)(Object(o.a)({},n),{},{connected:!1}))})),0!==t.length&&n({type:"set rooms",payload:t})}}),[r,O,n,h,s.rooms,s.users]),Object(a.useEffect)((function(){0===Object.values(s.rooms).length&&null!==r&&(C.a.get(L("/users/online")).then((function(e){return e.data})).then((function(e){n({type:"append users",payload:e})})),B(r.id).then((function(e){var t=[];e.forEach((function(e){var s=e.id,n=e.names;t.push({id:s,name:T(n,r.username)})})),n({type:"set rooms",payload:t}),n({type:"set current room",payload:"0"})})))}),[n,s.rooms,r]);var p=Object(a.useCallback)((function(e,t){"string"===typeof e&&0!==e.trim().length&&(h||console.error("Couldn't send message"),h.emit("message",{roomId:t,message:e,from:r.id,date:k()(new Date).unix()}))}),[r,h]);return{loading:b,user:r,state:s,dispatch:n,onLogIn:l,onMessageSend:p,onLogOut:j}},Oe=function(){var e=he(),t=e.loading,s=e.user,a=e.state,c=e.dispatch,r=e.onLogIn,o=e.onMessageSend,i=e.onLogOut;if(t)return Object(n.jsx)(oe,{});var l=!s;return Object(n.jsx)(v.Provider,{value:[a,c],children:Object(n.jsxs)("div",{className:"full-height ".concat(l?"bg-light":""),style:{backgroundColor:l?void 0:"#495057"},children:[Object(n.jsx)(le,{}),l?Object(n.jsx)(f,{onLogIn:r}):Object(n.jsx)(re,{user:s,onMessageSend:o,onLogOut:i})]})})};r.a.render(Object(n.jsx)(Oe,{}),document.getElementById("root"))},65:function(e,t,s){},66:function(e,t,s){},67:function(e,t,s){},72:function(e,t,s){},75:function(e,t,s){}},[[124,1,2]]]); 2 | //# sourceMappingURL=main.e15e5a37.chunk.js.map --------------------------------------------------------------------------------