├── OWNERS ├── test ├── style.mock.js ├── file.mock.js └── jest.setup.js ├── src ├── components │ ├── NotificationBar │ │ ├── NotificationBar.text.jsx │ │ ├── NotificationBar.css │ │ └── NotificationBar.jsx │ ├── ClientList │ │ ├── ClientList.css │ │ ├── ClientList.test.jsx │ │ ├── ClientList.jsx │ │ └── __snapshots__ │ │ │ └── ClientList.test.jsx.snap │ ├── ClientsPanel │ │ ├── ClientsPanel.css │ │ ├── __snapshots__ │ │ │ └── ClientsPanel.test.jsx.snap │ │ ├── ClientsPanel.test.jsx │ │ └── ClientsPanel.jsx │ ├── TitleBar │ │ ├── TitleBar.jsx │ │ ├── __snapshots__ │ │ │ └── TitleBar.test.jsx.snap │ │ ├── TitleBar.css │ │ └── TitleBar.test.jsx │ ├── MessageMenuBar │ │ ├── MessageMenuBar.css │ │ ├── MessageMenuBar.test.jsx │ │ ├── __snapshots__ │ │ │ └── MessageMenuBar.test.jsx.snap │ │ └── MessageMenuBar.jsx │ ├── SearchBar │ │ ├── SearchBar.css │ │ ├── __snapshots__ │ │ │ └── SearchBar.test.jsx.snap │ │ ├── SearchBar.test.jsx │ │ └── SearchBar.jsx │ ├── InputBar │ │ ├── InputBar.css │ │ ├── InputBar.test.jsx │ │ ├── __snapshots__ │ │ │ └── InputBar.test.jsx.snap │ │ └── InputBar.jsx │ ├── MessagePanel │ │ ├── MessagePanel.css │ │ ├── MessagePanel.test.jsx │ │ ├── __snapshots__ │ │ │ └── MessagePanel.test.jsx.snap │ │ └── MessagePanel.jsx │ ├── Skeleton │ │ └── Stateless │ ├── Message │ │ ├── __snapshots__ │ │ │ └── Message.test.jsx.snap │ │ ├── Message.test.jsx │ │ ├── Message.css │ │ └── Message.jsx │ ├── Application │ │ ├── Application.css │ │ ├── Application.test.jsx │ │ ├── __snapshots__ │ │ │ └── Application.test.jsx.snap │ │ └── Application.jsx │ ├── OperatorPanel │ │ ├── OperatorPanel.test.jsx │ │ ├── __snapshots__ │ │ │ └── OperatorPanel.test.jsx.snap │ │ ├── OperatorPanel.jsx │ │ └── OperatorPanel.css │ ├── OperatorSettingsMenu │ │ ├── OperatorSettingsMenu.test.jsx │ │ ├── OperatorSettingsStyles.css │ │ ├── __snapshots__ │ │ │ └── OperatorSettingsMenu.test.jsx.snap │ │ └── OperatorSettingsMenu.jsx │ ├── WelcomeScreen │ │ ├── WelcomeScreen.test.jsx │ │ ├── __snapshots__ │ │ │ └── WelcomeScreen.test.jsx.snap │ │ ├── WelcomeScreen.css │ │ └── WelcomeScreen.jsx │ ├── MessageList │ │ ├── MessageList.test.jsx │ │ ├── MessageList.css │ │ ├── __snapshots__ │ │ │ └── MessageList.test.jsx.snap │ │ └── MessageList.jsx │ ├── OperatorClientMenu │ │ ├── OperatorClientMenu.test.jsx │ │ ├── OperatorClientMenu.css │ │ ├── __snapshots__ │ │ │ └── OperatorClientMenu.test.jsx.snap │ │ └── OperatorClientMenu.jsx │ ├── SVG │ │ └── BrandLogo.jsx │ ├── Button │ │ ├── Button.jsx │ │ ├── Button.css │ │ ├── __snapshots__ │ │ │ └── Button.test.jsx.snap │ │ └── Button.test.jsx │ ├── SettingsPanel │ │ ├── SettingsPanel.css │ │ └── SettingsPanel.jsx │ ├── ClientCard │ │ ├── ClientCard.css │ │ └── ClientCard.jsx │ └── Toggle │ │ └── index.css ├── store │ ├── endpoints.js │ ├── middleware.js │ ├── Socket │ │ └── index.js │ ├── UI │ │ └── index.js │ ├── dummy.js │ ├── Config │ │ └── index.js │ └── Chat │ │ └── index.js ├── init.jsx └── socket.js ├── assets ├── icons │ ├── ss-air.eot │ ├── ss-air.ttf │ ├── ss-air.woff │ └── ss-air_02mf.eot ├── fonts │ ├── Roboto-Black.ttf │ ├── Roboto-Bold.ttf │ ├── Roboto-Light.ttf │ ├── Roboto-Thin.ttf │ ├── Roboto-Italic.ttf │ ├── Roboto-Medium.ttf │ ├── Roboto-Regular.ttf │ ├── Roboto-BoldItalic.ttf │ ├── Roboto-ThinItalic.ttf │ ├── Roboto-BlackItalic.ttf │ ├── Roboto-LightItalic.ttf │ └── Roboto-MediumItalic.ttf ├── images │ ├── icon_16x16.png │ ├── icon_32x32.png │ └── icon_96x96.png └── css │ ├── Roboto.css │ └── ss-air.css ├── .babelrc ├── .gitignore ├── Dockerfile ├── CONTRIBUTING.md ├── .github ├── ISSUE_TEMPLATE.md └── PULL_REQUEST_TEMPLATE.md ├── index.html ├── main.js ├── .eslintrc ├── LICENSE ├── server ├── events.js └── build-menu.js ├── README.md ├── webpack.config.js ├── Makefile ├── Window.js ├── CODE_OF_CONDUCT.md └── package.json /OWNERS: -------------------------------------------------------------------------------- 1 | mihok 2 | teesloane 3 | broneks 4 | -------------------------------------------------------------------------------- /test/style.mock.js: -------------------------------------------------------------------------------- 1 | module.exports = {}; 2 | -------------------------------------------------------------------------------- /src/components/NotificationBar/NotificationBar.text.jsx: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/file.mock.js: -------------------------------------------------------------------------------- 1 | module.exports = 'test-file-stub'; 2 | -------------------------------------------------------------------------------- /assets/icons/ss-air.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/minimalchat/operator-app/HEAD/assets/icons/ss-air.eot -------------------------------------------------------------------------------- /assets/icons/ss-air.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/minimalchat/operator-app/HEAD/assets/icons/ss-air.ttf -------------------------------------------------------------------------------- /assets/icons/ss-air.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/minimalchat/operator-app/HEAD/assets/icons/ss-air.woff -------------------------------------------------------------------------------- /assets/fonts/Roboto-Black.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/minimalchat/operator-app/HEAD/assets/fonts/Roboto-Black.ttf -------------------------------------------------------------------------------- /assets/fonts/Roboto-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/minimalchat/operator-app/HEAD/assets/fonts/Roboto-Bold.ttf -------------------------------------------------------------------------------- /assets/fonts/Roboto-Light.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/minimalchat/operator-app/HEAD/assets/fonts/Roboto-Light.ttf -------------------------------------------------------------------------------- /assets/fonts/Roboto-Thin.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/minimalchat/operator-app/HEAD/assets/fonts/Roboto-Thin.ttf -------------------------------------------------------------------------------- /assets/icons/ss-air_02mf.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/minimalchat/operator-app/HEAD/assets/icons/ss-air_02mf.eot -------------------------------------------------------------------------------- /assets/images/icon_16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/minimalchat/operator-app/HEAD/assets/images/icon_16x16.png -------------------------------------------------------------------------------- /assets/images/icon_32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/minimalchat/operator-app/HEAD/assets/images/icon_32x32.png -------------------------------------------------------------------------------- /assets/images/icon_96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/minimalchat/operator-app/HEAD/assets/images/icon_96x96.png -------------------------------------------------------------------------------- /assets/fonts/Roboto-Italic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/minimalchat/operator-app/HEAD/assets/fonts/Roboto-Italic.ttf -------------------------------------------------------------------------------- /assets/fonts/Roboto-Medium.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/minimalchat/operator-app/HEAD/assets/fonts/Roboto-Medium.ttf -------------------------------------------------------------------------------- /assets/fonts/Roboto-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/minimalchat/operator-app/HEAD/assets/fonts/Roboto-Regular.ttf -------------------------------------------------------------------------------- /assets/fonts/Roboto-BoldItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/minimalchat/operator-app/HEAD/assets/fonts/Roboto-BoldItalic.ttf -------------------------------------------------------------------------------- /assets/fonts/Roboto-ThinItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/minimalchat/operator-app/HEAD/assets/fonts/Roboto-ThinItalic.ttf -------------------------------------------------------------------------------- /assets/fonts/Roboto-BlackItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/minimalchat/operator-app/HEAD/assets/fonts/Roboto-BlackItalic.ttf -------------------------------------------------------------------------------- /assets/fonts/Roboto-LightItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/minimalchat/operator-app/HEAD/assets/fonts/Roboto-LightItalic.ttf -------------------------------------------------------------------------------- /assets/fonts/Roboto-MediumItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/minimalchat/operator-app/HEAD/assets/fonts/Roboto-MediumItalic.ttf -------------------------------------------------------------------------------- /src/components/ClientList/ClientList.css: -------------------------------------------------------------------------------- 1 | .ClientList__list { 2 | margin: 0; 3 | padding: 0; 4 | height: 100%; 5 | overflow-y: auto; 6 | } 7 | -------------------------------------------------------------------------------- /src/components/ClientsPanel/ClientsPanel.css: -------------------------------------------------------------------------------- 1 | .ClientsPanel { 2 | background: #F7F8FA; 3 | box-shadow: 1px 0 1px rgba(0,0,0,0.1); 4 | display: flex; 5 | flex-direction: column; 6 | flex: 2 0 350px; 7 | z-index: 1; 8 | } 9 | -------------------------------------------------------------------------------- /src/components/TitleBar/TitleBar.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import './TitleBar.css'; 3 | 4 | const TitleBar = () => ( 5 |
6 | Operator 7 |
8 | ); 9 | 10 | export default TitleBar; 11 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ [ "env", { 3 | "targets": { 4 | "electron": ["4"] 5 | }, 6 | "useBuiltIns": "usage" 7 | } ] ], 8 | "plugins": [ 9 | "@babel/plugin-proposal-class-properties", 10 | "@babel/plugin-transform-react-jsx" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /src/components/TitleBar/__snapshots__/TitleBar.test.jsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`TitleBar matches snapshot 1`] = ` 4 |
8 | 9 | Operator 10 | 11 |
12 | `; 13 | -------------------------------------------------------------------------------- /src/components/TitleBar/TitleBar.css: -------------------------------------------------------------------------------- 1 | #title { 2 | background: #666666; 3 | } 4 | 5 | #title span { 6 | display: block; 7 | padding: 9px 0; 8 | text-transform: uppercase; 9 | text-align: center; 10 | height: 50px; 11 | font-weight: 800; 12 | font-size: 22px; 13 | color: white; 14 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | yarn.lock 3 | dist/ 4 | dist/*-linux-armv7l 5 | dist/*-linux-arm64 6 | dist/*-win32-x64 7 | dist/*-mas-x64 8 | dist/*-linux-x64 9 | dist/*-darwin-x64 10 | dist/*-win32-ia32 11 | dist/*-linux-ia32 12 | 13 | assets/js/** 14 | .DS_Store 15 | 16 | config.json 17 | 18 | *.swp 19 | *.swo 20 | -------------------------------------------------------------------------------- /src/components/MessageMenuBar/MessageMenuBar.css: -------------------------------------------------------------------------------- 1 | .MessageMenuBar { 2 | align-items: stretch; 3 | box-sizing: border-box; 4 | height: 50px; 5 | justify-content: space-around; 6 | padding: 8px; 7 | } 8 | 9 | .MessageMenuBar .Button { 10 | height: 100%; 11 | line-height: inherit; 12 | font-weight: 400; 13 | } 14 | -------------------------------------------------------------------------------- /src/components/SearchBar/SearchBar.css: -------------------------------------------------------------------------------- 1 | .SearchBar { 2 | display: flex; 3 | width: 100%; 4 | height: 50px; 5 | min-height: 50px; 6 | max-height: 50px; 7 | } 8 | 9 | .SearchBar input { 10 | flex-grow: 1; 11 | border: 0; 12 | outline: 0; 13 | font-size: 14px; 14 | padding: 0.25rem 1rem; 15 | color: #555; 16 | } 17 | -------------------------------------------------------------------------------- /src/store/endpoints.js: -------------------------------------------------------------------------------- 1 | const ENDPOINTS = {}; 2 | 3 | ENDPOINTS.operators = '/api/operators'; 4 | ENDPOINTS.operator = '/api/operator'; 5 | 6 | ENDPOINTS.clients = '/api/clients'; 7 | ENDPOINTS.client = '/api/client'; 8 | 9 | ENDPOINTS.chats = '/api/chats'; 10 | ENDPOINTS.chat = '/api/chat'; 11 | 12 | export default ENDPOINTS; 13 | -------------------------------------------------------------------------------- /src/components/InputBar/InputBar.css: -------------------------------------------------------------------------------- 1 | .InputBar { 2 | display: flex; 3 | background: #fff; 4 | min-height: 50px; 5 | } 6 | 7 | .InputBar input { 8 | flex-grow: 1; 9 | outline: 0; 10 | display: flex; 11 | padding: 1rem; 12 | resize: none; 13 | border: none; 14 | } 15 | 16 | .InputBar .Button--send { 17 | margin: 4px; 18 | } 19 | -------------------------------------------------------------------------------- /src/components/SearchBar/__snapshots__/SearchBar.test.jsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`SearchBar matches snapshot 1`] = ` 4 |
7 | 13 |
14 | `; 15 | -------------------------------------------------------------------------------- /src/components/ClientsPanel/__snapshots__/ClientsPanel.test.jsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`ClientsPanel matches snapshot 1`] = ` 4 |
7 | 11 | 14 |
15 | `; 16 | -------------------------------------------------------------------------------- /src/components/MessagePanel/MessagePanel.css: -------------------------------------------------------------------------------- 1 | .MessagePanel { 2 | display: flex; 3 | flex-direction: column; 4 | flex-grow: 3; 5 | width: 50%; /* not sure if this is the best way*/ 6 | } 7 | 8 | .MessagePanel__container { 9 | border: 0; 10 | box-shadow: inset 10px 0 50px -15px #efefef; 11 | display: flex; 12 | flex-direction: column; 13 | flex-grow: 1; 14 | } 15 | -------------------------------------------------------------------------------- /src/components/Skeleton/Stateless: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'react-redux'; 3 | 4 | 5 | const Skeleton = props => ( 6 |
7 | ); 8 | 9 | 10 | const mapStateToProps = state => ({ 11 | }); 12 | 13 | const mapDispatchToProps = dispatch => ({ 14 | }); 15 | 16 | 17 | export default connect(mapStateToProps, mapDispatchToProps)(Skeleton); 18 | -------------------------------------------------------------------------------- /src/components/Message/__snapshots__/Message.test.jsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`MessageList matches snapshot 1`] = ` 4 |
  • 12 |
    13 |
      14 |
    • 17 | test message 18 |
    • 19 |
    20 |
    21 |
  • 22 | `; 23 | -------------------------------------------------------------------------------- /test/jest.setup.js: -------------------------------------------------------------------------------- 1 | // Setup anything for tests (but know that this is skipped by eslinting) 2 | var enzyme = require('enzyme'); 3 | var Adapter = require('enzyme-adapter-react-15'); 4 | 5 | enzyme.configure({ adapter: new Adapter() }); 6 | 7 | // Skip createElement warnings but fail tests on any other warning 8 | console.error = message => { 9 | if (!/(React.createElement: type should not be null)/.test(message)) { 10 | throw new Error(message); 11 | } 12 | }; 13 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:11 2 | 3 | RUN mkdir -p /tmp 4 | WORKDIR /tmp/operator 5 | 6 | ## Run these together otherwise we have to remember to run it with --no-cache 7 | # occasionally 8 | RUN dpkg --add-architecture i386 && \ 9 | apt update && \ 10 | apt install -y git build-essential wine wine32 libwine 11 | 12 | 13 | RUN apt autoremove -y 14 | 15 | COPY . . 16 | 17 | 18 | # Build the scripts 19 | RUN make clean dependencies 20 | 21 | CMD ["make", "compile"] 22 | -------------------------------------------------------------------------------- /src/components/Application/Application.css: -------------------------------------------------------------------------------- 1 | :focus { 2 | outline-offset: 0; 3 | } 4 | 5 | input, button { 6 | font-family: Roboto, sans-serif; 7 | } 8 | 9 | .App { 10 | display: flex; 11 | font-family: Roboto, sans-serif; 12 | } 13 | 14 | .App__mainview { 15 | display: flex; 16 | flex-grow: 2; 17 | position: relative; 18 | } 19 | 20 | .App__settingsview { 21 | background: #e3eaf0; 22 | display: flex; 23 | flex-grow: 1; 24 | flex-direction: column; 25 | } 26 | -------------------------------------------------------------------------------- /src/components/TitleBar/TitleBar.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { shallow } from 'enzyme'; 4 | 5 | import TitleBar from './TitleBar.jsx'; 6 | 7 | const store = { 8 | subscribe: jest.fn(), 9 | dispatch: jest.fn(), 10 | getState: jest.fn(() => ({ })), 11 | }; 12 | 13 | describe('TitleBar', () => { 14 | it('matches snapshot', () => { 15 | const component = shallow(); 16 | 17 | expect(component).toMatchSnapshot(); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src/components/OperatorPanel/OperatorPanel.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { shallow } from 'enzyme'; 4 | 5 | import OperatorPanel from './OperatorPanel.jsx'; 6 | 7 | const store = { 8 | subscribe: jest.fn(), 9 | dispatch: jest.fn(), 10 | getState: jest.fn(() => ({ })), 11 | }; 12 | 13 | describe('OperatorPanel', () => { 14 | it('matches snapshot', () => { 15 | const component = shallow(); 16 | 17 | expect(component).toMatchSnapshot(); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src/components/SearchBar/SearchBar.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { shallow } from 'enzyme'; 4 | import SearchBar from './SearchBar.jsx'; 5 | 6 | const store = { 7 | subscribe: jest.fn(), 8 | dispatch: jest.fn(), 9 | getState: jest.fn(() => ({ })), 10 | }; 11 | 12 | describe('SearchBar', () => { 13 | it('matches snapshot', () => { 14 | const component = shallow( {}} store={store} />); 15 | 16 | expect(component).toMatchSnapshot(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/components/MessagePanel/MessagePanel.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { shallow } from 'enzyme'; 4 | 5 | import MessagePanel from './MessagePanel.jsx'; 6 | 7 | const store = { 8 | subscribe: jest.fn(), 9 | dispatch: jest.fn(), 10 | getState: jest.fn(() => ({ 11 | chat: { }, 12 | })), 13 | }; 14 | 15 | describe('MessagePanel', () => { 16 | it('matches snapshot', () => { 17 | const component = shallow(); 18 | 19 | expect(component).toMatchSnapshot(); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/components/ClientsPanel/ClientsPanel.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { shallow } from 'enzyme'; 4 | 5 | import ClientsPanel from './ClientsPanel.jsx'; 6 | 7 | const store = { 8 | subscribe: jest.fn(), 9 | dispatch: jest.fn(), 10 | getState: jest.fn(() => ({ 11 | chat: { chats: [] }, 12 | })), 13 | }; 14 | 15 | describe('ClientsPanel', () => { 16 | it('matches snapshot', () => { 17 | const component = shallow(); 18 | 19 | expect(component).toMatchSnapshot(); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/components/Message/Message.test.jsx: -------------------------------------------------------------------------------- 1 | 2 | import React from 'react'; 3 | import { shallow } from 'enzyme'; 4 | import Message from './Message.jsx'; 5 | 6 | /* 7 | const store = { 8 | subscribe: jest.fn(), 9 | dispatch: jest.fn(), 10 | getState: jest.fn(() => ({})), 11 | }; 12 | */ 13 | 14 | 15 | describe('MessageList', () => { 16 | it('matches snapshot', () => { 17 | const component = shallow(); 18 | expect(component).toMatchSnapshot(); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /src/components/OperatorSettingsMenu/OperatorSettingsMenu.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { shallow } from 'enzyme'; 4 | 5 | import OperatorSettingsMenu from './OperatorSettingsMenu.jsx'; 6 | 7 | const store = { 8 | subscribe: jest.fn(), 9 | dispatch: jest.fn(), 10 | getState: jest.fn(() => ({ config: {} })), 11 | }; 12 | 13 | describe('OperatorSettingsMenu', () => { 14 | it('matches snapshot', () => { 15 | const component = shallow(); 16 | 17 | expect(component).toMatchSnapshot(); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src/components/MessageMenuBar/MessageMenuBar.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { shallow } from 'enzyme'; 4 | 5 | import MessageMenuBar from './MessageMenuBar.jsx'; 6 | 7 | const store = { 8 | subscribe: jest.fn(), 9 | dispatch: jest.fn(), 10 | getState: jest.fn(() => ({ 11 | chat: { activeId: '3r23fweefl' }, 12 | })), 13 | }; 14 | 15 | describe('MessageMenuBar', () => { 16 | it('matches snapshot', () => { 17 | const component = shallow(); 18 | 19 | expect(component).toMatchSnapshot(); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/components/WelcomeScreen/WelcomeScreen.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { shallow } from 'enzyme'; 4 | 5 | import WelcomeScreen from './WelcomeScreen.jsx'; 6 | 7 | const store = { 8 | subscribe: jest.fn(), 9 | dispatch: jest.fn(), 10 | getState: jest.fn(() => ({ 11 | config: { 12 | apiServer: '', 13 | }, 14 | })), 15 | }; 16 | 17 | describe('WelcomeScreen', () => { 18 | it('matches snapshot', () => { 19 | const component = shallow(); 20 | 21 | expect(component).toMatchSnapshot(); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /src/components/Application/Application.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { shallow } from 'enzyme'; 4 | 5 | import Application from './Application.jsx'; 6 | 7 | const store = { 8 | subscribe: jest.fn(), 9 | dispatch: jest.fn(), 10 | getState: jest.fn(() => ({ 11 | ui: { 12 | welcomeScreenOpen: false, 13 | settingsOpen: false, 14 | }, 15 | })), 16 | }; 17 | 18 | describe('Chat', () => { 19 | it('matches snapshot', () => { 20 | const component = shallow(); 21 | 22 | expect(component).toMatchSnapshot(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/components/MessageList/MessageList.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | import MessageList from './MessageList.jsx'; 4 | 5 | const store = { 6 | subscribe: jest.fn(), 7 | dispatch: jest.fn(), 8 | 9 | getState: jest.fn(() => ({ 10 | chat: { 11 | messages: [], 12 | typing: {}, 13 | }, 14 | config: {}, 15 | })), 16 | }; 17 | 18 | 19 | describe('MessageList', () => { 20 | it('matches snapshot', () => { 21 | const component = shallow(); 22 | expect(component).toMatchSnapshot(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/components/InputBar/InputBar.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { shallow } from 'enzyme'; 4 | 5 | import InputBar from './InputBar.jsx'; 6 | 7 | const store = { 8 | subscribe: jest.fn(), 9 | dispatch: jest.fn(), 10 | getState: jest.fn(() => ({ 11 | chat: { 12 | activeId: 'TEST', 13 | }, 14 | config: { 15 | operator: 'TEST', 16 | }, 17 | })), 18 | }; 19 | 20 | describe('InputBar', () => { 21 | it('matches snapshot', () => { 22 | const component = shallow(); 23 | 24 | expect(component).toMatchSnapshot(); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /src/components/SearchBar/SearchBar.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import './SearchBar.css'; 4 | 5 | const SearchBar = (props) => { 6 | const { query, onQueryChange } = props; 7 | 8 | return ( 9 |
    10 | 16 |
    17 | ); 18 | }; 19 | 20 | SearchBar.propTypes = { 21 | query: PropTypes.string.isRequired, 22 | onQueryChange: PropTypes.func.isRequired, 23 | }; 24 | 25 | export default SearchBar; 26 | -------------------------------------------------------------------------------- /src/components/OperatorClientMenu/OperatorClientMenu.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { shallow } from 'enzyme'; 4 | 5 | import OperatorClientMenu from './OperatorClientMenu.jsx'; 6 | 7 | const store = { 8 | subscribe: jest.fn(), 9 | dispatch: jest.fn(), 10 | getState: jest.fn(() => ({ 11 | chat: { 12 | chats: [], 13 | operatorFilter: 'all', 14 | }, 15 | })), 16 | }; 17 | 18 | describe('OperatorClientMenu', () => { 19 | it('matches snapshot', () => { 20 | const component = shallow(); 21 | 22 | expect(component).toMatchSnapshot(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing guidelines 2 | 3 | ## How to become a contributor and submit your own code 4 | 5 | ### Contributing A Patch 6 | 7 | 1. Submit an issue describing your proposed change to the repo in question. 8 | 1. The [repo owners](OWNERS) will respond to your issue promptly. 9 | 1. If instructed by the repo owners provide a short design document in a PR. 10 | 1. Fork the desired repo, develop and test your code changes. Unit tests are required for most PRs. 11 | 1. Submit a pull request. 12 | 13 | ## Bug reporting 14 | 15 | If you think you found a bug, please open a [new issue](https://github.com/minimalchat/operator-app/issues/new) 16 | -------------------------------------------------------------------------------- /src/components/OperatorSettingsMenu/OperatorSettingsStyles.css: -------------------------------------------------------------------------------- 1 | .SettingsMenu { 2 | width: 100%; 3 | } 4 | 5 | .Settings__box { 6 | display: flex; 7 | background: #F7F8FA; 8 | border-top: 1px solid #E8ECEE; 9 | border-bottom: 1px solid #E8ECEE; 10 | justify-content: flex-start; 11 | width: 100%; 12 | font-size: 12px; 13 | color: #8f8f8f; 14 | align-items: center; 15 | } 16 | 17 | .Settings__avatar { 18 | margin: 1rem; 19 | border-radius: 50%; 20 | border: 3px solid white; 21 | box-shadow: 1px 1px 0 rgba(0, 0, 0, 0.15); 22 | width: 58px; 23 | height: 58px; 24 | overflow: hidden; 25 | } 26 | 27 | .Settings__avatar img { 28 | width: 100%; 29 | } 30 | 31 | .Settings__operator { 32 | word-break: break-word; 33 | } 34 | -------------------------------------------------------------------------------- /src/components/ClientsPanel/ClientsPanel.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | 3 | import SearchBar from '../SearchBar/SearchBar.jsx'; 4 | import ClientList from '../ClientList/ClientList.jsx'; 5 | import './ClientsPanel.css'; 6 | 7 | 8 | class ClientsPanel extends Component { 9 | state = { query: '' } 10 | 11 | onQueryChange = (event) => { 12 | const query = event.target.value; 13 | this.setState({ query }); 14 | } 15 | 16 | render () { 17 | const { query } = this.state; 18 | 19 | return ( 20 |
    21 | 22 | 23 |
    24 | ); 25 | } 26 | } 27 | 28 | export default ClientsPanel; 29 | -------------------------------------------------------------------------------- /src/components/OperatorPanel/__snapshots__/OperatorPanel.test.jsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`OperatorPanel matches snapshot 1`] = ` 4 | 29 | `; 30 | -------------------------------------------------------------------------------- /src/components/ClientList/ClientList.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { shallow } from 'enzyme'; 4 | 5 | import ClientList from './ClientList.jsx'; 6 | 7 | const store = { 8 | subscribe: jest.fn(), 9 | dispatch: jest.fn(), 10 | getState: jest.fn(() => ({ 11 | chat: { 12 | operatorFilter: 'all', 13 | chats: { 14 | 'id-0': { client: { first_name: 'Robert', last_name: 'waffle' } }, 15 | 'id-1': { client: { first_name: 'Lisa', last_name: 'pancake' } }, 16 | }, 17 | config: {}, 18 | }, 19 | })), 20 | }; 21 | 22 | 23 | describe('ClientList', () => { 24 | const query = ''; 25 | it('matches snapshot', () => { 26 | const component = shallow(); 27 | expect(component).toMatchSnapshot(); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /src/components/SVG/BrandLogo.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const BrandLogo = props => ( 4 | 5 | Minimal Chat 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | ); 16 | 17 | export default BrandLogo; 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 10 | 11 | - `node` version: 12 | - `npm` (or `yarn`) version: 13 | 14 | Relevant code or config 15 | 16 | ```javascript 17 | 18 | ``` 19 | 20 | What you did: 21 | 22 | 23 | 24 | What happened: 25 | 26 | 27 | 28 | Reproduction repository: 29 | 30 | 34 | 35 | Problem description: 36 | 37 | 38 | 39 | Suggested solution: 40 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Operator 8 | 9 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 |
    24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /main.js: -------------------------------------------------------------------------------- 1 | const Electron = require('electron'); 2 | const Window = require('./Window.js'); 3 | 4 | // Module to control application life. 5 | const app = Electron.app; 6 | 7 | // This method will be called when Electron has finished 8 | // initialization and is ready to create browser windows. 9 | // Some APIs can only be used after this event occurs. 10 | app.on('ready', Window.onReady); 11 | 12 | // Quit when all windows are closed. 13 | app.on('window-all-closed', function () { 14 | // On OS X it is common for applications and their menu bar 15 | // to stay active until the user quits explicitly with Cmd + Q 16 | if (process.platform !== 'darwin') { 17 | app.quit(); 18 | } 19 | }); 20 | 21 | app.on('activate', function () { 22 | // On OS X it's common to re-create a window in the app when the 23 | // dock icon is clicked and there are no other windows open. 24 | if (Window.isNull()) { 25 | Window.create(); 26 | } 27 | }); 28 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "extends": "airbnb", 4 | "env": { 5 | "browser": true, 6 | "jest": true, 7 | }, 8 | "rules": { 9 | "arrow-body-style": ["error", "as-needed"], 10 | "camelcase": ["warn"], 11 | "class-methods-use-this": ["warn"], 12 | "space-before-function-paren": ["error", "always"], 13 | "prefer-const": ["warn"], 14 | "import/no-extraneous-dependencies": [ 15 | "error", { "devDependencies": ["**/*.test.js", "**/*.test.jsx", "**/*.spec.js", "**/*.spec.jsx"]} 16 | ], 17 | "import/extensions": ["error", { "jsx": "always" }], 18 | "no-unused-vars": ["warn"], 19 | "no-console": ["warn"], 20 | "no-prototype-builtins": ["off"], 21 | "no-multiple-empty-lines": ["off"], 22 | "no-use-before-define": ["warn"], 23 | "react/no-multi-comp": ["warn"], 24 | "react/jsx-no-bind": ["warn"], 25 | "react/forbid-prop-types": ["warn"], 26 | "react/button-has-type": ["warn"], 27 | }, 28 | } 29 | -------------------------------------------------------------------------------- /src/components/MessageList/MessageList.css: -------------------------------------------------------------------------------- 1 | .MessageList { 2 | background: #e3eaf0; 3 | flex-grow: 1; 4 | align-content: center; 5 | display: flex; 6 | flex-direction: column; 7 | justify-content: center; 8 | } 9 | 10 | .MessageList__empty { 11 | display: flex; 12 | flex-direction: column; 13 | flex-grow: 1; 14 | justify-content: center; 15 | align-self: center; 16 | text-align: center; 17 | align-items: center; 18 | color: rgba(0, 0, 0, 0.2); 19 | } 20 | 21 | .lil-ghost, .lil-mailbox { 22 | font-size: 40px; 23 | } 24 | 25 | .MessageList__box { 26 | flex-grow: 1; 27 | overflow-y: scroll; 28 | padding-left: 0; 29 | padding-right: 0.25rem; 30 | display: flex; 31 | flex-direction: column; 32 | justify-content: flex-end; 33 | padding-bottom: 12px; 34 | } 35 | 36 | .MessageList__box--done { 37 | opacity: 0.5; 38 | } 39 | 40 | .MessageList__status { 41 | position: absolute; 42 | bottom: 60px; 43 | min-height: 14px; /* size of status message text */ 44 | padding: 8px; 45 | margin-right: 16px; 46 | } 47 | -------------------------------------------------------------------------------- /src/components/OperatorClientMenu/OperatorClientMenu.css: -------------------------------------------------------------------------------- 1 | .OperatorClientMenu { 2 | display: flex; 3 | flex-direction: column; 4 | flex-grow: 1; 5 | padding: 0; 6 | margin: 0; 7 | } 8 | 9 | .OperatorClient__Menu__item { 10 | padding: 0; 11 | margin: 0; 12 | list-style-type: none; 13 | } 14 | 15 | .OperatorClient__Menu__item:hover { 16 | background: #fafafa; 17 | } 18 | 19 | .OperatorClient__selectedFilter { 20 | display: flex; 21 | background: #fafafa; 22 | border: none; 23 | outline: 0; 24 | height: 100%; 25 | width: 100%; 26 | padding: 15px 15px 15px 15px; 27 | border-left: 3px solid rgba(231, 76, 60, 1.0); 28 | } 29 | 30 | .OperatorClient__filter { 31 | display: flex; 32 | background: #fdfdfd; 33 | border: none; 34 | outline: 0; 35 | padding: 15px 15px 15px 15px; 36 | height: 100%; 37 | width: 100%; 38 | cursor: pointer; 39 | border-left: 3px solid #fdfdfd; 40 | } 41 | 42 | .OperatorClient__filter:hover { 43 | background: #fafafa; 44 | border-left: 3px solid rgba(231, 76, 60, 1.0); 45 | } 46 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | ## Description 19 | 20 | 21 | ### Motivation 22 | 23 | 24 | ### Changes 25 | 26 | - 27 | - 28 | 29 | 30 | 35 | -------------------------------------------------------------------------------- /src/store/middleware.js: -------------------------------------------------------------------------------- 1 | import { RECEIVE_MESSAGE, setActiveChat } from './Chat/index'; 2 | 3 | 4 | export const logger = store => next => (action) => { 5 | console.group(action.type); 6 | console.info('dispatching', action); 7 | console.log('next state', store.getState()); 8 | console.groupEnd(action.type); 9 | return next(action); 10 | }; 11 | 12 | export const notifications = store => next => (action) => { 13 | const messageLength = 80; 14 | if (action.type === RECEIVE_MESSAGE) { 15 | if (window.config.notificationsEnabled) { 16 | const newMessageNotification = new Notification('New Message', { 17 | body: `${action.payload.content.substring(0, messageLength)}${action.payload.content.length > messageLength ? '...' : ''}`, 18 | }); 19 | 20 | newMessageNotification.onclick = () => { 21 | store.dispatch(setActiveChat({ id: action.payload.chat, open: true })); 22 | }; 23 | 24 | try { 25 | newMessageNotification.show(); 26 | } catch (e) { 27 | // TODO: Manage errors better? 28 | } 29 | } 30 | } 31 | return next(action); 32 | }; 33 | -------------------------------------------------------------------------------- /src/components/MessagePanel/__snapshots__/MessagePanel.test.jsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`MessagePanel matches snapshot 1`] = ` 4 | 45 | `; 46 | -------------------------------------------------------------------------------- /src/components/OperatorSettingsMenu/__snapshots__/OperatorSettingsMenu.test.jsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`OperatorSettingsMenu matches snapshot 1`] = ` 4 | 47 | `; 48 | -------------------------------------------------------------------------------- /src/components/MessagePanel/MessagePanel.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { connect } from 'react-redux'; 4 | 5 | import MessageMenuBar from '../MessageMenuBar/MessageMenuBar.jsx'; 6 | import MessageList from '../MessageList/MessageList.jsx'; 7 | import InputBar from '../InputBar/InputBar.jsx'; 8 | import './MessagePanel.css'; 9 | 10 | const MessagePanel = (props) => { 11 | const { activeId } = props; 12 | const renderView = () => { 13 | if (!activeId) { 14 | return ( 15 |
    16 | 17 |
    18 | ); 19 | } 20 | 21 | return ( 22 |
    23 | 24 | 25 | 26 |
    27 | ); 28 | }; 29 | 30 | return renderView(); 31 | }; 32 | 33 | MessageList.propTypes = { 34 | activeId: PropTypes.string, 35 | }; 36 | 37 | 38 | const mapStateToProps = state => ({ 39 | activeId: state.chat.activeId, 40 | }); 41 | 42 | const mapDispatchToProps = dispatch => ({ 43 | dispatch, 44 | }); 45 | 46 | 47 | export default connect( 48 | mapStateToProps, 49 | mapDispatchToProps, 50 | )(MessagePanel); 51 | -------------------------------------------------------------------------------- /src/components/OperatorSettingsMenu/OperatorSettingsMenu.jsx: -------------------------------------------------------------------------------- 1 | /** OperatorSettingsMenu 2 | * Displays informaiton about the operator. 3 | * Might eventually hold a menu settings button? 4 | */ 5 | 6 | import React from 'react'; 7 | import PropTypes from 'prop-types'; 8 | import { connect } from 'react-redux'; 9 | import './OperatorSettingsStyles.css'; 10 | 11 | const OperatorSettingsMenu = ({ avatar, operatorName }) => ( 12 |
    13 |
    14 |
    15 | {operatorName} 19 |
    20 | {operatorName} 21 |
    22 |
    23 | ); 24 | 25 | OperatorSettingsMenu.propTypes = { 26 | operatorName: PropTypes.string, 27 | avatar: PropTypes.string, 28 | }; 29 | 30 | OperatorSettingsMenu.defaultProps = { 31 | operatorName: '', 32 | avatar: null, 33 | }; 34 | 35 | const mapStateToProps = state => ({ 36 | operatorName: state.config.operator, 37 | avatar: state.config.avatar, 38 | }); 39 | 40 | export default connect( 41 | mapStateToProps, 42 | )(OperatorSettingsMenu); 43 | -------------------------------------------------------------------------------- /src/components/WelcomeScreen/__snapshots__/WelcomeScreen.test.jsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`WelcomeScreen matches snapshot 1`] = ` 4 | 49 | `; 50 | -------------------------------------------------------------------------------- /src/components/WelcomeScreen/WelcomeScreen.css: -------------------------------------------------------------------------------- 1 | .WelcomeScreen.screen { 2 | width: 100%; 3 | height: 100%; 4 | display: flex; 5 | flex-direction: column; 6 | align-items: center; 7 | justify-content: center; 8 | background: #2c3e50; 9 | } 10 | 11 | .WelcomeScreen__header { 12 | width: 33%; 13 | min-width: 256px; 14 | margin: 18px 0 96px; 15 | display: flex; 16 | justify-content: center; 17 | align-items: center; 18 | font-family: Roboto, sans-serif; 19 | font-weight: 600; 20 | font-size: 13px; 21 | font-weight: 500; 22 | letter-spacing: 2px; 23 | text-transform: uppercase; 24 | color: white; 25 | } 26 | 27 | .WelcomeScreen__header .SVG__logo { 28 | height: 20px; 29 | margin-right: 10px; 30 | margin-bottom: 0; 31 | fill: rgba(255, 255, 255, 1.0); 32 | } 33 | 34 | .WelcomeScreen__body { 35 | width: 33%; 36 | min-width: 256px; 37 | max-width: 512px; 38 | } 39 | 40 | .WelcomeScreen__wrapper { 41 | position: relative; 42 | } 43 | 44 | .WelcomeScreen__wrapper input { 45 | width: 100%; 46 | height: 42px; 47 | box-sizing: border-box; 48 | padding: 14px; 49 | border-radius: 2px; 50 | border: 1px solid #ffffff; 51 | } 52 | 53 | .WelcomeScreen__wrapper input:focus { 54 | outline: none; 55 | } 56 | 57 | .WelcomeScreen__wrapper .Button { 58 | position: absolute; 59 | right: 3px; 60 | top: 3px; 61 | } 62 | -------------------------------------------------------------------------------- /src/components/MessageMenuBar/__snapshots__/MessageMenuBar.test.jsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`MessageMenuBar matches snapshot 1`] = ` 4 | 51 | `; 52 | -------------------------------------------------------------------------------- /src/components/Application/__snapshots__/Application.test.jsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Chat matches snapshot 1`] = ` 4 | 52 | `; 53 | -------------------------------------------------------------------------------- /src/components/MessageMenuBar/MessageMenuBar.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { connect } from 'react-redux'; 4 | import Button from '../Button/Button.jsx'; 5 | import { toggleChatOpen } from '../../store/Chat'; 6 | import './MessageMenuBar.css'; 7 | 8 | 9 | const MessageMenuBar = (props) => { 10 | const { activeChatId, activeChatIsOpen, toggleOpen } = props; 11 | const renderBtnMsg = () => { 12 | if (!activeChatId) return 'Select a chat'; 13 | if (activeChatId && activeChatIsOpen) return 'Mark as Done'; 14 | return 'Unarchive Chat'; 15 | }; 16 | 17 | return ( 18 |
    19 | 20 |
    21 | ); 22 | }; 23 | 24 | 25 | MessageMenuBar.propTypes = { 26 | activeChatId: PropTypes.string.isRequired, 27 | activeChatIsOpen: PropTypes.bool, 28 | toggleOpen: PropTypes.func.isRequired, 29 | }; 30 | 31 | MessageMenuBar.defaultProps = { 32 | activeChatIsOpen: false, 33 | }; 34 | 35 | 36 | const mapStateToProps = state => ({ 37 | activeChatId: state.chat.activeId, 38 | activeChatIsOpen: state.chat.activeIsOpen, 39 | }); 40 | 41 | const mapDispatchToProps = dispatch => ({ 42 | toggleOpen: chatId => dispatch(toggleChatOpen(chatId)), 43 | }); 44 | 45 | 46 | export default connect( 47 | mapStateToProps, 48 | mapDispatchToProps, 49 | )(MessageMenuBar); 50 | -------------------------------------------------------------------------------- /src/components/OperatorClientMenu/__snapshots__/OperatorClientMenu.test.jsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`OperatorClientMenu matches snapshot 1`] = ` 4 | 53 | `; 54 | -------------------------------------------------------------------------------- /src/components/NotificationBar/NotificationBar.css: -------------------------------------------------------------------------------- 1 | .NotificationBar { 2 | z-index: 2; 3 | display: flex; 4 | position: absolute; 5 | top: -50px; 6 | left: 0; 7 | right: 0; 8 | height: 50px; 9 | width: 100%; 10 | transition: top ease 500ms; 11 | align-items: center; 12 | text-align: center; 13 | justify-content: center; 14 | color: rgba(255,255,255,1); 15 | } 16 | 17 | .NotificationBar--active { 18 | top: 0; 19 | } 20 | 21 | .NotificationBar--red { 22 | background: rgba(235, 110, 96, 1); 23 | } 24 | 25 | .NotificationBar--green { 26 | background: rgba(137, 220, 203, 1); 27 | } 28 | 29 | .NotificationBar--orange { 30 | background: rgba(235, 190, 117, 1); 31 | } 32 | 33 | 34 | .NotificationBar__status { 35 | flex-grow: 1; 36 | display: flex; 37 | align-items: center; 38 | font-size: 14px; 39 | } 40 | 41 | .NotificationBar__icon { 42 | padding: 11px 8px 8px 8px; 43 | box-sizing: border-box; 44 | height: 50px; 45 | width: 36px; 46 | font-size: 24px; 47 | float: left; 48 | margin: 0 8px; 49 | } 50 | 51 | .NotificationBar__close { 52 | position: absolute; 53 | text-align: right; 54 | box-sizing: border-box; 55 | padding: 8px; 56 | font-size: 32px; 57 | line-height: 12px; 58 | height: 100%; 59 | display: flex; 60 | align-items: center; 61 | justify-content: flex-end; 62 | padding-top: 16px; 63 | cursor: pointer; 64 | color: rgba(230, 126, 34,1.0); 65 | right: 0; 66 | } 67 | -------------------------------------------------------------------------------- /src/components/Button/Button.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import './Button.css'; 4 | 5 | const iconRenderer = iconClassName => ( 6 | 7 | ); 8 | 9 | const Button = (props) => { 10 | const { 11 | icon, type, variant, children, onClick, disabled, 12 | } = props; 13 | const isIconButton = icon != null; 14 | 15 | const content = isIconButton ? iconRenderer(icon) : children; 16 | 17 | const buttonVariant = isIconButton ? 'icon' : Button.variants[variant]; 18 | const buttonVariantClassName = buttonVariant ? `Button--${buttonVariant}` : ''; 19 | 20 | return ( 21 | 29 | ); 30 | }; 31 | 32 | Button.variants = { 33 | icon: 'icon', 34 | send: 'send', 35 | primary: 'primary', 36 | transparent: 'transparent', 37 | }; 38 | 39 | Button.propTypes = { 40 | onClick: PropTypes.func, 41 | children: PropTypes.node, 42 | type: PropTypes.string, 43 | variant: PropTypes.string, 44 | icon: PropTypes.string, 45 | disabled: PropTypes.bool, 46 | }; 47 | 48 | Button.defaultProps = { 49 | type: 'button', 50 | variant: '', 51 | disabled: false, 52 | children: null, 53 | onClick: null, 54 | icon: null, 55 | }; 56 | 57 | 58 | export default Button; 59 | -------------------------------------------------------------------------------- /src/components/Message/Message.css: -------------------------------------------------------------------------------- 1 | .Message__client { 2 | align-self: flex-end; 3 | background: #ffffff; 4 | border-radius: 10px 10px 0 10px; 5 | color: #000; 6 | float: left; 7 | font-size: 12px; 8 | letter-spacing: 1px; 9 | list-style: none; 10 | margin-top: 6px; 11 | margin-right: 6px; 12 | /* account for absolutely position time of messages below msg: */ 13 | margin-bottom: 24px; 14 | max-width: 45%; 15 | min-width: 25%; 16 | padding: 10px; 17 | text-align: left; 18 | position: relative; 19 | } 20 | 21 | .Message__client ul li { 22 | text-align: right; 23 | } 24 | 25 | .Message__client.typing { 26 | padding: 3px 10px 3px 10px; 27 | min-width: auto; 28 | } 29 | 30 | .Message__time { 31 | position: absolute; 32 | bottom: -14px; 33 | right: 0px; 34 | color: rgba(149, 165, 166,1.0); 35 | font-size: 9px; 36 | font-style: italic; 37 | width: 94px; 38 | text-align: right; 39 | letter-spacing: 0.4px; 40 | } 41 | 42 | .Message__operator { 43 | align-self: flex-start; 44 | background: #0a6bef; 45 | border-radius: 10px 10px 10px 0; 46 | color: white; 47 | font-size: 12px; 48 | letter-spacing: 1px; 49 | list-style: none; 50 | padding: 0.5rem; 51 | margin: 0.5rem; 52 | max-width: 45%; 53 | min-width: 25%; 54 | } 55 | 56 | .Message__operator ul, 57 | .Message__client ul { 58 | list-style: none; 59 | padding: 0; 60 | margin: 0; 61 | } 62 | 63 | .Message__operator ul li { 64 | text-align: left; 65 | } 66 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016, Matthew Mihok 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 5 | 6 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 7 | 8 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 9 | 10 | 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 11 | 12 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /src/components/NotificationBar/NotificationBar.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { connect } from 'react-redux'; 4 | 5 | import './NotificationBar.css'; 6 | 7 | const NotificationBar = (props) => { 8 | const { 9 | notification, 10 | notificationIcon, 11 | notificationColour, 12 | } = props; 13 | 14 | const classes = [ 15 | 'NotificationBar', 16 | `NotificationBar--${notificationColour}`, 17 | notification !== '' ? 'NotificationBar--active' : '', 18 | ]; 19 | 20 | return ( 21 |
    22 |
    23 | 24 | {notificationIcon} 25 | 26 | {notification} 27 |
    28 | {/* Close */} 29 |
    30 | ); 31 | }; 32 | 33 | NotificationBar.defaultProps = { 34 | notificationIcon: 'info', 35 | notificationColour: 'green', 36 | }; 37 | 38 | NotificationBar.propTypes = { 39 | notification: PropTypes.string.isRequired, 40 | notificationIcon: PropTypes.string, 41 | notificationColour: PropTypes.string, 42 | }; 43 | 44 | const mapStateToProps = state => ({ 45 | notification: state.ui.notification, 46 | notificationIcon: state.ui.notificationIcon, 47 | notificationColour: state.ui.notificationColour, 48 | }); 49 | 50 | export default connect(mapStateToProps, null)(NotificationBar); 51 | -------------------------------------------------------------------------------- /src/components/InputBar/__snapshots__/InputBar.test.jsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`InputBar matches snapshot 1`] = ` 4 | 58 | `; 59 | -------------------------------------------------------------------------------- /src/components/SettingsPanel/SettingsPanel.css: -------------------------------------------------------------------------------- 1 | .Settings { 2 | display: flex; 3 | flex-direction: column; 4 | flex-grow: 2; 5 | } 6 | 7 | .Settings__header { 8 | font-size: 16px; 9 | display: flex; 10 | align-items: center; 11 | padding: 0 2rem; 12 | height: 50px; /* helps align with minimal chat top left bar thing */ 13 | color: #595959; 14 | border-bottom: 1px solid #D5D5D5; 15 | } 16 | 17 | .Settings__body { 18 | margin: 2rem; 19 | border-radius: 2px; 20 | display: flex; 21 | flex-direction: column; 22 | } 23 | 24 | .Settings__single { 25 | display: flex; 26 | margin: 1rem 0; 27 | align-content: center; 28 | justify-content: space-between; 29 | } 30 | 31 | .Settings__notification-label { 32 | font-size: 18px; 33 | color: #919191; 34 | align-self: center; 35 | } 36 | 37 | .Settings__disconnect-link .Button { 38 | font-size: 14px; 39 | font-weight: 400; 40 | align-self: center; 41 | padding: 0; 42 | margin: 0; 43 | border: 0; 44 | color: #c0392b; 45 | } 46 | 47 | .Settings__disconnect-link .Button:hover { 48 | border: 0; 49 | color: #c0392b; 50 | } 51 | 52 | .Settings__operator-label { 53 | font-size: 18px; 54 | color: #919191; 55 | align-self: center; 56 | padding-right: 1rem; 57 | flex-grow: 3; 58 | } 59 | 60 | .Settings__operator-name { 61 | outline: 0; 62 | display: flex; 63 | padding: 0.8rem; 64 | resize: none; 65 | border: none; 66 | border-radius: 2px; 67 | flex-grow: 2; 68 | } 69 | 70 | .Settings__operator-name:focus { 71 | outline-offset: 0; 72 | } 73 | -------------------------------------------------------------------------------- /server/events.js: -------------------------------------------------------------------------------- 1 | // This file handles the functions to call when certain events are received by the ipc renderer. 2 | const path = require('path'); 3 | const fs = require('fs') 4 | 5 | const configPath = path.join(__dirname, '../config.json'); 6 | 7 | // creates config file if it doesn't already exist 8 | // InitConfig creates a config if it does not exist; 9 | // Config must be mirrored in the client app for passing config payloads from client<->server 10 | // TODO: move the initialConfig to another file / share between client / server 11 | // TODO: Convert *sync to asynchronous file ops (faster?) 12 | function initConfig (event) { 13 | let config = null; 14 | 15 | if (!fs.existsSync(configPath)) { 16 | const fd = fs.openSync(configPath, 'w'); 17 | 18 | const initialConfig = { 19 | apiServer: '', 20 | operator: '', 21 | notificationsEnabled: true, 22 | }; 23 | 24 | fs.writeSync(fd, JSON.stringify(initialConfig, null, ' '), 0, 'utf8'); 25 | fs.closeSync(fd); 26 | } 27 | 28 | config = JSON.parse(fs.readFileSync(configPath).toString()); 29 | event.sender.send('config', config); 30 | } 31 | 32 | // Handle changing the settings via front end `config.index` reducer 33 | // Writes to file and then sends payload to front end via ipc event. 34 | function updateSettings(event, payload) { 35 | fs.writeFile(configPath, JSON.stringify(payload, null, 2), function (err) { 36 | if (err) return console.log(err); 37 | event.sender.send('config', payload) 38 | }); 39 | } 40 | 41 | module.exports = { 42 | initConfig, 43 | updateSettings 44 | }; 45 | -------------------------------------------------------------------------------- /src/components/MessageList/__snapshots__/MessageList.test.jsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`MessageList matches snapshot 1`] = ` 4 | 59 | `; 60 | -------------------------------------------------------------------------------- /src/components/Button/Button.css: -------------------------------------------------------------------------------- 1 | .Button { 2 | user-select: none; 3 | cursor: pointer; 4 | padding: 3px 10px; 5 | margin: 0; 6 | outline: none; 7 | white-space: nowrap; 8 | line-height: 38px; 9 | font-weight: 500; 10 | color: #555; 11 | background: linear-gradient(-180deg, #fff 0%, #f6f7fa 90%); 12 | border: 1px solid #d7dce6; 13 | border-radius: 3px; 14 | } 15 | 16 | .Button:disabled { 17 | cursor: default; 18 | color: #bbb; 19 | } 20 | 21 | .Button:not(:disabled):hover{ 22 | color: #222; 23 | border: 1px solid #c7cedc; 24 | background: #f6f7fa; 25 | } 26 | 27 | .Button:not(:disabled):active { 28 | background: #e5e6ea; 29 | } 30 | 31 | 32 | .Button--icon { 33 | background: transparent; 34 | width: 20px; 35 | height: 20px; 36 | font-size: 20px; 37 | line-height: 20px; 38 | border: none; 39 | padding: 0; 40 | } 41 | .Button--icon:not(:disabled):hover, 42 | .Button--icon:not(:disabled):active { 43 | color: #222; 44 | border: none; 45 | background: transparent; 46 | } 47 | 48 | .Button--send { 49 | background: #27ae60; 50 | color: #fff; 51 | line-height: 13px; 52 | height: 36px; 53 | } 54 | .Button--send:not(:disabled):hover, 55 | .Button--send:not(:disabled):active { 56 | background: #229955; 57 | color: #fff; 58 | } 59 | 60 | .Button--transparent { 61 | border-color: transparent; 62 | background: transparent; 63 | color: #2980b9; 64 | } 65 | .Button--transparent:not(:disabled):hover, 66 | .Button--transparent:not(:disabled):active { 67 | border-color: transparent; 68 | background: transparent; 69 | color: #2472a4; 70 | } 71 | -------------------------------------------------------------------------------- /src/components/Button/__snapshots__/Button.test.jsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Button matches basic button snapshot 1`] = ` 4 | 12 | `; 13 | 14 | exports[`Button matches disabled button snapshot 1`] = ` 15 | 34 | `; 35 | 36 | exports[`Button matches icon button snapshot 1`] = ` 37 | 47 | `; 48 | 49 | exports[`Button matches send button snapshot 1`] = ` 50 | ); 8 | 9 | expect(component).toMatchSnapshot(); 10 | }); 11 | 12 | it('matches submit button snapshot', () => { 13 | const component = shallow(, 35 | ); 36 | 37 | expect(component).toMatchSnapshot(); 38 | }); 39 | 40 | it('matches send button snapshot', () => { 41 | const component = shallow( 35 | Made with lurv by hoomans 36 | 37 |
    38 | ); 39 | 40 | OperatorPanel.propTypes = { 41 | toggleSettings: PropTypes.func.isRequired, 42 | }; 43 | 44 | 45 | const mapDispatchToProps = dispatch => ({ 46 | toggleSettings: () => dispatch(toggleSettings()), 47 | }); 48 | 49 | 50 | export default connect(null, mapDispatchToProps)(OperatorPanel); 51 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Minimal Chat operator application 2 | 3 | --- 4 | 5 | Minimal Chat is an open source live chat system providing live one on one messaging to a website visitor and an operator. 6 | 7 | Minimal Chat is: 8 | - **minimal**: simple, lightweight, accessible 9 | - **extensible**: modular, pluggable, hookable, composable 10 | 11 | We're glad you're interested in contributing, feel free to create an [issue](https://github.com/minimalchat/operator-app/issues/new) or pick one up but first check out our [contributing doc](https://github.com/minimalchat/operator-app/blob/master/CONTRIBUTING.md) and [code of conduct](https://github.com/minimalchat/operator-app/blob/master/CODE_OF_CONDUCT.md). Check out our [design documentation](https://github.com/minimalchat/client/wiki/Design-Documentation) as well. 12 | 13 | Screenshot 14 | --- 15 | ![operator-screenshot-1](https://user-images.githubusercontent.com/563301/32144257-84f8533e-bc8c-11e7-8875-48cb49c92a78.png) 16 | 17 | --- 18 | 19 | ### Development 20 | 21 | Developing for the operator application is fairly straightforward with a few caveats. All of the Minimal Chat repositories are run through `make`. To get the application running: 22 | 23 | 1. Clone the repository 24 | 2. `make dependencies` 25 | 3. `make run` 26 | 27 | To have the operator communicate with your local [daemon]() requires some confirguration. The operator application keeps a config.json file that it creates on run if it does not exist. **It is recommended to let the application run once in disconnected mode rather than creating your own `config.json`**. 28 | 29 | 1. Once you've run the application once, find the `config.json` file. 30 | 2. Open `config.json`, edit the `"apiServer"`, set this to the IP and port that the daemon is running on. (e.g. `http://localhost:8000`, the default setting) 31 | -------------------------------------------------------------------------------- /src/init.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { applyMiddleware, createStore, combineReducers } from 'redux'; 4 | import { Provider } from 'react-redux'; 5 | import { ipcRenderer } from 'electron'; 6 | 7 | import Application from './components/Application/Application.jsx'; 8 | import socketInit, { socketMessageHook } from './socket'; 9 | import { notifications } from './store/middleware'; 10 | 11 | // Reducers 12 | import chat, { loadChats } from './store/Chat'; 13 | import ui, { toggleWelcomeScreen } from './store/UI'; 14 | import config, { setConfig } from './store/Config'; 15 | import socket from './store/Socket'; 16 | 17 | // Create redux store 18 | const store = createStore( 19 | combineReducers({ 20 | chat, 21 | ui, 22 | config, 23 | socket, 24 | }), 25 | 26 | applyMiddleware(socketMessageHook, notifications), 27 | ); 28 | 29 | // TODO: Use a env var to disable this on build 30 | window.state = store.getState; 31 | 32 | // Configuration for the system 33 | ipcRenderer.on('config', async (event, newConfig) => { 34 | const { dispatch } = store; 35 | const state = store.getState(); 36 | 37 | dispatch(setConfig(newConfig)); 38 | 39 | // Is apiServer blank (erased, or a new config was just created?) 40 | if (newConfig.apiServer === '') { 41 | dispatch(toggleWelcomeScreen(true)); 42 | } 43 | 44 | // Create Socket Connection only if there is a new apiServer config 45 | if (newConfig.apiServer === state.config.apiServer) { 46 | return; 47 | } 48 | 49 | socketInit(store); 50 | 51 | dispatch(await loadChats(newConfig.apiServer)); 52 | }); 53 | 54 | ipcRenderer.send('init-config'); 55 | 56 | ReactDOM.render( 57 | 58 | 59 | , 60 | 61 | document.getElementById('app'), 62 | ); 63 | -------------------------------------------------------------------------------- /src/components/Message/Message.jsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @summary: Displays a single message in the Message List 3 | */ 4 | 5 | import React from 'react'; 6 | import PropTypes from 'prop-types'; 7 | import moment from 'moment'; 8 | 9 | import './Message.css'; 10 | 11 | const Message = (props) => { 12 | let { content } = props; 13 | const { author, timestamp } = props; 14 | const msgClass = () => ( 15 | author.indexOf('client') >= 0 ? 'Message__client' : 'Message__operator' 16 | ); 17 | 18 | // ESlint complains about using index here so we use it in a conveluted way 19 | // but I think thats OK. 20 | content = content.map((message, index) =>
  • {message}
  • ); 21 | 22 | let datetime = null; 23 | 24 | // Only show the timestamp on client messages 25 | if (author.indexOf('client') >= 0) { 26 | const momentTimestamp = moment(timestamp); 27 | 28 | // If the timestamp is less than 24 hours, show relative time, otherwise 29 | // show Day of week, Time 30 | datetime = ( 31 | 32 | { 33 | momentTimestamp.isBefore(moment(), 'day') 34 | ? momentTimestamp.format('ddd, h:mma') 35 | : momentTimestamp.fromNow() 36 | } 37 | 38 | ); 39 | } 40 | 41 | let message = ( 42 |
    43 | 46 | {datetime} 47 |
    48 | ); 49 | 50 | return ( 51 |
  • 52 | {message} 53 |
  • 54 | ); 55 | }; 56 | 57 | Message.propTypes = { 58 | author: PropTypes.string.isRequired, 59 | timestamp: PropTypes.string.isRequired, 60 | content: PropTypes.arrayOf( 61 | PropTypes.string, 62 | ), 63 | }; 64 | 65 | Message.defaultProps = { 66 | content: [], 67 | }; 68 | 69 | 70 | export default Message; 71 | 72 | -------------------------------------------------------------------------------- /src/store/Socket/index.js: -------------------------------------------------------------------------------- 1 | const initialState = { 2 | status: null, 3 | }; 4 | 5 | // Constants 6 | // 7 | 8 | const CONNECTED = 'SOCKET_CONNECTED'; 9 | const DISCONNECTED = 'SOCKET_DISCONNECTED'; 10 | const CONNECTION_ERROR = 'SOCKET_CONNECTION_ERROR'; 11 | const CONNECTION_TIMEOUT = 'SOCKET_CONNECTION_TIMEOUT'; 12 | const RECONNECTING = 'SOCKET_RECONNECTING'; 13 | const RECONNECTED = 'SOCKET_RECONNECTED'; 14 | const RECONNECT_ERROR = 'SOCKET_RECONNECT_ERROR'; 15 | const RECONNECT_FAILED = 'SOCKET_RECONNECT_FAILED'; 16 | const RECONNECT_TIMEOUT = 'SOCKET_RECONNECT_TIMEOUT'; 17 | 18 | 19 | // Actions 20 | // 21 | export function socketConnected () { 22 | return { 23 | type: CONNECTED, 24 | }; 25 | } 26 | 27 | export function socketDisconnected () { 28 | return { 29 | type: DISCONNECTED, 30 | }; 31 | } 32 | 33 | export function socketConnectionError (error) { 34 | return { 35 | type: CONNECTION_ERROR, 36 | error, 37 | }; 38 | } 39 | 40 | export function socketConnectionTimeout () { 41 | return { 42 | type: CONNECTION_TIMEOUT, 43 | }; 44 | } 45 | 46 | export function socketReconnecting () { 47 | return { 48 | type: RECONNECTING, 49 | }; 50 | } 51 | 52 | export function socketReconnected (attempt, timeout) { 53 | return { 54 | type: RECONNECTED, 55 | reconnectAttempt: attempt, 56 | reconnectTimeout: timeout, 57 | }; 58 | } 59 | 60 | export function socketReconnectError (error) { 61 | return { 62 | type: RECONNECT_ERROR, 63 | error, 64 | }; 65 | } 66 | 67 | export function socketReconnectFailed () { 68 | return { 69 | type: RECONNECT_FAILED, 70 | }; 71 | } 72 | 73 | export function socketReconnectTimeout () { 74 | return { 75 | type: RECONNECT_TIMEOUT, 76 | }; 77 | } 78 | 79 | 80 | // Reducer 81 | // 82 | function SocketReducer (state = initialState, action) { 83 | switch (action.type) { 84 | default: 85 | return state; 86 | } 87 | } 88 | 89 | export default SocketReducer; 90 | -------------------------------------------------------------------------------- /src/components/Application/Application.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { connect } from 'react-redux'; 4 | 5 | import WelcomeScreen from '../WelcomeScreen/WelcomeScreen.jsx'; 6 | import OperatorPanel from '../OperatorPanel/OperatorPanel.jsx'; 7 | import ClientsPanel from '../ClientsPanel/ClientsPanel.jsx'; 8 | import MessagePanel from '../MessagePanel/MessagePanel.jsx'; 9 | import SettingsPanel from '../SettingsPanel/SettingsPanel.jsx'; 10 | import NotificationBar from '../NotificationBar/NotificationBar.jsx'; 11 | 12 | import './Application.css'; 13 | 14 | 15 | class Application extends Component { 16 | renderScreen = () => { 17 | const { settingsOpen } = this.props; 18 | const { welcomeScreenOpen } = this.props; 19 | 20 | if (welcomeScreenOpen) { 21 | return ; 22 | } 23 | 24 | return [ 25 | , 26 | settingsOpen ? this.renderSettingsView() : this.renderMainView(), 27 | ]; 28 | } 29 | 30 | renderSettingsView = () => ( 31 |
    32 | 33 |
    34 | ) 35 | 36 | renderMainView = () => ( 37 |
    38 | 39 | 40 | 41 |
    42 | ) 43 | 44 | render () { 45 | return ( 46 |
    47 | {this.renderScreen()} 48 |
    49 | ); 50 | } 51 | } 52 | 53 | Application.propTypes = { 54 | welcomeScreenOpen: PropTypes.bool.isRequired, 55 | settingsOpen: PropTypes.bool.isRequired, 56 | }; 57 | 58 | 59 | const mapStateToProps = state => ({ 60 | welcomeScreenOpen: state.ui.welcomeScreenOpen, 61 | settingsOpen: state.ui.settingsOpen, 62 | }); 63 | 64 | const mapDispatchToProps = dispatch => ({ }); 65 | 66 | 67 | export default connect( 68 | mapStateToProps, 69 | mapDispatchToProps, 70 | )(Application); 71 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | let webpack = require('webpack'); 2 | let config = require('./package.json'); 3 | 4 | const development = process.env.NODE_ENV !== 'production'; 5 | 6 | const PATHS = { 7 | BUILD: __dirname + '/assets', 8 | SRC: __dirname + '/src', 9 | ELECTRON_MAIN: __dirname + '/main.js', 10 | ELECTRON_WINDOW: __dirname + '/Window.js', 11 | MODULES: __dirname + '/node_modules', 12 | } 13 | 14 | let plugins = []; 15 | 16 | if (!development) { 17 | plugins.push(new webpack.optimize.UglifyJsPlugin()); 18 | } 19 | 20 | module.exports = function (env) { 21 | console.log('Environment:', process.env.NODE_ENV || 'development'); 22 | 23 | return { 24 | context: __dirname, 25 | devtool: development ? 'source-map' : false, 26 | entry: PATHS.SRC + '/init.jsx', 27 | output: { 28 | filename: 'source.js', 29 | path: PATHS.BUILD + '/js', 30 | }, 31 | module: { 32 | rules: [ 33 | { 34 | test: /\.jsx?$/, 35 | include: [ 36 | PATHS.SRC, 37 | ], 38 | exclude: [ 39 | PATHS.MODULES, 40 | PATHS.ELECTRON_MAIN, 41 | PATHS.ELECTRON_WINDOW, 42 | ], 43 | loader: 'babel-loader', 44 | options: { 45 | presets: [ [ 'env', { // ['es2015', 'stage-0', 'react'], 46 | targets: { 47 | 'electron': ['4'], 48 | }, 49 | useBuiltIns: 'usage', 50 | } ] ], 51 | plugins: [ 52 | '@babel/plugin-proposal-class-properties', 53 | '@babel/plugin-transform-react-jsx', 54 | ], 55 | }, 56 | }, 57 | { 58 | test: /\.css$/, 59 | include: [ 60 | PATHS.SRC, 61 | ], 62 | exclude: [ 63 | PATHS.MODULES, 64 | ], 65 | loader: 'style-loader!css-loader', 66 | }, 67 | ] 68 | }, 69 | target: 'electron-renderer', 70 | plugins: plugins, 71 | }; 72 | } 73 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | 2 | NPM_CMD ?= npm 3 | ZIP_CMD ?= tar 4 | ZIP_OPTIONS ?= -zcf 5 | 6 | PACKAGE = `pwd` 7 | SRC = $(PACKAGE)/src 8 | ASSETS = $(PACKAGE)/assets 9 | DIST = $(PACKAGE)/dist 10 | 11 | .PHONY: coverage test 12 | 13 | default: test coverage clean compile distribute 14 | 15 | run: test lint build start 16 | 17 | dependencies: 18 | $(NPM_CMD) install 19 | 20 | lint: 21 | $(NPM_CMD) run lint 22 | 23 | test: 24 | $(NPM_CMD) test 25 | 26 | coverage: 27 | $(NPM_CMD) run coverage 28 | 29 | clean: 30 | rm -r $(DIST) | true #2>&1 >/dev/null #> /dev/null 2>&1 31 | mkdir -p $(DIST) 32 | 33 | rm -r $(ASSETS)/source.js* | true #2>&1 >/dev/null #> /dev/null 2>&1 34 | 35 | rm -r node_modules/ | true 36 | 37 | build: 38 | $(NPM_CMD) run build 39 | 40 | 41 | compile-linux: 42 | @echo "\nLinux\n" 43 | 44 | $(NPM_CMD) run compile:linux -- \ 45 | --ignore=README.md \ 46 | --ignore=webpack.config.js \ 47 | --ignore=Makefile \ 48 | --ignore=yarn.lock \ 49 | --ignore=.gitignore \ 50 | --ignore=src \ 51 | --ignore=node_modules \ 52 | --win32metadata.CompanyName='Minimal Chat' \ 53 | --win32metadata.ProductName=Operator \ 54 | --appname=Operator \ 55 | --app-copyright=BSD-3 56 | 57 | compile-win: 58 | @echo "\nWindows\n" 59 | $(NPM_CMD) run compile:win -- \ 60 | --ignore=README.md \ 61 | --ignore=webpack.config.js \ 62 | --ignore=Makefile \ 63 | --ignore=yarn.lock \ 64 | --ignore=.gitignore \ 65 | --ignore=src \ 66 | --ignore=node_modules \ 67 | --win32metadata.CompanyName='Minimal Chat' \ 68 | --win32metadata.ProductName=Operator \ 69 | --appname=Operator \ 70 | --app-copyright=BSD-3 71 | 72 | compile-osx: 73 | @echo "\nOSX\n" 74 | $(NPM_CMD) run compile:osx -- \ 75 | --ignore=README.md \ 76 | --ignore=webpack.config.js \ 77 | --ignore=Makefile \ 78 | --ignore=yarn.lock \ 79 | --ignore=.gitignore \ 80 | --ignore=src \ 81 | --ignore=node_modules \ 82 | --win32metadata.CompanyName='Minimal Chat' \ 83 | --win32metadata.ProductName=Operator \ 84 | --appname=Operator \ 85 | --app-copyright=BSD-3 86 | 87 | compile: build compile-linux compile-win compile-osx 88 | 89 | distribute: 90 | @echo 91 | @cd $(DIST); ls -d * | xargs -I [] $(ZIP_CMD) $(ZIP_OPTIONS) [].tar.gz [] 92 | 93 | start: 94 | $(NPM_CMD) start 95 | -------------------------------------------------------------------------------- /src/store/UI/index.js: -------------------------------------------------------------------------------- 1 | const initialState = { 2 | welcomeScreenOpen: false, 3 | settingsOpen: false, 4 | notification: '', 5 | notificationIcon: null, 6 | notificationColour: null, 7 | }; 8 | 9 | // Constants 10 | // 11 | 12 | const TOGGLE_SETTINGS = 'UI_TOGGLE_SETTINGS'; 13 | const TOGGLE_WELCOME_SCREEN = 'UI_TOGGLE_WELCOME_SCREEN'; 14 | 15 | const SHOW_NOTIFICATION = 'UI_SHOW_NOTIFICATION'; 16 | const HIDE_NOTIFICATION = 'UI_HIDE_NOTIFICATION'; 17 | 18 | 19 | // Actions 20 | // 21 | 22 | export function toggleSettings (payload) { 23 | return { type: TOGGLE_SETTINGS, payload }; 24 | } 25 | 26 | export function toggleWelcomeScreen (payload) { 27 | return { 28 | type: TOGGLE_WELCOME_SCREEN, 29 | payload, 30 | }; 31 | } 32 | 33 | export function showNotification (payload) { 34 | return { 35 | type: SHOW_NOTIFICATION, 36 | payload, 37 | }; 38 | } 39 | 40 | export function hideNotification () { 41 | return { 42 | type: HIDE_NOTIFICATION, 43 | }; 44 | } 45 | 46 | 47 | // Reducer 48 | // 49 | 50 | 51 | function UIReducer (state = initialState, action) { 52 | switch (action.type) { 53 | case TOGGLE_WELCOME_SCREEN: 54 | if (action.payload == null) { 55 | return { 56 | ...state, 57 | welcomeScreenOpen: !state.welcomeScreenOpen, 58 | }; 59 | } 60 | return { ...state, welcomeScreenOpen: action.payload }; 61 | // Can take a boolean, or no args, in which case this just toggles opposite 62 | // state value from before 63 | case TOGGLE_SETTINGS: 64 | if (action.payload == null) { 65 | return { 66 | ...state, 67 | settingsOpen: !state.settingsOpen, 68 | }; 69 | } 70 | return { ...state, settingsOpen: action.payload }; 71 | 72 | case SHOW_NOTIFICATION: 73 | return { 74 | ...state, 75 | notification: action.payload.notification, 76 | notificationIcon: action.payload.notificationIcon, 77 | notificationColour: action.payload.notificationColour, 78 | }; 79 | case HIDE_NOTIFICATION: 80 | return { 81 | ...state, 82 | notification: '', 83 | }; 84 | 85 | default: 86 | return state; 87 | } 88 | } 89 | 90 | 91 | export default UIReducer; 92 | -------------------------------------------------------------------------------- /src/components/OperatorPanel/OperatorPanel.css: -------------------------------------------------------------------------------- 1 | .OperatorPanel { 2 | background: #fdfdfd; 3 | box-shadow: 1px 0 1px rgba(0, 0, 0, 0.1); 4 | display: flex; 5 | flex-direction: column; 6 | flex: 0 0 200px; 7 | z-index: 4; 8 | } 9 | 10 | 11 | .OperatorPanel__header { 12 | -webkit-app-region: drag; 13 | position: relative; 14 | align-self: center; 15 | align-items: center; 16 | background: rgba(52, 73, 94, 1.0); 17 | color: #afafaf; 18 | display: flex; 19 | flex-direction: row; 20 | font-family: Roboto, sans-serif; 21 | font-size: 12px; 22 | height: 50px; 23 | width: 100%; 24 | justify-content: center; 25 | letter-spacing: 2px; 26 | text-transform: uppercase; 27 | text-align: center; 28 | } 29 | 30 | .OperatorPanel__header span { 31 | font-family: Roboto, sans-serif; 32 | font-weight: 600; 33 | color: white; 34 | } 35 | 36 | .OperatorPanel__header .SVG__logo { 37 | flex-grow: 1; 38 | height: 20px; 39 | margin-right: 10px; 40 | margin-bottom: 3px; 41 | fill: rgba(255, 255, 255, 1.0); 42 | } 43 | 44 | .OperatorPanel__appname { 45 | flex-grow: 1; 46 | text-align: left; 47 | } 48 | 49 | .OperatorPanel__footer { 50 | color: #777; 51 | font-size: 10px; 52 | text-align: center; 53 | } 54 | 55 | 56 | .OperatorPanel__settings { 57 | align-items: center; 58 | background: #F7F8FA; 59 | border: none; 60 | border-bottom: 1px solid #E8ECEE; 61 | border-top: 1px solid #E8ECEE; 62 | color: #8f8f8f; 63 | cursor: pointer; 64 | display: flex; 65 | font-size: 12px; 66 | justify-content: center; 67 | margin-bottom: 1rem; 68 | padding: 1rem 0 0.7rem 0; 69 | width: 100%; 70 | outline: none; 71 | } 72 | 73 | .OperatorPanel__settings .ss-icon { 74 | font-size: 18px; 75 | flex-grow: 1; 76 | } 77 | 78 | .OperatorPanel__settings span:not(.ss-icon) { 79 | height: 18px; 80 | flex-grow: 2; 81 | text-align: left; 82 | padding-left: 20px; 83 | } 84 | 85 | /* 86 | .OperatorPanel__settings:focus { 87 | outline: none; 88 | } 89 | */ 90 | 91 | .OperatorPanel__settings:hover { 92 | background: #EFF0F2; 93 | } 94 | 95 | .OperatorPanel__settings:active { 96 | background: #E8E9EB; 97 | } 98 | 99 | 100 | .OperatorPanel__madewith { 101 | display: flex; 102 | justify-content: center; 103 | padding-bottom: 1rem; 104 | } 105 | -------------------------------------------------------------------------------- /src/store/dummy.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This file generates dummy data for the chats array and messages array 3 | * to be used in the chat reducer. 4 | * makeDummy is the main function that returns `x` chats and `y` messages in a single object 5 | */ 6 | 7 | import faker from 'faker/locale/en'; 8 | 9 | 10 | // get a random author type for generating operator and client message types 11 | function generateUserType (chatSessionId) { 12 | const rnd = Math.floor(Math.random() * 2) + 1; 13 | switch (rnd) { 14 | case 1: 15 | return 'operator'; 16 | case 2: 17 | return `client.${chatSessionId}`; 18 | default: 19 | return `client.${chatSessionId}`; 20 | } 21 | } 22 | 23 | 24 | class Message { 25 | author = null 26 | 27 | chat = null 28 | 29 | content = faker.lorem.sentence() 30 | 31 | timestamp = faker.date.recent() 32 | 33 | constructor (chatSessionId) { 34 | this.author = generateUserType(chatSessionId); 35 | this.chat = chatSessionId; 36 | } 37 | } 38 | 39 | 40 | class Chat { 41 | client = { 42 | id: null, 43 | first_name: faker.name.firstName(), 44 | last_name: faker.name.lastName(), 45 | name: 'site visitor', 46 | } 47 | 48 | creationTime = faker.date.recent() 49 | 50 | updatedTime = faker.date.recent().toISOString() 51 | 52 | id = null 53 | 54 | operator = null 55 | 56 | open = Math.random() >= 0.5 57 | 58 | constructor (chatSessionId, clientId, operator) { 59 | this.id = chatSessionId; 60 | this.client.id = clientId; 61 | this.operator = operator; 62 | } 63 | } 64 | 65 | 66 | 67 | // creates one chatSession and multiple messages for that session 68 | // some messages belong to a client, some to a dummy operator. 69 | export default function makeDummy (numDummy, numMessages) { 70 | const chatSessions = []; 71 | const messages = []; 72 | const rndNumMessages = Math.floor(Math.random() * (numMessages - (0 + 1))) + 1; 73 | 74 | for (let i = 0; i < numDummy; i += 1) { 75 | const chatSessionId = faker.random.uuid(); 76 | const clientId = faker.random.uuid(); 77 | chatSessions.push(new Chat(chatSessionId, clientId, 'Joe')); 78 | 79 | // create the messages unique to the above created session. 80 | for (let j = 0; j < rndNumMessages; j += 1) { 81 | messages.push(new Message(chatSessionId, clientId)); 82 | } 83 | } 84 | 85 | return { chatSessions, messages }; 86 | } 87 | -------------------------------------------------------------------------------- /src/components/OperatorClientMenu/OperatorClientMenu.jsx: -------------------------------------------------------------------------------- 1 | /** OperatorClientMenu 2 | * Responsible for displaying all menu related things to an operator. 3 | * Filters chats by type (open, closed, all). 4 | * TODO: implement 'Assigned To Me' functionality (0.2) 5 | */ 6 | 7 | import React from 'react'; 8 | import PropTypes from 'prop-types'; 9 | import { connect } from 'react-redux'; 10 | import './OperatorClientMenu.css'; 11 | import { setOperatorFilter } from '../../store/Chat'; 12 | import { toggleSettings } from '../../store/UI'; 13 | 14 | const OperatorClientMenu = (props) => { 15 | const { setFilter, operatorFilter, openChats } = props; 16 | 17 | const menuItems = [ 18 | /* { name: 'Assigned to Me', id: 'assigned_to_me' }, */ // 0.2 19 | { name: 'All', id: 'all' }, 20 | { name: `Open (${openChats})`, id: 'open' }, 21 | { name: 'Closed', id: 'closed' }, 22 | ]; 23 | 24 | // toss the above array into a list with dynamic classes. 25 | const renderMenuItems = () => ( 26 | menuItems.map((i) => { 27 | const classes = i.id === operatorFilter 28 | ? 'OperatorClient__selectedFilter' 29 | : 'OperatorClient__filter'; 30 | 31 | return ( 32 |
  • 33 | 36 |
  • 37 | ); 38 | }) 39 | ); 40 | 41 | 42 | return ( 43 | 46 | ); 47 | }; 48 | 49 | OperatorClientMenu.propTypes = { 50 | setFilter: PropTypes.func.isRequired, 51 | operatorFilter: PropTypes.string, 52 | openChats: PropTypes.number, 53 | }; 54 | 55 | OperatorClientMenu.defaultProps = { 56 | openChats: 0, 57 | operatorFilter: '', 58 | }; 59 | 60 | 61 | const mapStateToProps = state => ({ 62 | operatorFilter: state.chat.operatorFilter, 63 | openChats: Object 64 | .keys(state.chat.chats) 65 | .map(k => state.chat.chats[k]) 66 | .filter(chat => chat.open) 67 | .length, 68 | }); 69 | 70 | const mapDispatchToProps = dispatch => ({ 71 | setFilter: (filterType) => { 72 | dispatch(setOperatorFilter(filterType)); 73 | dispatch(toggleSettings(false)); 74 | }, 75 | }); 76 | 77 | 78 | export default connect( 79 | mapStateToProps, 80 | mapDispatchToProps, 81 | )(OperatorClientMenu); 82 | -------------------------------------------------------------------------------- /src/components/ClientCard/ClientCard.css: -------------------------------------------------------------------------------- 1 | .ClientCard { 2 | display: flex; 3 | height: 3rem; 4 | border-bottom: 1px solid #efefef; 5 | cursor: pointer; 6 | } 7 | 8 | .ClientCard__btn { 9 | width: 100%; 10 | height: 100%; 11 | margin: 0; 12 | padding: 0; 13 | background: none; 14 | border: 0; 15 | outline: 0; 16 | cursor: pointer; 17 | display: flex; 18 | } 19 | 20 | .ClientCard--active .ClientCard__btn, 21 | .ClientCard__btn:hover { 22 | background: #fdfdfd; 23 | } 24 | 25 | .ClientCard--active .ClientCard__btn .ClientCard__icon, 26 | .ClientCard__btn:hover .ClientCard__icon { 27 | color: rgba(52, 152, 219, 1.0); 28 | } 29 | 30 | .ClientCard--done .ClientCard__btn, 31 | .ClientCard--done .ClientCard__btn:hover { 32 | background: transparent; 33 | opacity: 0.5; 34 | } 35 | 36 | .ClientCard--done .ClientCard__btn .ClientCard__icon, 37 | .ClientCard--done .ClientCard__btn:hover .ClientCard__icon { 38 | color: #ccc; 39 | } 40 | 41 | 42 | .ClientCard__icon { 43 | flex-grow: 1; 44 | height: 100%; 45 | padding: 8px 8px 0; 46 | box-sizing: border-box; 47 | font-size: 24px; 48 | line-height: 18px; 49 | color: #ccc; 50 | } 51 | 52 | .ClientCard__information { 53 | flex-grow: 20; 54 | } 55 | 56 | .ClientCard__information-row { 57 | display: flex; 58 | } 59 | 60 | .ClientCard__name { 61 | flex-grow: 4; 62 | text-align: left; 63 | } 64 | 65 | .ClientCard__status { 66 | flex-grow: 1; 67 | text-align: right; 68 | position: relative; 69 | } 70 | 71 | .ClientCard__status:after { 72 | content: ""; 73 | width: 8px; 74 | height: 8px; 75 | display: block; 76 | border-radius: 50%; 77 | position: absolute; 78 | right: 10px; 79 | top: 0; 80 | } 81 | 82 | .ClientCard__status--offline:after { 83 | background: rgba(236, 240, 241, 1.0); 84 | } 85 | 86 | .ClientCard__status--online:after { 87 | background: rgba(46, 204, 113, 1.0); 88 | } 89 | 90 | .ClientCard__lastmsg { 91 | flex-grow: 4; 92 | text-align: left; 93 | font-style: italic; 94 | font-size: 11px; 95 | color: #555; 96 | overflow: hidden; 97 | max-width: 300px; 98 | white-space: nowrap; 99 | text-overflow: ellipsis; 100 | } 101 | 102 | .ClientCard__lastmsgtime { 103 | flex-grow: 1; 104 | text-align: right; 105 | font-style: italic; 106 | font-size: 11px; 107 | padding-right: 10px; 108 | color: #555; 109 | } 110 | 111 | -------------------------------------------------------------------------------- /server/build-menu.js: -------------------------------------------------------------------------------- 1 | const electron = require("electron"); 2 | // Module to control application life. 3 | const { app, ipcMain } = electron; 4 | // Module to create native browser window. 5 | const BrowserWindow = electron.BrowserWindow; 6 | const Menu = electron.Menu; 7 | const MenuItem = electron.MenuItem; 8 | 9 | const menuTemplate = [ 10 | { 11 | label: "File", 12 | submenu: [ 13 | { 14 | label: "Preferences", 15 | click() { 16 | // TODO: send ipc to front end for opening settings 17 | console.log("do preferences stuff"); 18 | return; 19 | } 20 | }, 21 | { type: "separator" }, 22 | 23 | // NOTE: > 1.0 24 | /* { 25 | * label: "Close Conversation", 26 | * accelerator: "CmdOrCtrl+W", 27 | * click() { 28 | * return; 29 | * } 30 | * },*/ 31 | 32 | // NOTE: > 1.0 33 | /* { 34 | * label: "Close Window", 35 | * accelerator: "CmdOrCtrl+Shift+W", 36 | * click() { 37 | * return; 38 | * } 39 | * }, 40 | * { type: "separator" }, */ 41 | 42 | { 43 | label: "Quit", 44 | accelerator: "CmdOrCtrl+Q", 45 | role: "quit", 46 | click() { 47 | app.quit(); 48 | } 49 | } 50 | ] 51 | }, 52 | { 53 | label: "Edit", 54 | submenu: [ 55 | { role: "undo" }, 56 | { role: "redo" }, 57 | { type: "separator" }, 58 | { role: "cut" }, 59 | { role: "copy" }, 60 | { role: "paste" }, 61 | { type: "separator" } 62 | ] 63 | }, 64 | { 65 | label: "Help", 66 | submenu: [ 67 | { 68 | label: "Report Issue...", 69 | // open link to report issue on github 70 | // defaulting to a `new` issues template would be cool but it requires 71 | // being logged into github 72 | click() { 73 | require("electron").shell.openExternal( 74 | "https://github.com/minimalchat/operator-app/issues/" 75 | ); 76 | } 77 | }, 78 | { type: "separator" }, 79 | { 80 | label: "About Operator...", 81 | click() { 82 | require("electron").shell.openExternal( 83 | "https://github.com/minimalchat/operator-app#minimal-chat-operator-application" 84 | ); 85 | } 86 | } 87 | ] 88 | } 89 | ]; 90 | 91 | // Assign menu 92 | function buildMenu() { 93 | Menu.setApplicationMenu(Menu.buildFromTemplate(menuTemplate)); 94 | } 95 | 96 | module.exports = buildMenu; 97 | -------------------------------------------------------------------------------- /src/components/SettingsPanel/SettingsPanel.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { connect } from 'react-redux'; 4 | import Toggle from 'react-toggle'; 5 | 6 | import Button from '../Button/Button.jsx'; 7 | import { updateSettings } from '../../store/Config'; 8 | 9 | import '../Toggle/index.css'; 10 | import './SettingsPanel.css'; 11 | 12 | const SettingsPanel = (props) => { 13 | const { 14 | notificationsEnabled, changeSettings, operator, disconnect, 15 | } = props; 16 | 17 | return ( 18 |
    19 |

    Settings

    20 | 21 |
    22 |
    23 |
    Operator
    24 | changeSettings({ operator: ev.currentTarget.value })} 29 | /> 30 |
    31 |
    32 |
    Disable notifications
    33 | changeSettings({ notificationsEnabled: !notificationsEnabled })} 36 | /> 37 |
    38 |
    39 |
    40 | 41 |
    42 |
    43 |
    44 |
    45 | ); 46 | }; 47 | 48 | SettingsPanel.propTypes = { 49 | notificationsEnabled: PropTypes.bool, 50 | changeSettings: PropTypes.func.isRequired, 51 | disconnect: PropTypes.func.isRequired, 52 | operator: PropTypes.string, 53 | }; 54 | 55 | SettingsPanel.defaultProps = { 56 | notificationsEnabled: false, 57 | operator: '', 58 | }; 59 | 60 | const mapStateToProps = state => ({ 61 | notificationsEnabled: state.config.notificationsEnabled, 62 | operator: state.config.operator, 63 | }); 64 | 65 | const mapDispatchToProps = dispatch => ({ 66 | changeSettings: newSettings => dispatch(updateSettings(newSettings)), 67 | disconnect: () => { 68 | dispatch(updateSettings({ apiServer: '' })); 69 | // Reload the page 70 | return window.location.reload(); 71 | }, 72 | }); 73 | 74 | export default connect( 75 | mapStateToProps, 76 | mapDispatchToProps, 77 | )(SettingsPanel); 78 | -------------------------------------------------------------------------------- /Window.js: -------------------------------------------------------------------------------- 1 | const electron = require('electron'); 2 | // Module to control application life. 3 | const { app, ipcMain } = electron; 4 | // Module to create native browser window. 5 | const BrowserWindow = electron.BrowserWindow; 6 | const Menu = electron.Menu; 7 | const MenuItem = electron.MenuItem; 8 | 9 | const fs = require('fs'); 10 | const path = require('path'); 11 | const url = require('url'); 12 | 13 | const buildMenu = require('./server/build-menu'); 14 | const events = require('./server/events'); 15 | 16 | const WINDOW_HEIGHT = 768; 17 | const WINDOW_WIDTH = 1024; 18 | 19 | const defaultURL = url.format({ 20 | pathname: path.join(__dirname, 'index.html'), 21 | protocol: 'file:', 22 | slashes: true, 23 | }); 24 | 25 | let windowObject; 26 | 27 | module.exports = class Window { 28 | static isNull () { 29 | return windowObject === null; 30 | } 31 | 32 | // Convenience function for electron's 'ready' event 33 | static onReady () { 34 | return Window.create(); 35 | } 36 | 37 | // Shortcut function to create an instance of Window 38 | static create (url = defaultURL, width = WINDOW_WIDTH, height = WINDOW_HEIGHT) { 39 | return new Window(url, width, height); 40 | } 41 | 42 | constructor (url = defaultURL, width = WINDOW_WIDTH, height = WINDOW_HEIGHT) { 43 | // Keep a global reference of the window object, if you don't, the window will 44 | // be closed automatically when the JavaScript object is garbage collected. 45 | 46 | this.window = windowObject = new BrowserWindow({ 47 | webPreferences: { 48 | nodeIntegration: true, 49 | }, 50 | titleBarStyle: 'hidden', 51 | width, 52 | height, 53 | }); 54 | 55 | this.window.loadURL(url); 56 | 57 | if(process.env.NODE_ENV === "development") { 58 | this.window.webContents.openDevTools(); 59 | } 60 | 61 | this.window.on('closed', this.onClosed); 62 | 63 | // Setup IPC handling 64 | this.configureIpc(); 65 | 66 | // Build out the application menu 67 | buildMenu(); 68 | this.window.setAutoHideMenuBar(true); 69 | this.window.setMenuBarVisibility(false); 70 | } 71 | 72 | // sets up all event handlers for messages main<->renderer 73 | configureIpc () { 74 | ipcMain.on('init-config', events.initConfig); 75 | ipcMain.on('update-settings', events.updateSettings); 76 | } 77 | 78 | // Window events 79 | onClosed () { 80 | // Dereference the window object, usually you would store windows 81 | // in an array if your app supports multi windows, this is the time 82 | // when you should delete the corresponding element. 83 | this.window = windowObject = null; 84 | } 85 | }; 86 | -------------------------------------------------------------------------------- /src/components/ClientList/ClientList.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { connect } from 'react-redux'; 4 | 5 | import ClientCard from '../ClientCard/ClientCard.jsx'; 6 | import { loadChats } from '../../store/Chat'; 7 | import './ClientList.css'; 8 | 9 | class ClientList extends Component { 10 | static propTypes = { 11 | config: PropTypes.object.isRequired, 12 | chats: PropTypes.object.isRequired, 13 | operatorFilter: PropTypes.string.isRequired, 14 | query: PropTypes.string.isRequired, 15 | loadClientList: PropTypes.func.isRequired, 16 | } 17 | 18 | componentWillMount () { 19 | const { config: { apiServer }, loadClientList } = this.props; 20 | 21 | if (apiServer) { 22 | loadClientList(apiServer); 23 | } 24 | } 25 | 26 | getChats = () => { 27 | const { operatorFilter } = this.props; 28 | 29 | const filteredChats = this.filterByQuery().filter((chat) => { 30 | if (operatorFilter === 'open') return chat.open; 31 | if (operatorFilter === 'closed') return !chat.open; 32 | return chat; 33 | }); 34 | 35 | return filteredChats.map((chat) => { 36 | // Avoids an async issue where chat client isn't avail. Weird bug. 37 | if (chat.client == null) return null; 38 | return ( 39 | 40 | {`${chat.client.first_name} ${chat.client.last_name}`} 41 | 42 | ); 43 | }); 44 | } 45 | 46 | filterByQuery = () => { 47 | const { chats, query } = this.props; 48 | let filteredChats = Object.keys(chats) 49 | .map(k => Object.assign({}, chats[k], { 50 | id: k, 51 | })) 52 | .sort((curr, next) => ( 53 | new Date(next.updated_time) - new Date(curr.updated_time) 54 | )); 55 | 56 | if (query === '') return filteredChats; 57 | 58 | const lowerQuery = query.trim().toLowerCase(); 59 | 60 | return filteredChats 61 | .filter(chat => ( 62 | `${chat.client.first_name.toLowerCase()} ${chat.client.last_name.toLowerCase()}`.includes(lowerQuery) 63 | )); 64 | } 65 | 66 | render () { 67 | return ( 68 | 69 | ); 70 | } 71 | } 72 | 73 | const mapStateToProps = state => ({ 74 | operatorFilter: state.chat.operatorFilter, 75 | config: state.chat.config, 76 | chats: state.chat.chats, 77 | }); 78 | 79 | const mapDispatchToProps = dispatch => ({ 80 | loadClientList: async (apiServer) => { dispatch(await loadChats(apiServer)); }, 81 | }); 82 | 83 | export default connect( 84 | mapStateToProps, 85 | mapDispatchToProps, 86 | )(ClientList); 87 | -------------------------------------------------------------------------------- /src/store/Config/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This file handles the state / actions for writing / reading the config.json file 3 | * The config state object needs to match the keys of the back end config json file. 4 | * An example interaction with this file / these functions would be as follows: 5 | * User toggles a setting such as: "disable notifications" 6 | |> clicking the toggle dispatches an action 7 | |> the action triggers an ipcSend call sending the config object to the server 8 | |> the server takes the data and tries to write it to the config file 9 | |> -> on success: return payload, and, finally, update the config state object 10 | |> -> on failure: return a message indicating a failure; client config state doesn't change 11 | */ 12 | 13 | // setup 14 | 15 | import { ipcRenderer } from 'electron'; 16 | 17 | const initialState = { 18 | apiServer: null, 19 | operator: null, 20 | notificationsEnabled: null, 21 | }; 22 | 23 | // Constants 24 | 25 | const SET_CONFIG = 'SET_CONFIG'; 26 | const SET_API_SERVER = 'SET_API_SERVER'; 27 | const UPDATE_SETTINGS = 'UPDATE_SETTINGS'; 28 | 29 | 30 | // Actions 31 | 32 | export function setConfig (payload) { 33 | return { 34 | type: SET_CONFIG, 35 | payload, 36 | }; 37 | } 38 | 39 | export function updateSettings (payload) { 40 | return { 41 | type: UPDATE_SETTINGS, 42 | payload, 43 | }; 44 | } 45 | 46 | 47 | // Reducer 48 | 49 | function ConfigReducer (state = initialState, action) { 50 | let newSettings = {}; 51 | switch (action.type) { 52 | case SET_CONFIG: 53 | // Triggered by incoming IPC message: 'config' 54 | // NOTE: payload from server should match the exact initial state in this file; 55 | 56 | // IPC ON: 'config' <-- 57 | 58 | // Attach config to window to avoid doing all the passing around of the 59 | // state between reducers 60 | // Example: the chat reducer needs to know the config data, but that would 61 | // entail passing the entire store and accessing the config object 62 | // every time a message gets sent (referring to when to show a message 63 | // notification). 64 | 65 | // NOTE: consider refactoring. Works for now. 66 | window.config = Object.assign({}, state, action.payload); 67 | return window.config; 68 | 69 | 70 | case UPDATE_SETTINGS: 71 | // Sends payload to server to be written to file 72 | 73 | // IPC SEND: 'update-settings' --> 74 | newSettings = Object.assign({}, state, action.payload); 75 | 76 | ipcRenderer.send('update-settings', newSettings); 77 | return newSettings; 78 | 79 | default: 80 | return state; 81 | } 82 | } 83 | 84 | export default ConfigReducer; 85 | -------------------------------------------------------------------------------- /src/components/ClientList/__snapshots__/ClientList.test.jsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`ClientList matches snapshot 1`] = ` 4 | 98 | `; 99 | -------------------------------------------------------------------------------- /src/components/InputBar/InputBar.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { connect } from 'react-redux'; 4 | 5 | import Button from '../Button/Button.jsx'; 6 | import { sendMessage, operatorTyping } from '../../store/Chat'; 7 | import './InputBar.css'; 8 | 9 | // TODO: Move to a constants file 10 | const KEY_ENTER = 13; 11 | 12 | class InputBar extends Component { 13 | static propTypes = { 14 | operator: PropTypes.string, 15 | activeId: PropTypes.string, 16 | dispatch: PropTypes.func.isRequired, 17 | } 18 | 19 | static defaultProps = { 20 | operator: '', 21 | activeId: '', 22 | } 23 | 24 | state = { 25 | chatText: '', 26 | } 27 | 28 | onKeyPress = (e) => { 29 | const { 30 | key, keyCode, shiftKey, ctrlKey, altKey, 31 | } = e; 32 | const { 33 | dispatch, activeId, operator, 34 | } = this.props; 35 | console.log(`INPUT KEYPRESS ${key} (${keyCode}), SHIFT ${shiftKey}, CTRL ${ctrlKey}, ALT ${altKey}`); 36 | 37 | if (keyCode === KEY_ENTER) { 38 | if (!shiftKey) { 39 | console.log('SENDING MESSAGE ...'); 40 | 41 | this.sendChat(); 42 | this.setState({ [e.target.name]: '' }); 43 | 44 | e.preventDefault(); 45 | } 46 | } else { 47 | dispatch(operatorTyping(this.formatMessage(null, operator, activeId))); 48 | } 49 | } 50 | 51 | formatMessage = (content, operatorID, chatID) => ({ 52 | timestamp: (new Date()).toISOString(), 53 | author: `operator.${operatorID}`, 54 | content, 55 | chat: chatID, 56 | }) 57 | 58 | // Dummy function to stub out sending message via socket 59 | // this will eventually happen via redux actions. 60 | sendChat = () => { 61 | const { chatText } = this.state; 62 | const { dispatch, activeId, operator } = this.props; 63 | 64 | dispatch(sendMessage(this.formatMessage(chatText, operator, activeId))); 65 | } 66 | 67 | handleChange = (e) => { 68 | this.setState({ 69 | [e.target.name]: e.target.value, 70 | }); 71 | } 72 | 73 | render () { 74 | const { chatText } = this.state; 75 | return ( 76 |
    77 | 84 | 85 |
    86 | ); 87 | } 88 | } 89 | 90 | const mapStateToProps = state => ({ 91 | activeId: state.chat.activeId, 92 | operator: state.config.operator, 93 | }); 94 | 95 | const mapDispatchToProps = dispatch => ({ 96 | dispatch, 97 | }); 98 | 99 | 100 | export default connect( 101 | mapStateToProps, 102 | mapDispatchToProps, 103 | )(InputBar); 104 | -------------------------------------------------------------------------------- /src/components/WelcomeScreen/WelcomeScreen.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { connect } from 'react-redux'; 4 | 5 | import BrandLogo from '../SVG/BrandLogo.jsx'; 6 | import Button from '../Button/Button.jsx'; 7 | import { updateSettings } from '../../store/Config'; 8 | import { toggleWelcomeScreen } from '../../store/UI'; 9 | 10 | import './WelcomeScreen.css'; 11 | 12 | class WelcomeScreen extends Component { 13 | static propTypes = { 14 | changeSettings: PropTypes.func.isRequired, 15 | } 16 | 17 | state = { 18 | connString: '', 19 | } 20 | 21 | parseConnectionString = (str) => { 22 | let accessId; 23 | let accessToken; 24 | let apiServer; 25 | 26 | const [proto, protolessStr] = str.split('://'); 27 | 28 | if (protolessStr.lastIndexOf('@') > -1) { 29 | // Pull the access_id and access_token's out from the string 30 | [accessId, accessToken] = protolessStr.slice(0, protolessStr.lastIndexOf('@')).split(':'); 31 | 32 | // Pull the api server host from the string (Note: we dont care if the 33 | // port is left in there or not, it will work either way) 34 | apiServer = protolessStr.slice(protolessStr.lastIndexOf('@') + 1); 35 | } else { 36 | apiServer = protolessStr; 37 | } 38 | 39 | return { 40 | proto, 41 | accessId, 42 | accessToken, 43 | apiServer, 44 | }; 45 | } 46 | 47 | connect = () => { 48 | const { changeSettings } = this.props; 49 | const { connString } = this.state; 50 | 51 | // TODO: Test connection by pulling the operator information 52 | const { 53 | proto, 54 | accessId, 55 | accessToken, 56 | apiServer, 57 | } = this.parseConnectionString(connString); 58 | 59 | // loadOperator() 60 | console.log('NEW SETTINGS', apiServer, accessId, accessToken); 61 | 62 | changeSettings({ 63 | apiServerAccessId: accessId, 64 | apiServerAccessToken: accessToken, 65 | apiServer: `${proto}://${apiServer}`, 66 | }); 67 | } 68 | 69 | handleChange = (e) => { 70 | this.setState({ 71 | [e.target.name]: e.target.value, 72 | }); 73 | } 74 | 75 | render () { 76 | const { connString } = this.state; 77 | 78 | return ( 79 |
    80 |
    81 | 82 | Operator 83 |
    84 |
    85 |
    86 | 93 | 94 |
    95 |
    96 |
    97 | ); 98 | } 99 | } 100 | 101 | const mapStateToProps = state => ({ 102 | 103 | }); 104 | 105 | const mapDispatchToProps = dispatch => ({ 106 | changeSettings: newSettings => dispatch(updateSettings(newSettings)) 107 | && dispatch(toggleWelcomeScreen(false)), 108 | }); 109 | 110 | export default connect( 111 | mapStateToProps, 112 | mapDispatchToProps, 113 | )(WelcomeScreen); 114 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Minimal Chat Community Code of Conduct 2 | 3 | ## Contributor Code of Conduct 4 | 5 | ### Our Pledge 6 | 7 | In the interest of fostering an open and welcoming environment, we as 8 | contributors and maintainers pledge to making participation in our project and 9 | our community a harassment-free experience for everyone, regardless of age, body 10 | size, disability, ethnicity, gender identity and expression, level of experience, 11 | nationality, personal appearance, race, religion, or sexual identity and 12 | orientation. 13 | 14 | ### Our Standards 15 | 16 | Examples of behavior that contributes to creating a positive environment 17 | include: 18 | 19 | * Using welcoming and inclusive language 20 | * Being respectful of differing viewpoints and experiences 21 | * Gracefully accepting constructive criticism 22 | * Focusing on what is best for the community 23 | * Showing empathy towards other community members 24 | 25 | Examples of unacceptable behavior by participants include: 26 | 27 | * The use of sexualized language or imagery and unwelcome sexual attention or 28 | advances 29 | * Trolling, insulting/derogatory comments, and personal or political attacks 30 | * Public or private harassment 31 | * Publishing others' private information, such as a physical or electronic 32 | address, without explicit permission 33 | * Other conduct which could reasonably be considered inappropriate in a 34 | professional setting 35 | 36 | ### Our Responsibilities 37 | 38 | Project maintainers are responsible for clarifying the standards of acceptable 39 | behavior and are expected to take appropriate and fair corrective action in 40 | response to any instances of unacceptable behavior. 41 | 42 | Project maintainers have the right and responsibility to remove, edit, or 43 | reject comments, commits, code, wiki edits, issues, and other contributions 44 | that are not aligned to this Code of Conduct, or to ban temporarily or 45 | permanently any contributor for other behaviors that they deem inappropriate, 46 | threatening, offensive, or harmful. 47 | 48 | ### Scope 49 | 50 | This Code of Conduct applies both within project spaces and in public spaces 51 | when an individual is representing the project or its community. Examples of 52 | representing a project or community include using an official project e-mail 53 | address, posting via an official social media account, or acting as an appointed 54 | representative at an online or offline event. Representation of a project may be 55 | further defined and clarified by project maintainers. 56 | 57 | ### Enforcement 58 | 59 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 60 | reported by contacting the project team at contact@minimal.chat. All 61 | complaints will be reviewed and investigated and will result in a response that 62 | is deemed necessary and appropriate to the circumstances. The project team is 63 | obligated to maintain confidentiality with regard to the reporter of an incident. 64 | Further details of specific enforcement policies may be posted separately. 65 | 66 | Project maintainers who do not follow or enforce the Code of Conduct in good 67 | faith may face temporary or permanent repercussions as determined by other 68 | members of the project's leadership. 69 | 70 | ### Attribution 71 | 72 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 73 | available at [http://contributor-covenant.org/version/1/4][version] 74 | 75 | [homepage]: http://contributor-covenant.org 76 | [version]: http://contributor-covenant.org/version/1/4/ 77 | -------------------------------------------------------------------------------- /src/components/Toggle/index.css: -------------------------------------------------------------------------------- 1 | .react-toggle { 2 | touch-action: pan-x; 3 | 4 | display: inline-block; 5 | position: relative; 6 | cursor: pointer; 7 | background-color: transparent; 8 | border: 0; 9 | padding: 0; 10 | 11 | -webkit-touch-callout: none; 12 | -webkit-user-select: none; 13 | -khtml-user-select: none; 14 | -moz-user-select: none; 15 | -ms-user-select: none; 16 | user-select: none; 17 | 18 | -webkit-tap-highlight-color: rgba(0,0,0,0); 19 | -webkit-tap-highlight-color: transparent; 20 | } 21 | 22 | .react-toggle-screenreader-only { 23 | border: 0; 24 | clip: rect(0 0 0 0); 25 | height: 1px; 26 | margin: -1px; 27 | overflow: hidden; 28 | padding: 0; 29 | position: absolute; 30 | width: 1px; 31 | } 32 | 33 | .react-toggle--disabled { 34 | cursor: not-allowed; 35 | opacity: 0.5; 36 | -webkit-transition: opacity 0.25s; 37 | transition: opacity 0.25s; 38 | } 39 | 40 | .react-toggle-track { 41 | width: 50px; 42 | height: 24px; 43 | padding: 0; 44 | border-radius: 30px; 45 | background-color: #4D4D4D; 46 | -webkit-transition: all 0.2s ease; 47 | -moz-transition: all 0.2s ease; 48 | transition: all 0.2s ease; 49 | } 50 | 51 | .react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track { 52 | background-color: #000000; 53 | } 54 | 55 | .react-toggle--checked .react-toggle-track { 56 | background-color: #19AB27; 57 | } 58 | 59 | .react-toggle--checked:hover:not(.react-toggle--disabled) .react-toggle-track { 60 | background-color: #128D15; 61 | } 62 | 63 | .react-toggle-track-check { 64 | position: absolute; 65 | width: 14px; 66 | height: 10px; 67 | top: 0px; 68 | bottom: 0px; 69 | margin-top: auto; 70 | margin-bottom: auto; 71 | line-height: 0; 72 | left: 8px; 73 | opacity: 0; 74 | -webkit-transition: opacity 0.25s ease; 75 | -moz-transition: opacity 0.25s ease; 76 | transition: opacity 0.25s ease; 77 | } 78 | 79 | .react-toggle--checked .react-toggle-track-check { 80 | opacity: 1; 81 | -webkit-transition: opacity 0.25s ease; 82 | -moz-transition: opacity 0.25s ease; 83 | transition: opacity 0.25s ease; 84 | } 85 | 86 | .react-toggle-track-x { 87 | position: absolute; 88 | width: 10px; 89 | height: 10px; 90 | top: 0px; 91 | bottom: 0px; 92 | margin-top: auto; 93 | margin-bottom: auto; 94 | line-height: 0; 95 | right: 10px; 96 | opacity: 1; 97 | -webkit-transition: opacity 0.25s ease; 98 | -moz-transition: opacity 0.25s ease; 99 | transition: opacity 0.25s ease; 100 | } 101 | 102 | .react-toggle--checked .react-toggle-track-x { 103 | opacity: 0; 104 | } 105 | 106 | .react-toggle-thumb { 107 | transition: all 0.5s cubic-bezier(0.23, 1, 0.32, 1) 0ms; 108 | position: absolute; 109 | top: 1px; 110 | left: 1px; 111 | width: 22px; 112 | height: 22px; 113 | border: 1px solid #4D4D4D; 114 | border-radius: 50%; 115 | background-color: #FAFAFA; 116 | 117 | -webkit-box-sizing: border-box; 118 | -moz-box-sizing: border-box; 119 | box-sizing: border-box; 120 | 121 | -webkit-transition: all 0.25s ease; 122 | -moz-transition: all 0.25s ease; 123 | transition: all 0.25s ease; 124 | } 125 | 126 | .react-toggle--checked .react-toggle-thumb { 127 | left: 27px; 128 | border-color: #19AB27; 129 | } 130 | 131 | .react-toggle--focus .react-toggle-thumb { 132 | -webkit-box-shadow: 0px 0px 3px 2px #0099E0; 133 | -moz-box-shadow: 0px 0px 3px 2px #0099E0; 134 | box-shadow: 0px 0px 2px 3px #0099E0; 135 | } 136 | 137 | .react-toggle:active:not(.react-toggle--disabled) .react-toggle-thumb { 138 | -webkit-box-shadow: 0px 0px 5px 5px #0099E0; 139 | -moz-box-shadow: 0px 0px 5px 5px #0099E0; 140 | box-shadow: 0px 0px 5px 5px #0099E0; 141 | } 142 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "operator", 3 | "version": "1.0.0", 4 | "main": "main.js", 5 | "description": "Minimal Chat Operator application", 6 | "author": "Matthew Mihok ", 7 | "private": true, 8 | "license": "BSD-3-Clause", 9 | "repository": "minimalchat/mnml-app.git", 10 | "scripts": { 11 | "test": "jest", 12 | "test:watch": "jest --watch", 13 | "coverage": "jest --coverage", 14 | "lint": "eslint src --ext .js,.jsx", 15 | "build": "webpack --progress --colors", 16 | "build:watch": "webpack --progress --colors --watch", 17 | "compile": "electron-packager . --all --out=dist/", 18 | "compile:win": "electron-packager . Operator --platform=win32 --arch=all --out=dist/", 19 | "compile:osx": "electron-packager . Operator --platform=darwin --platform=mas --arch=all --out=dist/", 20 | "compile:linux": "electron-packager . operator --platform=linux --arch=all --out=dist/", 21 | "start": "NODE_ENV=development electron ." 22 | }, 23 | "jest": { 24 | "setupFiles": [ 25 | "./test/jest.setup.js" 26 | ], 27 | "setupTestFrameworkScriptFile": "./node_modules/jest-enzyme/lib/index.js", 28 | "unmockedModulePathPatterns": [ 29 | "react", 30 | "enzyme", 31 | "jest-enzyme" 32 | ], 33 | "collectCoverageFrom": [ 34 | "**/*.{js,jsx}", 35 | "!**/webpack*.js", 36 | "!**/node_modules/**", 37 | "!**/dist/**", 38 | "!**/coverage/**", 39 | "!**/test/**" 40 | ], 41 | "snapshotSerializers": [ 42 | "/node_modules/enzyme-to-json/serializer" 43 | ], 44 | "moduleNameMapper": { 45 | "^.+\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "/test/file.mock.js", 46 | "^.+\\.(css|scss)$": "/test/style.mock.js" 47 | }, 48 | "coverageReporters": [ 49 | "lcov", 50 | "text" 51 | ], 52 | "coverageDirectory": "coverage", 53 | "testPathIgnorePatterns": [ 54 | "/node_modules/", 55 | "/assets/", 56 | "/dist/" 57 | ], 58 | "testRegex": "((test|spec))\\.(js|jsx)$", 59 | "testURL": "http://localhost/" 60 | }, 61 | "dependencies": { 62 | "electron": "4.0.0", 63 | "faker": "^4.1.0", 64 | "file-loader": "^0.11.2", 65 | "moment": "^2.18.1", 66 | "prop-types": "^15.5.10", 67 | "react": "^15.3.2", 68 | "react-dom": "^15.3.2", 69 | "react-redux": "^4.4.5", 70 | "react-toggle": "^4.0.1", 71 | "redux": "^3.6.0", 72 | "socket.io-client": "^2.0.3", 73 | "whatwg-fetch": "^2.0.2" 74 | }, 75 | "devDependencies": { 76 | "@babel/core": "^7.2.2", 77 | "@babel/plugin-proposal-class-properties": "^7.2.3", 78 | "@babel/plugin-transform-destructuring": "^7.2.0", 79 | "@babel/plugin-transform-react-jsx": "^7.2.0", 80 | "@babel/preset-env": "^7.2.3", 81 | "babel-core": "^7.0.0-bridge.0", 82 | "babel-eslint": "^10.0.0", 83 | "babel-jest": "^24.1.0", 84 | "babel-loader": "^8.0.4", 85 | "babel-preset-env": "^1.7.0", 86 | "css-loader": "^2.0.0", 87 | "electron-packager": "^13.1.0", 88 | "enzyme": "^3.8.0", 89 | "enzyme-adapter-react-15": "^1.2.0", 90 | "enzyme-to-json": "^3.3.0", 91 | "eslint": "^5.0.0", 92 | "eslint-config-airbnb": "^17.0.0", 93 | "eslint-plugin-import": "^2.12.0", 94 | "eslint-plugin-jsx-a11y": "^6.0.3", 95 | "eslint-plugin-react": "^7.9.1", 96 | "jest": "^24.1.0", 97 | "jest-cli": "^24.1.0", 98 | "jest-enzyme": "^7.0.1", 99 | "react-addons-css-transition-group": "^15.3.2", 100 | "react-addons-test-utils": "^15.4.1", 101 | "react-test-renderer": "^15.4.1", 102 | "style-loader": "^0.13.1", 103 | "webpack": "^4.0.0", 104 | "webpack-cli": "^3.1.2" 105 | }, 106 | "optionalDependencies": { 107 | "bufferutil": "^4.0.1", 108 | "utf-8-validate": "^5.0.2" 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/components/ClientCard/ClientCard.jsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @summary: Displays a contact cards for a chat conversation 3 | * TODO: Style odd / even cards. 4 | * TODO: style based on currently selected card. 5 | */ 6 | 7 | import React, { Component } from 'react'; 8 | import PropTypes from 'prop-types'; 9 | import moment from 'moment'; 10 | import { connect } from 'react-redux'; 11 | 12 | import { setActiveChat, loadMessages } from '../../store/Chat'; 13 | 14 | import './ClientCard.css'; 15 | 16 | // TODO: Improve this by pulling ping/pong from daemon's connection with the 17 | // client. 18 | // If a client is not active for 2 minutes, consider them offline 19 | const ONLINE_TIMEOUT = 120000; 20 | 21 | class ClientCard extends Component { 22 | static propTypes = { 23 | chat: PropTypes.object.isRequired, 24 | messages: PropTypes.array.isRequired, 25 | config: PropTypes.object.isRequired, 26 | children: PropTypes.node.isRequired, 27 | resetActiveChat: PropTypes.func.isRequired, 28 | getMessages: PropTypes.func.isRequired, 29 | activeId: PropTypes.string, 30 | } 31 | 32 | static defaultProps = { 33 | activeId: '', 34 | } 35 | 36 | getClientMessages () { 37 | const { messages, chat } = this.props; 38 | 39 | return messages.filter(msg => ( 40 | msg.chat === chat.id && msg.author === `client.${msg.chat}` 41 | )); 42 | } 43 | 44 | getLastMessage () { 45 | const filteredMessages = this.getClientMessages(); 46 | 47 | if (filteredMessages.length > 0) { 48 | return filteredMessages[filteredMessages.length - 1]; 49 | } 50 | 51 | return {}; 52 | } 53 | 54 | isOnline () { 55 | const lastMessage = this.getLastMessage(); 56 | 57 | if (lastMessage.hasOwnProperty('timestamp')) { 58 | // Make the timestamp into a date object so we can do some comparisons 59 | const lastMessageTime = new Date(lastMessage.timestamp); 60 | 61 | // Current time minus the ONLINE_TIMEOUT static value 62 | const onlineThreshold = new Date().getTime() - ONLINE_TIMEOUT; 63 | 64 | return lastMessageTime >= onlineThreshold; 65 | } 66 | 67 | return false; 68 | } 69 | 70 | isActive () { 71 | const { chat, activeId } = this.props; 72 | 73 | return activeId === chat.id; 74 | } 75 | 76 | isDone () { 77 | const { chat } = this.props; 78 | return !chat.open; 79 | } 80 | 81 | renderLastMessage () { 82 | const lastMessage = this.getLastMessage(); 83 | 84 | if (lastMessage.hasOwnProperty('content') 85 | && lastMessage.content.hasOwnProperty('length') 86 | && lastMessage.content.length > 0) { 87 | const lastMessageContent = lastMessage.content; 88 | 89 | return ( 90 |

    91 | {lastMessageContent[lastMessageContent.length - 1]} 92 | … 93 |

    94 | ); 95 | } 96 | 97 | return null; 98 | } 99 | 100 | renderLastMessageTimestamp () { 101 | const lastMessage = this.getLastMessage(); 102 | 103 | if (lastMessage.hasOwnProperty('timestamp')) { 104 | return ( 105 | 106 | {moment(lastMessage.timestamp).fromNow()} 107 | 108 | ); 109 | } 110 | 111 | return null; 112 | } 113 | 114 | render () { 115 | const { 116 | chat, config: { apiServer }, activeId, children, 117 | } = this.props; 118 | const classes = [ 119 | 'ClientCard', 120 | (this.isActive() && !this.isDone()) ? 'ClientCard--active' : '', 121 | this.isDone() ? 'ClientCard--done' : '', 122 | ]; 123 | const statusClasses = [ 124 | 'ClientCard__status', 125 | this.isOnline() ? 'ClientCard__status--online' : 'ClientCard__status--offline', 126 | ]; 127 | 128 | return ( 129 |
  • 130 | 155 |
  • 156 | ); 157 | } 158 | } 159 | 160 | const mapStateToProps = state => ({ 161 | config: state.config, 162 | messages: state.chat.messages, 163 | activeId: state.chat.activeId, 164 | }); 165 | 166 | const mapDispatchToProps = dispatch => ({ 167 | resetActiveChat: (chat) => { dispatch(setActiveChat(chat)); }, 168 | getMessages: async (apiServer, id) => dispatch(await loadMessages(apiServer, id)), 169 | }); 170 | 171 | 172 | export default connect( 173 | mapStateToProps, 174 | mapDispatchToProps, 175 | )(ClientCard); 176 | 177 | -------------------------------------------------------------------------------- /src/components/MessageList/MessageList.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { connect } from 'react-redux'; 4 | 5 | import { loadMessages } from '../../store/Chat'; 6 | import Message from '../Message/Message.jsx'; 7 | 8 | import './MessageList.css'; 9 | 10 | 11 | /** 12 | * @summary: MessageList is responsible for handling the display of messages. 13 | */ 14 | 15 | class MessageList extends Component { 16 | static propTypes = { 17 | messages: PropTypes.arrayOf( 18 | PropTypes.shape({ 19 | author: PropTypes.string, 20 | content: PropTypes.arrayOf(PropTypes.string), 21 | }), 22 | ).isRequired, 23 | chat: PropTypes.object.isRequired, 24 | chats: PropTypes.object, 25 | config: PropTypes.object.isRequired, 26 | activeId: PropTypes.string, 27 | loadMessageList: PropTypes.func.isRequired, 28 | } 29 | 30 | static defaultProps = { 31 | chats: {}, 32 | activeId: '', 33 | } 34 | 35 | componentWillMount () { 36 | const { config: { apiServer }, activeId, loadMessageList } = this.props; 37 | 38 | if (apiServer && activeId) { 39 | loadMessageList(apiServer, activeId); 40 | } 41 | } 42 | 43 | renderEmpty () { 44 | const { chat, chats } = this.props; 45 | 46 | if (Object.keys(chats).length === 0) { 47 | return ( 48 |
    49 |
    50 |
    You have no chats!
    51 |
    We still love you.
    52 |
    53 | ); 54 | } 55 | 56 | if (chat.hasOwnProperty('open')) { 57 | return ( 58 |
    59 |
    60 |
    No chat selected
    61 |
    Click a chat on the left to get started!
    62 |
    63 | ); 64 | } 65 | 66 | return null; 67 | } 68 | 69 | renderTyping () { 70 | // TODO: Make this somehow not a giant eyesore on this file 71 | return ( 72 |
    73 |
      74 |
    • 75 | 76 | 77 | 90 | 91 | 103 | 104 | 116 | 117 |
    • 118 |
    119 |
    120 | ); 121 | } 122 | 123 | render () { 124 | const { messages, chat, activeId } = this.props; 125 | const activeMsgs = messages.filter(msg => msg.chat === activeId); 126 | const boxClass = ['MessageList__box', !chat.open ? 'MessageList__box--done' : ''].join(' '); 127 | 128 | // Render a map of components with their contents. 129 | const renderView = () => { 130 | if (!activeId) return this.renderEmpty(); 131 | 132 | return activeMsgs.map((msg, index) => { 133 | const key = `${index}_${msg.chat}`; 134 | return ( 135 | 141 | ); 142 | }); 143 | }; 144 | 145 | return ( 146 |
    147 |
      148 | { renderView() } 149 |
    • 150 | {this.renderTyping()} 151 |
    • 152 |
    153 |
    154 | ); 155 | } 156 | } 157 | 158 | 159 | const mapStateToProps = state => ({ 160 | messages: state.chat.messages.sort((curr, next) => ( 161 | new Date(curr.timestamp) - new Date(next.timestamp) 162 | )), 163 | typing: state.chat.typing, 164 | chat: state.chat.activeId ? state.chat.chats[state.chat.activeId] : {}, 165 | chats: state.chat.chats, 166 | config: state.config, 167 | activeId: state.chat.activeId, 168 | }); 169 | 170 | const mapDispatchToProps = dispatch => ({ 171 | loadMessageList: async (apiServer, chatId) => dispatch(await loadMessages(apiServer, chatId)), 172 | }); 173 | 174 | 175 | export default connect( 176 | mapStateToProps, 177 | mapDispatchToProps, 178 | )(MessageList); 179 | -------------------------------------------------------------------------------- /src/socket.js: -------------------------------------------------------------------------------- 1 | /* eslint no-param-reassign: ["error", { "props": false }] */ 2 | import io from 'socket.io-client'; 3 | import { 4 | socketConnected, 5 | socketDisconnected, 6 | socketConnectionError, 7 | socketConnectionTimeout, 8 | socketReconnecting, 9 | socketReconnected, 10 | socketReconnectError, 11 | socketReconnectFailed, 12 | socketReconnectTimeout, 13 | } from './store/Socket'; 14 | 15 | import { 16 | addChat, 17 | clientTyping, 18 | clientIdle, 19 | receiveMessage, 20 | triggerNotification, 21 | } from './store/Chat'; 22 | 23 | import { 24 | showNotification, 25 | hideNotification, 26 | } from './store/UI'; 27 | 28 | import { 29 | updateSettings, 30 | } from './store/Config'; 31 | 32 | const TYPING_TIMEOUT = 1000; 33 | const RECONNECTED_TIMEOUT = 3000; 34 | 35 | let socket = null; 36 | 37 | // Middleware for Redux to watch for new chat messages 38 | export function socketMessageHook (store) { 39 | return next => (action) => { 40 | const result = next(action); 41 | 42 | if (socket && action.type === 'CHAT_MESSAGE_OPERATOR') { 43 | socket.emit('operator:message', JSON.stringify(action.payload)); 44 | } 45 | 46 | if (socket && action.type === 'CHAT_OPERATOR_TYPING') { 47 | socket.emit('operator:typing', JSON.stringify(action.payload)); 48 | } 49 | }; 50 | } 51 | 52 | 53 | export default function socketInit (store) { 54 | const { dispatch } = store; 55 | const { config: { apiServer, apiServerAccessId, apiServerAccessToken } } = store.getState(); 56 | const socketPath = apiServer || 'http://127.0.0.1:8000'; 57 | 58 | if (!socketPath) { 59 | console.error('ERROR: No API Server defined', apiServer); 60 | } 61 | 62 | console.debug('CONNECTING SOCKET'); 63 | 64 | // Make connection 65 | socket = io.connect(socketPath, { 66 | reconnectionAttempts: 10, 67 | transports: ['websocket'], 68 | query: { 69 | type: 'operator', 70 | accessId: apiServerAccessId, 71 | accessToken: apiServerAccessToken, 72 | }, 73 | }); 74 | 75 | 76 | // Client events 77 | socket.on('client:typing', (data) => { 78 | const { chat: { chats } } = store.getState(); 79 | let buffer = data ? JSON.parse(data) : []; 80 | 81 | // Check if timeout exists for client 82 | if (chats.hasOwnProperty(buffer.chat)) { 83 | // Clear timeout for client as we'll be resetting it farther into 84 | // the future right after 85 | window.clearTimeout(chats[buffer.chat].typing); 86 | } 87 | 88 | // Start new timeout for client 89 | buffer.typing = window.setTimeout(() => dispatch(clientIdle(buffer)), TYPING_TIMEOUT); 90 | 91 | // Dispatch client typing action 92 | dispatch(clientTyping(buffer)); 93 | }); 94 | 95 | socket.on('client:message', (data) => { 96 | const { chat: { chats } } = store.getState(); 97 | let buffer = data ? JSON.parse(data) : []; 98 | 99 | if (chats.hasOwnProperty(buffer.chat)) { 100 | // We want to clear the timeout for typing here because we just received 101 | // a message so assuming they've stopped typing for now 102 | window.clearTimeout(chats[buffer.chat].typing); 103 | } 104 | 105 | // After recieving a message, we can go idle 106 | dispatch(clientIdle(buffer)); 107 | dispatch(receiveMessage(buffer)); 108 | }); 109 | 110 | socket.on('chat:new', data => dispatch(addChat(data ? JSON.parse(data) : []))); 111 | 112 | socket.on('operator:new', (data) => { 113 | const buffer = data ? JSON.parse(data) : []; 114 | const { access_id, access_token } = buffer; 115 | 116 | if (access_id === apiServerAccessId 117 | && access_token === apiServerAccessToken) { 118 | // Update if that operator is me! 119 | // 120 | const newSettings = {}; 121 | 122 | if (buffer.hasOwnProperty('avatar')) { 123 | const { avatar } = buffer; 124 | 125 | newSettings.avatar = avatar; 126 | } 127 | 128 | if (buffer.hasOwnProperty('first_name')) { 129 | let fullName; 130 | 131 | if (buffer.hasOwnProperty('last_name')) { 132 | const { first_name, last_name } = buffer; 133 | 134 | fullName = `${first_name} ${last_name}`; 135 | } else { 136 | const { first_name } = buffer; 137 | 138 | fullName = first_name; 139 | } 140 | 141 | newSettings.operator = fullName; 142 | } 143 | 144 | dispatch(updateSettings(newSettings)); 145 | } 146 | 147 | // TODO: In the future, we should have a separate list of what operators 148 | // are online, maybe let them converse 149 | }); 150 | 151 | // Connection events 152 | socket.on('connect', () => dispatch(socketConnected())); 153 | 154 | socket.on('disconnect', () => { 155 | dispatch(socketDisconnected()); 156 | 157 | // Show notification bar 158 | dispatch(showNotification({ 159 | notification: 'Disconnected', 160 | notificationIcon: 'flash off', 161 | notificationColour: 'red', 162 | })); 163 | }); 164 | 165 | socket.on('connect_error', error => dispatch(socketConnectionError(error))); 166 | 167 | socket.on('connect_timeout', () => dispatch(socketConnectionTimeout())); 168 | 169 | socket.on('reconnect', (attempt) => { 170 | let reconnectingTimeout = null; 171 | const { socket: { reconnecting } } = store.getState(); 172 | 173 | window.clearTimeout(reconnecting); 174 | 175 | reconnectingTimeout = window.setTimeout( 176 | () => dispatch(hideNotification()), 177 | RECONNECTED_TIMEOUT, 178 | ); 179 | 180 | dispatch(socketReconnected(attempt, reconnectingTimeout)); 181 | 182 | dispatch(showNotification({ 183 | notification: 'Reconnected', 184 | notificationIcon: 'flash', 185 | notificationColour: 'green', 186 | })); 187 | }); 188 | 189 | socket.on('reconnecting', (attempt) => { 190 | dispatch(socketReconnecting(attempt)); 191 | 192 | dispatch(showNotification({ 193 | notification: 'Disconnected; trying to reconnect...', 194 | notificationIcon: 'flash off', 195 | notificationColour: 'orange', 196 | })); 197 | }); 198 | 199 | socket.on('reconnect_error', error => dispatch(socketReconnectError(error))); 200 | 201 | socket.on('reconnect_failed', () => dispatch(socketReconnectFailed())); 202 | 203 | socket.on('reconnect_timeout', () => dispatch(socketReconnectTimeout())); 204 | 205 | socket.on('ping', () => { 206 | console.debug('PING'); 207 | }); 208 | 209 | socket.on('pong', latency => console.debug('PONG', latency, 'ms')); 210 | 211 | // Listen for anything, useful in debugging 212 | const { onevent } = socket; 213 | socket.onevent = function onEvent (packet) { 214 | const args = packet.data || []; 215 | onevent.call(this, packet); // original call 216 | packet.data = ['*', ...args]; 217 | onevent.call(this, packet); // additional call to catch-all 218 | }; 219 | socket.on('*', (...args) => console.debug('SOCKET', args)); 220 | 221 | return socket; 222 | } 223 | -------------------------------------------------------------------------------- /assets/css/Roboto.css: -------------------------------------------------------------------------------- 1 | /* Roboto Thin */ 2 | 3 | /* latin-ext */ 4 | @font-face { 5 | font-family: 'Roboto'; 6 | font-style: normal; 7 | font-weight: 100; 8 | src: local('Roboto Thin'), local('Roboto-Thin'), url(../fonts/Roboto-Thin.ttf) format('truetype'); 9 | unicode-range: U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, U+2C60-2C7F, U+A720-A7FF; 10 | } 11 | /* latin */ 12 | @font-face { 13 | font-family: 'Roboto'; 14 | font-style: normal; 15 | font-weight: 100; 16 | src: local('Roboto Thin'), local('Roboto-Thin'), url(../fonts/Roboto-Thin.ttf) format('truetype'); 17 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215; 18 | } 19 | 20 | /* Roboto Light */ 21 | 22 | /* latin-ext */ 23 | @font-face { 24 | font-family: 'Roboto'; 25 | font-style: normal; 26 | font-weight: 300; 27 | src: local('Roboto Light'), local('Roboto-Light'), url(../fonts/Roboto-Light.ttf) format('truetype'); 28 | unicode-range: U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, U+2C60-2C7F, U+A720-A7FF; 29 | } 30 | /* latin */ 31 | @font-face { 32 | font-family: 'Roboto'; 33 | font-style: normal; 34 | font-weight: 300; 35 | src: local('Roboto Light'), local('Roboto-Light'), url(../fonts/Roboto-Light.ttf) format('truetype'); 36 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215; 37 | } 38 | 39 | /* Roboto */ 40 | 41 | /* latin-ext */ 42 | @font-face { 43 | font-family: 'Roboto'; 44 | font-style: normal; 45 | font-weight: 400; 46 | src: local('Roboto'), local('Roboto-Regular'), url(../fonts/Roboto-Regular.ttf) format('truetype'); 47 | unicode-range: U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, U+2C60-2C7F, U+A720-A7FF; 48 | } 49 | /* latin */ 50 | @font-face { 51 | font-family: 'Roboto'; 52 | font-style: normal; 53 | font-weight: 400; 54 | src: local('Roboto'), local('Roboto-Regular'), url(../fonts/Roboto-Regular.ttf) format('truetype'); 55 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215; 56 | } 57 | 58 | /* Roboto Medium */ 59 | 60 | /* latin-ext */ 61 | @font-face { 62 | font-family: 'Roboto'; 63 | font-style: normal; 64 | font-weight: 500; 65 | src: local('Roboto Medium'), local('Roboto-Medium'), url(../fonts/Roboto-Medium.ttf) format('truetype'); 66 | unicode-range: U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, U+2C60-2C7F, U+A720-A7FF; 67 | } 68 | /* latin */ 69 | @font-face { 70 | font-family: 'Roboto'; 71 | font-style: normal; 72 | font-weight: 500; 73 | src: local('Roboto Medium'), local('Roboto-Medium'), url(../fonts/Roboto-Medium.ttf) format('truetype'); 74 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215; 75 | } 76 | 77 | /* Roboto Bold */ 78 | 79 | /* latin-ext */ 80 | @font-face { 81 | font-family: 'Roboto'; 82 | font-style: normal; 83 | font-weight: 700; 84 | src: local('Roboto Bold'), local('Roboto-Bold'), url(../fonts/Roboto-Bold.ttf) format('truetype'); 85 | unicode-range: U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, U+2C60-2C7F, U+A720-A7FF; 86 | } 87 | /* latin */ 88 | @font-face { 89 | font-family: 'Roboto'; 90 | font-style: normal; 91 | font-weight: 700; 92 | src: local('Roboto Bold'), local('Roboto-Bold'), url(../fonts/Roboto-Bold.ttf) format('truetype'); 93 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215; 94 | } 95 | 96 | /* Roboto Black */ 97 | 98 | /* latin-ext */ 99 | @font-face { 100 | font-family: 'Roboto'; 101 | font-style: normal; 102 | font-weight: 900; 103 | src: local('Roboto Black'), local('Roboto-Black'), url(../fonts/Roboto-Black.ttf) format('truetype'); 104 | unicode-range: U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, U+2C60-2C7F, U+A720-A7FF; 105 | } 106 | /* latin */ 107 | @font-face { 108 | font-family: 'Roboto'; 109 | font-style: normal; 110 | font-weight: 900; 111 | src: local('Roboto Black'), local('Roboto-Black'), url(../fonts/Roboto-Black.ttf) format('truetype'); 112 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215; 113 | } 114 | 115 | /* Roboto Thin Italic */ 116 | 117 | /* latin-ext */ 118 | @font-face { 119 | font-family: 'Roboto'; 120 | font-style: italic; 121 | font-weight: 100; 122 | src: local('Roboto Thin Italic'), local('Roboto-ThinItalic'), url(../fonts/Roboto-ThinItalic.ttf) format('truetype'); 123 | unicode-range: U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, U+2C60-2C7F, U+A720-A7FF; 124 | } 125 | /* latin */ 126 | @font-face { 127 | font-family: 'Roboto'; 128 | font-style: italic; 129 | font-weight: 100; 130 | src: local('Roboto Thin Italic'), local('Roboto-ThinItalic'), url(../fonts/Roboto-ThinItalic.ttf) format('truetype'); 131 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215; 132 | } 133 | 134 | /* Roboto Light Italic */ 135 | 136 | /* latin-ext */ 137 | @font-face { 138 | font-family: 'Roboto'; 139 | font-style: italic; 140 | font-weight: 300; 141 | src: local('Roboto Light Italic'), local('Roboto-LightItalic'), url(../fonts/Roboto-LightItalic.ttf) format('truetype'); 142 | unicode-range: U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, U+2C60-2C7F, U+A720-A7FF; 143 | } 144 | /* latin */ 145 | @font-face { 146 | font-family: 'Roboto'; 147 | font-style: italic; 148 | font-weight: 300; 149 | src: local('Roboto Light Italic'), local('Roboto-LightItalic'), url(../fonts/Roboto-LightItalic.ttf) format('truetype'); 150 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215; 151 | } 152 | 153 | /* Roboto Italic */ 154 | 155 | /* latin-ext */ 156 | @font-face { 157 | font-family: 'Roboto'; 158 | font-style: italic; 159 | font-weight: 400; 160 | src: local('Roboto Italic'), local('Roboto-Italic'), url(../fonts/Roboto-Italic.ttf) format('truetype'); 161 | unicode-range: U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, U+2C60-2C7F, U+A720-A7FF; 162 | } 163 | /* latin */ 164 | @font-face { 165 | font-family: 'Roboto'; 166 | font-style: italic; 167 | font-weight: 400; 168 | src: local('Roboto Italic'), local('Roboto-Italic'), url(../fonts/Roboto-Italic.ttf) format('truetype'); 169 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215; 170 | } 171 | 172 | /* Roboto Medium Italic */ 173 | 174 | /* latin-ext */ 175 | @font-face { 176 | font-family: 'Roboto'; 177 | font-style: italic; 178 | font-weight: 500; 179 | src: local('Roboto Medium Italic'), local('Roboto-MediumItalic'), url(../fonts/Roboto-MediumItalic.ttf) format('truetype'); 180 | unicode-range: U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, U+2C60-2C7F, U+A720-A7FF; 181 | } 182 | /* latin */ 183 | @font-face { 184 | font-family: 'Roboto'; 185 | font-style: italic; 186 | font-weight: 500; 187 | src: local('Roboto Medium Italic'), local('Roboto-MediumItalic'), url(../fonts/Roboto-MediumItalic.ttf) format('truetype'); 188 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215; 189 | } 190 | 191 | /* Roboto Bold Italic */ 192 | 193 | /* latin-ext */ 194 | @font-face { 195 | font-family: 'Roboto'; 196 | font-style: italic; 197 | font-weight: 700; 198 | src: local('Roboto Bold Italic'), local('Roboto-BoldItalic'), url(../fonts/Roboto-BoldItalic.ttf) format('truetype'); 199 | unicode-range: U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, U+2C60-2C7F, U+A720-A7FF; 200 | } 201 | /* latin */ 202 | @font-face { 203 | font-family: 'Roboto'; 204 | font-style: italic; 205 | font-weight: 700; 206 | src: local('Roboto Bold Italic'), local('Roboto-BoldItalic'), url(../fonts/Roboto-BoldItalic.ttf) format('truetype'); 207 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215; 208 | } 209 | 210 | /* Roboto Black Italic */ 211 | 212 | /* latin-ext */ 213 | @font-face { 214 | font-family: 'Roboto'; 215 | font-style: italic; 216 | font-weight: 900; 217 | src: local('Roboto Black Italic'), local('Roboto-BlackItalic'), url(../fonts/Roboto-BlackItalic.ttf) format('truetype'); 218 | unicode-range: U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, U+2C60-2C7F, U+A720-A7FF; 219 | } 220 | /* latin */ 221 | @font-face { 222 | font-family: 'Roboto'; 223 | font-style: italic; 224 | font-weight: 900; 225 | src: local('Roboto Black Italic'), local('Roboto-BlackItalic'), url(../fonts/Roboto-BlackItalic.ttf) format('truetype'); 226 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215; 227 | } 228 | -------------------------------------------------------------------------------- /src/store/Chat/index.js: -------------------------------------------------------------------------------- 1 | import API from '../endpoints'; 2 | import makeDummy from '../dummy'; 3 | 4 | const initialState = { 5 | activeId: '', 6 | activeIsOpen: null, 7 | chats: {}, 8 | messages: [], 9 | operatorFilter: 'all', 10 | config: { 11 | apiServer: null, 12 | operator: null, 13 | }, 14 | }; 15 | 16 | // Constants 17 | 18 | const LOAD_CHATS_SUCCESS = 'CHAT_LOAD_CHATS_SUCCESS'; 19 | const LOAD_CHATS_FAILURE = 'CHAT_LOAD_CHATS_FAILURE'; 20 | const LOAD_MESSAGES_SUCCESS = 'CHAT_LOAD_MESSAGES_SUCCESS'; 21 | const LOAD_MESSAGES_FAILURE = 'CHAT_LOAD_MESSAGES_FAILURE'; 22 | 23 | const SET_OPERATOR = 'CHAT_SET_OPERATOR'; 24 | const SET_OPERATOR_FILTER = 'CHAT_SET_OPERATOR_FILTER'; 25 | const SET_ACTIVE_CHAT = 'CHAT_SET_ACTIVE_CHAT'; 26 | 27 | const TOGGLE_OPEN = 'CHAT_TOGGLE_OPEN'; 28 | 29 | const OPERATOR_TYPING = 'CHAT_OPERATOR_TYPING'; 30 | const SEND_MESSAGE = 'CHAT_MESSAGE_OPERATOR'; 31 | export const RECEIVE_MESSAGE = 'CHAT_MESSAGE_CLIENT'; 32 | 33 | const CLIENT_TYPING = 'CHAT_CLIENT_TYPING'; 34 | const CLIENT_IDLE = 'CHAT_CLIENT_IDLE'; 35 | const TRIGGER_NOTIFICATION = 'TRIGGER_NOTIFICATION'; 36 | 37 | const ADD_CHAT = 'CHAT_ADD_CHAT'; 38 | 39 | // Actions 40 | 41 | // TODO: not sure what this is doing but it's broken? 42 | export function loadChats (host) { 43 | return fetch(`${host}${API.chats}`) 44 | .then(res => res.json()) 45 | .then(data => ({ 46 | type: LOAD_CHATS_SUCCESS, 47 | payload: data.chats || [], 48 | })).catch(err => ({ 49 | type: LOAD_CHATS_FAILURE, 50 | err, 51 | })); 52 | } 53 | 54 | export function loadMessages (host, activeId) { 55 | return fetch(`${host}${API.chat}/${activeId}/messages`) 56 | .then(res => res.json()) 57 | .then(data => ({ 58 | type: LOAD_MESSAGES_SUCCESS, 59 | payload: data.messages || [], 60 | })).catch(err => ({ 61 | type: LOAD_MESSAGES_FAILURE, 62 | err, 63 | })); 64 | } 65 | 66 | export function setActiveChat (payload) { 67 | return { 68 | type: SET_ACTIVE_CHAT, 69 | payload, 70 | }; 71 | } 72 | 73 | export function setOperatorFilter (payload) { 74 | return { 75 | type: SET_OPERATOR_FILTER, 76 | payload, 77 | }; 78 | } 79 | 80 | export function toggleChatOpen (payload) { 81 | return { 82 | type: TOGGLE_OPEN, 83 | payload, 84 | }; 85 | } 86 | 87 | export function addChat (payload) { 88 | return { 89 | type: ADD_CHAT, 90 | payload, 91 | }; 92 | } 93 | 94 | export function operatorTyping (payload) { 95 | return { 96 | type: OPERATOR_TYPING, 97 | payload, 98 | }; 99 | } 100 | 101 | export function clientTyping (payload) { 102 | return { 103 | type: CLIENT_TYPING, 104 | payload, 105 | }; 106 | } 107 | 108 | export function clientIdle (payload) { 109 | return { 110 | type: CLIENT_IDLE, 111 | payload, 112 | }; 113 | } 114 | 115 | export function sendMessage (payload) { 116 | return { 117 | type: SEND_MESSAGE, 118 | payload, 119 | }; 120 | } 121 | 122 | export function receiveMessage (payload) { 123 | return { 124 | type: RECEIVE_MESSAGE, 125 | payload, 126 | }; 127 | } 128 | 129 | export function triggerNotification (payload) { 130 | return { 131 | type: TRIGGER_NOTIFICATION, 132 | payload, 133 | }; 134 | } 135 | 136 | // Reducer 137 | 138 | function ChatReducer (state = initialState, action) { 139 | // TODO: Cleanup dangling variables that lose their meaning at the top 140 | // of this list 141 | let messages = []; 142 | let uniqueMessages = []; 143 | let sortedPayload = []; 144 | const chat = {}; 145 | let chats = {}; 146 | 147 | switch (action.type) { 148 | case LOAD_CHATS_SUCCESS: 149 | // Turn the array of chats into an object with the chat ID as the key 150 | for (let i = 0; i < (action.payload || []).length; i += 1) { 151 | chats[action.payload[i].id] = { 152 | client: action.payload[i].client, 153 | update_time: action.payload[i].update_time, 154 | creation_time: action.payload[i].creation_time, 155 | open: action.payload[i].open, 156 | typing: null, 157 | }; 158 | } 159 | 160 | return { 161 | ...state, 162 | chats, 163 | }; 164 | 165 | case LOAD_CHATS_FAILURE: 166 | // TODO: Handle error 167 | return state; 168 | 169 | case LOAD_MESSAGES_SUCCESS: { 170 | // We need to run through the entire array of messages and aggregate them 171 | // into similar sets 172 | if (action.payload.length > 0) { 173 | sortedPayload = action.payload.sort( 174 | (curr, next) => new Date(curr.timestamp) - new Date(next.timestamp), 175 | ); 176 | 177 | // TODO: There should be an algorithm here that would speed things up 178 | for (let i = 0; i < sortedPayload.length; i += 1) { 179 | // All we have to do is see if the last message has the same author 180 | 181 | if ( 182 | messages.length > 0 183 | && messages[messages.length - 1].author === sortedPayload[i].author 184 | ) { 185 | // If it is the same author, do our usual slice magic 186 | messages[messages.length - 1].content.push(sortedPayload[i].content); 187 | 188 | // We update the root 'message' with the most recent timestamp 189 | messages[messages.length - 1].timestamp = sortedPayload[i].timestamp; 190 | } else { 191 | messages.push({ 192 | ...sortedPayload[i], 193 | content: [sortedPayload[i].content], 194 | }); 195 | } 196 | } 197 | } 198 | 199 | return { 200 | ...state, 201 | messages: state.messages.concat(messages).filter((msg) => { 202 | const msgId = `message.${msg.chat}-${(new Date(msg.timestamp).getTime() / 1000)}`; 203 | if (uniqueMessages.includes(msgId)) { 204 | // Don't let the same message go through twice 205 | return false; 206 | } 207 | 208 | uniqueMessages.push(msgId); 209 | return true; 210 | }), 211 | }; 212 | } 213 | 214 | case LOAD_MESSAGES_FAILURE: 215 | // TODO: Handle error 216 | return state; 217 | 218 | case SET_ACTIVE_CHAT: 219 | return { 220 | ...state, 221 | activeId: action.payload.id, 222 | activeIsOpen: action.payload.open, 223 | }; 224 | 225 | case SET_OPERATOR_FILTER: 226 | return { 227 | ...state, 228 | operatorFilter: action.payload, 229 | }; 230 | 231 | case TOGGLE_OPEN: 232 | return { 233 | ...state, 234 | chats: Object.assign({}, state.chats, { 235 | [action.payload]: { 236 | ...state.chats[action.payload], 237 | open: !state.chats[action.payload].open, 238 | }, 239 | }), 240 | activeId: '', 241 | }; 242 | 243 | case ADD_CHAT: 244 | // Pull the chat ID out of the payload and use it as the key 245 | return { 246 | ...state, 247 | chats: Object.assign({}, state.chats, { 248 | [action.payload.id]: { 249 | client: action.payload.client, 250 | update_time: action.payload.update_time, 251 | creation_time: action.payload.creation_time, 252 | open: action.payload.open, 253 | typing: null, 254 | }, 255 | }), 256 | }; 257 | 258 | case SEND_MESSAGE: 259 | if ( 260 | state.messages.length > 0 261 | // TODO: This should check if author === operator username 262 | && state.messages[state.messages.length - 1].author === action.payload.author 263 | ) { 264 | messages = [...state.messages[state.messages.length - 1].content, action.payload.content]; 265 | 266 | return { 267 | ...state, 268 | messages: [ 269 | ...state.messages.slice(0, state.messages.length - 1), 270 | { ...state.messages[state.messages.length - 1], content: messages }, 271 | ], 272 | }; 273 | } 274 | 275 | return { 276 | ...state, 277 | messages: [...state.messages, { ...action.payload, content: [action.payload.content] }], 278 | }; 279 | 280 | case RECEIVE_MESSAGE: 281 | 282 | if ( 283 | state.messages.length > 0 284 | // TODO: This should check if the author = client ID 285 | && state.messages[state.messages.length - 1].author === action.payload.author 286 | ) { 287 | messages = [...state.messages[state.messages.length - 1].content, action.payload.content]; 288 | 289 | return { 290 | ...state, 291 | messages: [ 292 | ...state.messages.slice(0, state.messages.length - 1), 293 | { 294 | ...state.messages[state.messages.length - 1], 295 | content: messages, 296 | timestamp: action.payload.timestamp, 297 | }, 298 | ], 299 | }; 300 | } 301 | 302 | return { 303 | ...state, 304 | messages: [ 305 | ...state.messages, 306 | { 307 | ...action.payload, 308 | content: [action.payload.content], 309 | }, 310 | ], 311 | }; 312 | 313 | case CLIENT_TYPING: 314 | return { 315 | ...state, 316 | chats: Object.assign({}, state.chats, { 317 | [action.payload.chat]: { 318 | ...state.chats[action.payload.chat], 319 | typing: action.payload.typing, 320 | }, 321 | }), 322 | }; 323 | 324 | case CLIENT_IDLE: 325 | return { 326 | ...state, 327 | chats: Object.assign({}, state.chats, { 328 | [action.payload.chat]: { 329 | ...state.chats[action.payload.chat], 330 | typing: null, 331 | }, 332 | }), 333 | }; 334 | 335 | default: 336 | return state; 337 | } 338 | } 339 | 340 | export default ChatReducer; 341 | -------------------------------------------------------------------------------- /assets/css/ss-air.css: -------------------------------------------------------------------------------- 1 | @charset "UTF-8"; 2 | 3 | /* 4 | * Symbolset 5 | * www.symbolset.com 6 | * Copyright © 2013 Oak Studios LLC 7 | * 8 | * Upload this file to your web server 9 | * and place this within your tags. 10 | * 11 | */ 12 | 13 | @font-face { 14 | font-family: "SSAir"; 15 | src: url('../icons/ss-air.eot'); 16 | src: url('../icons/ss-air.eot?#iefix') format('embedded-opentype'), 17 | url('../icons/ss-air.woff') format('woff'), 18 | url('../icons/ss-air.ttf') format('truetype'), 19 | url('../icons/ss-air.svg#SSAir') format('svg'); 20 | font-weight: 400; 21 | font-style: normal; 22 | } 23 | 24 | /* This triggers a redraw in IE to Fix IE8's :before content rendering. */ 25 | html:hover [class^="ss-"]{-ms-zoom: 1;} 26 | 27 | .ss-icon, .ss-icon.ss-air, 28 | [class^="ss-"]:before, [class*=" ss-"]:before, 29 | [class^="ss-"].ss-air:before, [class*=" ss-"].ss-air:before, 30 | [class^="ss-"].right:after, [class*=" ss-"].right:after, 31 | [class^="ss-"].ss-air.right:after, [class*=" ss-"].ss-air.right:after { 32 | font-family: "SSAir"; 33 | font-style: normal; 34 | font-weight: 400; 35 | text-decoration: none; 36 | text-rendering: optimizeLegibility; 37 | white-space: nowrap; 38 | /*-webkit-font-feature-settings: "liga"; Currently broken in Chrome >= v22. Falls back to text-rendering. Safari is unaffected. */ 39 | -moz-font-feature-settings: "liga=1"; 40 | -moz-font-feature-settings: "liga"; 41 | -ms-font-feature-settings: "liga" 1; 42 | -o-font-feature-settings: "liga"; 43 | font-feature-settings: "liga"; 44 | -webkit-font-smoothing: antialiased; 45 | } 46 | 47 | [class^="ss-"].right:before, 48 | [class*=" ss-"].right:before{display:none;content:'';} 49 | 50 | .ss-cursor:before,.ss-cursor.right:after{content:''}.ss-crosshair:before,.ss-crosshair.right:after{content:'⌖'}.ss-search:before,.ss-search.right:after{content:'🔎'}.ss-zoomin:before,.ss-zoomin.right:after{content:''}.ss-zoomout:before,.ss-zoomout.right:after{content:''}.ss-view:before,.ss-view.right:after{content:'👀'}.ss-viewdisabled:before,.ss-viewdisabled.right:after{content:''}.ss-binoculars:before,.ss-binoculars.right:after{content:''}.ss-attach:before,.ss-attach.right:after{content:'📎'}.ss-link:before,.ss-link.right:after{content:'🔗'}.ss-move:before,.ss-move.right:after{content:''}.ss-write:before,.ss-write.right:after{content:'✎'}.ss-writingdisabled:before,.ss-writingdisabled.right:after{content:''}.ss-compose:before,.ss-compose.right:after{content:'📝'}.ss-lock:before,.ss-lock.right:after{content:'🔒'}.ss-unlock:before,.ss-unlock.right:after{content:'🔓'}.ss-key:before,.ss-key.right:after{content:'🔑'}.ss-backspace:before,.ss-backspace.right:after{content:'⌫'}.ss-ban:before,.ss-ban.right:after{content:'🚫'}.ss-trash:before,.ss-trash.right:after{content:''}.ss-target:before,.ss-target.right:after{content:'◎'}.ss-skull:before,.ss-skull.right:after{content:'💀'}.ss-tag:before,.ss-tag.right:after{content:''}.ss-bookmark:before,.ss-bookmark.right:after{content:'🔖'}.ss-flag:before,.ss-flag.right:after{content:'⚑'}.ss-like:before,.ss-like.right:after{content:'👍'}.ss-dislike:before,.ss-dislike.right:after{content:'👎'}.ss-heart:before,.ss-heart.right:after{content:'♥'}.ss-unheart:before,.ss-unheart.right:after{content:''}.ss-star:before,.ss-star.right:after{content:'⋆'}.ss-unstar:before,.ss-unstar.right:after{content:''}.ss-sample:before,.ss-sample.right:after{content:''}.ss-crop:before,.ss-crop.right:after{content:''}.ss-cut:before,.ss-cut.right:after{content:'✂'}.ss-clipboard:before,.ss-clipboard.right:after{content:'📋'}.ss-ruler:before,.ss-ruler.right:after{content:''}.ss-gridlines:before,.ss-gridlines.right:after{content:''}.ss-pencilbrushpen:before,.ss-pencilbrushpen.right:after{content:''}.ss-paintroller:before,.ss-paintroller.right:after{content:''}.ss-paint:before,.ss-paint.right:after{content:''}.ss-paintdisabled:before,.ss-paintdisabled.right:after{content:''}.ss-paintedit:before,.ss-paintedit.right:after{content:''}.ss-pixels:before,.ss-pixels.right:after{content:''}.ss-phone:before,.ss-phone.right:after{content:'📞'}.ss-phonedisabled:before,.ss-phonedisabled.right:after{content:''}.ss-addressbook:before,.ss-addressbook.right:after{content:'📑'}.ss-voicemail:before,.ss-voicemail.right:after{content:'⌕'}.ss-mailbox:before,.ss-mailbox.right:after{content:'📫'}.ss-send:before,.ss-send.right:after{content:''}.ss-paperairplane:before,.ss-paperairplane.right:after{content:''}.ss-mail:before,.ss-mail.right:after{content:'✉'}.ss-inbox:before,.ss-inbox.right:after{content:'📥'}.ss-inboxes:before,.ss-inboxes.right:after{content:''}.ss-outbox:before,.ss-outbox.right:after{content:'📤'}.ss-chat:before,.ss-chat.right:after{content:'💬'}.ss-textchat:before,.ss-textchat.right:after{content:''}.ss-ellipsischat:before,.ss-ellipsischat.right:after{content:''}.ss-ellipsis:before,.ss-ellipsis.right:after{content:'…'}.ss-smile:before,.ss-smile.right:after{content:'☻'}.ss-frown:before,.ss-frown.right:after{content:'☹'}.ss-surprise:before,.ss-surprise.right:after{content:'😮'}.ss-user:before,.ss-user.right:after{content:'👤'}.ss-femaleuser:before,.ss-femaleuser.right:after{content:'👧'}.ss-users:before,.ss-users.right:after{content:'👥'}.ss-robot:before,.ss-robot.right:after{content:''}.ss-ghost:before,.ss-ghost.right:after{content:'👻'}.ss-contacts:before,.ss-contacts.right:after{content:'📇'}.ss-pointup:before,.ss-pointup.right:after{content:'👆'}.ss-pointright:before,.ss-pointright.right:after{content:'👉'}.ss-pointdown:before,.ss-pointdown.right:after{content:'👇'}.ss-pointleft:before,.ss-pointleft.right:after{content:'👈'}.ss-cart:before,.ss-cart.right:after{content:''}.ss-shoppingbag:before,.ss-shoppingbag.right:after{content:''}.ss-store:before,.ss-store.right:after{content:'🏪'}.ss-creditcard:before,.ss-creditcard.right:after{content:'💳'}.ss-banknote:before,.ss-banknote.right:after{content:'💵'}.ss-calculator:before,.ss-calculator.right:after{content:''}.ss-calculate:before,.ss-calculate.right:after{content:''}.ss-bank:before,.ss-bank.right:after{content:'🏦'}.ss-presentation:before,.ss-presentation.right:after{content:''}.ss-barchart:before,.ss-barchart.right:after{content:'📊'}.ss-piechart:before,.ss-piechart.right:after{content:''}.ss-activity:before,.ss-activity.right:after{content:''}.ss-box:before,.ss-box.right:after{content:'📦'}.ss-home:before,.ss-home.right:after{content:'⌂'}.ss-fence:before,.ss-fence.right:after{content:''}.ss-buildings:before,.ss-buildings.right:after{content:'🏢'}.ss-lodging:before,.ss-lodging.right:after{content:'🏨'}.ss-globe:before,.ss-globe.right:after{content:'🌐'}.ss-navigate:before,.ss-navigate.right:after{content:''}.ss-compass:before,.ss-compass.right:after{content:''}.ss-signpost:before,.ss-signpost.right:after{content:''}.ss-map:before,.ss-map.right:after{content:''}.ss-location:before,.ss-location.right:after{content:''}.ss-pin:before,.ss-pin.right:after{content:'📍'}.ss-pushpin:before,.ss-pushpin.right:after{content:'📌'}.ss-code:before,.ss-code.right:after{content:''}.ss-puzzle:before,.ss-puzzle.right:after{content:''}.ss-floppydisk:before,.ss-floppydisk.right:after{content:'💾'}.ss-window:before,.ss-window.right:after{content:''}.ss-music:before,.ss-music.right:after{content:'♫'}.ss-mic:before,.ss-mic.right:after{content:'🎤'}.ss-headphones:before,.ss-headphones.right:after{content:'🎧'}.ss-mutevolume:before,.ss-mutevolume.right:after{content:''}.ss-volume:before,.ss-volume.right:after{content:'🔈'}.ss-lowvolume:before,.ss-lowvolume.right:after{content:'🔉'}.ss-highvolume:before,.ss-highvolume.right:after{content:'🔊'}.ss-radio:before,.ss-radio.right:after{content:'📻'}.ss-airplay:before,.ss-airplay.right:after{content:''}.ss-disc:before,.ss-disc.right:after{content:'💿'}.ss-camera:before,.ss-camera.right:after{content:'📷'}.ss-picture:before,.ss-picture.right:after{content:'🌄'}.ss-pictures:before,.ss-pictures.right:after{content:''}.ss-video:before,.ss-video.right:after{content:'📹'}.ss-film:before,.ss-film.right:after{content:''}.ss-clapboard:before,.ss-clapboard.right:after{content:'🎬'}.ss-tv:before,.ss-tv.right:after{content:'📺'}.ss-flatscreen:before,.ss-flatscreen.right:after{content:''}.ss-play:before,.ss-play.right:after{content:'▶'}.ss-pause:before,.ss-pause.right:after{content:''}.ss-stop:before,.ss-stop.right:after{content:'■'}.ss-record:before,.ss-record.right:after{content:'●'}.ss-rewind:before,.ss-rewind.right:after{content:'⏪'}.ss-fastforward:before,.ss-fastforward.right:after{content:'⏩'}.ss-skipforward:before,.ss-skipforward.right:after{content:'⏭'}.ss-skipback:before,.ss-skipback.right:after{content:'⏮'}.ss-eject:before,.ss-eject.right:after{content:'⏏'}.ss-filecabinet:before,.ss-filecabinet.right:after{content:''}.ss-books:before,.ss-books.right:after{content:'📚'}.ss-notebook:before,.ss-notebook.right:after{content:'📓'}.ss-newspaper:before,.ss-newspaper.right:after{content:'📰'}.ss-grid:before,.ss-grid.right:after{content:''}.ss-rows:before,.ss-rows.right:after{content:''}.ss-columns:before,.ss-columns.right:after{content:''}.ss-thumbnails:before,.ss-thumbnails.right:after{content:''}.ss-menu:before,.ss-menu.right:after{content:''}.ss-filter:before,.ss-filter.right:after{content:''}.ss-desktop:before,.ss-desktop.right:after{content:'💻'}.ss-laptop:before,.ss-laptop.right:after{content:''}.ss-tablet:before,.ss-tablet.right:after{content:''}.ss-cell:before,.ss-cell.right:after{content:'📱'}.ss-battery:before,.ss-battery.right:after{content:'🔋'}.ss-highbattery:before,.ss-highbattery.right:after{content:''}.ss-mediumbattery:before,.ss-mediumbattery.right:after{content:''}.ss-lowbattery:before,.ss-lowbattery.right:after{content:''}.ss-emptybattery:before,.ss-emptybattery.right:after{content:''}.ss-batterydisabled:before,.ss-batterydisabled.right:after{content:''}.ss-lightbulb:before,.ss-lightbulb.right:after{content:'💡'}.ss-flashlight:before,.ss-flashlight.right:after{content:'🔦'}.ss-flashlighton:before,.ss-flashlighton.right:after{content:''}.ss-picnictable:before,.ss-picnictable.right:after{content:''}.ss-birdhouse:before,.ss-birdhouse.right:after{content:''}.ss-lamp:before,.ss-lamp.right:after{content:''}.ss-onedie:before,.ss-onedie.right:after{content:'⚀'}.ss-twodie:before,.ss-twodie.right:after{content:'⚁'}.ss-threedie:before,.ss-threedie.right:after{content:'⚂'}.ss-fourdie:before,.ss-fourdie.right:after{content:'⚃'}.ss-fivedie:before,.ss-fivedie.right:after{content:'⚄'}.ss-sixdie:before,.ss-sixdie.right:after{content:'⚅'}.ss-downloadcloud:before,.ss-downloadcloud.right:after{content:''}.ss-download:before,.ss-download.right:after{content:''}.ss-uploadcloud:before,.ss-uploadcloud.right:after{content:''}.ss-upload:before,.ss-upload.right:after{content:''}.ss-transfer:before,.ss-transfer.right:after{content:'⇆'}.ss-replay:before,.ss-replay.right:after{content:'↺'}.ss-refresh:before,.ss-refresh.right:after{content:'↻'}.ss-sync:before,.ss-sync.right:after{content:''}.ss-loading:before,.ss-loading.right:after{content:''}.ss-wifi:before,.ss-wifi.right:after{content:''}.ss-file:before,.ss-file.right:after{content:'📄'}.ss-files:before,.ss-files.right:after{content:''}.ss-searchfile:before,.ss-searchfile.right:after{content:''}.ss-folder:before,.ss-folder.right:after{content:'📁'}.ss-downloadfolder:before,.ss-downloadfolder.right:after{content:''}.ss-uploadfolder:before,.ss-uploadfolder.right:after{content:''}.ss-quote:before,.ss-quote.right:after{content:'“'}.ss-anchor:before,.ss-anchor.right:after{content:''}.ss-print:before,.ss-print.right:after{content:'⎙'}.ss-fax:before,.ss-fax.right:after{content:'📠'}.ss-shredder:before,.ss-shredder.right:after{content:''}.ss-typewriter:before,.ss-typewriter.right:after{content:''}.ss-list:before,.ss-list.right:after{content:''}.ss-action:before,.ss-action.right:after{content:''}.ss-redirect:before,.ss-redirect.right:after{content:'↪'}.ss-additem:before,.ss-additem.right:after{content:''}.ss-checkitem:before,.ss-checkitem.right:after{content:''}.ss-expand:before,.ss-expand.right:after{content:'⤢'}.ss-contract:before,.ss-contract.right:after{content:''}.ss-scaleup:before,.ss-scaleup.right:after{content:''}.ss-scaledown:before,.ss-scaledown.right:after{content:''}.ss-lifepreserver:before,.ss-lifepreserver.right:after{content:''}.ss-help:before,.ss-help.right:after{content:'❓'}.ss-info:before,.ss-info.right:after{content:'ℹ'}.ss-alert:before,.ss-alert.right:after{content:'⚠'}.ss-caution:before,.ss-caution.right:after{content:'⛔'}.ss-plus:before,.ss-plus.right:after{content:'+'}.ss-hyphen:before,.ss-hyphen.right:after{content:'-'}.ss-check:before,.ss-check.right:after{content:'✓'}.ss-delete:before,.ss-delete.right:after{content:'␡'}.ss-fish:before,.ss-fish.right:after{content:'🐟'}.ss-bird:before,.ss-bird.right:after{content:'🐦'}.ss-bone:before,.ss-bone.right:after{content:''}.ss-tooth:before,.ss-tooth.right:after{content:''}.ss-poo:before,.ss-poo.right:after{content:'💩'}.ss-tree:before,.ss-tree.right:after{content:'🌲'}.ss-settings:before,.ss-settings.right:after{content:'⚙'}.ss-dashboard:before,.ss-dashboard.right:after{content:''}.ss-dial:before,.ss-dial.right:after{content:''}.ss-notifications:before,.ss-notifications.right:after{content:'🔔'}.ss-notificationsdisabled:before,.ss-notificationsdisabled.right:after{content:'🔕'}.ss-toggles:before,.ss-toggles.right:after{content:''}.ss-flash:before,.ss-flash.right:after{content:'⌁'}.ss-flashoff:before,.ss-flashoff.right:after{content:''}.ss-magnet:before,.ss-magnet.right:after{content:''}.ss-toolbox:before,.ss-toolbox.right:after{content:''}.ss-wrench:before,.ss-wrench.right:after{content:'🔧'}.ss-clock:before,.ss-clock.right:after{content:'⏲'}.ss-stopwatch:before,.ss-stopwatch.right:after{content:'⏱'}.ss-alarmclock:before,.ss-alarmclock.right:after{content:'⏰'}.ss-counterclockwise:before,.ss-counterclockwise.right:after{content:'⥀'}.ss-calendar:before,.ss-calendar.right:after{content:'📅'}.ss-keyboard:before,.ss-keyboard.right:after{content:''}.ss-keyboardup:before,.ss-keyboardup.right:after{content:''}.ss-keyboarddown:before,.ss-keyboarddown.right:after{content:''}.ss-chickenleg:before,.ss-chickenleg.right:after{content:'🍗'}.ss-burger:before,.ss-burger.right:after{content:'🍔'}.ss-mug:before,.ss-mug.right:after{content:'☕'}.ss-coffee:before,.ss-coffee.right:after{content:''}.ss-tea:before,.ss-tea.right:after{content:'🍵'}.ss-wineglass:before,.ss-wineglass.right:after{content:'🍷'}.ss-paperbag:before,.ss-paperbag.right:after{content:''}.ss-utensils:before,.ss-utensils.right:after{content:'🍴'}.ss-droplet:before,.ss-droplet.right:after{content:'💧'}.ss-sun:before,.ss-sun.right:after{content:'☀'}.ss-cloud:before,.ss-cloud.right:after{content:'☁'}.ss-partlycloudy:before,.ss-partlycloudy.right:after{content:'⛅'}.ss-umbrella:before,.ss-umbrella.right:after{content:'☂'}.ss-crescentmoon:before,.ss-crescentmoon.right:after{content:'🌙'}.ss-plug:before,.ss-plug.right:after{content:'🔌'}.ss-outlet:before,.ss-outlet.right:after{content:''}.ss-car:before,.ss-car.right:after{content:'🚘'}.ss-taxi:before,.ss-taxi.right:after{content:'🚖'}.ss-train:before,.ss-train.right:after{content:'🚆'}.ss-bus:before,.ss-bus.right:after{content:'🚍'}.ss-truck:before,.ss-truck.right:after{content:'🚚'}.ss-plane:before,.ss-plane.right:after{content:'✈'}.ss-bike:before,.ss-bike.right:after{content:'🚲'}.ss-rocket:before,.ss-rocket.right:after{content:'🚀'}.ss-briefcase:before,.ss-briefcase.right:after{content:'💼'}.ss-theatre:before,.ss-theatre.right:after{content:'🎭'}.ss-flask:before,.ss-flask.right:after{content:''}.ss-up:before,.ss-up.right:after{content:'⬆'}.ss-upright:before,.ss-upright.right:after{content:'⬈'}.ss-right:before,.ss-right.right:after{content:'➡'}.ss-downright:before,.ss-downright.right:after{content:'⬊'}.ss-down:before,.ss-down.right:after{content:'⬇'}.ss-downleft:before,.ss-downleft.right:after{content:'⬋'}.ss-left:before,.ss-left.right:after{content:'⬅'}.ss-upleft:before,.ss-upleft.right:after{content:'⬉'}.ss-navigateup:before,.ss-navigateup.right:after{content:''}.ss-navigateright:before,.ss-navigateright.right:after{content:'▻'}.ss-navigatedown:before,.ss-navigatedown.right:after{content:''}.ss-navigateleft:before,.ss-navigateleft.right:after{content:'◅'}.ss-share:before,.ss-share.right:after{content:''} 51 | --------------------------------------------------------------------------------