├── .env ├── .gitignore ├── .vscode └── settings.json ├── README.md ├── config.jsx ├── package-lock.json ├── package.json ├── public └── index.html ├── src ├── App.css ├── App.js ├── AppContainer.jsx ├── api │ ├── authentication.jsx │ ├── index.jsx │ ├── scripts.jsx │ └── utils.jsx ├── components │ ├── authenticating │ │ └── Authenticating.jsx │ ├── common │ │ ├── Checkbox.jsx │ │ └── checkbox.scss │ ├── compose-message │ │ ├── ComposeMessage.jsx │ │ └── composeMessage.scss │ ├── content │ │ └── message-list │ │ │ ├── MessageList.jsx │ │ │ ├── actions │ │ │ └── message-list.actions.jsx │ │ │ ├── list-footer │ │ │ └── ListFooter.jsx │ │ │ ├── list-toolbar │ │ │ ├── ListActionButtons.jsx │ │ │ ├── ListToolbar.jsx │ │ │ └── listToolbar.scss │ │ │ ├── message-content │ │ │ ├── MessageContent.jsx │ │ │ └── messageContent.scss │ │ │ ├── message-row │ │ │ ├── AttachmentDateFields.jsx │ │ │ ├── MessageCheckbox.jsx │ │ │ ├── MessageRow.jsx │ │ │ └── NameSubjectFields.jsx │ │ │ ├── message-toolbar │ │ │ ├── MessageToolbar.jsx │ │ │ └── messageToolbar.scss │ │ │ ├── messageList.scss │ │ │ ├── pager-buttons │ │ │ └── PagerButtons.jsx │ │ │ └── reducers │ │ │ └── message-list.reducers.jsx │ ├── header │ │ ├── Header.jsx │ │ └── header.scss │ ├── login │ │ └── Login.jsx │ ├── main │ │ ├── Main.jsx │ │ └── _main.scss │ ├── not-found │ │ └── NotFound.jsx │ ├── sidebar │ │ ├── LabelItem.jsx │ │ ├── Sidebar.jsx │ │ ├── sidebar.actions.jsx │ │ ├── sidebar.reducers.jsx │ │ └── sidebar.scss │ └── signout │ │ └── Signout.jsx ├── constants │ └── index.jsx ├── index.css ├── index.js ├── reducers │ ├── gapi.reducers.jsx │ └── rootReducer.jsx ├── serviceWorker.js ├── store.jsx └── utils │ └── index.jsx └── yarn.lock /.env: -------------------------------------------------------------------------------- 1 | REACT_APP_API_KEY=' ' 2 | REACT_APP_CLIENT_ID=' ' 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # misc 13 | .DS_Store 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | A simple Gmail client made with [Create-React-App](https://github.com/facebook/create-react-app) + [React-Redux](https://github.com/reduxjs/react-redux), using [Gmail's public Javascript API](https://developers.google.com/gmail/api/). It also uses [React Router](https://github.com/ReactTraining/react-router) to add some routing features. 2 | 3 | It is meant to be a simple demo of utilizing live data from a RESTful API by using React development tools. It can be useful as a starting point for anyone wanting to fork it and extend it for their own ideas of a custom JavaScript Gmail client; or simply as a reference on using client-side Javascript to consume Gmail data. It is a non-ejected [Create-React-App v2](https://github.com/facebook/create-react-app) app; convenient as you can [customize](https://facebook.github.io/create-react-app/docs/available-scripts#npm-run-eject) project configs if you need to. 4 | 5 | 6 | **How does it work?** 7 | The account sign-in and authentication process is **totally managed by Gmail's secure protocols**. The workflow is as follows: 8 | 9 | - First-time users will see a landing page with a button to sign in to 10 | Gmail. 11 | - Once successfully signed-in, Gmail will display a screen asking the 12 | user for permission to use the account in the application. 13 | - After permission is granted, the application will load all account data and display the Inbox folder. 14 | 15 | **IMPORTANT:** The application does **NOT** store or persist any account or user data in any way at all. It simply fetches data from Gmail's API and displays it in the browser. 16 | 17 | 18 | 19 | 20 | 21 | **Requirements:** 22 | 23 | 24 | 25 | - All Gmail API requests require an ***API Key*** and an ***OAuth 2.0 Client ID***. You can follow [these instructions](https://developers.google.com/fit/android/get-api-key) to obtain those credentials. Then, store those two values in the ***[.env](https://facebook.github.io/create-react-app/docs/adding-custom-environment-variables)*** file located in the root folder by replacing `` and `` respectively. 26 | 27 | 28 | 29 | 30 | 31 | Features: 32 | 33 | - Responsive Viewport (with Bootstrap and CSS3 flexbox styling) 34 | 35 | - Read, Send, Reply, Move to Trash. 36 | 37 | 38 | 39 | TODO Features: 40 | 41 | - [ ] Caching / memoizing fetched data (important due to [Gmail API Usage Limits](https://developers.google.com/gmail/api/v1/reference/quota)) 42 | 43 | - [ ] Add support for push notifications 44 | 45 | - [ ] Improve responsive layout for mobile devices 46 | 47 | - [ ] TDD tests 48 | 49 | - [ ] Display message label markers 50 | 51 | - [ ] Add message forwarding functionality 52 | 53 | - [x] Add message search functionality 54 | 55 | - [ ] Add hover action buttons for each message in list view 56 | 57 | - [ ] Add support for sending message attachments 58 | 59 | - [ ] Add support for label create/edit 60 | 61 | - [ ] Add support for changing message labels 62 | 63 | - [ ] Add advanced WYSIWYG text editor 64 | 65 | - [ ] Move / Drag & Drop messages into folders/labels 66 | 67 | - [ ] Add support for theming 68 | 69 | - [ ] Add support for localization 70 | 71 | 72 | --- 73 | LICENSE: [MIT License](https://opensource.org/licenses/MIT) 74 | -------------------------------------------------------------------------------- /config.jsx: -------------------------------------------------------------------------------- 1 | export const config = { 2 | CLIENT_ID: '', // Must be taken from Google API Console (https://console.developers.google.com/) 3 | API_KEY: '' // https://developers.google.com/maps/documentation/javascript/get-api-key 4 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-gmail-client", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@fortawesome/fontawesome-svg-core": "^1.2.25", 7 | "@fortawesome/free-solid-svg-icons": "^5.11.2", 8 | "@fortawesome/react-fontawesome": "^0.1.5", 9 | "bootstrap": "^4.3.1", 10 | "gapi-script": "^1.2.0", 11 | "moment": "^2.24.0", 12 | "react": "^16.10.2", 13 | "react-dom": "^16.10.2", 14 | "react-google-button": "^0.5.3", 15 | "react-perfect-scrollbar": "^1.5.3", 16 | "react-quill": "^1.3.3", 17 | "react-redux": "^5.1.2", 18 | "react-router-dom": "^4.3.1", 19 | "react-scripts": "3.2.0", 20 | "reactstrap": "^6.5.0", 21 | "redux": "^4.0.4", 22 | "redux-thunk": "^2.3.0", 23 | "sass": "^1.60.0" 24 | }, 25 | "scripts": { 26 | "start": "react-scripts start", 27 | "build": "react-scripts build", 28 | "test": "react-scripts test", 29 | "eject": "react-scripts eject" 30 | }, 31 | "eslintConfig": { 32 | "extends": "react-app" 33 | }, 34 | "browserslist": [ 35 | ">0.2%", 36 | "not dead", 37 | "not ie <= 11", 38 | "not op_mini all" 39 | ] 40 | } 41 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 12 | 13 | React Gmail Client 14 | 15 | 16 | 17 | 18 | 19 | 21 | 23 | 25 | 26 |
27 | 28 | 29 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | /* some basic styles. nothing to do with flexbox */ 2 | header, footer, 3 | nav, article, aside { 4 | border: 0px solid silver; 5 | padding: 0; 6 | margin: 0px; 7 | 8 | } 9 | 10 | 11 | html, body, .viewport { 12 | width: 100%; 13 | height: 100%; 14 | margin: 0; 15 | 16 | font-size: 12px; 17 | } 18 | 19 | div#root { 20 | 21 | 22 | display: -webkit-flex; 23 | display: -moz-flex; 24 | display: -ms-flex; 25 | display: flex; 26 | 27 | -webkit-flex-direction: column; 28 | -moz-flex-direction: column; 29 | -ms-flex-direction: column; 30 | flex-direction: column; 31 | 32 | width: 100%; 33 | height: 100%; 34 | margin: 0; 35 | 36 | } 37 | 38 | 39 | .vbox { 40 | 41 | display: -webkit-flex; 42 | display: -moz-flex; 43 | display: -ms-flex; 44 | display: flex; 45 | 46 | -webkit-flex-direction: column; 47 | -moz-flex-direction: column; 48 | -ms-flex-direction: column; 49 | flex-direction: column; 50 | } 51 | 52 | /* items flex/expand horizontally */ 53 | .hbox { 54 | display: -webkit-flex; 55 | display: -moz-flex; 56 | display: -ms-flex; 57 | display: flex; 58 | 59 | -webkit-flex-direction: row; 60 | -moz-flex-direction: row; 61 | -ms-flex-direction: row; 62 | flex-direction: row; 63 | } 64 | 65 | .space-between { 66 | -webkit-justify-content: space-between; 67 | -moz-justify-content: space-between; 68 | -ms-justify-content: space-between; 69 | justify-content: space-between; 70 | } 71 | 72 | header, footer { 73 | height: 50px; 74 | } 75 | 76 | .main { 77 | -webkit-flex: 1; 78 | -moz-flex: 1; 79 | -ms-flex: 1; 80 | flex: 1; 81 | overflow: auto; 82 | } 83 | 84 | article { 85 | -webkit-flex: 5; 86 | -moz-flex: 5; 87 | -ms-flex: 5; 88 | flex: 5; 89 | } 90 | 91 | aside, nav { 92 | -webkit-flex: 1; 93 | -moz-flex: 1; 94 | -ms-flex: 1; 95 | flex: 1; 96 | } 97 | -------------------------------------------------------------------------------- /src/App.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import { BrowserRouter as Router } from "react-router-dom"; 3 | import AppContainer from "./AppContainer"; 4 | 5 | import "./App.css"; 6 | 7 | class App extends Component { 8 | 9 | render() { 10 | return ( 11 | 12 | 13 | 14 | ); 15 | } 16 | } 17 | 18 | export default App; 19 | -------------------------------------------------------------------------------- /src/AppContainer.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import { Redirect, withRouter } from "react-router-dom"; 3 | import Main from "./components/main/Main"; 4 | import Login from "./components/login/Login"; 5 | import Authenticating from "./components/authenticating/Authenticating"; 6 | import 'react-perfect-scrollbar/dist/css/styles.css'; 7 | 8 | import { signOut, signIn, checkSignInStatus } from "./api/authentication"; 9 | import { mountScripts } from "./api/scripts"; 10 | 11 | import { 12 | SIGNED_OUT, 13 | SIGNED_IN, 14 | AUTH_SUCCESS, 15 | AUTH_FAIL, 16 | AUTH_IN_PROGRESS 17 | } from "./constants"; 18 | 19 | export class AppContainer extends Component { 20 | 21 | constructor(props) { 22 | super(props); 23 | 24 | this.state = { 25 | signInStatus: SIGNED_OUT, 26 | googleUser: undefined 27 | } 28 | 29 | this.init = this.init.bind(this); 30 | this.initClient = this.initClient.bind(this); 31 | this.onSignout = this.onSignout.bind(this); 32 | this.onSignInSuccess = this.onSignInSuccess.bind(this); 33 | this.onSignIn = this.onSignIn.bind(this); 34 | } 35 | 36 | componentDidMount() { 37 | mountScripts().then(this.init); 38 | } 39 | 40 | init() { 41 | window.gapi.load("client:auth2", this.initClient); 42 | } 43 | 44 | initClient() { 45 | checkSignInStatus() 46 | .then(this.onSignInSuccess) 47 | .catch(_ => { 48 | this.setState({ 49 | signInStatus: AUTH_FAIL 50 | }) 51 | }); 52 | } 53 | 54 | onSignout() { 55 | this.props.signOut(); 56 | } 57 | 58 | onSignIn() { 59 | signIn().then(); 60 | } 61 | 62 | onSignInSuccess(googleUser) { 63 | this.setState({ 64 | signInStatus: AUTH_SUCCESS, 65 | googleUser 66 | }); 67 | } 68 | 69 | renderView() { 70 | const { signInStatus } = this.state; 71 | console.log('signInStatus>>>>', signInStatus, AUTH_SUCCESS===signInStatus); 72 | if (signInStatus === AUTH_SUCCESS) { 73 | return
; 74 | } else if (signInStatus === AUTH_IN_PROGRESS) { 75 | return ; 76 | } else { 77 | return ; 78 | } 79 | } 80 | 81 | render() { 82 | return ( 83 | 84 | {this.props.location.pathname === "/" ? ( 85 | 86 | ) : ( 87 | this.renderView() 88 | )} 89 | 90 | ); 91 | } 92 | } 93 | 94 | export default withRouter(AppContainer); 95 | -------------------------------------------------------------------------------- /src/api/authentication.jsx: -------------------------------------------------------------------------------- 1 | export const signIn = () => { 2 | return window.gapi.auth2 3 | .getAuthInstance() 4 | .signIn() 5 | } 6 | 7 | export const initGmailClient = (apiKey, clientId) => { 8 | const API_KEY = process.env.REACT_APP_API_KEY; 9 | const CLIENT_ID = process.env.REACT_APP_CLIENT_ID; 10 | 11 | // Array of API discovery doc URLs for APIs 12 | const DISCOVERY_DOCS = [ 13 | "https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest" 14 | ]; 15 | 16 | // Authorization scopes required by the API; multiple scopes can be 17 | // included, separated by spaces. 18 | // More info: https://developers.google.com/identity/protocols/googlescopes 19 | const SCOPES = "https://mail.google.com/"; // Scope for Read, send, delete, and manage your email 20 | 21 | const gapi = window.gapi; 22 | 23 | return gapi.client.init({ 24 | apiKey: API_KEY, 25 | clientId: CLIENT_ID, 26 | discoveryDocs: DISCOVERY_DOCS, 27 | scope: SCOPES 28 | }); 29 | }; 30 | 31 | export const checkSignInStatus = () => { 32 | console.log('checking...'); 33 | return new Promise((resolve, reject) => { 34 | initGmailClient().then(_ => { 35 | const gapi = window.gapi; 36 | const googleAuthInstance = gapi.auth2.getAuthInstance(); 37 | 38 | const isSignedIn = googleAuthInstance.isSignedIn.get(); 39 | 40 | if (isSignedIn) { 41 | // Listen for sign-in state changes. 42 | googleAuthInstance.isSignedIn.listen(isSignedIn => { 43 | updateSigninStatus(isSignedIn); 44 | }); 45 | 46 | resolve(googleAuthInstance.currentUser.le); 47 | 48 | } else { 49 | reject(); 50 | } 51 | }) 52 | .catch(error => { 53 | console.log('error'); 54 | reject(error); 55 | }); 56 | }) 57 | 58 | }; 59 | 60 | // Listener for sign-in state 61 | export const updateSigninStatus = (isSignedIn) => { 62 | if (!isSignedIn) { 63 | // TODO: react to logged out status 64 | } 65 | }; 66 | 67 | export const signOut = () => { 68 | return window.gapi.auth2 69 | .getAuthInstance() 70 | .signOut() 71 | }; -------------------------------------------------------------------------------- /src/api/index.jsx: -------------------------------------------------------------------------------- 1 | import { MAX_RESULTS } from "../constants"; 2 | import {getBody, isHTML} from './utils'; 3 | 4 | const getLabelDetailPromise = async (labelId) => { 5 | return await window.gapi.client.gmail.users.labels.get({ 6 | userId: "me", 7 | id: labelId 8 | }); 9 | }; 10 | 11 | const getLabelDetails = async (labelList) => { 12 | const labelPromises = labelList.result.labels.map(async (el) => { 13 | return await getLabelDetailPromise(el.id); 14 | }); 15 | 16 | return Promise.all(labelPromises); 17 | }; 18 | 19 | export const getLabelList = async () => { 20 | console.log('gettting lables'); 21 | const labelIds = await window.gapi.client.gmail.users.labels.list({userId: "me"}); 22 | const labelDetails = await getLabelDetails(labelIds); 23 | console.log('get label', labelIds,labelDetails ); 24 | return labelDetails.map(el => el.result); 25 | } 26 | 27 | export const getMessageList = async ({ labelIds, maxResults, q, pageToken }) => { 28 | const rawList = await getMessageRawList({ labelIds, maxResults, pageToken, q }); 29 | const messageHeaders = await getMessageHeaders(rawList); 30 | const flattenedMessages = await flattenMessagesWithLabel(messageHeaders.messages, labelIds); 31 | return { 32 | ...messageHeaders, 33 | messages: flattenedMessages.messages, 34 | label: flattenedMessages.label 35 | }; 36 | } 37 | 38 | export const flattenMessagesWithLabel = async (messages, labelIds) => { 39 | if (!labelIds) { 40 | return { 41 | messages, 42 | label: { 43 | result: { 44 | messagesTotal: 0 45 | } 46 | } 47 | }; 48 | } 49 | 50 | const labels = await window.gapi.client.gmail.users.labels.get({userId: "me", id: labelIds[0]}); 51 | 52 | return { 53 | messages, 54 | label: labels 55 | }; 56 | } 57 | 58 | const getMessageRawList = async ({ labelIds, maxResults, pageToken, q = "" }) => { 59 | return await window.gapi.client.gmail.users.messages 60 | .list({ 61 | userId: "me", 62 | q, 63 | maxResults: maxResults || MAX_RESULTS, 64 | ...(labelIds && {labelIds}), 65 | ...(pageToken && { pageToken }) 66 | }); 67 | } 68 | 69 | const getMessageHeaders = async (messageRawList) => { 70 | const messageResult = messageRawList.result; 71 | 72 | const headerPromises = (messageResult.messages || []).map(async (el) => { 73 | return await getMessageHeader(el.id); 74 | }); 75 | 76 | const messages = await Promise.all(headerPromises); 77 | 78 | return { 79 | ...messageResult, 80 | messages 81 | }; 82 | }; 83 | 84 | const getMessageHeader = async (id) => { 85 | const messages = await window.gapi.client.gmail.users.messages 86 | .get({ 87 | userId: "me", 88 | id: id, 89 | format: "metadata", 90 | metadataHeaders: [ 91 | "Delivered-To", 92 | "X-Received", 93 | "To", 94 | "Message-ID", 95 | "Date", 96 | "Content-Type", 97 | "MIME-Version", 98 | "Reply-To", 99 | "From", 100 | "Subject", 101 | "Return-Path", 102 | // See https://www.iana.org/assignments/message-headers/message-headers.xhtml 103 | // for more headers 104 | ] 105 | }); 106 | return messages.result; 107 | }; 108 | 109 | export const getMessage = async(messageId) => { 110 | const response = await window.gapi.client.gmail.users.messages 111 | .get({ 112 | userId: "me", 113 | id: messageId, 114 | format: "full" 115 | }); 116 | 117 | const { result } = response; 118 | 119 | let body = getBody(result.payload, "text/html"); 120 | 121 | if (body === "") { 122 | body = getBody(result.payload, "text/plain"); 123 | body = body.replace(/(\r\n)+/g, '
').replace(/[\n\r]+/g, '
'); 124 | } 125 | 126 | if (body !== "" && !isHTML(body)) { 127 | body = body.replace(/(\r\n)+/g, '
').replace(/[\n\r]+/g, '
'); 128 | } 129 | 130 | return { 131 | body, 132 | headers: response.headers, 133 | result: { ...result, messageHeaders: response.result.payload.headers, payload: undefined } 134 | }; 135 | }; 136 | 137 | export const sendMessage = ({ headers, body }) => { 138 | let email = ""; 139 | 140 | const headersClone = { ...headers }; 141 | headersClone["Content-Type"] = "text/html; charset='UTF-8'"; 142 | headersClone["Content-Transfer-Encoding"] = "base64"; 143 | 144 | for (let header in headersClone) { 145 | email += `${header}: ${headersClone[header]}\r\n`; 146 | } 147 | 148 | email += `\r\n${body}`; 149 | const encodedEmail = unescape(encodeURIComponent(email)); 150 | 151 | return window.gapi.client.gmail.users.messages.send({ 152 | userId: "me", 153 | resource: { 154 | raw: window.btoa(encodedEmail).replace(/\+/g, "-").replace(/\//g, "_") 155 | } 156 | }); 157 | }; 158 | 159 | export const batchModify = async ({ids, addLabelIds = [], removeLabelIds = []}) => { 160 | const batchModifyResult = await window.gapi.client.gmail.users.messages 161 | .batchModify({ 162 | userId: "me", 163 | ids, 164 | addLabelIds, 165 | removeLabelIds 166 | }); 167 | 168 | return ids; 169 | } 170 | -------------------------------------------------------------------------------- /src/api/scripts.jsx: -------------------------------------------------------------------------------- 1 | import { loadGapiInsideDOM } from 'gapi-script'; 2 | export const mountScripts = () => { 3 | return new Promise(async(resolve, reject) => { 4 | const script = document.createElement("script"); 5 | const gapi = await loadGapiInsideDOM(); 6 | resolve(); 7 | // script.src = "https://apis.google.com/js/api.js"; 8 | // script.async = true; 9 | // script.defer = true; 10 | // script.onload = () => { 11 | // script.onload = () => { }; 12 | // resolve(); 13 | // }; 14 | // script.onreadystatechange = () => { 15 | // if (script.readyState === "complete") script.onload(); 16 | // }; 17 | // document.body.appendChild(script); 18 | }); 19 | }; 20 | 21 | -------------------------------------------------------------------------------- /src/api/utils.jsx: -------------------------------------------------------------------------------- 1 | 2 | 3 | export const getBody = (message, mimeType) => { 4 | let encodedBody = ""; 5 | if (typeof message.parts === "undefined") { 6 | encodedBody = message.body.data; 7 | } else { 8 | encodedBody = getHTMLPart(message.parts, mimeType); 9 | } 10 | encodedBody = encodedBody 11 | .replace(/-/g, "+") 12 | .replace(/_/g, "/") 13 | .replace(/\s/g, ""); 14 | return decodeURIComponent(escape(window.atob(encodedBody))); 15 | }; 16 | 17 | const getHTMLPart = (arr, mimeType) => { 18 | for (let x = 0; x < arr.length; x++) { 19 | if (typeof arr[x].parts === "undefined") { 20 | if (arr[x].mimeType === mimeType) { 21 | return arr[x].body.data; 22 | } 23 | } else { 24 | return getHTMLPart(arr[x].parts, mimeType); 25 | } 26 | } 27 | return ""; 28 | }; 29 | 30 | export const isHTML = str => { 31 | const doc = new DOMParser().parseFromString(str, "text/html"); 32 | return Array.from(doc.body.childNodes).some(node => node.nodeType === 1); 33 | } -------------------------------------------------------------------------------- /src/components/authenticating/Authenticating.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import GoogleButton from "react-google-button"; 3 | 4 | export class Authenticating extends Component { 5 | 6 | render() { 7 | return ( 8 |
9 |
10 | 14 |
15 |
16 | ); 17 | } 18 | } 19 | 20 | export default Authenticating; 21 | -------------------------------------------------------------------------------- /src/components/common/Checkbox.jsx: -------------------------------------------------------------------------------- 1 | import React, {PureComponent} from 'react'; 2 | import './checkbox.scss'; 3 | 4 | export class Checkbox extends PureComponent { 5 | 6 | constructor(props) { 7 | super(props); 8 | 9 | this.state = { 10 | isChecked: !!props.checked 11 | } 12 | 13 | this.onChange = this.onChange.bind(this); 14 | } 15 | 16 | componentDidUpdate(prevProps) { 17 | if (prevProps.checked !== this.props.checked) { 18 | this.setState({ 19 | isChecked: this.props.checked 20 | }) 21 | } 22 | } 23 | 24 | onChange(evt) { 25 | this.setState({ 26 | isChecked: !this.state.isChecked 27 | }) 28 | this.props.onChange(evt); 29 | } 30 | 31 | render() { 32 | return ( 33 | 48 | ) 49 | } 50 | } 51 | 52 | export default Checkbox; -------------------------------------------------------------------------------- /src/components/common/checkbox.scss: -------------------------------------------------------------------------------- 1 | $background_color_1: var(--color); 2 | $background_color_2: #fff; 3 | $border_color_1: var(--color); 4 | 5 | .custom-control.material-checkbox { 6 | --color: #26a69a; 7 | .material-control-input { 8 | display: none; 9 | &:checked { 10 | &~.material-control-indicator { 11 | border-color: $border_color_1; 12 | -webkit-transform: rotateZ(45deg) translate(1px, -5px); 13 | transform: rotateZ(45deg) translate(1px, -5px); 14 | width: 10px; 15 | border-top: 0px solid #fff; 16 | border-left: 0px solid #fff; 17 | } 18 | } 19 | } 20 | .material-control-indicator { 21 | display: inline-block; 22 | position: absolute; 23 | top: 4px; 24 | left: 0; 25 | width: 16px; 26 | height: 16px; 27 | border: 2px solid #aaa; 28 | transition: .1s; 29 | } 30 | } 31 | .custom-control.fill-checkbox { 32 | --color: #f8f9fa; 33 | margin: 0 0 0 0; 34 | display: inline-block; 35 | .fill-control-input { 36 | display: none; 37 | &:checked { 38 | &~.fill-control-indicator { 39 | background-color: $background_color_1; 40 | border-color: #aaa; 41 | background-size: 80%; 42 | } 43 | } 44 | } 45 | .fill-control-indicator { 46 | border-radius: 3px; 47 | display: inline-block; 48 | position: absolute; 49 | top: 2px; 50 | left: 0; 51 | width: 16px; 52 | height: 16px; 53 | border: 1px solid #aaa; 54 | transition: .1s; 55 | background: transparent; 56 | background-size: 0%; 57 | background-position: center; 58 | background-repeat: no-repeat; 59 | background-image: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3E%3Cpath fill='%23aaa' d='M6.564.75l-3.59 3.612-1.538-1.55L0 4.26 2.974 7.25 8 2.193z'/%3E%3C/svg%3E"); 60 | } 61 | } 62 | .custom-control.overflow-checkbox { 63 | .overflow-control-input { 64 | display: none; 65 | &:checked { 66 | &~.overflow-control-indicator { 67 | &::after { 68 | -webkit-transform: rotateZ(45deg) scale(1); 69 | transform: rotateZ(45deg) scale(1); 70 | top: -6px; 71 | left: 5px; 72 | } 73 | &::before { 74 | opacity: 1; 75 | } 76 | } 77 | } 78 | } 79 | .overflow-control-indicator { 80 | border-radius: 3px; 81 | display: inline-block; 82 | position: absolute; 83 | top: 4px; 84 | left: 0; 85 | width: 16px; 86 | height: 16px; 87 | border: 2px solid #aaa; 88 | &::after { 89 | content: ''; 90 | display: block; 91 | position: absolute; 92 | width: 16px; 93 | height: 16px; 94 | transition: .3s; 95 | -webkit-transform: rotateZ(90deg) scale(0); 96 | transform: rotateZ(90deg) scale(0); 97 | width: 10px; 98 | border-bottom: 4px solid #aaa; 99 | border-right: 4px solid #aaa; 100 | border-radius: 3px; 101 | top: -2px; 102 | left: 2px; 103 | } 104 | &::before { 105 | content: ''; 106 | display: block; 107 | position: absolute; 108 | width: 16px; 109 | height: 16px; 110 | transition: .3s; 111 | width: 10px; 112 | border-right: 7px solid #fff; 113 | border-radius: 3px; 114 | -webkit-transform: rotateZ(45deg) scale(1); 115 | transform: rotateZ(45deg) scale(1); 116 | top: -4px; 117 | left: 5px; 118 | opacity: 0; 119 | } 120 | } 121 | } 122 | 123 | -------------------------------------------------------------------------------- /src/components/compose-message/ComposeMessage.jsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from "react"; 2 | import { sendMessage } from "../../api"; 3 | import { getValidEmails } from "../../utils"; 4 | 5 | import { 6 | Button, 7 | Modal, 8 | ModalHeader, 9 | ModalBody, 10 | ModalFooter, 11 | InputGroup, 12 | InputGroupAddon, 13 | Input 14 | } from "reactstrap"; 15 | 16 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 17 | import { faTrash } from "@fortawesome/free-solid-svg-icons"; 18 | 19 | import ReactQuill from "react-quill"; 20 | import "../../../node_modules/react-quill/dist/quill.snow.css"; 21 | import "./composeMessage.scss"; 22 | 23 | export class Compose extends PureComponent { 24 | constructor(props) { 25 | super(props); 26 | console.log(props); 27 | this.state = { 28 | displayModal: false, 29 | to: props.to || "", 30 | cc: props.cc || "", 31 | bcc: props.bcc || "", 32 | subject: props.subject || "", 33 | content: props.content || "" 34 | }; 35 | 36 | this.showModal = this.showModal.bind(this); 37 | this.closeModal = this.closeModal.bind(this); 38 | this.handleChange = this.handleChange.bind(this); 39 | this.sendEmail = this.sendEmail.bind(this); 40 | this.setField = this.setField.bind(this); 41 | } 42 | 43 | showModal() { 44 | this.setState({ 45 | displayModal: true 46 | }); 47 | } 48 | 49 | closeModal() { 50 | this.setState({ 51 | displayModal: false 52 | }); 53 | } 54 | 55 | handleChange(value) { 56 | this.setState({ content: value }); 57 | } 58 | 59 | sendEmail() { 60 | const validTo = getValidEmails(this.state.to); 61 | 62 | if ( 63 | !validTo.length || 64 | this.state.subject.trim() === "" || 65 | this.state.content === "" 66 | ) { 67 | return; 68 | } 69 | 70 | const headers = { 71 | To: validTo.join(", "), 72 | Subject: this.state.subject 73 | }; 74 | 75 | const validCc = getValidEmails(this.state.cc); 76 | if (validCc.length) { 77 | headers.Cc = validCc.join(", "); 78 | } 79 | 80 | const validBcc = getValidEmails(this.state.bcc); 81 | if (validBcc.length) { 82 | headers.Bcc = validBcc.join(", "); 83 | } 84 | 85 | sendMessage({ 86 | headers, 87 | body: this.state.content 88 | }).then(_ => { 89 | this.closeModal(); 90 | this.resetFields(); 91 | }); 92 | 93 | this.closeModal(); 94 | } 95 | 96 | resetFields() { 97 | this.setState({ 98 | to: this.props.to || "", 99 | cc: this.props.cc || "", 100 | bcc: this.props.bcc || "", 101 | subject: this.props.subject || "", 102 | content: this.props.content || "" 103 | }); 104 | } 105 | 106 | setField(field, trimValue = true) { 107 | return evt => { 108 | this.setState({ 109 | [field]: trimValue ? evt.target.value.trim() : evt.target.value 110 | }); 111 | }; 112 | } 113 | 114 | isInvalid(field) { 115 | const fieldValue = this.state[field].trim(); 116 | return fieldValue.length > 0 && !getValidEmails(fieldValue).length; 117 | } 118 | 119 | 120 | render() { 121 | return ( 122 | 123 | { 124 | React.cloneElement(this.props.children, { 125 | onClick: this.showModal 126 | }) 127 | } 128 | {this.state.displayModal ? ( 129 | 137 | Compose Message 138 | 139 |
140 | 141 | To: 142 | 149 | 150 | 151 | Cc: 152 | 159 | 160 | 161 | Bcc: 162 | 168 | 169 | 170 | 171 | Subject: 172 | 173 | 179 | 180 |
181 |
182 | 187 |
188 |
189 | 190 | {" "} 199 | 202 | 203 |
204 | ) : null} 205 |
206 | ); 207 | } 208 | } 209 | 210 | export default Compose; 211 | -------------------------------------------------------------------------------- /src/components/compose-message/composeMessage.scss: -------------------------------------------------------------------------------- 1 | .compose-panel { 2 | 3 | border-bottom: 1px solid #d9dfe2; 4 | .compose-btn { 5 | height: 50px; 6 | } 7 | } 8 | 9 | .compose-dialog { 10 | height: 100%; 11 | > .modal-content { 12 | height: 80%; 13 | .editor-wrapper { 14 | position: absolute; 15 | left: 0; 16 | right: 0; 17 | top: 142px; 18 | bottom: 42px; 19 | .quill { 20 | height: 100%; 21 | .ql-container.ql-snow { 22 | border: 0; 23 | } 24 | } 25 | 26 | } 27 | } 28 | 29 | .message-fields { 30 | .input-group { 31 | 32 | 33 | margin-bottom: 2px; 34 | 35 | .input-group-text { 36 | min-width: 75px; 37 | background-color: #f9f9f9; 38 | } 39 | 40 | .form-control { 41 | border-left-width: 0; 42 | &.is-invalid { 43 | border-color: red; 44 | } 45 | &:placeholder-shown { 46 | font-style: italic; 47 | font-size: 11px; 48 | } 49 | &:focus { 50 | outline: none; 51 | box-shadow: none; 52 | } 53 | } 54 | 55 | } 56 | 57 | } 58 | } 59 | 60 | -------------------------------------------------------------------------------- /src/components/content/message-list/MessageList.jsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from "react"; 2 | import PerfectScrollbar from "react-perfect-scrollbar"; 3 | import MessageRow from "./message-row/MessageRow"; 4 | 5 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 6 | import { faSpinner } from "@fortawesome/free-solid-svg-icons"; 7 | 8 | import ListToolbar from "./list-toolbar/ListToolbar"; 9 | import ListFooter from "./list-footer/ListFooter"; 10 | 11 | import "./messageList.scss"; 12 | 13 | const ViewMode = { 14 | LIST: 1, 15 | CONTENT: 2, 16 | EDIT: 3 17 | }; 18 | 19 | export class MessageList extends PureComponent { 20 | constructor(props) { 21 | super(props); 22 | 23 | this.state = { 24 | viewMode: ViewMode.LIST, 25 | contentMessageId: undefined, 26 | currentLabel: "" 27 | }; 28 | 29 | this.onSelectionChange = this.onSelectionChange.bind(this); 30 | this.renderView = this.renderView.bind(this); 31 | this.renderMessages = this.renderMessages.bind(this); 32 | } 33 | 34 | componentDidMount() { 35 | const searchParam = this.props.location.search; 36 | const token = searchParam.indexOf("?") === 0 ? searchParam.slice(1) : null; 37 | 38 | if (token && this.props.messagesResult.pageTokens.length === 0) { 39 | this.props.addInitialPageToken(token); 40 | } 41 | 42 | const labelIds = this.props.searchQuery === "" ? [this.props.parentLabel.id] : undefined; 43 | console.log('-------------------------'); 44 | 45 | console.log(searchParam); 46 | console.log(labelIds); 47 | console.log(token); 48 | console.log('---------------------------'); 49 | this.props.getLabelMessages({ 50 | ...labelIds && {labelIds}, 51 | pageToken: token 52 | }); 53 | } 54 | 55 | componentDidUpdate(prevProps, prevState) { 56 | if (prevProps.location.search !== this.props.location.search) { 57 | const searchParam = this.props.location.search; 58 | const token = searchParam.indexOf("?") === 0 ? searchParam.slice(1) : null; 59 | 60 | const labelIds = this.props.searchQuery === "" ? [this.props.parentLabel.id] : undefined; 61 | 62 | this.props.getLabelMessages({ 63 | ...labelIds && {labelIds}, 64 | pageToken: token 65 | }); 66 | } 67 | } 68 | 69 | 70 | onSelectionChange(selected, msgId) { 71 | this.props.toggleSelected([msgId], selected); 72 | } 73 | 74 | 75 | renderSpinner() { 76 | return ( 77 |
78 | 79 |
80 | ); 81 | } 82 | 83 | renderMessages() { 84 | if (this.props.messagesResult.loading) { 85 | return this.renderSpinner(); 86 | } else if (this.props.messagesResult.messages.length === 0) { 87 | return ( 88 |
89 | There are no messages with this label. 90 |
91 | ); 92 | } 93 | 94 | return this.props.messagesResult.messages.map(el => { 95 | return ( 96 | 102 | ); 103 | }); 104 | } 105 | 106 | 107 | renderView() { 108 | const { viewMode } = this.state; 109 | 110 | switch (viewMode) { 111 | 112 | case ViewMode.EDIT: 113 | return this.renderEditView(); 114 | 115 | default: 116 | return this.renderMessages(); 117 | } 118 | } 119 | 120 | getPageTokens() { 121 | if (this.props.messagesResult.loading) { 122 | return { nextToken: null, prevToken: null } 123 | } 124 | const { messagesResult, location } = this.props; 125 | const pathname = location.pathname; 126 | let prevToken; 127 | let nextToken = messagesResult.nextPageToken; 128 | const searchParam = location.search; 129 | const currentToken = searchParam.indexOf("?") === 0 ? searchParam.slice(1) : null; 130 | if (currentToken) { 131 | const tokenIndex = messagesResult.pageTokens.indexOf(currentToken); 132 | if (tokenIndex > -1) { 133 | nextToken = messagesResult.pageTokens[tokenIndex + 1]; 134 | prevToken = messagesResult.pageTokens[tokenIndex - 1]; 135 | if (!prevToken) { 136 | if (tokenIndex > 0) { 137 | } 138 | } 139 | prevToken = prevToken ? `${pathname}?${prevToken}` : pathname; 140 | } 141 | else { 142 | prevToken = pathname; 143 | } 144 | } 145 | nextToken = nextToken ? `${pathname}?${nextToken}` : null; 146 | return { nextToken, prevToken }; 147 | } 148 | 149 | render() { 150 | const { messagesResult } = this.props; 151 | const messagesTotal = messagesResult.label ? messagesResult.label.result.messagesTotal : 0; 152 | const { nextToken, prevToken } = this.getPageTokens(); 153 | return ( 154 | 155 | 161 | 162 | {this.renderView()} 163 | 164 | 165 | 166 | ); 167 | } 168 | } 169 | 170 | export default MessageList; 171 | -------------------------------------------------------------------------------- /src/components/content/message-list/actions/message-list.actions.jsx: -------------------------------------------------------------------------------- 1 | import { getMessageList } from "../../../../api"; 2 | import { getMessage } from "../../../../api"; 3 | import { batchModify } from "../../../../api"; 4 | import { selectLabel } from "../../../sidebar/sidebar.actions"; 5 | 6 | export const GET_MESSAGES = "GET_MESSAGES"; 7 | export const GET_MESSAGES_LOAD_IN_PROGRESS = "GET_MESSAGES_LOAD_IN_PROGRESS"; 8 | export const GET_MESSAGES_FAILED = 'GET_MESSAGES_FAILED'; 9 | export const TOGGLE_SELECTED = "TOGGLE_SELECTED"; 10 | export const MESSAGE_LOAD_IN_PROGRESS = "MESSAGE_LOAD_IN_PROGRESS"; 11 | export const MESSAGE_LOAD_SUCCESS = "MESSAGE_LOAD_SUCCESS"; 12 | export const MESSAGE_LOAD_FAIL = "MESSAGE_LOAD_FAIL"; 13 | export const EMPTY_MESSAGES = "EMPTY_MESSAGES"; 14 | export const SET_PAGE_TOKENS = "SET_PAGE_TOKENS"; 15 | export const ADD_INITIAL_PAGE_TOKEN = "ADD_INITIAL_PAGE_TOKEN"; 16 | export const CLEAR_PAGE_TOKENS = "CLEAR_PAGE_TOKENS"; 17 | export const MODIFY_MESSAGES_SUCCESS = "MODIFY_MESSAGES_SUCCESS"; 18 | export const MODIFY_MESSAGES_FAILED = "MODIFY_MESSAGES_FAILED"; 19 | export const SET_SEARCH_QUERY = "SET_SEARCH_QUERY"; 20 | 21 | export const getLabelMessages = ({ 22 | labelIds, 23 | q = "", 24 | pageToken 25 | }) => (dispatch, getState) => { 26 | dispatch(setMessageListLoadInProgress()); 27 | 28 | const state = getState(); 29 | const {searchQuery} = state; 30 | 31 | if (searchQuery !== "") { 32 | dispatch(selectLabel("-1")); 33 | } 34 | 35 | getMessageList({ labelIds, maxResults: 20, q: searchQuery, pageToken }).then(response => { 36 | dispatch({ 37 | type: GET_MESSAGES, 38 | payload: response 39 | }); 40 | 41 | dispatch(setPageTokens({ 42 | nextPageToken: response.nextPageToken || "" 43 | })); 44 | 45 | }).catch(err => { 46 | dispatch({ 47 | type: GET_MESSAGES_FAILED, 48 | payload: err 49 | }) 50 | }); 51 | }; 52 | 53 | export const setSearchQuery = q => ({ 54 | type: SET_SEARCH_QUERY, 55 | payload: q 56 | }) 57 | 58 | export const setPageTokens = tokens => ({ 59 | type: SET_PAGE_TOKENS, 60 | payload: tokens 61 | }); 62 | 63 | export const emptyLabelMessages = () => ({ 64 | type: EMPTY_MESSAGES 65 | }); 66 | 67 | export const toggleSelected = (messageIds, selected) => ({ 68 | type: TOGGLE_SELECTED, 69 | payload: { 70 | messageIds, 71 | selected 72 | } 73 | }); 74 | 75 | export const getEmailMessage = messageId => dispatch => { 76 | dispatch(setMessageLoadInProgress()); 77 | getMessage(messageId) 78 | .then(response => { 79 | dispatch({ 80 | type: MESSAGE_LOAD_SUCCESS, 81 | payload: response 82 | }); 83 | }) 84 | .catch(error => { 85 | dispatch({ 86 | type: MESSAGE_LOAD_FAIL, 87 | payload: error 88 | }); 89 | }); 90 | }; 91 | 92 | const setMessageLoadInProgress = () => ({ 93 | type: MESSAGE_LOAD_IN_PROGRESS 94 | }); 95 | 96 | const setMessageListLoadInProgress = () => ({ 97 | type: GET_MESSAGES_LOAD_IN_PROGRESS 98 | }); 99 | 100 | export const addInitialPageToken = token => ({ 101 | type: ADD_INITIAL_PAGE_TOKEN, 102 | payload: token 103 | }) 104 | 105 | export const clearPageTokens = () => ({ 106 | type: CLEAR_PAGE_TOKENS 107 | }) 108 | 109 | export const modifyMessages = ({ids, addLabelIds = [], removeLabelIds = []}) => dispatch => { 110 | batchModify({ids, addLabelIds, removeLabelIds}) 111 | .then(modifiedIds => { 112 | dispatch({ 113 | type: MODIFY_MESSAGES_SUCCESS, 114 | payload: {modifiedIds, addLabelIds, removeLabelIds} 115 | }) 116 | }) 117 | .catch(error => { 118 | dispatch({ 119 | type: MODIFY_MESSAGES_FAILED 120 | }) 121 | }) 122 | } 123 | 124 | -------------------------------------------------------------------------------- /src/components/content/message-list/list-footer/ListFooter.jsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from "react"; 2 | 3 | export class Footer extends PureComponent { 4 | render() { 5 | const { messagesTotal } = this.props; 6 | let totalLabel = ''; 7 | if (messagesTotal > 0) { 8 | totalLabel = `${messagesTotal.toLocaleString()} messages`; 9 | } 10 | 11 | return ( 12 |
13 |
14 |
{totalLabel}
15 | 16 |
17 |
18 |
19 | ); 20 | } 21 | } 22 | 23 | export default Footer; 24 | -------------------------------------------------------------------------------- /src/components/content/message-list/list-toolbar/ListActionButtons.jsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from "react"; 2 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 3 | import { faTrash } from "@fortawesome/free-solid-svg-icons"; 4 | import './listToolbar.scss'; 5 | 6 | export class ListActionButtons extends PureComponent { 7 | constructor(props) { 8 | super(props); 9 | this.getClickHandler = this.getClickHandler.bind(this); 10 | this.trashHandler = this.getClickHandler(["TRASH"]); 11 | } 12 | 13 | getClickHandler(action) { 14 | return evt => { 15 | this.props.onClick(action); 16 | }; 17 | } 18 | 19 | render() { 20 | return ( 21 |
22 |
23 | 29 |
30 |
31 | ); 32 | } 33 | } 34 | 35 | export default ListActionButtons; 36 | -------------------------------------------------------------------------------- /src/components/content/message-list/list-toolbar/ListToolbar.jsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from "react"; 2 | import Checkbox from "../../../common/Checkbox"; 3 | import { connect } from "react-redux"; 4 | import { bindActionCreators } from "redux"; 5 | import { 6 | toggleSelected, 7 | modifyMessages 8 | } from "../actions/message-list.actions"; 9 | import Pager from "../pager-buttons/PagerButtons"; 10 | import ListActionButtons from "./ListActionButtons"; 11 | 12 | export class MessageToolbar extends PureComponent { 13 | constructor(props) { 14 | super(props); 15 | 16 | this.onSelectionChange = this.onSelectionChange.bind(this); 17 | this.navigateToNextPage = this.navigateToNextPage.bind(this); 18 | this.navigateToPrevPage = this.navigateToPrevPage.bind(this); 19 | this.modifyMessages = this.modifyMessages.bind(this); 20 | 21 | this.state = { 22 | selectedMessageIds: [] 23 | }; 24 | } 25 | 26 | onSelectionChange(evt) { 27 | const checked = evt.target.checked; 28 | 29 | const messageIds = this.props.messagesResult.messages.reduce((acc, el) => { 30 | acc.push(el.id); 31 | return acc; 32 | }, []); 33 | 34 | this.setState({ 35 | selectedMessageIds: messageIds 36 | }); 37 | 38 | this.props.toggleSelected(messageIds, checked); 39 | } 40 | 41 | navigateToNextPage() { 42 | this.props.navigateToNextPage(this.props.nextToken); 43 | } 44 | 45 | navigateToPrevPage() { 46 | this.props.navigateToPrevPage(this.props.prevToken); 47 | } 48 | 49 | modifyMessages(addLabelIds, removeLabelIds) { 50 | const ids = this.props.messagesResult.messages.filter(el => el.selected).map(el => el.id); 51 | const actionParams = { 52 | ...addLabelIds && {addLabelIds}, 53 | ...removeLabelIds && {removeLabelIds} 54 | }; 55 | this.props.modifyMessages({ ids, ...actionParams}); 56 | } 57 | 58 | render() { 59 | 60 | let checked = false; 61 | let selectedMessages = []; 62 | 63 | if (this.props.messagesResult) { 64 | selectedMessages = this.props.messagesResult.messages.filter(el => el.selected); 65 | checked = this.props.messagesResult.messages.length > 0 && selectedMessages.length === this.props.messagesResult.messages.length; 66 | } 67 | 68 | return ( 69 |
70 |
71 |
72 |
73 | 74 |
75 |
76 | 77 |
78 |
79 | {selectedMessages.length ? ( 80 | 81 | ) : null} 82 |
83 |
84 |
85 | 86 | 92 |
93 |
94 | ); 95 | } 96 | } 97 | 98 | const mapStateToProps = state => ({ 99 | messagesResult: state.messagesResult 100 | }); 101 | 102 | const mapDispatchToProps = dispatch => 103 | bindActionCreators( 104 | { 105 | toggleSelected, 106 | modifyMessages 107 | }, 108 | dispatch 109 | ); 110 | 111 | export default connect( 112 | mapStateToProps, 113 | mapDispatchToProps 114 | )(MessageToolbar); 115 | -------------------------------------------------------------------------------- /src/components/content/message-list/list-toolbar/listToolbar.scss: -------------------------------------------------------------------------------- 1 | .action-btn { 2 | cursor: pointer; 3 | display: inline-block; 4 | } -------------------------------------------------------------------------------- /src/components/content/message-list/message-content/MessageContent.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import { connect } from "react-redux"; 3 | import { Redirect, withRouter } from "react-router-dom"; 4 | import { bindActionCreators, compose } from "redux"; 5 | import { 6 | getEmailMessage, 7 | modifyMessages 8 | } from "../actions/message-list.actions"; 9 | 10 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 11 | import { faSpinner } from "@fortawesome/free-solid-svg-icons"; 12 | 13 | import { Button, Modal, ModalHeader, ModalBody, ModalFooter } from "reactstrap"; 14 | import MessageToolbar from "../message-toolbar/MessageToolbar"; 15 | 16 | import "./messageContent.scss"; 17 | 18 | export class MessageContent extends Component { 19 | constructor(props) { 20 | super(props); 21 | 22 | this.state = { 23 | errorMessage: undefined 24 | }; 25 | 26 | this.iframeRef = React.createRef(); 27 | this.modifyMessage = this.modifyMessage.bind(this); 28 | } 29 | 30 | componentDidMount(prevProps) { 31 | const messageId = this.props.match.params.id; 32 | this.props.getEmailMessage(messageId); 33 | } 34 | 35 | componentDidUpdate(prevProps) { 36 | const { emailMessageResult } = this.props; 37 | if (!emailMessageResult.loading) { 38 | if (!emailMessageResult.failed) { 39 | if (this.iframeRef.current) { 40 | const { body } = this.iframeRef.current.contentWindow.document; 41 | body.style.margin = "0px"; 42 | body.style.fontFamily = "Arial, Helvetica, sans-serif"; 43 | body.style.fontSize = "13px"; 44 | body.innerHTML = this.props.emailMessageResult.body; 45 | } 46 | } else { 47 | if (!this.state.errorMessage) { 48 | this.setState({ 49 | errorMessage: emailMessageResult.error.result.error.message, 50 | modal: true 51 | }); 52 | } 53 | } 54 | } 55 | } 56 | 57 | renderSpinner() { 58 | return ( 59 |
60 | 61 |
62 | ); 63 | } 64 | 65 | renderErrorModal() { 66 | return ; 67 | } 68 | 69 | modifyMessage(addLabelIds, removeLabelIds) { 70 | const id = this.props.emailMessageResult.result.id; 71 | const actionParams = { 72 | ...(addLabelIds && { addLabelIds }), 73 | ...(removeLabelIds && { removeLabelIds }) 74 | }; 75 | this.props.modifyMessages({ ids: [id], ...actionParams }); 76 | this.props.history.goBack(); 77 | } 78 | 79 | render() { 80 | if (this.props.emailMessageResult.loading) { 81 | return this.renderSpinner(); 82 | } 83 | 84 | return ( 85 | 86 | 90 |
91 | {this.props.emailMessageResult.loading ? this.renderSpinner() : null} 92 | {this.state.errorMessage ? ( 93 | this.renderErrorModal() 94 | ) : ( 95 |