├── .github └── FUNDING.yml ├── .gitignore ├── README.md ├── client ├── .babelrc ├── .dockerignore ├── .gitignore ├── Dockerfile ├── README.md ├── docs │ └── example_messages.json ├── nginx.conf ├── package-lock.json ├── package.json ├── public │ ├── favicon.svg │ ├── index.html │ └── manifest.json ├── src │ ├── assets │ │ ├── img │ │ │ └── messenger │ │ │ │ ├── 1.png │ │ │ │ ├── 2.png │ │ │ │ ├── 3.png │ │ │ │ ├── 4.png │ │ │ │ ├── 5.png │ │ │ │ ├── 6.png │ │ │ │ ├── 7.png │ │ │ │ └── 8.png │ │ └── svg │ │ │ ├── camera.svg │ │ │ ├── chat.svg │ │ │ ├── chatting.svg │ │ │ ├── emoji.svg │ │ │ ├── heart.svg │ │ │ ├── image.svg │ │ │ ├── index.js │ │ │ ├── instagram.svg │ │ │ ├── link.svg │ │ │ ├── messenger.svg │ │ │ ├── telegram.svg │ │ │ ├── upload.svg │ │ │ └── whatsapp.svg │ ├── components │ │ ├── chat-bubble │ │ │ ├── index.tsx │ │ │ └── styles.scss │ │ ├── dropdown │ │ │ ├── index.tsx │ │ │ └── styles.scss │ │ ├── loader │ │ │ ├── index.tsx │ │ │ └── styles.scss │ │ ├── ribbon │ │ │ ├── index.tsx │ │ │ └── styles.scss │ │ └── waves │ │ │ ├── index.tsx │ │ │ └── styles.scss │ ├── constants │ │ └── social-network.ts │ ├── declaration.d.ts │ ├── index.tsx │ ├── pages │ │ ├── analytics │ │ │ ├── index.tsx │ │ │ ├── sections │ │ │ │ ├── date-time-messages │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── styles.scss │ │ │ │ ├── emojis │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── styles.scss │ │ │ │ ├── header │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── styles.scss │ │ │ │ ├── index.tsx │ │ │ │ ├── overview │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── styles.scss │ │ │ │ └── participant-messages │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── styles.scss │ │ │ └── styles.scss │ │ ├── home │ │ │ ├── index.tsx │ │ │ ├── sections │ │ │ │ ├── footer │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── styles.scss │ │ │ │ ├── get-started │ │ │ │ │ ├── index.tsx │ │ │ │ │ ├── steps.ts │ │ │ │ │ └── styles.scss │ │ │ │ ├── hero │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── styles.scss │ │ │ │ └── index.tsx │ │ │ └── styles.scss │ │ └── not-found │ │ │ ├── index.tsx │ │ │ └── styles.scss │ ├── styles │ │ ├── colors.scss │ │ └── globals.scss │ └── utils │ │ ├── formatters.ts │ │ └── types.ts ├── tsconfig.json └── webpack.config.js ├── docker-compose.yml ├── docs ├── chat-report.png └── demo.gif └── server ├── .dockerignore ├── .gitignore ├── Dockerfile ├── README.md ├── requirements.txt ├── resources ├── chat.py ├── handlers │ └── messenger.py └── utils │ └── utils.py └── server.py /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | ko_fi: vitorcodes 4 | custom: ['https://www.buymeacoffee.com/vitorcodes', 'https://tinyurl.com/cryptorequest'] 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_store -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ChatView 2 | 3 | Web app that generates reports on social media chat history, such as **Messenger**, **Whatsapp**, **Instagram** and **Telegram**. 4 | 5 |

6 | chat report 7 |

8 | 9 | Once downloaded, you'll get a report similar to [this one](docs/chat-report.png). 10 | 11 | ## 🚀 Setup 12 | 13 | ### Docker 14 | 15 | 1. Clone/Download the repository 16 | 2. Make sure you have [Docker](https://www.docker.com/) installed in your machine 17 | 3. From the `terminal`, `cd` to the root of the project and run: 18 | 19 | ``` 20 | docker-compose up 21 | ``` 22 | 23 | Once the containers are up, open your browser and run the web app hosted at [http://localhost:3000](http://localhost:3000). 24 | 25 | ### Local 26 | 27 | #### Server 28 | 29 | Follow the [README](./server/README.md) in the server module. 30 | 31 | #### Client 32 | 33 | Once you get the server up and running, it's time to run the client app. You can do so by following the [README](./client/README.md) in the client module. 34 | 35 | Similarly to the [Docker setup](#Docker), you can now open your browser and run the web app hosted at [http://localhost:3000](http://localhost:3000) 36 | 37 | ## 🗒 Roadmap 38 | 39 | ### Client 40 | 41 | * Responsive UI 42 | * Add more report customizations: 43 | * Color scheme 44 | * Change titles and labels 45 | * Override participant names 46 | * Add/hide sections (e.g. Emojis, Participant Messages) 47 | * New data section analytics 48 | * Handle group chat history reports 49 | * Extend current sections to be able to handle group chats 50 | * Improve error handling 51 | 52 | ### Server 53 | 54 | * Implement Whatsapp, Instagram and Telegram chat handlers 55 | * Add more chat analytics 56 | * Improve error handling 57 | 58 | ## 🎗 Donate 59 | 60 | If you like the project and want to support further development, consider sending a tip ❤️ 61 | 62 | * Crypto asset: 63 | **Ethereum** (or any other crypto asset) either via [CryptoRequest](https://cryptorequest.finance/#/pay/?req=eyJ2ZXJzaW9uIjoiMS4wLjAiLCJhbW91bnQiOiIiLCJpc1RpcCI6dHJ1ZSwicmVjZWl2ZXIiOiIweGYyMTUxMjZBMjc2NmZGZjdCNzNjRGMxNkE4MWI5RGI2ODFmMDMyRUEiLCJjaGFpbklkIjoiMSIsImlzVG9rZW4iOmZhbHNlLCJhc3NldEFkZHJlc3MiOiIweDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAiLCJzeW1ib2wiOiJFVEgiLCJ0aXRsZSI6IiVGMCU5RiU4RSU5NyUyMFRpcCUyMENoYXRWaWV3JTIwbnBtJTIwcGFja2FnZSUyMCVGMCU5RiU4RSU5NyJ9) or directly to my wallet address `0xf215126A2766fFf7B73cDc16A81b9Db681f032EA` 64 | 65 | * A humble mug of coffee: 66 | Buy Me A Coffee 67 | 68 | * Or perhaps a ko-fi? 69 | Buy Me A Coffee -------------------------------------------------------------------------------- /client/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env", 4 | "@babel/preset-react" 5 | ] 6 | } -------------------------------------------------------------------------------- /client/.dockerignore: -------------------------------------------------------------------------------- 1 | # Ignore everything 2 | ** 3 | 4 | # Allow files and directories 5 | !/src/** 6 | !/public 7 | !/webpack.config.js 8 | !/tsconfig.json 9 | !/package.json 10 | !/.babelrc 11 | !/nginx.conf 12 | 13 | # Ignore unnecessary files inside allowed directories 14 | **/.DS_Store -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist -------------------------------------------------------------------------------- /client/Dockerfile: -------------------------------------------------------------------------------- 1 | # Stage 1 - Install node dependencies and build with webpack 2 | FROM node:latest as builder 3 | ARG SRC_PATH=/usr/src/app 4 | WORKDIR ${SRC_PATH} 5 | COPY . . 6 | RUN npm install --silent 7 | RUN npm run build 8 | 9 | # Stage 2 - Production environment 10 | FROM nginx:alpine 11 | ARG SRC_PATH=/usr/src/app 12 | COPY --from=builder ${SRC_PATH}/dist /usr/share/nginx/html 13 | COPY --from=builder ${SRC_PATH}/nginx.conf /etc/nginx/nginx.conf 14 | EXPOSE 80 15 | CMD ["nginx", "-g", "daemon off;"] -------------------------------------------------------------------------------- /client/README.md: -------------------------------------------------------------------------------- 1 | # ChatView Client 2 | 3 | ## 1. Install dependencies 4 | 5 | Install all dependencies specified in the `package.json` file: 6 | 7 | ``` 8 | npm i 9 | ``` 10 | 11 | ## 2. Run server 12 | 13 | Start the webpack dev server: 14 | 15 | ``` 16 | npm run dev 17 | ``` 18 | -------------------------------------------------------------------------------- /client/docs/example_messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "participants": [ 3 | { 4 | "name": "Jane Doe" 5 | }, 6 | { 7 | "name": "John Doe" 8 | } 9 | ], 10 | "messages": [ 11 | { 12 | "sender_name": "John Doe", 13 | "timestamp_ms": 1641556389326, 14 | "content": "Snow use. The joke is over.", 15 | "type": "Generic", 16 | "is_unsent": false 17 | }, 18 | { 19 | "sender_name": "Jane Doe", 20 | "timestamp_ms": 1641556363793, 21 | "content": "Snow who?", 22 | "type": "Generic", 23 | "is_unsent": false 24 | }, 25 | { 26 | "sender_name": "John Doe", 27 | "timestamp_ms": 1641556322319, 28 | "content": "Snow.", 29 | "type": "Generic", 30 | "is_unsent": false 31 | }, 32 | { 33 | "sender_name": "Jane Doe", 34 | "timestamp_ms": 1641556112297, 35 | "content": "Who's there?", 36 | "type": "Generic", 37 | "is_unsent": false 38 | }, 39 | { 40 | "sender_name": "John Doe", 41 | "timestamp_ms": 1641556101072, 42 | "content": "Knock knock.", 43 | "type": "Generic", 44 | "is_unsent": false 45 | }, 46 | { 47 | "sender_name": "John Doe", 48 | "timestamp_ms": 1607618529160, 49 | "content": "Foo", 50 | "type": "Generic", 51 | "is_unsent": false 52 | }, 53 | { 54 | "sender_name": "John Doe", 55 | "timestamp_ms": 1607617994502, 56 | "content": "Foo", 57 | "type": "Generic", 58 | "is_unsent": false 59 | }, 60 | { 61 | "sender_name": "Jane Doe", 62 | "timestamp_ms": 1607617901171, 63 | "content": "Foo", 64 | "reactions": [ 65 | { 66 | "reaction": "\u00e2\u009d\u00a4", 67 | "actor": "John Doe" 68 | } 69 | ], 70 | "type": "Generic", 71 | "is_unsent": false 72 | }, 73 | { 74 | "sender_name": "Jane Doe", 75 | "timestamp_ms": 1607602058941, 76 | "content": "Foo", 77 | "type": "Generic", 78 | "is_unsent": false 79 | }, 80 | { 81 | "sender_name": "John Doe", 82 | "timestamp_ms": 1607601305992, 83 | "content": "Foo", 84 | "type": "Generic", 85 | "is_unsent": false 86 | }, 87 | { 88 | "sender_name": "John Doe", 89 | "timestamp_ms": 1607596642073, 90 | "content": "Foo", 91 | "type": "Generic", 92 | "is_unsent": false 93 | }, 94 | { 95 | "sender_name": "John Doe", 96 | "timestamp_ms": 1607596623687, 97 | "content": "Foo", 98 | "type": "Generic", 99 | "is_unsent": false 100 | }, 101 | { 102 | "sender_name": "Jane Doe", 103 | "timestamp_ms": 1607595717465, 104 | "content": "Foo \u00f0\u009f\u0098\u009b\u00f0\u009f\u0098\u009b", 105 | "type": "Generic", 106 | "is_unsent": false 107 | }, 108 | { 109 | "sender_name": "Jane Doe", 110 | "timestamp_ms": 1607595716806, 111 | "content": "Foo \u00f0\u009f\u0098\u009b", 112 | "type": "Generic", 113 | "is_unsent": false 114 | }, 115 | { 116 | "sender_name": "John Doe", 117 | "timestamp_ms": 1607595492142, 118 | "content": "Foo \u00e2\u009d\u00a4\u00ef\u00b8\u008f\u00e2\u009d\u00a4\u00ef\u00b8\u008f", 119 | "type": "Generic", 120 | "is_unsent": false 121 | } 122 | ], 123 | "title": "Jane Doe", 124 | "is_still_participant": true, 125 | "thread_type": "Regular", 126 | "magic_words": [ 127 | 128 | ] 129 | } -------------------------------------------------------------------------------- /client/nginx.conf: -------------------------------------------------------------------------------- 1 | worker_processes auto; 2 | 3 | events { 4 | worker_connections 8000; 5 | multi_accept on; 6 | } 7 | 8 | http { 9 | include /etc/nginx/mime.types; 10 | default_type application/octet-stream; 11 | log_format compression '$remote_addr - $remote_user [$time_local] ' 12 | '"$request" $status $upstream_addr ' 13 | '"$http_referer" "$http_user_agent"'; 14 | 15 | server { 16 | listen 80; 17 | access_log /var/log/nginx/access.log compression; 18 | root /usr/share/nginx/html; 19 | index index.html index.htm; 20 | server_name localhost; 21 | 22 | # Docker API server service 23 | location ^~ /api { 24 | client_max_body_size 50M; 25 | rewrite /api/(.*) /$1 break; 26 | proxy_pass http://server:5000; 27 | } 28 | 29 | # First attempt to serve request as file, then 30 | # as directory, then fall back to redirecting to index.html 31 | location / { 32 | try_files $uri $uri/ /index.html; 33 | } 34 | 35 | # Media: images, icons, video, audio, HTC 36 | location ~* \.(?:jpg|jpeg|gif|png|ico|cur|gz|svg|svgz|mp4|ogg|ogv|webm|htc)$ { 37 | expires 1M; 38 | access_log off; 39 | add_header Cache-Control "public"; 40 | } 41 | # Javascript and CSS files 42 | location ~* \.(?:css|js)$ { 43 | try_files $uri =404; 44 | expires 1y; 45 | access_log off; 46 | add_header Cache-Control "public"; 47 | } 48 | 49 | # Any route containing a file extension (e.g. /devicesfile.js) 50 | location ~ ^.+\..+$ { 51 | try_files $uri =404; 52 | } 53 | } 54 | } -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chat-view", 3 | "version": "1.0.0", 4 | "description": "Chat data visualizer", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "dev": "webpack serve --inline", 9 | "build": "webpack" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/VitorCodes/chat-frame.git" 14 | }, 15 | "author": "", 16 | "license": "ISC", 17 | "bugs": { 18 | "url": "https://github.com/VitorCodes/chat-frame/issues" 19 | }, 20 | "homepage": "https://github.com/VitorCodes/chat-frame#readme", 21 | "dependencies": { 22 | "axios": "^0.20.0", 23 | "bizcharts": "^4.0.14", 24 | "chroma-js": "^2.1.0", 25 | "file-saver": "^2.0.2", 26 | "html2canvas": "^1.0.0-rc.7", 27 | "react": "^16.14.0", 28 | "react-dom": "^16.14.0", 29 | "react-hot-toast": "^2.2.0", 30 | "react-router-dom": "^5.2.0" 31 | }, 32 | "devDependencies": { 33 | "@babel/core": "^7.12.3", 34 | "@babel/preset-env": "^7.12.1", 35 | "@babel/preset-react": "^7.12.1", 36 | "@types/react-dom": "^16.9.8", 37 | "babel-loader": "^8.1.0", 38 | "css-loader": "^5.0.0", 39 | "file-loader": "^6.1.1", 40 | "html-webpack-plugin": "^4.5.0", 41 | "sass": "^1.32.12", 42 | "sass-loader": "^10.0.3", 43 | "style-loader": "^2.0.0", 44 | "ts-loader": "^8.0.5", 45 | "typescript": "^4.0.3", 46 | "webpack": "^5.36.1", 47 | "webpack-cli": "^4.6.0", 48 | "webpack-dev-server": "^3.11.0" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /client/public/favicon.svg: -------------------------------------------------------------------------------- 1 | 10 | 18 | 19 | 20 | 21 | 22 | 23 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /client/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | ChatView 12 | 13 | 14 | 15 |
16 | 17 | 18 | -------------------------------------------------------------------------------- /client/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Chatview", 3 | "name": "Chatview - Messages visualizer", 4 | "lang": "en-US", 5 | "description": "Visualize chat data", 6 | "icons": [ 7 | { 8 | "src": "favicon.ico", 9 | "sizes": "64x64 32x32 24x24 16x16", 10 | "type": "image/x-icon" 11 | } 12 | ], 13 | "start_url": ".", 14 | "display": "standalone", 15 | "theme_color": "#3A11BA", 16 | "background_color": "#FFFFFF" 17 | } 18 | -------------------------------------------------------------------------------- /client/src/assets/img/messenger/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/VitorCodes/chatview/182075e14c4166eb23a435e3122a258b32053271/client/src/assets/img/messenger/1.png -------------------------------------------------------------------------------- /client/src/assets/img/messenger/2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/VitorCodes/chatview/182075e14c4166eb23a435e3122a258b32053271/client/src/assets/img/messenger/2.png -------------------------------------------------------------------------------- /client/src/assets/img/messenger/3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/VitorCodes/chatview/182075e14c4166eb23a435e3122a258b32053271/client/src/assets/img/messenger/3.png -------------------------------------------------------------------------------- /client/src/assets/img/messenger/4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/VitorCodes/chatview/182075e14c4166eb23a435e3122a258b32053271/client/src/assets/img/messenger/4.png -------------------------------------------------------------------------------- /client/src/assets/img/messenger/5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/VitorCodes/chatview/182075e14c4166eb23a435e3122a258b32053271/client/src/assets/img/messenger/5.png -------------------------------------------------------------------------------- /client/src/assets/img/messenger/6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/VitorCodes/chatview/182075e14c4166eb23a435e3122a258b32053271/client/src/assets/img/messenger/6.png -------------------------------------------------------------------------------- /client/src/assets/img/messenger/7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/VitorCodes/chatview/182075e14c4166eb23a435e3122a258b32053271/client/src/assets/img/messenger/7.png -------------------------------------------------------------------------------- /client/src/assets/img/messenger/8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/VitorCodes/chatview/182075e14c4166eb23a435e3122a258b32053271/client/src/assets/img/messenger/8.png -------------------------------------------------------------------------------- /client/src/assets/svg/camera.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 14 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | -------------------------------------------------------------------------------- /client/src/assets/svg/chat.svg: -------------------------------------------------------------------------------- 1 | 9 | 17 | 18 | 19 | 20 | 24 | 25 | -------------------------------------------------------------------------------- /client/src/assets/svg/chatting.svg: -------------------------------------------------------------------------------- 1 | 10 | 18 | 19 | 20 | 21 | 22 | 23 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /client/src/assets/svg/emoji.svg: -------------------------------------------------------------------------------- 1 | 11 | 19 | 20 | 21 | 22 | 23 | 38 | 39 | -------------------------------------------------------------------------------- /client/src/assets/svg/heart.svg: -------------------------------------------------------------------------------- 1 | 7 | 15 | 16 | 17 | 18 | 22 | 23 | -------------------------------------------------------------------------------- /client/src/assets/svg/image.svg: -------------------------------------------------------------------------------- 1 | 7 | 15 | 16 | 17 | 18 | 22 | 26 | 30 | 31 | -------------------------------------------------------------------------------- /client/src/assets/svg/index.js: -------------------------------------------------------------------------------- 1 | export default { 2 | Chats: require('./chatting.svg').default, 3 | Chat: require('./chat.svg').default, 4 | Camera: require('./camera.svg').default, 5 | Emoji: require('./emoji.svg').default, 6 | Heart: require('./heart.svg').default, 7 | Image: require('./image.svg').default, 8 | Link: require('./link.svg').default, 9 | Messenger: require('./messenger.svg').default, 10 | Whatsapp: require('./whatsapp.svg').default, 11 | Instagram: require('./instagram.svg').default, 12 | Telegram: require('./telegram.svg').default, 13 | Upload: require('./upload.svg').default 14 | } 15 | -------------------------------------------------------------------------------- /client/src/assets/svg/instagram.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/assets/svg/link.svg: -------------------------------------------------------------------------------- 1 | 9 | 17 | 18 | 19 | 20 | 24 | 25 | -------------------------------------------------------------------------------- /client/src/assets/svg/messenger.svg: -------------------------------------------------------------------------------- 1 | 9 | 12 | 13 | -------------------------------------------------------------------------------- /client/src/assets/svg/telegram.svg: -------------------------------------------------------------------------------- 1 | 8 | 12 | 13 | -------------------------------------------------------------------------------- /client/src/assets/svg/upload.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /client/src/assets/svg/whatsapp.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 14 | 15 | 28 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | -------------------------------------------------------------------------------- /client/src/components/chat-bubble/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode } from 'react' 2 | import styles from './styles.scss' 3 | 4 | type Variant = 'primary' | 'secondary' 5 | type ArrowDirection = 'right' | 'left' 6 | type Mode = 'filled' | 'outline' 7 | 8 | type ChatBubbleProps = { 9 | children?: ReactNode 10 | variant: Variant 11 | arrowDirection: ArrowDirection 12 | scale?: number 13 | mode: Mode 14 | } 15 | 16 | const ChatBubble = (props: ChatBubbleProps) => { 17 | const { variant, arrowDirection, scale, mode } = props 18 | const baseBubbleClass = 'chat-bubble__bubble' 19 | 20 | return ( 21 |
22 |
31 | 32 |
{props.children}
33 |
34 | ) 35 | } 36 | 37 | ChatBubble.defaultProps = { 38 | variant: 'primary', 39 | arrowDirection: 'right', 40 | scale: 1, 41 | mode: 'filled', 42 | } 43 | 44 | export default ChatBubble 45 | -------------------------------------------------------------------------------- /client/src/components/chat-bubble/styles.scss: -------------------------------------------------------------------------------- 1 | @import '../../styles//colors.scss'; 2 | 3 | $bubble-padding: 10px; 4 | $bubble-width: 200px; 5 | $bubble-height: 120px; 6 | $bubble-border-radius: 10px; 7 | $arrow-width: 70px; 8 | $arrow-height: 70px; 9 | $arrow-border-radius: 4px; 10 | 11 | .chat-bubble { 12 | position: relative; 13 | width: $bubble-width; 14 | height: $bubble-height; 15 | 16 | &__content { 17 | position: absolute; 18 | top: 0; 19 | bottom: 0; 20 | left: 0; 21 | right: 0; 22 | display: flex; 23 | flex-direction: column; 24 | align-items: center; 25 | justify-content: center; 26 | } 27 | 28 | &__bubble { 29 | position: relative; 30 | width: $bubble-width; 31 | height: $bubble-height; 32 | 33 | &::before { 34 | content: ''; 35 | position: absolute; 36 | bottom: 0; 37 | width: $arrow-width; 38 | height: $arrow-height; 39 | border-radius: $arrow-border-radius; 40 | transform-origin: center center; 41 | transform: rotate(30deg); 42 | } 43 | 44 | &::after { 45 | content: ''; 46 | position: absolute; 47 | top: 0; 48 | left: 0; 49 | right: 0; 50 | bottom: 0; 51 | transform-origin: top right; 52 | border-radius: $bubble-border-radius; 53 | } 54 | 55 | &-right { 56 | &::before { 57 | right: $bubble-width * 0.1; 58 | transform: rotate(30deg); 59 | } 60 | } 61 | 62 | &-left { 63 | &::before { 64 | left: $bubble-width * 0.1; 65 | transform: rotate(-30deg); 66 | } 67 | } 68 | 69 | &-variant-primary { 70 | &::before { 71 | background: $green-dark; 72 | } 73 | 74 | &::after { 75 | background: linear-gradient(180deg, $green-light 0%, $green-dark 80%); 76 | } 77 | } 78 | 79 | &-variant-secondary { 80 | &::before { 81 | background: $pink-dark; 82 | } 83 | 84 | &::after { 85 | background: linear-gradient(180deg, $pink-light 0%, $pink-dark 80%); 86 | } 87 | } 88 | 89 | &-outline { 90 | background: linear-gradient(180deg, $green-light 0%, $green-dark 80%); 91 | border-radius: $bubble-border-radius + 5px; 92 | 93 | &::before { 94 | border: 5px solid $green-dark; 95 | background: $blue-darkest; 96 | } 97 | 98 | &::after { 99 | top: 5px; 100 | left: 5px; 101 | right: 5px; 102 | bottom: 5px; 103 | background: $blue-darkest; 104 | } 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /client/src/components/dropdown/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useRef, useEffect } from 'react' 2 | import styles from './styles.scss' 3 | 4 | type DropdownOption = { 5 | icon?: string 6 | text: string 7 | enabled: boolean 8 | } 9 | 10 | type DropdownProps = { 11 | options: DropdownOption[] 12 | selectedOption?: DropdownOption 13 | } 14 | 15 | const Dropdown = (props: DropdownProps) => { 16 | const [isOpen, setIsOpen] = useState(false) 17 | const [selectedOption, setSelectedOption] = useState( 18 | props.options[0], 19 | ) 20 | const dropdownRef = useRef() 21 | 22 | useEffect(() => { 23 | document.addEventListener('mousedown', clickOutsideHandler) 24 | return () => document.removeEventListener('mousedown', clickOutsideHandler) 25 | }, [isOpen]) 26 | 27 | const clickOutsideHandler = (e: MouseEvent) => 28 | isOpen && 29 | dropdownRef.current && 30 | !dropdownRef.current.contains(e.target as Node) && 31 | setIsOpen(false) 32 | 33 | const setOption = (option: DropdownOption) => { 34 | setSelectedOption(option) 35 | setIsOpen(false) 36 | } 37 | 38 | const onOptionClick = (option: DropdownOption) => 39 | option.enabled && setOption(option) 40 | 41 | return ( 42 |
43 |
47 | {selectedOption.icon && } 48 | {selectedOption.text} 49 |
50 |
51 | {isOpen && ( 52 |
    53 | {props.options.map((option, index) => ( 54 |
  • 65 | {option.icon && } 66 | {option.text} 67 |
  • 68 | ))} 69 |
70 | )} 71 |
72 | ) 73 | } 74 | 75 | export default Dropdown 76 | -------------------------------------------------------------------------------- /client/src/components/dropdown/styles.scss: -------------------------------------------------------------------------------- 1 | @import '../../styles/colors.scss'; 2 | 3 | .dropdown { 4 | position: relative; 5 | display: flex; 6 | flex: 1; 7 | font-size: 0.9rem; 8 | height: 40px; 9 | font-weight: 300; 10 | 11 | &:hover { 12 | .dropdown__btn { 13 | span { 14 | color: $blue-dark; 15 | } 16 | > img { 17 | filter: invert(11%) sepia(78%) saturate(7407%) hue-rotate(258deg) 18 | brightness(83%) contrast(99%); 19 | } 20 | .arrow-down { 21 | border-top: 6px solid $blue-dark; 22 | } 23 | } 24 | } 25 | 26 | &__btn { 27 | display: flex; 28 | flex: 1; 29 | align-items: center; 30 | 31 | > img { 32 | margin: 0; 33 | margin-right: 10px; 34 | width: 20px; 35 | height: 20px; 36 | transition: filter 0.2s; 37 | filter: invert(6%) sepia(13%) saturate(2661%) hue-rotate(159deg) 38 | brightness(93%) contrast(100%); 39 | } 40 | 41 | > span { 42 | display: flex; 43 | flex: 1; 44 | transition: color 0.2s; 45 | } 46 | 47 | .arrow-down { 48 | margin-top: 4px; 49 | width: 0; 50 | height: 0; 51 | border-left: 5px solid transparent; 52 | border-right: 5px solid transparent; 53 | border-top: 6px solid $blue-darkest; 54 | transition: border-top 0.2s; 55 | } 56 | 57 | &:hover { 58 | cursor: pointer; 59 | } 60 | } 61 | 62 | &__menu { 63 | background: $white; 64 | position: absolute; 65 | list-style-type: none; 66 | top: 40px; 67 | width: 180px; 68 | z-index: 2; 69 | box-shadow: rgba(0, 0, 0, 0.35) 0px 5px 15px; 70 | font-weight: 200; 71 | 72 | &--option-disabled { 73 | opacity: 0.3; 74 | &:hover { 75 | cursor: default; 76 | } 77 | } 78 | 79 | &--option-enabled { 80 | &:hover { 81 | cursor: pointer; 82 | background: $white-text; 83 | } 84 | } 85 | 86 | img { 87 | margin: 0; 88 | margin-right: 10px; 89 | width: 20px; 90 | height: 20px; 91 | filter: invert(13%) sepia(12%) saturate(1070%) hue-rotate(160deg) 92 | brightness(103%) contrast(97%); 93 | } 94 | 95 | li { 96 | display: flex; 97 | height: 40px; 98 | margin: 0; 99 | padding: 10px; 100 | align-items: center; 101 | color: $blue-darkest; 102 | transition: background 0.2s; 103 | } 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /client/src/components/loader/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useRef, useEffect } from 'react' 2 | import styles from './styles.scss' 3 | 4 | type LoaderProps = { 5 | size: number 6 | } 7 | 8 | const Dropdown = (props: LoaderProps) => 9 |
10 | 11 | Dropdown.defaultProps = { 12 | size: 2 13 | } 14 | 15 | export default Dropdown 16 | -------------------------------------------------------------------------------- /client/src/components/loader/styles.scss: -------------------------------------------------------------------------------- 1 | @import '../../styles/colors.scss'; 2 | 3 | .loader, 4 | .loader:after { 5 | border-radius: 50%; 6 | width: 10em; 7 | height: 10em; 8 | } 9 | .loader { 10 | margin: 0 auto; 11 | position: relative; 12 | text-indent: -9999em; 13 | border-top: 1.1em solid rgba(255, 255, 255, 0.2); 14 | border-right: 1.1em solid rgba(255, 255, 255, 0.2); 15 | border-bottom: 1.1em solid rgba(255, 255, 255, 0.2); 16 | border-left: 1.1em solid #ffffff; 17 | -webkit-transform: translateZ(0); 18 | -ms-transform: translateZ(0); 19 | transform: translateZ(0); 20 | -webkit-animation: load8 1.1s infinite linear; 21 | animation: load8 1.1s infinite linear; 22 | } 23 | @-webkit-keyframes load8 { 24 | 0% { 25 | -webkit-transform: rotate(0deg); 26 | transform: rotate(0deg); 27 | } 28 | 100% { 29 | -webkit-transform: rotate(360deg); 30 | transform: rotate(360deg); 31 | } 32 | } 33 | @keyframes load8 { 34 | 0% { 35 | -webkit-transform: rotate(0deg); 36 | transform: rotate(0deg); 37 | } 38 | 100% { 39 | -webkit-transform: rotate(360deg); 40 | transform: rotate(360deg); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /client/src/components/ribbon/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode } from 'react' 2 | import styles from './styles.scss' 3 | 4 | type RibbonProps = { 5 | children?: ReactNode 6 | } 7 | 8 | const Ribbon = (props: RibbonProps) => ( 9 |
10 |
11 |
{props.children}
12 |
13 |
14 | ) 15 | 16 | export default Ribbon 17 | -------------------------------------------------------------------------------- /client/src/components/ribbon/styles.scss: -------------------------------------------------------------------------------- 1 | @import '../../styles/colors.scss'; 2 | 3 | .ribbon { 4 | position: relative; 5 | z-index: 1; 6 | 7 | &__container { 8 | font-size: 20px !important; 9 | border-top-left-radius: 4px; 10 | border-top-right-radius: 4px; 11 | position: relative; 12 | background: linear-gradient( 13 | 90deg, 14 | $green-dark 0%, 15 | $green-light 50%, 16 | $green-dark 100% 17 | ); 18 | text-align: center; 19 | padding: 15px 0; 20 | 21 | &::after, 22 | &::before { 23 | content: ''; 24 | position: absolute; 25 | display: block; 26 | bottom: -1rem; 27 | border: 1.5rem solid 28 | linear-gradient( 29 | 135deg, 30 | $green-light 0%, 31 | $green-light 50%, 32 | $green-light 100% 33 | ); 34 | border: 1.5rem solid $green-darker; 35 | z-index: -1; 36 | } 37 | 38 | &::before { 39 | left: -2rem; 40 | border-right-width: 1.5rem; 41 | border-left-color: transparent; 42 | } 43 | 44 | &::after { 45 | right: -2rem; 46 | border-left-width: 1.5rem; 47 | border-right-color: transparent; 48 | } 49 | 50 | &-inner { 51 | &::after, 52 | &::before { 53 | content: ''; 54 | position: absolute; 55 | display: block; 56 | border-style: solid; 57 | border-color: $green-darkest transparent transparent; 58 | bottom: -1rem; 59 | } 60 | 61 | &::before { 62 | left: 0; 63 | border-width: 1rem 0 0 1rem; 64 | } 65 | 66 | &::after { 67 | right: 0; 68 | border-width: 1rem 1rem 0 0; 69 | } 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /client/src/components/waves/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode } from 'react' 2 | import styles from './styles.scss' 3 | 4 | type WaveFill = { 5 | baseWave?: string 6 | lowWave?: string 7 | midWave?: string 8 | topWave?: string 9 | } 10 | 11 | type WaveProps = { 12 | style?: any 13 | fill: WaveFill 14 | } 15 | 16 | const Waves = (props: WaveProps) => ( 17 |
18 | 27 | 28 | 32 | 33 | 34 | 40 | 46 | 52 | 53 | 54 | 55 |
56 | ) 57 | 58 | Waves.defaultProps = { 59 | fill: { 60 | baseWave: '#fff', 61 | lowWave: 'rgba(255, 255, 255, 0.2)', 62 | midWave: 'rgba(208, 104, 208, 0.5)', 63 | topWave: 'rgba(9, 169, 200, 0.8)', 64 | }, 65 | } 66 | 67 | export default Waves 68 | -------------------------------------------------------------------------------- /client/src/components/waves/styles.scss: -------------------------------------------------------------------------------- 1 | .waves { 2 | position: absolute; 3 | width: 100vw; 4 | bottom: 0; 5 | left: 0; 6 | min-height: 2rem; 7 | max-height: 4rem; 8 | 9 | &__parallax { 10 | > use { 11 | animation: parallax-translation 25s cubic-bezier(0.55, 0.5, 0.45, 0.5) infinite; 12 | } 13 | > use:nth-child(1) { 14 | animation-delay: -2s; 15 | animation-duration: 12s; 16 | } 17 | > use:nth-child(2) { 18 | animation-delay: -7s; 19 | animation-duration: 12s; 20 | } 21 | > use:nth-child(3) { 22 | animation-delay: -8s; 23 | animation-duration: 12s; 24 | } 25 | > use:nth-child(4) { 26 | animation-delay: -5s; 27 | animation-duration: 20s; 28 | } 29 | } 30 | } 31 | 32 | @keyframes parallax-translation { 33 | 0% { 34 | transform: translate3d(-90px, 0, 0); 35 | } 36 | 100% { 37 | transform: translate3d(85px, 0, 0); 38 | } 39 | } 40 | 41 | @media (max-width: 768px) { 42 | .waves { 43 | height: 40px; 44 | min-height: 40px; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /client/src/constants/social-network.ts: -------------------------------------------------------------------------------- 1 | import Icon from '../assets/svg' 2 | 3 | export const socialNetworkDropdownOptions = [ 4 | { icon: Icon.Messenger, text: 'Messenger', enabled: true }, 5 | { icon: Icon.Whatsapp, text: 'Whatsapp', enabled: false }, 6 | { icon: Icon.Instagram, text: 'Instagram', enabled: false }, 7 | { icon: Icon.Telegram, text: 'Telegram', enabled: false }, 8 | ] 9 | -------------------------------------------------------------------------------- /client/src/declaration.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.scss' { 2 | const content: { [className: string]: string } 3 | export default content 4 | } 5 | -------------------------------------------------------------------------------- /client/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import { BrowserRouter, Switch, Route } from 'react-router-dom' 4 | import Home from './pages/home' 5 | import Analytics from './pages/analytics' 6 | import NotFound from './pages/not-found' 7 | import './styles/globals.scss' 8 | 9 | const App = () => ( 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | ) 18 | 19 | ReactDOM.render(, document.getElementById('app')) 20 | -------------------------------------------------------------------------------- /client/src/pages/analytics/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef } from 'react' 2 | import styles from './styles.scss' 3 | import globalStyles from '../../styles/globals.scss' 4 | import { useHistory } from 'react-router-dom' 5 | import Sections from './sections' 6 | import html2canvas, { Options } from 'html2canvas' 7 | import FileSaver from 'file-saver' 8 | 9 | const Analytics = () => { 10 | const history = useHistory() 11 | const locationState = history.location.state 12 | const reportRef = useRef() 13 | const verticalPadding = 50 14 | 15 | if (!locationState) { 16 | history.replace('/') 17 | return <> 18 | } 19 | 20 | const data = locationState.data 21 | 22 | const download = () => { 23 | const report: HTMLElement = reportRef.current 24 | const options: Partial = { 25 | y: verticalPadding, 26 | height: report.clientHeight, 27 | backgroundColor: null, 28 | } 29 | 30 | html2canvas(reportRef.current, options).then((canvasElement) => { 31 | canvasElement.toBlob((blob) => { 32 | FileSaver.saveAs(blob, 'chat-report.png') 33 | }) 34 | }) 35 | } 36 | 37 | return ( 38 |
42 |
43 |
44 | 45 | 46 | 56 | 57 | 61 | 62 | 66 | 67 | 73 |
74 | 75 |
76 |
77 | 80 |
81 |
82 |
83 | ) 84 | } 85 | 86 | export default Analytics 87 | -------------------------------------------------------------------------------- /client/src/pages/analytics/sections/date-time-messages/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styles from './styles.scss' 3 | import analyticsStyles from '../../styles.scss' 4 | import { Chart, Interval, Tooltip, Axis, Coordinate } from 'bizcharts' 5 | import { Padding, TimeMessage } from '../../../../utils/types' 6 | import { 7 | withCommas, 8 | timestampToDate, 9 | withKs, 10 | formatHour, 11 | formatDateString, 12 | } from '../../../../utils/formatters' 13 | 14 | type DateTimeMessagesProps = { 15 | avg_messages_per_day: number 16 | top_messages_per_day: Array> 17 | weekday_messages: TimeMessage 18 | hour_messages: TimeMessage 19 | } 20 | 21 | const DateTimeMessages = (props: DateTimeMessagesProps) => { 22 | const { 23 | top_messages_per_day, 24 | avg_messages_per_day, 25 | weekday_messages, 26 | hour_messages, 27 | } = props 28 | 29 | const daysToDataset = (days) => { 30 | return days 31 | .map((array) => ({ 32 | day: array[0], 33 | messages: array[1], 34 | })) 35 | .sort( 36 | (dayA, dayB) => 37 | new Date(dayA.day).getTime() - new Date(dayB.day).getTime(), 38 | ) 39 | } 40 | 41 | const weekdayMessagesToDataset = (weekdayMessages) => { 42 | const weekdays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] 43 | 44 | return Object.keys(weekdayMessages).map((weekdayMessage, index) => ({ 45 | weekday: weekdays[weekdayMessage], 46 | messages: weekdayMessages[index], 47 | })) 48 | } 49 | 50 | const hourMessagesToDataset = (hourMessages) => { 51 | return Object.keys(hourMessages).map((hour, index) => ({ 52 | hour, 53 | messages: hourMessages[index], 54 | })) 55 | } 56 | 57 | const renderBarChart = ( 58 | dataset, 59 | xKey: string, 60 | yKey: string, 61 | transpose: boolean, 62 | tickInterval?: number, 63 | height: number = 300, 64 | width: number = 400, 65 | padding?: Padding, 66 | ) => { 67 | const anchorSize = transpose ? height : width 68 | const intervalSize = Math.trunc((anchorSize / dataset.length) * 0.9) 69 | const axisStyle = { 70 | fontSize: 12, 71 | fill: '#fff', 72 | fontFamily: `-apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif`, 73 | } 74 | 75 | const intervalStyle = { 76 | fill: '#09A9C8', 77 | } 78 | 79 | const tooltipStyle = { 80 | 'g2-tooltip-title': { color: '#333', fontWeight: 'bold' }, 81 | 'g2-tooltip-name': { color: '#333' }, 82 | 'g2-tooltip-value': { color: '#aa3aff', fontWeight: 'bold' }, 83 | 'g2-tooltip-marker': { display: 'none' }, 84 | } 85 | 86 | return ( 87 | withKs(data), 96 | type: 'linear', 97 | tickInterval, 98 | }, 99 | hour: { 100 | formatter: (data: number) => formatHour(data), 101 | }, 102 | day: { 103 | type: 'timeCat', 104 | formatter: (data: number) => timestampToDate(data), 105 | }, 106 | }} 107 | > 108 | 109 | 110 | 115 | 116 | 117 | 118 | ) 119 | } 120 | 121 | const renderBusiestHour = () => { 122 | let max = { 123 | hour: null, 124 | messages: null, 125 | } 126 | 127 | for (let i = 0; i < Object.keys(hour_messages).length; i++) { 128 | const hour = Object.keys(hour_messages)[i] 129 | const messages = hour_messages[Object.keys(hour_messages)[i]] 130 | 131 | if (!max.messages || max.messages < messages) { 132 | max = { hour, messages } 133 | } 134 | } 135 | 136 | if (max.hour < 10) max.hour = `0${max.hour}` 137 | return ( 138 | <> 139 |

{max.hour}:00

140 |

is the busiest hour with

141 |

142 | {withCommas(max.messages)} 143 | messages 144 |

145 | 146 | ) 147 | } 148 | 149 | const renderAvgMessages = () => ( 150 | <> 151 |

{avg_messages_per_day}

152 |

average messages sent

153 |

on a daily basis

154 | 155 | ) 156 | 157 | const renderBusiestDay = () => { 158 | let max = { 159 | date: null, 160 | messages: null, 161 | } 162 | 163 | for (let i = 0; i < top_messages_per_day.length; i++) { 164 | const date = formatDateString(top_messages_per_day[i][0]) 165 | const messages = top_messages_per_day[i][1] 166 | 167 | if (!max.messages || max.messages < messages) { 168 | max = { date, messages } 169 | } 170 | } 171 | 172 | return ( 173 | <> 174 |

{max.date}

175 |

was the busiest day with

176 |

177 | {withCommas(max.messages)} messages 178 |

179 | 180 | ) 181 | } 182 | 183 | return ( 184 |
188 |

189 | When do we chat the most? 190 |

191 |
192 |
193 | {renderAvgMessages()} 194 |
195 |
196 | {renderBarChart( 197 | weekdayMessagesToDataset(weekday_messages), 198 | 'weekday', 199 | 'messages', 200 | true, 201 | null, 202 | 200, 203 | 500, 204 | )} 205 |
206 |
207 |
208 |
209 | {renderBusiestHour()} 210 |
211 |
212 | {renderBarChart( 213 | hourMessagesToDataset(hour_messages), 214 | 'hour', 215 | 'messages', 216 | false, 217 | null, 218 | 300, 219 | 500, 220 | )} 221 |
222 |
223 |
224 |
225 | {renderBusiestDay()} 226 |
227 |
228 | {renderBarChart( 229 | daysToDataset(top_messages_per_day), 230 | 'day', 231 | 'messages', 232 | false, 233 | null, 234 | 200, 235 | 500, 236 | )} 237 |
238 |
239 |
240 | ) 241 | } 242 | 243 | export default DateTimeMessages 244 | -------------------------------------------------------------------------------- /client/src/pages/analytics/sections/date-time-messages/styles.scss: -------------------------------------------------------------------------------- 1 | @import '../../../../styles/colors.scss'; 2 | 3 | .analytics-date-time-messages { 4 | padding-top: 20px !important; 5 | 6 | h2 { 7 | margin-bottom: 60px; 8 | } 9 | 10 | &__summary { 11 | flex: 1; 12 | text-align: center; 13 | margin: 0 10px; 14 | 15 | h1 { 16 | color: $purple-light; 17 | font-size: 2.5rem; 18 | margin-bottom: 20px; 19 | } 20 | 21 | h2 { 22 | margin-bottom: 0; 23 | font-size: 1rem; 24 | font-weight: 500; 25 | margin-bottom: 5px; 26 | } 27 | 28 | span { 29 | font-size: 1.2rem; 30 | font-weight: bold; 31 | } 32 | } 33 | 34 | &__chart { 35 | height: 300px; 36 | display: flex; 37 | flex: 1.5; 38 | overflow: hidden; 39 | align-items: center; 40 | justify-content: flex-end; 41 | } 42 | 43 | &__content-details { 44 | display: flex; 45 | margin-bottom: 40px; 46 | align-items: center; 47 | justify-content: center; 48 | background: #03334421; 49 | padding: 20px 20px; 50 | border-radius: 4px; 51 | 52 | &:last-child { 53 | margin-bottom: 0; 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /client/src/pages/analytics/sections/emojis/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react' 2 | import styles from './styles.scss' 3 | import analyticsStyles from '../../styles.scss' 4 | import chroma from 'chroma-js' 5 | import { Chart, Interval, Tooltip, Axis, Coordinate, Legend } from 'bizcharts' 6 | import { withCommas } from '../../../../utils/formatters' 7 | 8 | type EmojisProps = { 9 | top_emojis: Array> 10 | different_emojis_used: number 11 | } 12 | 13 | const Emojis = (props: EmojisProps) => { 14 | const [parentWidth, setParentWidth] = useState(0) 15 | const { top_emojis, different_emojis_used } = props 16 | 17 | useEffect(() => { 18 | const offsetWidth = document.getElementById(styles['analytics-emojis'])?.offsetWidth 19 | offsetWidth && setParentWidth(offsetWidth) 20 | }, [document.getElementById(styles['analytics-emojis'])]) 21 | 22 | const arraysToDataset = (arrays, sum?: number) => { 23 | return arrays.map((array) => ({ 24 | item: array[0], 25 | value: array[1], 26 | percent: array[1] / sum, 27 | })) 28 | } 29 | 30 | const renderChart = (dataset, height: number = parentWidth / 3) => { 31 | const colorScale = chroma.scale([ 32 | '#09A9C8', 33 | '#067593', 34 | '#055772', 35 | '#001F3D', 36 | ]) 37 | const cols = { 38 | percent: { 39 | formatter: (val) => { 40 | val = val * 100 + '%' 41 | return val 42 | }, 43 | }, 44 | } 45 | const labelLineStyle = { 46 | lineWidth: 1, 47 | stroke: '#ffffff44', 48 | } 49 | const intervalStyle = { 50 | fontSize: 11, 51 | fontWeight: 400, 52 | fill: 'rgb(230, 230, 230)', 53 | fontFamily: `-apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif`, 54 | } 55 | 56 | return ( 57 | 58 | 59 | 60 | 61 | 67 | colorScale( 68 | dataset.find((value) => value.item === item).percent, 69 | ).hex(), 70 | ]} 71 | style={{ 72 | lineWidth: 1, 73 | stroke: '#055772', 74 | }} 75 | label={[ 76 | 'value', 77 | { 78 | offset: 20, 79 | labelLine: { 80 | style: labelLineStyle, 81 | }, 82 | style: intervalStyle, 83 | content: ({ item, value, percent }) => 84 | `${item} ${withCommas(value)} (${(percent * 100).toFixed(1)}%)`, 85 | }, 86 | ]} 87 | /> 88 | 89 | 90 | ) 91 | } 92 | 93 | const hasEmojis = () => top_emojis.length && top_emojis[0].length 94 | 95 | return hasEmojis() && ( 96 |
100 |
101 |

102 | And we don't only express ourselves verbally... 103 |

104 |

105 | In our conversation, we used 106 | {different_emojis_used} 107 | different Emojis 108 |

109 |
110 | 111 |
112 |
113 | {renderChart( 114 | arraysToDataset( 115 | top_emojis, 116 | top_emojis.reduce((acc, emoji) => acc + emoji[1], 0), 117 | ), 118 | )} 119 |
120 |
121 |

122 | With 123 | {withCommas(top_emojis[0][1])} 124 | appearances: 125 |

126 |

{top_emojis[0][0]}

127 |

128 | was our 129 | favourite 130 | Emoji 131 |

132 |
133 |
134 |
135 | ) 136 | } 137 | 138 | export default Emojis 139 | -------------------------------------------------------------------------------- /client/src/pages/analytics/sections/emojis/styles.scss: -------------------------------------------------------------------------------- 1 | @import '../../../../styles/colors.scss'; 2 | 3 | .analytics-emojis { 4 | padding-top: 20px !important; 5 | 6 | &__title-container { 7 | h2, 8 | p { 9 | text-align: center; 10 | } 11 | 12 | p { 13 | margin-top: 10px; 14 | font-size: 0.75rem; 15 | margin-bottom: 60px; 16 | } 17 | 18 | span { 19 | font-size: 0.8rem; 20 | font-weight: 300; 21 | padding: 0 5px; 22 | color: $purple-light; 23 | font-weight: bold; 24 | } 25 | } 26 | 27 | &__fav-emoji { 28 | display: flex; 29 | flex: 1; 30 | flex-direction: column; 31 | align-items: center; 32 | justify-content: center; 33 | border-left: 1px solid $green-border; 34 | margin: 0 10px; 35 | 36 | h1 { 37 | font-size: 7rem; 38 | padding: 10px 0; 39 | text-shadow: 0.5rem 0.5rem darken($green-lighter, 30%); 40 | } 41 | 42 | p { 43 | font-size: 0.8rem; 44 | } 45 | 46 | span { 47 | font-size: 0.9rem; 48 | font-weight: bold; 49 | color: $green-lighter; 50 | } 51 | } 52 | 53 | &__chart { 54 | flex: 1.5; 55 | overflow: hidden; 56 | } 57 | 58 | .content-details { 59 | display: flex; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /client/src/pages/analytics/sections/header/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styles from './styles.scss' 3 | import Ribbon from '../../../../components/ribbon' 4 | import { Participant } from '../../../../utils/types' 5 | 6 | type HeaderProps = { 7 | participants: Array 8 | } 9 | 10 | const Header = (props: HeaderProps) => { 11 | const { participants } = props 12 | const participantA = participants[0].name 13 | const participantB = participants[1].name 14 | 15 | return ( 16 |
17 |

The beautiful story of

18 |
19 | 20 |

21 | {participantA} 22 | and 23 | {participantB} 24 |

25 |
26 |
27 |
28 | ) 29 | } 30 | 31 | export default Header 32 | -------------------------------------------------------------------------------- /client/src/pages/analytics/sections/header/styles.scss: -------------------------------------------------------------------------------- 1 | @import '../../../../styles/colors.scss'; 2 | 3 | .analytics-header { 4 | text-align: center; 5 | 6 | h1 { 7 | font-weight: 400; 8 | font-size: 1.3rem; 9 | color: $white-text; 10 | text-shadow: 0 0 1rem $green-dark; 11 | } 12 | 13 | h2 { 14 | font-weight: 400; 15 | font-size: 0.8rem; 16 | color: $green-light; 17 | text-transform: uppercase; 18 | } 19 | 20 | span { 21 | font-weight: 300; 22 | font-size: 0.9rem; 23 | margin: 0 5px; 24 | text-shadow: 0 0 1rem $green-dark; 25 | } 26 | 27 | &__participants { 28 | margin: 10px 60px; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /client/src/pages/analytics/sections/index.tsx: -------------------------------------------------------------------------------- 1 | export default { 2 | Header: require('./header').default, 3 | Overview: require('./overview').default, 4 | ParticipantMessages: require('./participant-messages').default, 5 | Emojis: require('./emojis').default, 6 | DateTimeMessages: require('./date-time-messages').default, 7 | } 8 | -------------------------------------------------------------------------------- /client/src/pages/analytics/sections/overview/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactElement } from 'react' 2 | import styles from './styles.scss' 3 | import { 4 | formatDate, 5 | formatDuration, 6 | withCommas, 7 | } from '../../../../utils/formatters' 8 | import { Date, Duration } from '../../../../utils/types' 9 | import Icon from '../../../../assets/svg' 10 | 11 | type OverviewProps = { 12 | start_date: Date 13 | end_date: Date 14 | duration: Duration 15 | total_messages: number 16 | total_emojis: number 17 | total_links_shared: number 18 | photos: number 19 | reactions: number 20 | } 21 | 22 | const Overview = (props: OverviewProps) => { 23 | const { 24 | start_date, 25 | end_date, 26 | duration, 27 | total_messages, 28 | total_emojis, 29 | total_links_shared, 30 | photos, 31 | reactions, 32 | } = props 33 | 34 | const entities = [ 35 | { name: 'Messages', total: total_messages, icon: Icon.Chat }, 36 | { name: 'Emojis', total: total_emojis, icon: Icon.Emoji }, 37 | { name: 'Reactions', total: reactions, icon: Icon.Heart }, 38 | { name: 'Photos', total: photos, icon: Icon.Image }, 39 | { name: 'Links', total: total_links_shared, icon: Icon.Link }, 40 | ] 41 | 42 | const renderEntitySummary = ( 43 | entityName: string, 44 | total: number, 45 | icon?: string, 46 | ): ReactElement => { 47 | return ( 48 |
52 | 53 |

{withCommas(total)}

54 |

{entityName}

55 |
56 | ) 57 | } 58 | 59 | return ( 60 |
61 |
62 |

63 | From 64 | {formatDate(start_date)} 65 | to 66 | {formatDate(end_date)} 67 |

68 |

{formatDuration(duration)}

69 |
70 | 71 |
72 | {entities.map(({ name, total, icon }) => 73 | renderEntitySummary(name, total, icon), 74 | )} 75 |
76 |
77 | ) 78 | } 79 | 80 | export default Overview 81 | -------------------------------------------------------------------------------- /client/src/pages/analytics/sections/overview/styles.scss: -------------------------------------------------------------------------------- 1 | @import '../../../../styles/colors.scss'; 2 | 3 | $vertical-padding: 30px; 4 | $horizontal-padding: 20px; 5 | 6 | .analytics-overview { 7 | text-align: center; 8 | 9 | h2 { 10 | font-size: 1.4rem; 11 | font-weight: 400; 12 | } 13 | 14 | h3 { 15 | font-weight: 300; 16 | font-size: 0.9rem; 17 | } 18 | 19 | p { 20 | font-weight: 300; 21 | font-size: 0.8rem; 22 | color: $white-text; 23 | } 24 | 25 | &__chronology { 26 | margin-bottom: $vertical-padding; 27 | 28 | h2 { 29 | font-size: 1rem; 30 | color: $green-lighter; 31 | } 32 | 33 | p { 34 | margin-top: 5px; 35 | font-size: 0.75rem; 36 | } 37 | 38 | span { 39 | font-size: 0.8rem; 40 | font-weight: 300; 41 | padding: 0 5px; 42 | font-weight: bold; 43 | } 44 | } 45 | 46 | &__entities { 47 | display: flex; 48 | justify-content: center; 49 | overflow: hidden; 50 | 51 | &-entity { 52 | padding: 10px $horizontal-padding 0 $horizontal-padding; 53 | text-align: center; 54 | 55 | img { 56 | height: 50px; 57 | } 58 | 59 | .filter { 60 | &_green-1 { 61 | filter: invert(24%) sepia(57%) saturate(2606%) hue-rotate(166deg) 62 | brightness(88%) contrast(85%); 63 | } 64 | 65 | &_green-2 { 66 | filter: invert(40%) sepia(60%) saturate(855%) hue-rotate(137deg) 67 | brightness(91%) contrast(87%); 68 | } 69 | 70 | &_yellow { 71 | filter: invert(88%) sepia(25%) saturate(415%) hue-rotate(7deg) 72 | brightness(99%) contrast(87%); 73 | filter: invert(76%) sepia(7%) saturate(1268%) hue-rotate(348deg) 74 | brightness(84%) contrast(83%); 75 | filter: invert(99%) sepia(76%) saturate(1698%) hue-rotate(21deg) 76 | brightness(86%) contrast(86%); 77 | } 78 | 79 | &_orange { 80 | filter: invert(71%) sepia(94%) saturate(2988%) hue-rotate(331deg) 81 | brightness(98%) contrast(90%); 82 | } 83 | 84 | &_red { 85 | filter: invert(51%) sepia(9%) saturate(4108%) hue-rotate(315deg) 86 | brightness(94%) contrast(100%); 87 | } 88 | 89 | &_blue { 90 | // 2E9BFA 91 | filter: invert(65%) sepia(72%) saturate(4377%) hue-rotate(185deg) 92 | brightness(97%) contrast(102%); 93 | filter: invert(83%) sepia(58%) saturate(1860%) hue-rotate(158deg) 94 | brightness(94%) contrast(87%); 95 | } 96 | } 97 | 98 | h2 { 99 | margin: 5px 0; 100 | color: $purple-light; 101 | } 102 | 103 | h3 { 104 | font-size: 0.75rem; 105 | font-weight: bold; 106 | } 107 | } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /client/src/pages/analytics/sections/participant-messages/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react' 2 | import styles from './styles.scss' 3 | import analyticsStyles from '../../styles.scss' 4 | import chroma from 'chroma-js' 5 | import { Chart, Interval, Tooltip, Axis, Coordinate, Legend } from 'bizcharts' 6 | import { Participant } from '../../../../utils/types' 7 | import { withCommas, shortenName } from '../../../../utils/formatters' 8 | import ChatBubble from '../../../../components/chat-bubble' 9 | 10 | type ParticipantMessagesProps = { 11 | participants: Array 12 | total_messages: number 13 | } 14 | 15 | const ParticipantMessages = (props: ParticipantMessagesProps) => { 16 | const [parentWidth, setParentWidth] = useState(0) 17 | const { participants, total_messages } = props 18 | const participantA = { 19 | name: participants[0].name, 20 | messages: participants[0].total_messages_sent, 21 | } 22 | const participantB = { 23 | name: participants[1].name, 24 | messages: participants[1].total_messages_sent, 25 | } 26 | const topParticipantMessagesDataset = participants.map((participantData) => ({ 27 | item: participantData.name, 28 | value: participantData.total_messages_sent, 29 | percent: participantData.total_messages_sent / total_messages, 30 | })) 31 | 32 | useEffect(() => { 33 | const offsetWidth = document.getElementById( 34 | styles['analytics-participant-messages'], 35 | ).offsetWidth 36 | offsetWidth && setParentWidth(offsetWidth) 37 | }, [document.getElementById(styles['analytics-participant-messages'])]) 38 | 39 | const renderParticipantMessagesChart = ( 40 | dataset, 41 | height: number = parentWidth / 4, 42 | ) => { 43 | const colorScale = chroma.scale(['#09A9C8', '#067593']) 44 | const cols = { 45 | percent: { 46 | formatter: (val) => { 47 | val = val * 100 + '%' 48 | return val 49 | }, 50 | }, 51 | } 52 | const labelLineStyle = { 53 | lineWidth: 1, 54 | stroke: '#ffffff44', 55 | } 56 | const intervalStyle = { 57 | fontSize: 11, 58 | fontWeight: 400, 59 | fill: 'rgb(240, 240, 240)', 60 | fontFamily: `-apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif`, 61 | } 62 | 63 | return ( 64 | 65 | 66 | 67 | 68 | { 74 | const i = dataset.findIndex((value) => value.item === item) 75 | const scaleValue = i / (dataset.length - 1) 76 | return colorScale(scaleValue).hex() 77 | }, 78 | ]} 79 | style={{ 80 | lineWidth: 1, 81 | stroke: '#055772', 82 | }} 83 | label={[ 84 | 'value', 85 | { 86 | offset: 20, 87 | labelLine: { 88 | style: labelLineStyle, 89 | }, 90 | style: intervalStyle, 91 | content: ({ item, _, percent }) => 92 | `${shortenName(item)}\n${(percent * 100).toFixed(1)}%`, 93 | }, 94 | ]} 95 | /> 96 | 97 | 98 | ) 99 | } 100 | 101 | const renderTitle = () => { 102 | let balanceOpinion = '' 103 | const ratio = topParticipantMessagesDataset 104 | .map( 105 | (participantMessages) => 106 | `${Math.trunc(participantMessages.percent * 100).toFixed(1)}%`, 107 | ) 108 | .join(' - ') 109 | const percent = 110 | topParticipantMessagesDataset[0].percent < 0.5 111 | ? 1 - topParticipantMessagesDataset[0].percent 112 | : topParticipantMessagesDataset[0].percent 113 | 114 | if (percent <= 0.2) balanceOpinion = ` someone's been quiet lately...` 115 | else if (percent <= 0.4) balanceOpinion = ` someone's clearly taking over.` 116 | else balanceOpinion = ` we're in perfect balance and harmony!` 117 | 118 | return ( 119 |
122 |

123 | How balanced is our communication? 124 |

125 |

126 | Considering our 127 | {ratio} 128 | ratio, I'd say that 129 | {balanceOpinion} 130 |

131 |
132 | ) 133 | } 134 | 135 | return ( 136 |
140 | {renderTitle()} 141 |
142 |
143 |
146 | 147 |

148 | {participantA.name} sent 149 |

150 |

{withCommas(participantA.messages)}

151 | messages 152 |
153 |
154 |
157 | 158 |

159 | {participantB.name} sent 160 |

161 |

{withCommas(participantB.messages)}

162 | messages 163 |
164 |
165 |
166 |
167 | {renderParticipantMessagesChart(topParticipantMessagesDataset)} 168 |
169 |
170 |
171 | ) 172 | } 173 | 174 | export default ParticipantMessages 175 | -------------------------------------------------------------------------------- /client/src/pages/analytics/sections/participant-messages/styles.scss: -------------------------------------------------------------------------------- 1 | @import '../../../../styles/colors.scss'; 2 | 3 | .analytics-participant-messages { 4 | padding-top: 20px !important; 5 | 6 | p { 7 | font-weight: 300; 8 | font-size: 0.8rem; 9 | color: $white-text; 10 | } 11 | 12 | &__title-container { 13 | text-align: center; 14 | margin-bottom: 60px; 15 | 16 | p { 17 | margin-top: 10px; 18 | font-size: 0.75rem; 19 | } 20 | 21 | span { 22 | font-size: 0.8rem; 23 | font-weight: 300; 24 | padding: 0 5px; 25 | color: $purple-light; 26 | font-weight: bold; 27 | } 28 | } 29 | 30 | &__summary { 31 | position: relative; 32 | flex: 1; 33 | display: flex; 34 | flex-direction: column; 35 | align-items: flex-end; 36 | justify-content: center; 37 | margin: 0 10px; 38 | 39 | p { 40 | font-size: 0.9rem; 41 | font-weight: 300; 42 | } 43 | 44 | h4 { 45 | margin: 5px 0; 46 | } 47 | 48 | span { 49 | font-weight: 200; 50 | font-size: 0.85rem; 51 | } 52 | 53 | &__upper { 54 | margin-right: 100px; 55 | } 56 | 57 | &__lower { 58 | margin-left: 100px; 59 | margin-top: -15px; 60 | } 61 | } 62 | 63 | &__chart { 64 | flex: 1.5; 65 | overflow: hidden; 66 | } 67 | 68 | .content-details { 69 | display: flex; 70 | align-items: center; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /client/src/pages/analytics/styles.scss: -------------------------------------------------------------------------------- 1 | @import '../../styles/colors.scss'; 2 | 3 | .analytics { 4 | background: $blue-darkest; 5 | display: flex; 6 | justify-content: center; 7 | 8 | h1, 9 | h2, 10 | h3, 11 | h4, 12 | h5, 13 | p, 14 | span { 15 | color: $white-text; 16 | } 17 | 18 | &__customize { 19 | flex: 1; 20 | max-width: 30%; 21 | } 22 | 23 | &__report { 24 | width: 60%; 25 | background: $blue-darkest; 26 | border-radius: 6px; 27 | padding: 0 40px; 28 | border: 1px solid $green-border; 29 | -webkit-box-shadow: 0 0 30px 0 $green-shadow; 30 | -moz-box-shadow: 0 0 30px 0 $green-shadow; 31 | box-shadow: 0 0 30px 0 $green-shadow; 32 | 33 | & > section { 34 | border-bottom: 1px solid $green-border; 35 | padding: 40px 0; 36 | 37 | &:first-child { 38 | padding-bottom: 0; 39 | } 40 | &:first-child, 41 | &:last-child { 42 | border-bottom: none; 43 | } 44 | } 45 | } 46 | 47 | &__checkout { 48 | flex: 1; 49 | max-width: 30%; 50 | 51 | &-fixed { 52 | position: fixed; 53 | bottom: 60px; 54 | padding: 0 20px; 55 | width: 20%; 56 | 57 | button { 58 | width: 100%; 59 | } 60 | } 61 | } 62 | 63 | .section-headline { 64 | font-weight: 400; 65 | font-size: 1rem; 66 | text-align: center; 67 | color: $green-lighter; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /client/src/pages/home/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styles from './styles.scss' 3 | import Sections from './sections' 4 | 5 | const Home = () => ( 6 |
7 | 8 | 9 | 10 |
11 | ) 12 | 13 | export default Home 14 | -------------------------------------------------------------------------------- /client/src/pages/home/sections/footer/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styles from './styles.scss' 3 | 4 | const GITHUB_URL = 'https://github.com/VitorCodes' 5 | const LINKEDIN_URL = 'https://www.linkedin.com/in/vitorcodes/' 6 | const DONATE_URL = 'https://github.com/VitorCodes/chatview#-donate' 7 | 8 | const Footer = () => { 9 | return ( 10 | 26 | ) 27 | } 28 | 29 | export default Footer 30 | -------------------------------------------------------------------------------- /client/src/pages/home/sections/footer/styles.scss: -------------------------------------------------------------------------------- 1 | @import '../../../../styles/colors.scss'; 2 | 3 | .footer { 4 | color: $white-text; 5 | display: flex; 6 | font-size: 0.75rem; 7 | flex-direction: column; 8 | align-items: center; 9 | padding: 3rem 0; 10 | background: linear-gradient(135deg, $pink-dark 0%, $blue-dark 100%); 11 | 12 | >div { 13 | margin-top: 10px; 14 | } 15 | 16 | a { 17 | margin: 10px; 18 | font-size: 1rem; 19 | color: $pure-white; 20 | padding-bottom: 5px; 21 | font-weight: bold; 22 | 23 | &:hover { 24 | color: $green-lightest; 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /client/src/pages/home/sections/get-started/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import styles from './styles.scss' 3 | import Steps, { StepImagePosition } from './steps' 4 | 5 | const GetStarted = () => { 6 | const [selectedStep, setSelectedStep] = useState(0) 7 | 8 | const getImgPositionStyle = (imagePosition: StepImagePosition) => { 9 | switch (imagePosition) { 10 | case 'top-left': 11 | return { 12 | top: 0, 13 | left: 0, 14 | } 15 | case 'top-right': 16 | return { 17 | top: 0, 18 | right: 0, 19 | } 20 | case 'top-center': 21 | return { 22 | top: 0, 23 | left: 0, 24 | right: 0, 25 | margin: 'auto', 26 | } 27 | case 'center-right': 28 | return { 29 | top: 0, 30 | bottom: 0, 31 | right: 0, 32 | } 33 | case 'bottom-left': 34 | return { 35 | bottom: 0, 36 | left: 0, 37 | } 38 | case 'bottom-right': 39 | return { 40 | bottom: 0, 41 | right: 0, 42 | } 43 | case 'bottom-center': 44 | return { 45 | bottom: 0, 46 | left: 0, 47 | right: 0, 48 | margin: 'auto', 49 | } 50 | default: 51 | return {} 52 | } 53 | } 54 | 55 | return ( 56 | <> 57 |
58 |
59 | {Steps.messenger[selectedStep].images.map((img, index) => { 60 | return ( 61 | 72 | ) 73 | })} 74 |
75 | 76 |
77 |

HOW IT WORKS

78 |

Get started easily.

79 | 80 |
    81 | {Steps.messenger.map((step, index) => { 82 | return ( 83 |
  1. setSelectedStep(index)} 86 | className={ 87 | styles[ 88 | index === selectedStep 89 | ? 'get-started__steps__step-container--selected' 90 | : 'get-started__steps__step-container' 91 | ] 92 | } 93 | > 94 | 97 | {step.text} 98 |
  2. 99 | ) 100 | })} 101 |
102 |
103 |
104 | 105 | ) 106 | } 107 | 108 | export default GetStarted 109 | -------------------------------------------------------------------------------- /client/src/pages/home/sections/get-started/steps.ts: -------------------------------------------------------------------------------- 1 | type StepImagePosition = 2 | | 'top-left' 3 | | 'top-right' 4 | | 'top-center' 5 | | 'bottom-left' 6 | | 'bottom-right' 7 | | 'bottom-center' 8 | | 'center-right' 9 | 10 | type StepImage = { 11 | url: string 12 | position: StepImagePosition; 13 | width?: string; 14 | height?: string; 15 | customStyle?: any; 16 | } 17 | 18 | type Step = { 19 | text: string 20 | images: StepImage[] 21 | } 22 | 23 | const messenger: Step[] = [ 24 | { 25 | text: 'Open Facebook app and click on the Account button', 26 | images: [ 27 | { 28 | url: require('../../../../assets/img/messenger/1.png').default, 29 | position: 'top-center', 30 | width: '90%' 31 | } 32 | ], 33 | }, 34 | { 35 | text: 'Under Settings & privacy, click on Settings', 36 | images: [ 37 | { 38 | url: require('../../../../assets/img/messenger/2.png').default, 39 | position: 'top-center', 40 | width: '90%' 41 | } 42 | ], 43 | }, 44 | { 45 | text: 'Select Your Facebook Information option from the left side of the screen', 46 | images: [ 47 | { 48 | url: require('../../../../assets/img/messenger/3.png').default, 49 | position: 'top-center', 50 | width: '60%' 51 | } 52 | ], 53 | }, 54 | { 55 | text: 'Look for the Download Your Information option and click on View', 56 | images: [ 57 | { 58 | url: require('../../../../assets/img/messenger/4.png').default, 59 | position: 'top-center', 60 | width: '100%' 61 | } 62 | ], 63 | }, 64 | { 65 | text: 'Under Request a Download tab, choose JSON file format, select the image quality and date range (optional). Make sure you also select at least the Messages option in the options that appear bellow and then click Request Download', 66 | images: [ 67 | { 68 | url: require('../../../../assets/img/messenger/5.png').default, 69 | position: 'top-left', 70 | width: '90%', 71 | }, 72 | { 73 | url: require('../../../../assets/img/messenger/6.png').default, 74 | position: 'center-right', 75 | width: '90%', 76 | customStyle: { 77 | top: '50%', 78 | right: 0 79 | } 80 | } 81 | ], 82 | }, 83 | { 84 | text: 'After Facebook notifies that your information is available for download, select the Available Files tab and download your files. Finally, browse the files to your conversation, select the .JSON files and import them to Chatview', 85 | images: [ 86 | { 87 | url: require('../../../../assets/img/messenger/7.png').default, 88 | position: 'top-center', 89 | width: '95%' 90 | }, 91 | { 92 | url: require('../../../../assets/img/messenger/8.png').default, 93 | position: 'bottom-center', 94 | width: '60%' 95 | }, 96 | ], 97 | }, 98 | ] 99 | 100 | export default { messenger } 101 | export type { StepImagePosition } 102 | -------------------------------------------------------------------------------- /client/src/pages/home/sections/get-started/styles.scss: -------------------------------------------------------------------------------- 1 | @import '../../../../styles/colors.scss'; 2 | @import '../../../../styles//globals.scss'; 3 | 4 | .get-started { 5 | display: flex; 6 | padding: 4rem 5vw 12rem 5vw; 7 | color: $blue-darkest; 8 | 9 | &__illustrations { 10 | position: relative; 11 | flex: 1; 12 | margin-top: 7rem; 13 | min-height: 50vh; 14 | margin-right: 2rem; 15 | 16 | img { 17 | border-radius: 10px; 18 | box-shadow: rgba(100, 100, 111, 0.2) 0px 7px 29px 0px; 19 | animation: fadeIn .5s cubic-bezier(0.785, 0.135, 0.15, 0.86) 20 | } 21 | } 22 | 23 | &__steps { 24 | flex: 1.3; 25 | 26 | h2 { 27 | font-size: 3rem; 28 | margin-bottom: 5rem; 29 | } 30 | 31 | h3 { 32 | font-size: 0.8rem; 33 | color: $pink-dark; 34 | } 35 | 36 | &__step-container { 37 | padding-bottom: 2rem; 38 | display: flex; 39 | align-items: flex-start; 40 | justify-content: flex-start; 41 | position: relative; 42 | cursor: pointer; 43 | 44 | button { 45 | border: 1px solid $light-gray; 46 | background: transparent; 47 | color: $blue-darkest; 48 | width: 2.2rem; 49 | height: 2.2rem; 50 | border-radius: 1.1rem; 51 | padding: 0; 52 | margin-right: 1rem; 53 | vertical-align: middle; 54 | transition: all 0.2s; 55 | 56 | span { 57 | font-size: 1rem; 58 | font-weight: 500; 59 | color: $dark-gray; 60 | } 61 | } 62 | 63 | > span { 64 | flex: 1; 65 | padding-top: 0.4rem; 66 | font-size: 0.9rem; 67 | min-height: 2.2rem; 68 | } 69 | 70 | &:last-child::after { 71 | display: none; 72 | } 73 | 74 | &::after { 75 | position: absolute; 76 | top: calc(2.2rem + .4rem); 77 | bottom: .4rem; 78 | left: calc(1.1rem - 1px); 79 | width: 2px; 80 | content: ' '; 81 | background-color: $light-gray; 82 | -webkit-border-radius: 3px; 83 | -moz-border-radius: 3px; 84 | border-radius: 3px; 85 | } 86 | } 87 | 88 | &__step-container--selected { 89 | @extend .get-started__steps__step-container; 90 | 91 | button { 92 | background: $purple-light; 93 | border: none; 94 | 95 | span { 96 | color: $white; 97 | } 98 | } 99 | 100 | span { 101 | font-weight: 600; 102 | } 103 | } 104 | } 105 | } 106 | 107 | @keyframes fadeIn { 108 | 0% { 109 | opacity: 0; 110 | margin-top: -2rem; 111 | } 112 | 100% { 113 | opacity: 1; 114 | margin-top: 0; 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /client/src/pages/home/sections/hero/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useRef } from 'react' 2 | import styles from './styles.scss' 3 | import globalStyles from '../../../../styles/globals.scss' 4 | import axios, { AxiosRequestConfig } from 'axios' 5 | import { useHistory } from 'react-router-dom' 6 | import Dropdown from '../../../../components/dropdown' 7 | import Icon from '../../../../assets/svg' 8 | import Loader from '../../../../components/loader' 9 | import toast, { Toaster } from 'react-hot-toast' 10 | import Waves from '../../../../components/waves' 11 | import { socialNetworkDropdownOptions } from '../../../../constants/social-network' 12 | 13 | type ToastId = { 14 | [id: string]: string 15 | } 16 | 17 | const Hero = () => { 18 | const [files, setFiles] = useState(Array()) 19 | const [isFetching, setIsFetching] = useState(false) 20 | const [toastId, setToastId] = useState({}) 21 | const fileInputRef = useRef() 22 | const history = useHistory() 23 | 24 | const uploadFiles = () => fileInputRef.current.click() 25 | 26 | const onLoadFiles = (event: React.ChangeEvent) => 27 | setFiles(Array.from(event.target.files)) 28 | 29 | const generateAnalytics = () => { 30 | files.length 31 | ? postAnalytics() 32 | : displayErrorToast('You must import at least one file') 33 | } 34 | 35 | const displayErrorToast = (message: string) => { 36 | const id = toast.error(message, { 37 | position: 'top-center', 38 | duration: 3000, 39 | style: { 40 | fontSize: '.9rem', 41 | fontWeight: 300, 42 | }, 43 | }) 44 | toastId[message] && toast.remove(toastId[message]) 45 | toastId[message] = id 46 | setToastId(toastId) 47 | } 48 | 49 | const postAnalytics = () => { 50 | const data: FormData = new FormData() 51 | files.forEach((file: File) => data.append('files', file)) 52 | 53 | const config: AxiosRequestConfig = { 54 | method: 'POST', 55 | url: '/api/chat', 56 | data, 57 | headers: { 58 | 'Content-Type': 'multipart/form-data', 59 | }, 60 | } 61 | 62 | setIsFetching(true) 63 | axios 64 | .request(config) 65 | .then((res) => history.push('/analytics', { data: res.data })) 66 | .catch(() => displayErrorToast('An error occurred, please try again')) 67 | .finally(() => setIsFetching(false)) 68 | } 69 | 70 | const formatSelectedFilesLabel = () => { 71 | switch (files.length) { 72 | case 0: 73 | return 'Import files...' 74 | case 1: 75 | return `${files.length} file selected` 76 | default: 77 | return `${files.length} files selected` 78 | } 79 | } 80 | 81 | return ( 82 |
83 | 84 |
85 | 86 |

CHATVIEW

87 |
88 |

89 | Have you ever wanted to get insights of your conversations? 90 |
91 | ChatView provides data visualizations of your social 92 | media chat history. 93 |

94 | 95 |
96 |
100 | 108 |
109 |
110 | 114 |
115 | 122 |
123 | 124 | 132 | 133 |
134 | ) 135 | } 136 | 137 | export default Hero 138 | -------------------------------------------------------------------------------- /client/src/pages/home/sections/hero/styles.scss: -------------------------------------------------------------------------------- 1 | @import '../../../../styles/colors.scss'; 2 | @import '../../../../styles//globals.scss'; 3 | 4 | $icon-width: 120px; 5 | 6 | .hero { 7 | position: relative; 8 | background: $blue-darkest; 9 | display: flex; 10 | height: 80vh; 11 | justify-content: center; 12 | align-items: center; 13 | flex-direction: column; 14 | padding-bottom: 4rem; 15 | 16 | .cta-wrapper { 17 | position: relative; 18 | margin: 0; 19 | border: none; 20 | border-radius: 6px; 21 | background: $white; 22 | display: flex; 23 | align-items: center; 24 | 25 | .file-upload-btn-wrapper { 26 | @extend .clickable; 27 | display: flex; 28 | flex: 1; 29 | height: 100%; 30 | padding: 10px 0; 31 | 32 | .file-upload { 33 | &-btn { 34 | @extend .clickable; 35 | background: transparent; 36 | display: flex; 37 | align-items: center; 38 | min-width: 180px; 39 | margin-left: 20px; 40 | border: none; 41 | border-right: 1px solid #c0c0c0; 42 | font-weight: 300; 43 | 44 | span { 45 | display: flex; 46 | flex: 1; 47 | margin-right: 20px; 48 | transition: color 0.2s; 49 | font-size: 0.9rem; 50 | } 51 | 52 | img { 53 | margin: 0; 54 | margin-right: 20px; 55 | width: 20px; 56 | height: 20px; 57 | filter: invert(6%) sepia(13%) saturate(2661%) hue-rotate(159deg) 58 | brightness(93%) contrast(100%); 59 | transition: filter 0.2s; 60 | } 61 | } 62 | 63 | &-placeholder { 64 | color: #85929b; 65 | } 66 | } 67 | 68 | &:hover { 69 | img { 70 | filter: invert(11%) sepia(78%) saturate(7407%) hue-rotate(258deg) 71 | brightness(83%) contrast(99%); 72 | } 73 | } 74 | } 75 | 76 | .dropdown-wrapper { 77 | display: flex; 78 | flex: 1; 79 | height: 100%; 80 | margin: 0 20px; 81 | min-width: 180px; 82 | align-items: center; 83 | } 84 | } 85 | 86 | .hero-title-container { 87 | display: flex; 88 | align-items: flex-end; 89 | justify-content: flex-end; 90 | margin-bottom: 60px; 91 | padding-right: $icon-width; 92 | 93 | img { 94 | height: auto; 95 | width: $icon-width; 96 | margin: 0 50px; 97 | overflow: hidden; 98 | display: inline; 99 | } 100 | 101 | h1 { 102 | font-size: 5rem; 103 | color: $white-text; 104 | display: inline; 105 | } 106 | } 107 | 108 | p { 109 | color: $white-text; 110 | width: 45%; 111 | font-size: 1.2rem; 112 | font-weight: 200; 113 | display: block; 114 | line-height: 2rem; 115 | text-align: center; 116 | margin-bottom: 60px; 117 | } 118 | 119 | .hidden { 120 | display: none; 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /client/src/pages/home/sections/index.tsx: -------------------------------------------------------------------------------- 1 | export default { 2 | Hero: require('./hero').default, 3 | GetStarted: require('./get-started').default, 4 | Footer: require('./footer').default, 5 | } 6 | -------------------------------------------------------------------------------- /client/src/pages/home/styles.scss: -------------------------------------------------------------------------------- 1 | .home { 2 | overflow-x: hidden; 3 | justify-content: center; 4 | flex-direction: column; 5 | width: 100vw; 6 | height: 100vh; 7 | } 8 | -------------------------------------------------------------------------------- /client/src/pages/not-found/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styles from './styles.scss' 3 | import Waves from '../../components/waves' 4 | 5 | const NotFound = () => { 6 | return ( 7 |
8 |

404

9 |

Page Not Found

10 | 11 |
12 | ) 13 | } 14 | 15 | export default NotFound 16 | -------------------------------------------------------------------------------- /client/src/pages/not-found/styles.scss: -------------------------------------------------------------------------------- 1 | @import '../../styles/colors.scss'; 2 | 3 | .not-found { 4 | display: flex; 5 | align-items: center; 6 | justify-content: center; 7 | flex-direction: column; 8 | height: 100vh; 9 | padding-bottom: 20vh; 10 | background: $blue-darkest; 11 | background: linear-gradient(135deg, $green-darkest 0%, $blue-darkest 100%); 12 | 13 | h1 { 14 | color: $white-text; 15 | font-size: 8rem; 16 | font-weight: bolder; 17 | margin-bottom: 1rem; 18 | } 19 | 20 | h3 { 21 | color: $white-text; 22 | font-size: 2rem; 23 | font-weight: 200; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /client/src/styles/colors.scss: -------------------------------------------------------------------------------- 1 | $blue-darkest: #031018; 2 | $blue-dark: #461fd3; 3 | $pink-light: #d068d0; 4 | $pink-dark: #aa3aff; 5 | $purple-light: #c47efa; 6 | $purple-dark: #ab3bff; 7 | $green-lighter: #09a9c8; 8 | $green-lightest: #3adeff; 9 | $green-light: #0c8196; 10 | $green-dark: #033f53; 11 | $green-darker: #022e3d; 12 | $green-darkest: #021b24; 13 | $green-border: rgba(5, 87, 114, 0.5); 14 | $green-shadow: rgba(5, 87, 114, 0.3); 15 | $blue-light: #008ce9; 16 | $blue-dark: #0055b6; 17 | $white-text: #e6e6e6; 18 | $white: #f3f3f3; 19 | $pure-white: #fff; 20 | $light-gray: #d8dada; 21 | $dark-gray: #292929; 22 | -------------------------------------------------------------------------------- /client/src/styles/globals.scss: -------------------------------------------------------------------------------- 1 | @import './colors.scss'; 2 | 3 | * { 4 | font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, 5 | Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; 6 | -ms-overflow-style: none; 7 | scrollbar-width: none; 8 | padding: 0; 9 | margin: 0; 10 | box-sizing: border-box; 11 | 12 | &::-webkit-scrollbar { 13 | display: none; 14 | } 15 | } 16 | 17 | a { 18 | color: inherit; 19 | text-decoration: none; 20 | } 21 | 22 | .clickable { 23 | cursor: pointer; 24 | } 25 | 26 | .bold { 27 | font-weight: 600; 28 | } 29 | 30 | .cta { 31 | @extend .clickable; 32 | background: linear-gradient(135deg, $pink-dark 0%, $blue-dark 100%); 33 | background-size: 150% 150%; 34 | border: none; 35 | display: block; 36 | margin: 0 auto; 37 | height: 50px; 38 | font-size: 1rem; 39 | color: $white-text; 40 | border-radius: 6px; 41 | -webkit-animation: anim-background-out 0.2s ease; 42 | -moz-animation: anim-background-out 0.2s ease; 43 | animation: anim-background-out 0.2s ease; 44 | animation-fill-mode: backwards; 45 | 46 | &:hover { 47 | -webkit-animation: anim-background-in 0.2s ease; 48 | -moz-animation: anim-background-in 0.2s ease; 49 | animation: anim-background-in 0.2s ease; 50 | animation-fill-mode: forwards; 51 | } 52 | 53 | &:focus { 54 | outline: none; 55 | } 56 | } 57 | 58 | .main-cta { 59 | @extend .cta; 60 | margin: 4px; 61 | height: 42px; 62 | width: 180px; 63 | border-radius: 6px; 64 | display: flex; 65 | align-items: center; 66 | justify-content: center; 67 | } 68 | 69 | @keyframes anim-background-in { 70 | from { 71 | background-position: 0% 50%; 72 | } 73 | to { 74 | background-position: 100% 50%; 75 | } 76 | } 77 | 78 | @keyframes anim-background-out { 79 | from { 80 | background-position: 100% 50%; 81 | } 82 | to { 83 | background-position: 0% 50%; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /client/src/utils/formatters.ts: -------------------------------------------------------------------------------- 1 | import { Date, Duration } from './types' 2 | 3 | export const withCommas = (number: number) => 4 | number.toString().replace(/\B(?=(\d{3})+(?!\d))/g, '.') 5 | 6 | export const daySuffix = (day: number): string => { 7 | if (day > 3) return 'th' 8 | 9 | switch (day % 10) { 10 | case 1: 11 | return 'st' 12 | case 2: 13 | return 'nd' 14 | case 3: 15 | return 'rd' 16 | default: 17 | return 'th' 18 | } 19 | } 20 | 21 | export const formatDate = (date: Date): string => { 22 | const { year, month, day } = date 23 | const monthString = new Date(year, month - 1, day).toLocaleString('default', { 24 | month: 'long', 25 | }) 26 | return `${monthString} ${day}${daySuffix(day)} ${year}` 27 | } 28 | 29 | export const formatDateString = ( 30 | date: string, 31 | sourceDelimiter: string = '-', 32 | targetDelimiter: string = '/', 33 | ): string => { 34 | const split = date.split(sourceDelimiter) 35 | const temp = split[0] 36 | 37 | // Swap year with day 38 | split[0] = split[2] 39 | split[2] = temp 40 | 41 | // Adds leading zeros to day and month 42 | if (Number(split[0]) < 10) split[0] = `0${split[0]}` 43 | if (Number(split[1]) < 10) split[1] = `0${split[1]}` 44 | 45 | return `${split.join(targetDelimiter)}` 46 | } 47 | 48 | export const shortenName = (fullName: string) => { 49 | const split = fullName.split(' ') 50 | const name = split[0] 51 | const surnameInitial = 52 | split.length > 1 ? `${split[split.length - 1][0]}.` : '' 53 | return `${name} ${surnameInitial}` 54 | } 55 | 56 | export const firstName = (fullName: string) => { 57 | return fullName.split(' ')[0] 58 | } 59 | 60 | export const firstAndLastName = (fullName: string) => { 61 | const split = fullName.split(' ') 62 | const name = split[0] 63 | const surname = split.length > 1 ? split[split.length - 1] : '' 64 | return `${name} ${surname}` 65 | } 66 | 67 | export const formatDuration = (duration: Duration): string => { 68 | const { years, months, days, hours, minutes, seconds } = duration 69 | return `(${years} years, 70 | ${months} months, 71 | ${days} days, 72 | ${hours} hours, 73 | ${minutes} minutes, 74 | ${seconds} seconds)` 75 | } 76 | 77 | export const timestampToDate = ( 78 | timestamp: number, 79 | breakLine: boolean = true, 80 | ): string => { 81 | const date = new Date(timestamp) 82 | const splitDate = date.toISOString().split('T')[0].split('-') 83 | const year = splitDate[0] 84 | const month = date.toLocaleString('default', { month: 'short' }) 85 | let day = `${Number(splitDate[2])}` 86 | day = day.length === 1 ? `0${day}` : day 87 | return `${day} ${month}${breakLine ? '\n' : ''}${year}` 88 | } 89 | 90 | export const formatHour = (hour: number): string => `${hour}h` 91 | 92 | export const withKs = (value: number): string => { 93 | if (value >= 1000) { 94 | let withDecimals = (value / 1000).toFixed(1) 95 | 96 | if (withDecimals[withDecimals.length - 1] === '0') { 97 | withDecimals = withDecimals.substr(0, withDecimals.length - 2) 98 | } 99 | 100 | return `${withDecimals}k` 101 | } 102 | 103 | return `${value}` 104 | } 105 | -------------------------------------------------------------------------------- /client/src/utils/types.ts: -------------------------------------------------------------------------------- 1 | export type Duration = { 2 | years: number, 3 | months: number, 4 | days: number, 5 | hours: number, 6 | minutes: number, 7 | seconds: number 8 | } 9 | 10 | export type Date = { 11 | year: number, 12 | month: number, 13 | day: number, 14 | } 15 | 16 | export type Padding = [ 17 | top: number, 18 | right: number, 19 | bottom: number, 20 | left: number 21 | ] 22 | 23 | export type Participant = { 24 | name: string, 25 | total_messages_sent: number, 26 | top_emojis_sent: Array> 27 | } 28 | 29 | export type TimeMessage = { 30 | [key: number]: number 31 | } -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "jsx": "react", 4 | "module": "esnext", 5 | "noImplicitAny": false, 6 | "outDir": "./dist", 7 | "preserveConstEnums": true, 8 | "removeComments": true, 9 | "sourceMap": true, 10 | "target": "es5", 11 | "allowSyntheticDefaultImports": true, 12 | "moduleResolution": "node" 13 | }, 14 | "include": ["src/**/*"], 15 | "exclude": ["node_modules", "build"] 16 | } 17 | -------------------------------------------------------------------------------- /client/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const HTMLWebPackPlugin = require('html-webpack-plugin') 3 | 4 | module.exports = { 5 | mode: 'development', 6 | entry: './src/index.tsx', 7 | output: { 8 | path: path.resolve(__dirname, 'dist'), 9 | filename: '[name].[hash].js', 10 | publicPath: '/' 11 | }, 12 | devServer: { 13 | contentBase: 'public', 14 | port: 3000, 15 | historyApiFallback: true, 16 | proxy: { 17 | '/api/chat': { 18 | target: 'http://localhost:5000', 19 | pathRewrite: { '^/api': '' } 20 | }, 21 | } 22 | }, 23 | module: { 24 | rules: [ 25 | { 26 | test: /\.(js|jsx)$/, 27 | loader: 'babel-loader' 28 | }, 29 | { 30 | test: /\.(ts|tsx)?$/, 31 | use: 'ts-loader', 32 | exclude: /node_modules/, 33 | }, 34 | { 35 | test: /\.(css|sass|scss)$/, 36 | use: [ 37 | { loader: 'style-loader' }, 38 | { 39 | loader: 'css-loader', 40 | options: { 41 | modules: { 42 | compileType: 'module', 43 | localIdentName: '[hash:base64:8]' 44 | }, 45 | } 46 | }, 47 | { loader: 'sass-loader' } 48 | ] 49 | }, 50 | { 51 | test: /\.(jpg|png)(\?v=\d+\.\d+\.\d+)?$/, 52 | use: [{ 53 | loader: 'file-loader', 54 | options: { 55 | name: '[name].[ext]', 56 | outputPath: 'assets/img/' 57 | } 58 | }] 59 | }, 60 | { 61 | test: /\.(svg)(\?v=\d+\.\d+\.\d+)?$/, 62 | use: [{ 63 | loader: 'file-loader', 64 | options: { 65 | name: '[name].[ext]', 66 | outputPath: 'assets/svg/' 67 | } 68 | }] 69 | }, 70 | { 71 | test: /favicon\.ico$/, 72 | loader: 'url' 73 | }, 74 | { 75 | test: /\.(woff(2)?|ttf|otf)(\?v=\d+\.\d+\.\d+)?$/, 76 | use: [{ 77 | loader: 'file-loader', 78 | options: { 79 | name: '[name].[ext]', 80 | outputPath: 'fonts/' 81 | } 82 | }] 83 | } 84 | ] 85 | }, 86 | resolve: { 87 | extensions: ['.tsx', '.ts', '.js', '.jsx'], 88 | }, 89 | plugins: [ 90 | new HTMLWebPackPlugin({ 91 | template: './public/index.html', 92 | favicon: './public/favicon.svg' 93 | }) 94 | ] 95 | }; -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | client: 4 | build: ./client 5 | restart: always 6 | networks: 7 | - api 8 | ports: 9 | - "3000:80" 10 | server: 11 | build: ./server 12 | restart: always 13 | networks: 14 | - api 15 | ports: 16 | - "5000:5000" 17 | networks: 18 | api: 19 | driver: bridge 20 | -------------------------------------------------------------------------------- /docs/chat-report.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/VitorCodes/chatview/182075e14c4166eb23a435e3122a258b32053271/docs/chat-report.png -------------------------------------------------------------------------------- /docs/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/VitorCodes/chatview/182075e14c4166eb23a435e3122a258b32053271/docs/demo.gif -------------------------------------------------------------------------------- /server/.dockerignore: -------------------------------------------------------------------------------- 1 | env 2 | Dockerfile 3 | **/*.md 4 | **/__pycache__ -------------------------------------------------------------------------------- /server/.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | env 3 | __pycache__ -------------------------------------------------------------------------------- /server/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.8.3 2 | 3 | # Environment 4 | ENV PORT=5000 5 | 6 | # Setup 7 | ARG SRC_PATH=/usr/src/app 8 | WORKDIR ${SRC_PATH} 9 | COPY . . 10 | 11 | # Build 12 | RUN pip install -r requirements.txt 13 | 14 | # Run 15 | EXPOSE 5000 16 | CMD python server.py -------------------------------------------------------------------------------- /server/README.md: -------------------------------------------------------------------------------- 1 | # CharView Server 2 | 3 | ## 1. Setup environment 4 | 5 | Create environment: 6 | 7 | ``` 8 | python3 -m venv env 9 | ``` 10 | 11 | Activate environment: 12 | 13 | ``` 14 | source env/bin/activate 15 | ``` 16 | 17 | ## 2. Install dependencies 18 | 19 | Install all dependencies specified in `requirements.txt` file: 20 | 21 | ``` 22 | pip install -r requirements.txt 23 | ``` 24 | 25 | ## 3. Run server 26 | 27 | Start server.py: 28 | 29 | ``` 30 | python server.py 31 | ``` -------------------------------------------------------------------------------- /server/requirements.txt: -------------------------------------------------------------------------------- 1 | Flask>=2.1.0,<2.2.0 2 | Flask-RESTful>=0.3.8,<0.4.0 3 | Flask-Cors>=3.0.9,<3.1.0 4 | pandas>=1.1.2,<1.2.0 5 | emoji>=0.6.0,<0.7.0 -------------------------------------------------------------------------------- /server/resources/chat.py: -------------------------------------------------------------------------------- 1 | from flask_restful import Resource, request 2 | from .handlers.messenger import MessengerChat 3 | 4 | class Chat(Resource): 5 | def post(self): 6 | return MessengerChat(request).to_json() -------------------------------------------------------------------------------- /server/resources/handlers/messenger.py: -------------------------------------------------------------------------------- 1 | from emoji.core import emoji_count, emoji_lis 2 | from flask import jsonify 3 | from pandas.core.frame import DataFrame 4 | from typing import Dict, List, Iterable 5 | from datetime import datetime 6 | import pandas as pd 7 | import numpy as np 8 | import math 9 | from werkzeug.wrappers import Request 10 | from ..utils import utils 11 | 12 | 13 | class MessengerChat: 14 | messages = [] 15 | participants = set() 16 | data = DataFrame() 17 | 18 | def __init__(self, request: Request, files_key: str = 'files'): 19 | parsed_files = utils.request_files_to_json(request, files_key) 20 | self.setup_chat(parsed_files) 21 | self.setup_chat_dataframe(self.messages) 22 | 23 | def setup_chat(self, parsed_files: List): 24 | """[summary] 25 | Iterates a list of .json messenger chat files and stores all messages 26 | and participants in the local variables 27 | 28 | Args: 29 | parsed_files (List): [description] 30 | """ 31 | self.messages = [] 32 | self.participants = set() 33 | 34 | for parsed_file in parsed_files: 35 | for participant in parsed_file['participants']: 36 | self.participants.add(participant['name']) 37 | 38 | self.messages += parsed_file['messages'] 39 | 40 | def nullcheck_dataframe_columns(self, dataframe: pd.DataFrame, columns: List[str]): 41 | for column in columns: 42 | if(column not in dataframe.columns): 43 | dataframe[column] = np.nan 44 | 45 | def setup_chat_dataframe(self, messages: List): 46 | """[summary] 47 | Transforms a chat message list into a panda dataframe. 48 | The dataframe has the columns: 49 | [sender_name, timestamp_ms, photos, type, content, gifs, reactions] 50 | 51 | 1. Sorts messages by their timestamps, ascending 52 | 2. Adds total gif and photo counts to the dataframe 53 | 3. Decodes each message and adds the emojis found to the dataframe 54 | 55 | Args: 56 | messages (List): [description] 57 | 58 | Returns: 59 | [type]: [description] 60 | """ 61 | self.data = pd.json_normalize(messages).sort_values( 62 | by='timestamp_ms', 63 | ascending=True) # Order by timestamps, ascending 64 | 65 | self.nullcheck_dataframe_columns(self.data, ['gifs', 'photos', 'reactions']) 66 | self.data = self.apply_list_count(self.data, ['gifs', 'photos']) # Apply total gifs and total photos to dataframe 67 | self.data = self.apply_text_decode(self.data,['content']) # Apply emoji count and list emojis for each content message 68 | self.data['reactions'] = self.data['reactions'].transform( 69 | lambda reactions: len(reactions) 70 | if type(reactions) is list else None) 71 | 72 | self.data = self.data[[ 73 | 'sender_name', 'timestamp_ms', 'photos', 'type', 'content', 'gifs', 74 | 'reactions' 75 | ]] 76 | return self.data 77 | 78 | def apply_list_count(self, dataframe: pd.DataFrame, columns: List[str]): 79 | """[summary] 80 | Apply a list count to the specified dataframe column items. 81 | These items are expected to be of type List. 82 | 83 | Args: 84 | dataframe (pd.DataFrame): [description] 85 | columns (List[str]): [description] 86 | 87 | Returns: 88 | [type]: [description] 89 | """ 90 | for column in columns: 91 | if(column in dataframe): 92 | dataframe[column] = dataframe[column].apply( 93 | lambda value: len(value) if type(value) is list else None) 94 | 95 | return dataframe 96 | 97 | def apply_text_decode(self, dataframe: pd.DataFrame, columns: List[str]): 98 | """[summary] 99 | Save and decode all chat messages for the specified dataframe columns. 100 | 101 | Args: 102 | dataframe (pd.DataFrame): [description] 103 | columns (List[str]): [description] 104 | 105 | Returns: 106 | [type]: [description] 107 | """ 108 | for column in columns: 109 | dataframe[column] = dataframe[column].apply( 110 | lambda value: self.parse_message_content(value) 111 | if type(value) is str else None) 112 | 113 | return dataframe 114 | 115 | def parse_message_content(self, text: str): 116 | """ 117 | Decodes a text string into a collction containing the text and 118 | additionally, the emoji_count and emoji_list. 119 | 120 | Args: 121 | text (str): [description] 122 | 123 | Returns: 124 | [type]: [description] 125 | """ 126 | emojis = emoji_lis(utils.decode_utf8_text(text)) 127 | 128 | return { 129 | 'text': text, 130 | 'emoji_count': emoji_count(utils.decode_utf8_text(text)), 131 | 'emoji_list': [emoji['emoji'] for emoji in emojis] 132 | } 133 | 134 | def get_participant_emojis(self, emoji_list: Iterable, 135 | all_emojis_sent: Dict): 136 | 137 | """[summary] 138 | Iterates an emoji_list and counts each emoji by adding to the 139 | all_emojis_sent dictionary on each iteration. 140 | 141 | Returns: 142 | [type]: [description] 143 | """ 144 | emojis = {} 145 | 146 | for _, value in emoji_list: 147 | for item in value: 148 | if (item in emojis): 149 | emojis[item] += 1 150 | else: 151 | emojis[item] = 1 152 | 153 | if (item in all_emojis_sent): 154 | all_emojis_sent[item] += 1 155 | else: 156 | all_emojis_sent[item] = 1 157 | 158 | return emojis 159 | 160 | def get_participant_messages_per_day(self, participant_data: DataFrame): 161 | """[summary] 162 | Transforms a chat participant data into a dictionary that represents 163 | each date where that participant sent a message, and for each date, 164 | the hour the message was sent. 165 | 166 | Args: 167 | participant_data (DataFrame): [description] 168 | 169 | Returns: 170 | [type]: [description] 171 | """ 172 | msg_per_day = {} 173 | 174 | for _, value in participant_data['timestamp_ms'].iteritems(): 175 | dt = datetime.fromtimestamp(value / 1000) 176 | dt_key = f'{dt.year}-{dt.month}-{dt.day}' 177 | hr_key = dt.hour 178 | 179 | if (dt_key in msg_per_day): 180 | if (hr_key in msg_per_day[dt_key]): 181 | msg_per_day[dt_key][hr_key] += 1 182 | else: 183 | msg_per_day[dt_key][hr_key] = 1 184 | else: 185 | msg_per_day[dt_key] = {hr_key: 1} 186 | 187 | return msg_per_day 188 | 189 | def get_participant_stats(self, participant: str, msg_per_day: Dict, 190 | emojis: Dict, participant_data: DataFrame, 191 | content_data: DataFrame): 192 | """[summary] 193 | Assembles a set of participant statistics into a json-ready format 194 | payload. 195 | 196 | Args: 197 | participant (str): [description] 198 | msg_per_day (Dict): [description] 199 | emojis (Dict): [description] 200 | participant_data (DataFrame): [description] 201 | content_data (DataFrame): [description] 202 | 203 | Returns: 204 | [type]: [description] 205 | """ 206 | return { 207 | 'name': utils.decode_utf8_text(participant), 208 | 'total_messages_sent': len(participant_data.index), 209 | 'reactions_received': int(participant_data['reactions'].sum()), 210 | 'gifs_sent': int(participant_data['gifs'].sum()), 211 | 'photos_sent': int(participant_data['photos'].sum()), 212 | 'links_shared': len(participant_data[participant_data['type'] == 'Share'].index), 213 | 'total_emojis_sent': int(content_data['emoji_count'].sum()), 214 | 'top_emojis_sent': emojis[:10], 215 | # 'messages_sent': msg_per_day, 216 | } 217 | 218 | def add_messages_to_weekday(self, date: str, current_weekday_messages: Dict, total_messages: int): 219 | """[summary] 220 | From a date's weekday, check if it exists in the dictionary and 221 | adds the messages_to_add amount to that weekday 222 | 223 | Args: 224 | date (str): [description] 225 | current_weekday_messages (Dict): [description] 226 | total_messages (int): [description] 227 | """ 228 | weekday = datetime.strptime(date, '%Y-%m-%d').weekday() # monday is 0, sunday is 6 229 | if(weekday not in current_weekday_messages): 230 | current_weekday_messages[weekday] = total_messages 231 | else: 232 | current_weekday_messages[weekday] += total_messages 233 | 234 | def add_messages_to_dict(self, key: str, current_messages: Dict, messages_to_add: int): 235 | """[summary] 236 | Checks if a key exists in a dictionary and adds the messages_to_add 237 | amount to that key 238 | 239 | Args: 240 | key (str): [description] 241 | current_messages (Dict): [description] 242 | messages_to_add (int): [description] 243 | """ 244 | if(key not in current_messages): 245 | current_messages[key] = messages_to_add 246 | else: 247 | current_messages[key] += messages_to_add 248 | 249 | def add_participant_message_stats(self, participant_messages: Dict, message_stats: Dict): 250 | """[summary] 251 | Adds message statistics to message_stats for the participant. 252 | These statistics consist messages per year/month, messages per date, 253 | messages per weekday and messages per hour 254 | 255 | Args: 256 | participant_messages (Dict): [description] 257 | message_stats (Dict): [description] 258 | """ 259 | for date in participant_messages: 260 | year, month, day = date.split('-') 261 | 262 | if(year not in message_stats['year_month_messages']): 263 | message_stats['year_month_messages'][year] = { month: 0 } 264 | 265 | if(month not in message_stats['year_month_messages'][year]): 266 | message_stats['year_month_messages'][year][month] = 0 267 | 268 | for hour in participant_messages[date]: 269 | total_hour_messages = participant_messages[date][hour] 270 | 271 | # Add messages per month and year 272 | message_stats['year_month_messages'][year][month] += total_hour_messages 273 | 274 | # Add messages per weekday 275 | self.add_messages_to_weekday(date, message_stats['weekday_messages'], total_hour_messages) 276 | 277 | # Add messages per date 278 | self.add_messages_to_dict(date, message_stats['day_messages'], total_hour_messages) 279 | 280 | # Add messages per hour 281 | self.add_messages_to_dict(hour, message_stats['hour_messages'], total_hour_messages) 282 | 283 | def get_participants_stats(self): 284 | """[summary] 285 | Iterates the participants list and returns a list of participants 286 | and their statistics, as well as statistics that refer the whole 287 | conversation (such as all_emojis_sent). 288 | 289 | Returns: 290 | [type]: [description] 291 | """ 292 | all_emojis_sent = {} 293 | message_stats = { 294 | 'year_month_messages': {}, 295 | 'day_messages': {}, 296 | 'weekday_messages': {}, 297 | 'hour_messages': {} 298 | } 299 | participants_stats = [] 300 | 301 | for participant in self.participants: 302 | participant_data = self.data.query( 303 | f'sender_name == "{participant}"' 304 | ) # Filter data for this participant 305 | content_data = pd.json_normalize( 306 | participant_data.loc[participant_data['content'].notnull()] 307 | ['content']) # Filter content only 308 | 309 | self.nullcheck_dataframe_columns(content_data, ['emoji_list', 'emoji_count']) 310 | 311 | # Count all emojis separately in a set for this participant 312 | emoji_list = content_data['emoji_list'].iteritems() 313 | 314 | emojis = utils.sort_dict( 315 | self.get_participant_emojis(emoji_list, all_emojis_sent)) 316 | 317 | # Count messages per day and hour 318 | msg_per_day = self.get_participant_messages_per_day( 319 | participant_data) 320 | 321 | # Adds participant message statistics to message_stats 322 | self.add_participant_message_stats(msg_per_day, message_stats) 323 | 324 | participants_stats.append( 325 | self.get_participant_stats(participant, msg_per_day, emojis, 326 | participant_data, content_data)) 327 | 328 | different_emojis = len(all_emojis_sent.keys()) 329 | all_emojis_sent = utils.sort_dict(all_emojis_sent) 330 | avg_messages_per_day = math.floor((sum(message_stats['day_messages'].values())) / (len(message_stats['day_messages'].keys()))) 331 | top_messages_per_day = utils.sort_dict(message_stats['day_messages']) 332 | messages_per_year_month = self.sort_year_month_messages(message_stats['year_month_messages']) 333 | weekday_messages = message_stats['weekday_messages'] 334 | hour_messages = message_stats['hour_messages'] 335 | 336 | return (all_emojis_sent, different_emojis, participants_stats, avg_messages_per_day, top_messages_per_day, messages_per_year_month, weekday_messages, hour_messages) 337 | 338 | def sort_year_month_messages(self, messages: Dict): 339 | """[summary] 340 | Sorts year dict and month total messages dicts ascending. 341 | Dict follows the structure: { 'year_number': { 'month_number': total_messages } } 342 | 343 | Args: 344 | messages (Dict): [description] 345 | 346 | Returns: 347 | [type]: [description] 348 | """ 349 | for year in messages: 350 | messages[year] = utils.sort_dict_num_keys(messages[year], descending=True) 351 | 352 | return utils.sort_dict_num_keys(messages, descending=False) 353 | 354 | def to_json(self): 355 | """[summary] 356 | Takes messages, participant and data variables and creates 357 | a json payload for the messenger chat. 358 | 359 | Returns: 360 | [type]: [description] 361 | """ 362 | stats = {} 363 | 364 | all_emojis_sent, different_emojis, participants_stats, avg_messages_per_day, top_messages_per_day, messages_per_year_month, weekday_messages, hour_messages = self.get_participants_stats() 365 | start_date = utils.date_from_timestamp( 366 | float(self.data.iloc[0]['timestamp_ms'])) 367 | end_date = utils.date_from_timestamp( 368 | float(self.data.iloc[-1]['timestamp_ms'])) 369 | 370 | stats = { 371 | 'participants': participants_stats, 372 | 'total_messages': len(self.data.index), 373 | 'photos': int(self.data['photos'].sum()), 374 | 'total_emojis': utils.get_total_emojis(self.data), 375 | 'top_emojis': all_emojis_sent[:10], 376 | 'top_messages_per_day': top_messages_per_day[:10], 377 | 'messages_per_year_month': messages_per_year_month, 378 | 'weekday_messages': weekday_messages, 379 | 'hour_messages': hour_messages, 380 | 'different_emojis_used': different_emojis, 381 | 'avg_messages_per_day': avg_messages_per_day, 382 | 'reactions': int(self.data['reactions'].sum()), 383 | 'start_date': utils.json_date(start_date), 384 | 'end_date': utils.json_date(end_date), 385 | 'duration': utils.date_diff(start_date, end_date), 386 | 'total_links_shared': len(self.data[self.data['type'] == 'Share'].index) 387 | } 388 | 389 | return jsonify(stats) -------------------------------------------------------------------------------- /server/resources/utils/utils.py: -------------------------------------------------------------------------------- 1 | import json 2 | from werkzeug.wrappers import Request 3 | from typing import Dict 4 | from datetime import datetime 5 | from pandas.core.frame import DataFrame 6 | import pandas as pd 7 | 8 | ALLOWED_EXTENSIONS = {'json'} 9 | 10 | def request_files_to_json(request: Request, files_key: str): 11 | files = request.files.getlist(files_key) 12 | 13 | parsed = [] 14 | 15 | for file in files: 16 | if file and allowed_file(file.filename): 17 | parsed.append(json.load(file)) 18 | 19 | return parsed 20 | 21 | def allowed_file(filename: str): 22 | return '.' in filename and \ 23 | filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS 24 | 25 | def decode_utf8_text(text: str, codec: str = 'latin1'): 26 | return text.encode(codec).decode('utf8') 27 | 28 | def sort_dict(dict: Dict, descending = True): 29 | return sorted(dict.items(), key=lambda x: x[1], reverse=descending) 30 | 31 | def sort_dict_num_keys(dict: Dict, descending = True): 32 | return sorted(dict.items(), key=lambda x: int(x[0]), reverse=descending) 33 | 34 | def date_from_timestamp(timestamp: float): 35 | return datetime.fromtimestamp(timestamp / 1000) 36 | 37 | def date_diff(start_date: datetime, end_date: datetime): 38 | if start_date > end_date: 39 | raise ValueError(f"Start date {start_date} is not before end date {end_date}") 40 | 41 | seconds = (end_date - start_date).total_seconds() 42 | years = seconds // 3.154e+7 43 | seconds %= 3.154e+7 44 | months = seconds // 2592000 45 | seconds %= 2592000 46 | days = seconds // 86400 47 | seconds %= 86400 48 | hours = seconds // 3600 49 | seconds %= 3600 50 | minutes = seconds // 60 51 | seconds %= 60 52 | 53 | return { 54 | 'years': int(years), 55 | 'months': int(months), 56 | 'days': int(days), 57 | 'hours': int(hours), 58 | 'minutes': int(minutes), 59 | 'seconds': int(seconds) 60 | } 61 | 62 | def json_date(date: datetime): 63 | return { 64 | 'year': date.year, 65 | 'month': date.month, 66 | 'day': date.day 67 | } 68 | 69 | def get_total_emojis(chat_dataframe: DataFrame): 70 | df = pd.json_normalize(chat_dataframe.loc[chat_dataframe['content'].notnull()]['content']) 71 | return int(df['emoji_count'].sum()) -------------------------------------------------------------------------------- /server/server.py: -------------------------------------------------------------------------------- 1 | import os 2 | from flask import Flask 3 | from flask_restful import Api 4 | from flask_cors import CORS 5 | from resources.chat import Chat 6 | 7 | app = Flask(__name__) 8 | CORS(app) 9 | api = Api(app) 10 | 11 | ## 12 | ## Actually setup the Api resource routing here 13 | ## 14 | api.add_resource(Chat, '/chat') 15 | 16 | port = os.environ['PORT'] if os.environ['PORT'] else 5000 17 | 18 | if __name__ == '__main__': 19 | app.run(host="0.0.0.0", port=port, debug=True) 20 | --------------------------------------------------------------------------------