cleaned up for github

This commit is contained in:
Sharon Kennedy 2020-05-07 10:45:17 -04:00
commit ec22cb8585
38 changed files with 13921 additions and 0 deletions

19
.eslintrc Normal file
View File

@ -0,0 +1,19 @@
{
"env": {
"browser": true,
"jest": true
},
"parser": "babel-eslint",
"extends": "airbnb",
"rules": {
"no-underscore-dangle": 0,
"react/jsx-filename-extension": [1, { "extensions": [".js", ".jsx"] }],
"import/prefer-default-export": 1,
"import/no-extraneous-dependencies": 1,
"no-await-in-loop": 1
},
"plugins": ["react"],
"settings": {
"import/resolver": "webpack"
}
}

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
node_modules
coverage
.DS_Store

101
README.md Normal file
View File

@ -0,0 +1,101 @@
# Embeddable Matrix Chatbox
![Demo video of chatbox](https://media.giphy.com/media/IhmtP0NoG22k6FRQDF/giphy.gif)
Live demo: https://nomadic-labs.github.io/safesupport-chatbox/
Built on:
- [Embeddable React Widget](https://github.com/seriousben/embeddable-react-widget)
- [Matrix JS SDK](https://github.com/matrix-org/matrix-js-sdk)
## Usage
```
<script src="https://unpkg.com/safesupport-chatbox" type="text/javascript"></script>
<script>
var config = {
matrixServerUrl: 'https://matrix.rhok.space',
botId: '@help-bot:rhok.space',
roomName: 'Support Chat',
termsUrl: 'https://tosdr.org/',
introMessage: 'This chat application does not collect any of your personal data or any data from your use of this service.',
agreementMessage: 'Do you want to continue?',
confirmationMessage: 'Waiting for a facilitator to join the chat...',
exitMessage: 'The chat is closed. You may close this window.',
chatUnavailableMessage: 'The chat service is not available right now. Please try again later.',
anonymousDisplayName: 'Anonymous',
}
EmbeddableChatbox.mount(config);
</script>
```
Options:
| Name | Description | Default
| ----------- | ----------- | --------- |
| `matrixServerUrl` (required) | URL for the Matrix homeserver you want to connect to | `https://matrix.rhok.space` |
| `botId` (required) | User ID for the bot account that handles invites | `@help-bot:rhok.space` |
| `introMessage` (optional) | First message the user sees before agreeing to the Terms of Use | `This chat application does not collect any of your personal data or any data from your use of this service.` |
| `termsUrl` (optional) | URL for the Terms of Use for the chat service | `https://tosdr.org/` |
| `roomName` (optional) | Name of the chatroom generated in Riot | 'Support Chat' |
| `agreementMessage` (optional) | Text to show to request agreement to the Terms of Use | `Do you want to continue?` |
| `confirmationMessage` (optional) | Text to show to ask for agreement to continue | `Waiting for a facilitator to join the chat...` |
| `exitMessage` (optional) | Text to show if the user rejects the Terms of Use. | `The chat is closed. You may close this window.` |
| `anonymousDisplayName` (optional) | The display name for the chat user. | `Anonymous` |
| `chatUnavailableMessage` (optional) | Text to show if no-one is available to respond | `The chat service is not available right now. Please try again later.` |
## Feature list
- [x] Can be embedded on any website with Javascript enabled
- [x] WCAG AA compliant
- [x] Light and dark theme
- [x] Support seekers are anonymous
- [x] Uses Matrix's end to end encryption
- [x] If encryption fails, falls back to unencrypted chat
- [x] Status texts are customizeable
- [x] Only initiates chat after user agrees to Terms of Service
- [x] Upon exiting chat, user data is wiped - account deleted, local datastore cleared
- [x] If enabled, the bot account can provide transcript of the conversation
- [x] Automatically parses incoming text messages for URLs and adds the <a> tags
- [x] Easy to customize colour scheme
- [x] Bookmarklet allows users to open the chat on any website
## Bot account
This chatbox is meant to be used with a bot account that handles a number of functions:
* Sends out invitations to facilitators to join the support chat
* Revokes unused invitations when the first facilitator join the chat
* Keeps a transcript of the conversation
* Notifies user if there are not facilitators available
The bot account is invited to the chatroom when a support request is initiated.
You can find the code for the bot at [safesupport-bot](https://github.com/nomadic-labs/safesupport-bot).
## Bookmarklet
The bookmarklet is a special link that runs a script on any website. The user saves the link by dragging it to their bookmarks bar. Then they can click on the bookmark on any page to run the script and load the chatbox.
You can try this out on the [live demo](https://nomadic-labs.github.io/safesupport-chatbox/).
## Local development
Clone the project:
```
git clone https://github.com/nomadic-labs/safesupport-chatbox.git
```
Install the dependencies:
```
cd safesupport-chatbox
yarn
```
Start the development server:
```
yarn start
```
Open the demo page at http://localhost:9000/
## Production build
```
yarn build
```

View File

@ -0,0 +1,6 @@
import React from "react";
import { ReactElement } from "react";
const MockPicker = () => <div>Emoji Picker</div>
export default MockPicker;

View File

@ -0,0 +1,75 @@
export const mockRegisterRequest = jest
.fn()
.mockImplementation((params) => {
if (!params.auth) {
return Promise.reject({
data: { session: "session_id_1234" }
})
} else {
return Promise.resolve({
data: {
device_id: 'device_id_1234',
access_token: 'token_1234',
user_id: 'user_id_1234',
session: "session_id_1234"
}
})
}
})
export const mockLeave = jest.fn(() => {
return Promise.resolve('value');
});
export const mockInitCrypto = jest.fn()
export const mockStartClient = jest.fn(() => {
return Promise.resolve('value');
});
export const mockOnce = jest.fn()
export const mockStopClient = jest.fn(() => {
return Promise.resolve('value');
});
export const mockClearStores = jest.fn(() => {
return Promise.resolve('value');
});
export const mockGetRoom = jest.fn()
export const mockDownloadKeys = jest.fn()
export const mockSetDeviceVerified = jest.fn()
export const mockIsCryptoEnabled = jest.fn()
export const mockIsRoomEncrypted = jest.fn()
export const mockCreateRoom = jest.fn().mockReturnValue({ room_id: 'room_id_1234' })
export const mockSetPowerLevel = jest.fn()
export const mockSendTextMessage = jest.fn(() => {
return Promise.resolve('value');
});
export const mockSetDeviceKnown = jest.fn()
export const mockDeactivateAccount = jest.fn(() => {
return Promise.resolve('value');
});
export const mockOn = jest.fn()
export const mockSetDisplayName = jest.fn()
export const mockClient = {
registerRequest: mockRegisterRequest,
initCrypto: mockInitCrypto,
startClient: mockStartClient,
on: mockOn,
once: mockOnce,
leave: mockLeave,
stopClient: mockStopClient,
clearStores: mockClearStores,
getRoom: mockGetRoom,
downloadKeys: mockDownloadKeys,
setDeviceVerified: mockSetDeviceVerified,
setDeviceKnown: mockSetDeviceKnown,
isCryptoEnabled: mockIsCryptoEnabled,
isRoomEncrypted: mockIsRoomEncrypted,
createRoom: mockCreateRoom,
setPowerLevel: mockSetPowerLevel,
sendTextMessage: mockSendTextMessage,
deactivateAccount: mockDeactivateAccount,
setDisplayName: mockSetDisplayName,
}
export const WebStorageSessionStore = jest.fn()
export const createClient = jest.fn().mockReturnValue(mockClient)

60
dist/bookmarklet.js vendored Normal file

File diff suppressed because one or more lines are too long

60
dist/chatbox.js vendored Normal file

File diff suppressed because one or more lines are too long

37
dist/index.html vendored Normal file
View File

@ -0,0 +1,37 @@
<!DOCTYPE html>
<html>
<head>
<title>Embeddable Chatbox Demo</title>
<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1">
</head>
<body style="height: 100vh;">
<p style="font-family:sans-serif; padding: 3rem 5rem;">
<a id="bookmarklet">Bookmarklet (drag it to your bookmarks bar)</a>
</p>
<p style="font-family:sans-serif; padding: 3rem 5rem;">Look down!</p>
<script src="./chatbox.js"></script>
<script>
var config = {
matrixServerUrl: 'https://matrix.safesupport.chat',
botId: '@help-bot:safesupport.chat',
roomName: 'Support Chat',
termsUrl: 'https://tosdr.org/',
introMessage: "This chat application does not collect any of your personal data or any data from your use of this service.",
agreementMessage: 'Do you want to continue?',
confirmationMessage: 'Waiting for a facilitator to join the chat...',
exitMessage: 'The chat is closed. You may close this window.',
chatUnavailableMessage: 'The chat service is not available right now. Please try again later.',
anonymousDisplayName: 'Anonymous',
}
EmbeddableChatbox.mount(config);
</script>
<script>
var bookmarklet = "var s= document.createElement('script'); s.setAttribute('src', '"+window.location.href+"bookmarklet.js'); s.setAttribute('crossorigin', 'anonymous'); document.body.appendChild(s);"
bookmarklet = '(function(){'+ bookmarklet +'})();'
document.querySelector('a#bookmarklet').setAttribute("href", "javascript:" + encodeURIComponent(bookmarklet));
</script>
</body>
</html>

BIN
dist/olm.wasm vendored Normal file

Binary file not shown.

8
jest/cssTransform.js Normal file
View File

@ -0,0 +1,8 @@
module.exports = {
process() {
return 'module.exports = {};';
},
getCacheKey() {
return 'cssTransform';
},
};

7
jest/fileTransform.js Normal file
View File

@ -0,0 +1,7 @@
const path = require('path');
module.exports = {
process(src, filename) {
return `module.exports = ${JSON.stringify(path.basename(filename))};`;
},
};

11
jest/setup.js Normal file
View File

@ -0,0 +1,11 @@
import Enzyme from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
Enzyme.configure({ adapter: new Adapter() });
Object.defineProperty(document, 'readyState', {
value: 'complete',
writable: true,
enumerable: true,
configurable: true,
});

139
package.json Normal file
View File

@ -0,0 +1,139 @@
{
"name": "safesupport-chatbox",
"version": "1.1.3",
"description": "A secure and private embeddable chatbox that connects to Riot",
"main": "dist/chatbox.js",
"scripts": {
"build": "NODE_ENV=production webpack-cli --mode production",
"start": "webpack-dev-server",
"test": "jest",
"deploy": "yarn build && gh-pages -d dist",
"lint": "./node_modules/.bin/eslint ."
},
"babel": {
"presets": [
"airbnb",
[
"@babel/preset-env",
{
"targets": {
"node": "12"
}
}
],
"@babel/preset-react"
],
"plugins": [
"@babel/plugin-syntax-dynamic-import",
"@babel/plugin-syntax-import-meta",
"@babel/plugin-proposal-class-properties",
"@babel/plugin-proposal-json-strings",
[
"@babel/plugin-proposal-decorators",
{
"legacy": true
}
],
"@babel/plugin-proposal-function-sent",
"@babel/plugin-proposal-export-namespace-from",
"@babel/plugin-proposal-numeric-separator",
"@babel/plugin-proposal-throw-expressions",
"@babel/plugin-transform-runtime"
]
},
"browserslist": "> 0.25%, not dead",
"jest": {
"coverageDirectory": "./coverage/",
"collectCoverage": true,
"collectCoverageFrom": [
"<rootDir>/src/**/*.js?(x)"
],
"coveragePathIgnorePatterns": [
"/node_modules/",
"/test-helpers/"
],
"transform": {
"^.+\\.(js|jsx|mjs)$": "<rootDir>/node_modules/babel-jest",
"^.+\\.(css|scss)$": "<rootDir>/jest/cssTransform.js",
"^(?!.*\\.(js|jsx|css|json)$)": "<rootDir>/jest/fileTransform.js"
},
"setupFiles": [
"<rootDir>/jest/setup.js"
]
},
"serve": {
"content": [
"./dist",
"./public"
]
},
"author": "Nomadic Labs <sharon@nomadiclabs.ca",
"license": "MIT",
"devDependencies": {
"@babel/core": "7.7.7",
"@babel/plugin-proposal-class-properties": "7.7.4",
"@babel/plugin-proposal-decorators": "7.7.4",
"@babel/plugin-proposal-export-namespace-from": "7.7.4",
"@babel/plugin-proposal-function-sent": "7.7.4",
"@babel/plugin-proposal-json-strings": "7.7.4",
"@babel/plugin-proposal-numeric-separator": "7.7.4",
"@babel/plugin-proposal-throw-expressions": "7.7.4",
"@babel/plugin-syntax-dynamic-import": "7.7.4",
"@babel/plugin-syntax-import-meta": "7.7.4",
"@babel/plugin-transform-runtime": "^7.9.0",
"@babel/preset-env": "^7.9.0",
"@babel/preset-react": "^7.9.4",
"autoprefixer": "^9.7.5",
"babel-core": "7.0.0-bridge.0",
"babel-eslint": "10.0.3",
"babel-jest": "24.9.0",
"babel-loader": "8.0.6",
"babel-preset-airbnb": "4.4.0",
"clean-webpack-plugin": "3.0.0",
"copy-webpack-plugin": "5.1.1",
"css-loader": "3.4.1",
"cssimportant-loader": "0.4.0",
"enzyme": "^3.11.0",
"enzyme-adapter-react-16": "^1.15.2",
"enzyme-to-json": "3.4.3",
"enzyme-wait": "^1.0.9",
"eslint": "6.8.0",
"eslint-config-airbnb": "18.0.1",
"eslint-import-resolver-webpack": "0.12.0",
"eslint-loader": "3.0.3",
"eslint-plugin-import": "2.19.1",
"eslint-plugin-jsx-a11y": "6.2.3",
"eslint-plugin-react": "7.17.0",
"gh-pages": "^2.2.0",
"jest": "24.9.0",
"jest-cli": "24.9.0",
"mini-css-extract-plugin": "0.9.0",
"node-sass": "^4.13.1",
"postcss-increase-specificity": "0.6.0",
"postcss-loader": "3.0.0",
"sass-loader": "8.0.0",
"style-loader": "1.1.2",
"wait-for-expect": "^3.0.2",
"webpack": "4.41.5",
"webpack-cli": "3.3.10",
"webpack-dev-server": "3.10.1",
"webpack-obfuscator": "0.22.0",
"webpack-serve": "3.2.0"
},
"dependencies": {
"browser-encrypt-attachment": "^0.3.0",
"emoji-picker-react": "^3.1.3",
"isomorphic-fetch": "^2.2.1",
"linkifyjs": "^2.1.9",
"matrix-js-sdk": "^4.0.0",
"node-localstorage": "^2.1.5",
"olm": "https://packages.matrix.org/npm/olm/olm-3.1.4.tgz",
"prop-types": "^15.6.2",
"react": "^16.8.6",
"react-dom": "^16.8.6",
"react-onclickoutside": "^6.9.0",
"react-test-renderer": "^16.13.0",
"react-transition-group": "^4.0.0",
"uuidv4": "^6.0.2"
}
}

37
public/index.html Normal file
View File

@ -0,0 +1,37 @@
<!DOCTYPE html>
<html>
<head>
<title>Embeddable Chatbox Demo</title>
<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1">
</head>
<body style="height: 100vh;">
<p style="font-family:sans-serif; padding: 3rem 5rem;">
<a id="bookmarklet">Bookmarklet (drag it to your bookmarks bar)</a>
</p>
<p style="font-family:sans-serif; padding: 3rem 5rem;">Look down!</p>
<script src="./chatbox.js"></script>
<script>
var config = {
matrixServerUrl: 'https://matrix.safesupport.chat',
botId: '@help-bot:safesupport.chat',
roomName: 'Support Chat',
termsUrl: 'https://tosdr.org/',
introMessage: "This chat application does not collect any of your personal data or any data from your use of this service.",
agreementMessage: 'Do you want to continue?',
confirmationMessage: 'Waiting for a facilitator to join the chat...',
exitMessage: 'The chat is closed. You may close this window.',
chatUnavailableMessage: 'The chat service is not available right now. Please try again later.',
anonymousDisplayName: 'Anonymous',
}
EmbeddableChatbox.mount(config);
</script>
<script>
var bookmarklet = "var s= document.createElement('script'); s.setAttribute('src', '"+window.location.href+"bookmarklet.js'); s.setAttribute('crossorigin', 'anonymous'); document.body.appendChild(s);"
bookmarklet = '(function(){'+ bookmarklet +'})();'
document.querySelector('a#bookmarklet').setAttribute("href", "javascript:" + encodeURIComponent(bookmarklet));
</script>
</body>
</html>

BIN
public/olm.wasm Normal file

Binary file not shown.

9
renovate.json Normal file
View File

@ -0,0 +1,9 @@
{
"extends": [
"config:base"
],
"automerge": true,
"major": {
"automerge": false
}
}

547
src/components/_chat.scss Normal file
View File

@ -0,0 +1,547 @@
* {
box-sizing: border-box;
}
@keyframes slideInUp {
from {
transform: translate3d(0, 100%, 0);
display: inherit;
visibility: visible;
}
to {
transform: translate3d(0, 0, 0);
}
}
@keyframes slideOutDown {
from {
transform: translate3d(0, 0, 0);
}
to {
display: none;
visibility: hidden;
transform: translate3d(0, 100%, 0);
}
}
.docked-widget {
position: fixed;
bottom: 10px;
right: 10px;
z-index: 9999;
width: 400px;
max-width: 100vw;
font-size: $base-font-size;
}
.dock {
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
width: 400px;
max-width: calc(100vw - 10px);
color: $white;
font-family: $theme-font;
font-size: 1em;
border: none;
color: $white;
font-size: 1em;
line-height: 1;
background-color: transparent;
padding: 5px;
#open-chatbox-label {
background: $theme-color;
padding: 0.75em;
flex: 1 1 auto;
text-align: left;
margin-right: 0.25em;
border: 1px solid $white;
border-radius: 0.625em;
transition: all 0.2s ease-in-out;
}
.label-icon {
background: $theme-color;
height: 2.625em;
width: 2.625em;
border-radius: 2.625em;
display: flex;
justify-content: center;
align-items: center;
border: 1px solid $white;
transition: all 0.2s ease-in-out;
}
&:hover {
#open-chatbox-label, .label-icon {
border: 1px solid $dark-color;
box-shadow: inset 0px 0px 0px 1px $dark-color;
}
}
&:focus {
outline: none;
#open-chatbox-label, .label-icon {
border: 1px solid $dark-color;
box-shadow: inset 0px 0px 0px 1px $dark-color;
background-color: $theme-highlight-color;
}
}
}
.widget {
width: 400px;
max-width: calc(100vw - 10px);
border-bottom: none;
animation-duration: 0.2s;
animation-fill-mode: forwards;
&-entering {
animation-name: slideInUp;
}
&-entered {
display: inherit;
visibility: visible;
}
&-exiting {
animation-name: slideOutDown;
}
&-exited {
display: none;
visibility: hidden;
}
&-header {
display: flex;
align-items: center;
margin-bottom: 0.2em;
justify-content: flex-end;
flex: 0 0 auto;
&-title {
display: flex;
flex-grow: 1;
}
&-minimize {
cursor: pointer;
display: flex;
align-items: center;
justify-content: flex-start;
border: 1px solid $dark-color !important;
background: $white;
color: $dark-color;
flex: 1 1 auto;
font-family: $theme-font;
font-size: 1em;
padding: 0.5em;
border-radius: 0.625em;
transition: all 0.2s ease-in-out;
&:hover {
box-shadow: inset 0px 0px 0px 1px $dark-color;
}
&:focus {
outline: none;
box-shadow: inset 0px 0px 0px 1px $dark-color;
background-color: $theme-light-color;
}
}
&-close {
font-size: inherit;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid $dark-color !important;
background: $white;
border-radius: 2.625em;
padding: 0.5em;
margin-left: 0.2em;
color: $dark-color;
transition: all 0.2s ease-in-out;
width: 2.625em;
&:hover {
box-shadow: inset 0px 0px 0px 1px $dark-color;
}
&:focus {
outline: none;
box-shadow: inset 0px 0px 0px 1px $dark-color;
background-color: $theme-light-color;
}
}
}
&-body {
background: white;
padding: 10px;
height: 150px;
}
&-footer {
background: green;
line-height: 30px;
padding-left: 10px;
}
button {
transition: all 0.2s ease-in-out;
&:hover {
box-shadow: inset 0px 0px 0px 1px $dark-color;
}
&:focus {
background-color: $theme-light-color;
outline: none;
}
}
}
.btn-icon {
font-size: 1.5em;
line-height: 1;
transform: rotateX(0deg);
transition: all 0.5s linear;
display: flex;
align-items: center;
justify-content: center;
}
.arrow {
margin-right: 0.5em;
transform: translateY(0.15em);
&.opened {
color: $dark-color;
transform: rotateX(180deg) translateY(0.15em);
}
}
#safesupport-chatbox {
font-family: $theme-font;
display: flex;
flex-direction: column;
height: calc(40vh + 180px);
max-height: 100vh;
padding: 5px;
a {
color: inherit;
transition: all 0.2s ease-in-out;
&:hover, &:focus {
color: $theme-color;
}
}
.message-window {
background-color: $white;
border: 1px solid $dark-color;
flex: 1 1 auto;
padding: 0.5em;
overflow: scroll;
display: flex;
flex-direction: column-reverse;
justify-content: space-between;
margin-bottom: 0.2em;
border-radius: 0.625em;
}
.notices {
color: $gray-color;
font-size: 0.9em;
> div {
margin-top: 0.5em;
margin-bottom: 0.5em;
}
}
.message {
margin-top: 0.5em;
margin-bottom: 0.5em;
.text {
width: fit-content;
line-height: 1.2;
}
.buttons {
display: flex;
align-items: center;
button {
background-color: transparent;
padding: 0.25em 0.5em;
font-size: 0.9em;
color: inherit;
font-weight: bold;
font-family: $theme-font;
cursor: pointer;
display: flex;
flex: 0 1 auto;
border: 1px solid $theme-color;
transition: all 0.2s ease-in-out;
border-radius: 0.625em;
margin-left: 0.25em;
&:hover {
border: 1px solid $dark-color;
box-shadow: inset 0px 0px 0px 1px $dark-color;
}
&:focus {
outline: none;
color: $white;
border: 1px solid $dark-color;
box-shadow: inset 0px 0px 0px 1px $dark-color;
background-color: $theme-highlight-color;
}
}
}
&.from-bot {
color: $gray-color;
font-size: 0.9em;
}
&.from-me {
display: flex;
justify-content: flex-end;
&.placeholder {
opacity: 0.5;
}
.text {
border: 1px solid $theme-color;
background-color: $theme-color;
color: $white;
border-radius: 1em 1em 0 1em;
margin-left: 10%;
padding: 0.3em 0.6em;
}
a {
color: $white;
&:hover, &:focus {
color: $light-purple;
}
}
}
&.from-support {
display: flex;
justify-content: flex-start;
.text {
border: 1px solid $light-color;
background-color: $light-color;
color: $dark-color;
border-radius: 1em 1em 1em 0;
margin-right: 10%;
padding: 0.5em 0.75em;
}
a {
color: $dark-color;
&:hover, &:focus {
color: $medium-purple;
}
}
}
}
.input-window {
flex: 0 0 auto;
form {
display: flex;
align-items: center;
margin-bottom: 0;
}
input[type="submit"] {
background-color: $theme-color;
height: 100%;
padding: 0.5em 1em;
font-size: 1em;
color: $white;
font-weight: bold;
font-family: $theme-font;
cursor: pointer;
display: flex;
flex: 0 1 auto;
border: 1px solid $theme-color;
transition: all 0.2s ease-in-out;
border-radius: 0.625em;
&:hover {
border: 1px solid $dark-color;
box-shadow: inset 0px 0px 0px 1px $dark-color;
}
&:focus {
outline: none;
border: 1px solid $dark-color;
box-shadow: inset 0px 0px 0px 1px $dark-color;
background-color: $theme-highlight-color;
}
}
.message-input-container {
display: flex;
flex: 1 1 auto;
position: relative;
input[type="text"] {
font-size: 1em;
padding: 0.5em;
padding-right: 32px;
border: none;
display: flex;
flex: 1 1 auto;
background: $white;
color: $dark-color;
font-family: $theme-font;
margin-right: 0.2em;
transition: all 0.2s ease-in-out;
border-radius: 0.625em;
border: 1px solid $dark-color;
&:hover {
box-shadow: inset 0px 0px 0px 1px $dark-color;
}
&:focus {
outline: none;
box-shadow: inset 0px 0px 0px 1px $dark-color;
background: $theme-light-color;
}
}
.emoji-button-container {
position: absolute;
right: 6px;
top: 0;
bottom: 0;
height: 100%;
display: flex;
align-items: center;
justify-content: flex-start;
button {
transition: all 0.2s ease-in-out;
&:hover {
box-shadow: none;
}
&:focus {
outline: none;
}
&#emoji-button {
background: transparent;
border: none;
padding: 0;
margin-right: 3px;
transition: all 0.2s ease-in-out;
&:hover {
svg path#icon {
fill: $theme-color;
}
}
&:focus {
svg path#icon {
fill: $theme-highlight-color;
}
}
}
}
}
.emoji-picker {
animation-duration: 0.2s;
animation-fill-mode: forwards;
position: absolute;
bottom: 32px;
right: -4px;
&-entering {
animation-name: slideInUp;
opacity: 0.5;
}
&-entered {
display: inherit;
visibility: visible;
opacity: 1;
}
&-exiting {
animation-name: slideOutDown;
opacity: 0.5;
}
&-exited {
display: none;
visibility: hidden;
opacity: 0;
}
}
}
}
.highlight-text {
color: $theme-color;
}
.pos-relative {
position: relative;
}
}
.hidden {
display: none;
}
@media screen and (max-width: 420px){
.docked-widget {
right: 0;
left: 0;
bottom: 0;
}
.dock, .widget {
width: 100vw;
max-width: 100vw;
padding: 5px;
}
#safesupport-chatbox {
height: calc(180px + 60vh);
}
}
@media screen and (max-width: 360px){
#safesupport-chatbox .input-window .message-input-container .emoji-picker {
position: fixed;
left: 5px;
right: 5px;
bottom: 42px;
}
}

View File

@ -0,0 +1,187 @@
@media (prefers-color-scheme: dark) {
.loader {
color: $dark-theme-color;
}
.dock {
#open-chatbox-label, .label-icon {
border-color: $white;
}
&:hover {
#open-chatbox-label, .label-icon {
border: 1px solid $dark-color;
box-shadow: inset 0px 0px 0px 1px $dark-color;
}
}
}
.widget-header-minimize, .widget-header-close {
background: $dark-background-color;
color: $light-text-color;
border: 1px solid $white;
transition: all 0.2s ease-in-out;
&:hover {
border-color: $theme-color;
box-shadow: inset 0px 0px 0px 1px $theme-color;
}
&:focus {
border-color: $theme-color;
background: $dark-theme-highlight-color;
box-shadow: inset 0px 0px 0px 1px $theme-color;
outline: none;
}
}
.widget {
button {
transition: all 0.2s ease-in-out;
&:hover {
border-color: $theme-color;
box-shadow: inset 0px 0px 0px 1px $theme-color;
}
&:focus {
background: $dark-theme-highlight-color;
box-shadow: inset 0px 0px 0px 1px $theme-color;
outline: none;
}
}
}
#safesupport-chatbox {
.btn-icon {
color: $light-text-color;
}
.message-window {
background-color: $dark-background-color;
border: 1px solid $white;
}
.notices {
color: transparentize($light-text-color, 0.3);
}
.message {
&.from-bot {
color: transparentize($light-text-color, 0.3);
font-size: 0.9rem;
}
&.from-me {
.text {
background-color: $theme-color;
color: $light-text-color;
border: 1px solid $theme-color;
}
}
&.from-support {
.text {
background-color: $dark-theme-color;
color: $light-text-color;
border: 1px solid $dark-theme-color;
}
a {
color: $light-text-color;
&:hover, &:focus {
color: $light-purple;
}
}
}
.buttons {
button {
background-color: transparent;
border: 1px solid $theme-color;
&:hover {
border: 1px solid $light-purple;
box-shadow: inset 0px 0px 0px 1px $light-purple;
}
&:focus {
outline: none;
color: $white;
border: 1px solid $light-purple;
box-shadow: inset 0px 0px 0px 1px $light-purple;
background-color: $dark-theme-highlight-color;
}
}
}
}
.input-window {
.message-input-container {
input[type="text"] {
background-color: $dark-background-color;
color: $light-text-color;
border: 1px solid $white;
&:hover {
border: 1px solid $theme-color;
box-shadow: inset 0px 0px 0px 1px $theme-color;
}
&:focus {
outline: none;
border: 1px solid $theme-color;
box-shadow: inset 0px 0px 0px 1px $theme-color;
background: $dark-theme-highlight-color;
}
}
::placeholder {
color: transparentize($light-text-color, 0.3);
}
.emoji-button-container {
button {
&#emoji-button {
&:hover {
svg path#icon {
fill: $theme-color;
}
}
&:focus {
svg path#icon {
fill: $light-purple;
}
}
}
}
}
}
input[type="submit"] {
background-color: $dark-theme-color;
color: $light-text-color;
border: 1px solid $white;
&:hover {
border: 1px solid $theme-color;
box-shadow: inset 0px 0px 0px 1px $theme-color;
}
&:focus {
outline: none;
border: 1px solid $theme-color;
box-shadow: inset 0px 0px 0px 1px $theme-color;
background-color: $dark-theme-highlight-color;
}
}
}
.highlight-text {
color: $light-text-color;
}
}
}

View File

@ -0,0 +1,48 @@
.loader,
.loader:before,
.loader:after {
border-radius: 50%;
width: 2.5em;
height: 2.5em;
-webkit-animation-fill-mode: both;
animation-fill-mode: both;
-webkit-animation: load7 1.8s infinite ease-in-out;
animation: load7 1.8s infinite ease-in-out;
}
.loader {
color: $theme-color;
font-size: 10px;
margin: 1rem auto;
margin-bottom: 2rem;
position: relative;
text-indent: -9999em;
-webkit-transform: translateZ(0);
-ms-transform: translateZ(0);
transform: translateZ(0);
-webkit-animation-delay: -0.16s;
animation-delay: -0.16s;
}
.loader:before,
.loader:after {
content: '';
position: absolute;
top: 0;
}
.loader:before {
left: -3.5em;
-webkit-animation-delay: -0.32s;
animation-delay: -0.32s;
}
.loader:after {
left: 3.5em;
}
@keyframes load7 {
0%,
80%,
100% {
box-shadow: 0 2.5em 0 -1.3em;
}
40% {
box-shadow: 0 2.5em 0 0;
}
}

View File

@ -0,0 +1,30 @@
@import url('https://fonts.googleapis.com/css?family=Assistant&display=swap');
$purple: #785BEC;
$light-purple: #ebe6fc;
$medium-purple: #4D3A97;
$charcoal: #828282;
$light-color: #F2F2F2;
$gray-color: $charcoal;
$dark-color: #04090F;
$yellow: #FFFACD;
$dark-blue: #2660A4;
$white: #ffffff;
$highlight-color: $yellow;
$theme-color: $purple;
$theme-light-color: $light-purple;
$theme-font: 'Assistant', 'Helvetica', sans-serif;
$theme-highlight-color: $medium-purple;
/* Dark mode colors */
$dark-background-color: #0F1116;
$light-background-color: #ffffff;
$light-text-color: #ffffff;
$dark-text-color: #0F1116;
$dark-theme-color: #4F4F4F;
$dark-theme-highlight-color: #211943;
$base-font-size: 16px;

733
src/components/chatbox.jsx Normal file
View File

@ -0,0 +1,733 @@
import React from "react"
import PropTypes from "prop-types"
import { Transition } from 'react-transition-group';
import * as util from "util";
import * as os from "os";
import * as path from "path";
import * as fs from "fs";
import { LocalStorage } from "node-localstorage";
import * as olm from "olm/olm_legacy.js"
global.Olm = olm
import * as matrix from "matrix-js-sdk";
import {uuid} from "uuidv4"
import Message from "./message";
import Dock from "./dock";
import Header from "./header";
import EmojiSelector from './emoji-selector';
import './styles.scss';
const ENCRYPTION_CONFIG = { "algorithm": "m.megolm.v1.aes-sha2" };
const ENCRYPTION_NOTICE = "Messages in this chat are secured with end-to-end encryption."
const UNENCRYPTION_NOTICE = "Messages in this chat are not encrypted."
const RESTARTING_UNENCRYPTED_CHAT_MESSAGE = "Restarting chat without encryption."
const WAIT_TIME_MS = 120000 // 2 minutes
const CHAT_IS_OFFLINE_NOTICE = "Chat is offline"
const DEFAULT_MATRIX_SERVER = "https://matrix.rhok.space/"
const DEFAULT_BOT_ID = "@help-bot:rhok.space"
const DEFAULT_TERMS_URL = "https://tosdr.org/"
const DEFAULT_ROOM_NAME = "Support Chat"
const DEFAULT_INTRO_MESSAGE = "This chat application does not collect any of your personal data or any data from your use of this service."
const DEFAULT_AGREEMENT_MESSAGE = "Do you want to continue?"
const DEFAULT_CONFIRMATION_MESSAGE = "Waiting for a facilitator to join the chat..."
const DEFAULT_EXIT_MESSAGE = "The chat is closed. You may close this window."
const DEFAULT_ANONYMOUS_DISPLAY_NAME="Anonymous"
const DEFAULT_CHAT_UNAVAILABLE_MESSAGE = "The chat service is not available right now. Please try again later."
const DEFAULT_WAIT_MESSAGE = "Please be patient, our online facilitators are currently responding to other support requests."
class ChatBox extends React.Component {
constructor(props) {
super(props)
this.initialState = {
opened: false,
showDock: true,
client: null,
ready: true,
accessToken: null,
userId: null,
password: null,
localStorage: null,
messages: [],
inputValue: "",
errors: [],
roomId: null,
typingStatus: null,
awaitingAgreement: true,
emojiSelectorOpen: false,
facilitatorInvited: false,
isMobile: true,
isSlowConnection: true,
decryptionErrors: {},
messagesInFlight: []
}
this.state = this.initialState
this.chatboxInput = React.createRef();
this.messageWindow = React.createRef();
this.termsUrl = React.createRef();
}
detectMobile = () => {
let isMobile = false;
if ( /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) ) {
isMobile = true;
}
if (screen.width < 767) {
isMobile = true;
}
this.setState({ isMobile })
}
detectSlowConnection = () => {
let isSlowConnection = false;
const connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
if (typeof connection !== 'undefined' || connection === null) {
const connectionType = connection.effectiveType;
const slowConnections = ['slow-2g', '2g']
isSlowConnection = slowConnections.includes(connectionType)
}
this.setState({ isSlowConnection })
}
handleToggleOpen = () => {
this.setState((prev) => {
let { showDock } = prev;
if (!prev.opened) {
showDock = false;
}
return {
showDock,
opened: !prev.opened,
};
});
}
toggleEmojiSelector = (e) => {
e.preventDefault();
this.setState({ emojiSelectorOpen: !this.state.emojiSelectorOpen })
}
closeEmojiSelector = () => {
this.setState({ emojiSelectorOpen: false })
}
handleWidgetExit = () => {
this.setState({
showDock: true,
});
}
handleWidgetEnter = () => {
if (this.state.awaitingAgreement) {
this.termsUrl.current.focus()
} else {
this.chatboxInput.current.focus()
}
}
handleExitChat = () => {
if (this.state.client) {
this.exitChat()
} else {
this.setState(this.initialState)
}
}
exitChat = async () => {
if (!this.state.client) return null;
await this.state.client.leave(this.state.roomId)
const auth = {
type: 'm.login.password',
user: this.state.userId,
identifier: {
type: "m.id.user",
user: this.state.userId,
},
password: this.state.password,
};
await this.state.client.deactivateAccount(auth, true)
await this.state.client.stopClient()
await this.state.client.clearStores()
this.state.localStorage.clear()
this.setState(this.initialState)
}
createLocalStorage = async (deviceId, sessionId) => {
let localStorage = global.localStorage;
if (typeof localStorage === "undefined" || localStorage === null) {
const deviceDesc = `matrix-chat-${deviceId}-${sessionId}`
const localStoragePath = path.resolve(path.join(os.homedir(), ".local-storage", deviceDesc))
localStorage = new LocalStorage(localStoragePath);
}
return localStorage;
}
createClientWithAccount = async () => {
const tmpClient = matrix.createClient(this.props.matrixServerUrl)
try {
await tmpClient.registerRequest({})
} catch(err) {
const username = uuid()
const password = uuid()
const sessionId = err.data.session
const account = await tmpClient.registerRequest({
auth: {session: sessionId, type: "m.login.dummy"},
inhibit_login: false,
password: password,
username: username,
x_show_msisdn: true,
})
const localStorage = await this.createLocalStorage(account.device_id, sessionId)
this.setState({
accessToken: account.access_token,
userId: account.user_id,
username: username,
password: password,
localStorage: localStorage,
sessionId: sessionId,
deviceId: account.device_id,
})
let opts = {
baseUrl: this.props.matrixServerUrl,
accessToken: account.access_token,
userId: account.user_id,
deviceId: account.device_id,
sessionStore: new matrix.WebStorageSessionStore(localStorage),
}
return matrix.createClient(opts)
}
}
initializeChat = async () => {
this.setState({ ready: false })
const client = await this.createClientWithAccount()
this.setState({
client: client
})
client.setDisplayName(this.props.anonymousDisplayName)
this.setMatrixListeners(client)
try {
await client.initCrypto()
} catch(err) {
return this.initializeUnencryptedChat()
}
await client.startClient()
await this.createRoom(client)
}
initializeUnencryptedChat = async () => {
if (this.state.client) {
this.state.client.leave(this.state.roomId)
this.state.client.stopClient()
this.state.client.clearStores()
this.state.localStorage.clear()
}
this.setState({
ready: false,
facilitatorInvited: false,
decryptionErrors: {},
roomId: null,
typingStatus: null,
client: null,
isCryptoEnabled: false,
})
this.displayBotMessage({ body: RESTARTING_UNENCRYPTED_CHAT_MESSAGE })
let opts = {
baseUrl: this.props.matrixServerUrl,
accessToken: this.state.accessToken,
userId: this.state.userId,
deviceId: this.state.deviceId,
}
let client;
client = matrix.createClient(opts)
this.setState({
client: client,
})
try {
this.setMatrixListeners(client)
client.setDisplayName(this.props.anonymousDisplayName)
await this.createRoom(client)
await client.startClient()
this.displayBotMessage({ body: UNENCRYPTION_NOTICE })
} catch(err) {
console.log("error", err)
this.handleInitError(err)
}
}
handleInitError = (err) => {
console.log("error", err)
this.displayBotMessage({ body: this.props.chatUnavailableMessage })
this.setState({ ready: true })
}
handleDecryptionError = async (event, err) => {
if (this.state.client) {
const isCryptoEnabled = await this.state.client.isCryptoEnabled()
const isRoomEncrypted = this.state.client.isRoomEncrypted(this.state.roomId)
if (!isCryptoEnabled || !isRoomEncrypted) {
return this.initializeUnencryptedChat()
}
}
const eventId = event.getId()
this.displayFakeMessage({ body: '** Unable to decrypt message **' }, event.getSender(), eventId)
this.setState({ decryptionErrors: { [eventId]: true }})
}
verifyAllRoomDevices = async (client, room) => {
if (!room) return;
if (!client) return;
if (!this.state.isCryptoEnabled) return;
let members = (await room.getEncryptionTargetMembers()).map(x => x["userId"])
let memberkeys = await client.downloadKeys(members);
for (const userId in memberkeys) {
for (const deviceId in memberkeys[userId]) {
await client.setDeviceVerified(userId, deviceId);
}
}
}
createRoom = async (client) => {
const currentDate = new Date()
const chatDate = currentDate.toLocaleDateString()
const chatTime = currentDate.toLocaleTimeString()
let roomConfig = {
room_alias_name: `private-support-chat-${uuid()}`,
invite: [this.props.botId],
visibility: 'private',
name: `${chatTime}, ${chatDate} - ${this.props.roomName}`,
}
const isCryptoEnabled = await client.isCryptoEnabled()
if (isCryptoEnabled) {
roomConfig.initial_state = [
{
type: 'm.room.encryption',
state_key: '',
content: ENCRYPTION_CONFIG,
},
]
}
const { room_id } = await client.createRoom(roomConfig)
client.setPowerLevel(room_id, this.props.botId, 100)
this.setState({
roomId: room_id,
isCryptoEnabled
})
}
sendMessage = async (message) => {
if (this.state.client && this.state.roomId) {
try {
await this.state.client.sendTextMessage(this.state.roomId, message)
} catch(err) {
switch (err["name"]) {
case "UnknownDeviceError":
Object.keys(err.devices).forEach((userId) => {
Object.keys(err.devices[userId]).map(async (deviceId) => {
await this.state.client.setDeviceKnown(userId, deviceId, true);
});
});
this.sendMessage(message)
break;
default:
this.displayBotMessage({ body: "Your message was not sent." })
console.log("Error sending message", err);
}
}
}
}
displayFakeMessage = (content, sender, messageId=uuid()) => {
const msgList = [...this.state.messages]
const msg = {
id: messageId,
type: 'm.room.message',
sender: sender,
roomId: this.state.roomId,
content: content,
}
msgList.push(msg)
this.setState({ messages: msgList })
}
displayBotMessage = (content, roomId) => {
const msgList = [...this.state.messages]
const msg = {
id: uuid(),
type: 'm.room.message',
sender: this.props.botId,
roomId: roomId || this.state.roomId,
content: content,
}
msgList.push(msg)
this.setState({ messages: msgList })
}
handleMessageEvent = event => {
const message = {
id: event.getId(),
type: event.getType(),
sender: event.getSender(),
roomId: event.getRoomId(),
content: event.getContent(),
}
if (message.content.showToUser && message.content.showToUser !== this.state.userId) {
return;
}
if (message.content.body.startsWith('!bot') && message.sender !== this.state.userId) {
return;
}
const messagesInFlight = [...this.state.messagesInFlight]
const placeholderMessageIndex = messagesInFlight.findIndex(msg => msg === message.content.body)
if (placeholderMessageIndex > -1) {
messagesInFlight.splice(placeholderMessageIndex, 1)
this.setState({ messagesInFlight })
}
// check for decryption error message and replace with decrypted message
// or push message to messages array
const messages = [...this.state.messages]
const decryptionErrors = {...this.state.decryptionErrors}
delete decryptionErrors[message.id]
const existingMessageIndex = messages.findIndex(({ id }) => id === message.id)
if (existingMessageIndex > -1) {
messages.splice(existingMessageIndex, 1, message)
} else {
messages.push(message)
}
this.setState({ messages, decryptionErrors })
}
handleKeyDown = (e) => {
switch (e.keyCode) {
case 27:
if (this.state.emojiSelectorOpen) {
this.closeEmojiSelector()
} else if (this.state.opened) {
this.handleToggleOpen()
};
default:
break;
}
}
setMatrixListeners = client => {
client.on("Room.timeline", (event, room) => {
const eventType = event.getType()
const content = event.getContent()
const sender = event.getSender()
if (eventType === "m.room.encryption") {
this.displayBotMessage({ body: ENCRYPTION_NOTICE }, room.room_id)
this.verifyAllRoomDevices(client, room)
}
if (eventType === "m.room.message" && !this.state.isCryptoEnabled) {
if (event.isEncrypted()) {
return;
}
this.handleMessageEvent(event)
}
if (eventType === "m.room.member" && content.membership === "invite" && sender === this.props.botId) {
this.setState({ facilitatorInvited: true })
}
if (eventType === "m.room.member" && content.membership === "join" && sender !== this.props.botId && sender !== this.state.userId) {
this.verifyAllRoomDevices(client, room)
this.setState({ facilitatorId: sender, ready: true })
window.clearInterval(this.state.timeoutId)
}
});
client.on("Event.decrypted", (event, err) => {
if (err) {
return this.handleDecryptionError(event, err)
}
if (event.getType() === "m.room.message") {
const content = event.getContent()
if (content.msgtype === "m.notice" && content.body === CHAT_IS_OFFLINE_NOTICE) {
this.setState({ ready: true })
return window.clearInterval(this.state.timeoutId)
}
this.handleMessageEvent(event)
}
});
client.on("RoomMember.typing", (event, member) => {
if (member.typing && member.roomId === this.state.roomId) {
this.setState({ typingStatus: `${member.name} is typing...` })
}
else {
this.setState({ typingStatus: null })
}
});
}
componentDidUpdate(prevProps, prevState) {
if (prevState.messages.length !== this.state.messages.length) {
if (this.messageWindow.current.scrollTo) {
this.messageWindow.current.scrollTo(0, this.messageWindow.current.scrollHeight)
}
}
if (!prevState.facilitatorInvited && this.state.facilitatorInvited) {
this.displayBotMessage({ body: this.props.confirmationMessage })
}
if (!prevState.opened && this.state.opened) {
this.detectMobile()
// not sure what to do with this
// this.detectSlowConnection()
}
}
componentDidMount() {
document.addEventListener("keydown", this.handleKeyDown, false);
window.addEventListener('beforeunload', this.exitChat)
}
componentWillUnmount() {
document.removeEventListener("keydown", this.handleKeyDown, false);
window.removeEventListener('beforeunload', this.exitChat)
this.exitChat();
}
handleInputChange = e => {
this.setState({ inputValue: e.target.value })
}
handleAcceptTerms = () => {
this.setState({ awaitingAgreement: false })
this.startWaitTimeForFacilitator()
try {
this.initializeChat()
} catch(err) {
this.handleInitError(err)
}
}
startWaitTimeForFacilitator = () => {
const timeoutId = window.setInterval(() => {
if (!this.state.facilitatorId) {
this.displayBotMessage({ body: this.props.waitMessage })
}
}, WAIT_TIME_MS)
this.setState({ timeoutId })
}
handleRejectTerms = () => {
this.exitChat()
this.displayBotMessage({ body: this.props.exitMessage })
}
handleSubmit = e => {
e.preventDefault()
const message = this.state.inputValue
if (!Boolean(message)) return null;
if (this.state.isCryptoEnabled && !(this.state.client.isRoomEncrypted(this.state.roomId) && this.state.client.isCryptoEnabled())) return null;
if (this.state.client && this.state.roomId) {
const messagesInFlight = [...this.state.messagesInFlight]
messagesInFlight.push(message)
this.setState({ inputValue: "", messagesInFlight }, () => this.sendMessage(message))
this.chatboxInput.current.focus()
}
}
onEmojiClick = (event, emojiObject) => {
event.preventDefault()
const { emoji } = emojiObject;
this.setState({
inputValue: this.state.inputValue.concat(emoji),
emojiSelectorOpen: false,
}, this.chatboxInput.current.focus())
}
render() {
const { ready, messages, messagesInFlight, inputValue, userId, roomId, typingStatus, opened, showDock, emojiSelectorOpen, isMobile, decryptionErrors } = this.state;
const inputLabel = 'Send a message...'
return (
<div className="docked-widget" role="complementary">
<Transition in={opened} timeout={250} onExited={this.handleWidgetExit} onEntered={this.handleWidgetEnter}>
{(status) => {
return (
<div className={`widget widget-${status}`} aria-hidden={!opened}>
<div id="safesupport-chatbox" aria-haspopup="dialog">
<Header handleToggleOpen={this.handleToggleOpen} opened={opened} handleExitChat={this.handleExitChat} />
<div className="message-window" ref={this.messageWindow}>
<div className="messages">
<div className={`message from-bot`}>
<div className="text">{ this.props.introMessage }</div>
</div>
<div className={`message from-bot`}>
<div className="text">Please read the full <a href={this.props.termsUrl} ref={this.termsUrl} target='_blank' rel='noopener noreferrer'>terms and conditions</a>. By using this chat, you agree to these terms.</div>
</div>
<div className={`message from-bot`}>
<div className="text">{ this.props.agreementMessage }</div>
</div>
<div className={`message from-bot`}>
<div className="text buttons">
{`👉`}
<button className="btn" id="accept" onClick={this.handleAcceptTerms}>YES</button>
<button className="btn" id="reject" onClick={this.handleRejectTerms}>NO</button>
</div>
</div>
{
messages.map((message, index) => {
return(
<Message key={message.id} message={message} userId={userId} botId={this.props.botId} client={this.state.client} />
)
})
}
{
messagesInFlight.map((message, index) => {
return(
<Message key={`message-inflight-${index}`} message={{ content: { body: message }}} placeholder={true} />
)
})
}
{ typingStatus &&
<div className="notices">
<div role="status">{typingStatus}</div>
</div>
}
{ Boolean(Object.keys(decryptionErrors).length) &&
<div className={`message from-bot`}>
<div className="text buttons">
{`Restart chat without encryption?`}
<button className="btn" id="accept" onClick={this.initializeUnencryptedChat}>RESTART</button>
</div>
</div>
}
{ !ready && <div className={`loader`}>loading...</div> }
</div>
</div>
<div className="input-window">
<form onSubmit={this.handleSubmit}>
<div className="message-input-container">
<input
id="message-input"
type="text"
onChange={this.handleInputChange}
value={inputValue}
aria-label={inputLabel}
placeholder={inputLabel}
autoFocus={true}
ref={this.chatboxInput}
/>
{
(status === "entered") && !isMobile &&
<EmojiSelector
onEmojiClick={this.onEmojiClick}
emojiSelectorOpen={emojiSelectorOpen}
toggleEmojiSelector={this.toggleEmojiSelector}
closeEmojiSelector={this.closeEmojiSelector}
/>
}
</div>
<input type="submit" value="Send" id="submit" onClick={this.handleSubmit} />
</form>
</div>
</div>
</div>
)}
}
</Transition>
{showDock && !roomId && <Dock handleToggleOpen={this.handleToggleOpen} />}
{showDock && roomId && <Header handleToggleOpen={this.handleToggleOpen} opened={opened} handleExitChat={this.handleExitChat} />}
</div>
);
}
};
ChatBox.propTypes = {
matrixServerUrl: PropTypes.string.isRequired,
botId: PropTypes.string.isRequired,
termsUrl: PropTypes.string,
introMessage: PropTypes.string,
roomName: PropTypes.string,
agreementMessage: PropTypes.string,
confirmationMessage: PropTypes.string,
exitMessage: PropTypes.string,
chatUnavailableMessage: PropTypes.string,
anonymousDisplayName: PropTypes.string,
waitMessage: PropTypes.string,
}
ChatBox.defaultProps = {
matrixServerUrl: DEFAULT_MATRIX_SERVER,
botId: DEFAULT_BOT_ID,
termsUrl: DEFAULT_TERMS_URL,
roomName: DEFAULT_ROOM_NAME,
introMessage: DEFAULT_INTRO_MESSAGE,
agreementMessage: DEFAULT_AGREEMENT_MESSAGE,
confirmationMessage: DEFAULT_CONFIRMATION_MESSAGE,
exitMessage: DEFAULT_EXIT_MESSAGE,
anonymousDisplayName: DEFAULT_ANONYMOUS_DISPLAY_NAME,
chatUnavailableMessage: DEFAULT_CHAT_UNAVAILABLE_MESSAGE,
waitMessage: DEFAULT_WAIT_MESSAGE,
}
export default ChatBox;

View File

@ -0,0 +1,274 @@
import React from 'react';
import Chatbox from './chatbox';
import {
createClient,
mockClient,
mockRegisterRequest,
mockInitCrypto,
mockStartClient,
mockSetPowerLevel,
mockCreateRoom,
mockLeave,
mockDeactivateAccount,
mockStopClient,
mockClearStores,
mockOn,
mockOnce,
mockSendTextMessage,
mockIsCryptoEnabled,
mockIsRoomEncrypted,
} from "matrix-js-sdk";
import { mount, shallow } from 'enzyme';
import { createWaitForElement } from 'enzyme-wait';
import { config } from 'react-transition-group';
import waitForExpect from 'wait-for-expect'
config.disabled = true
var testConfig = {
matrixServerUrl: 'https://matrix.rhok.space',
botId: '@help-bot:rhok.space',
roomName: 'Support Chat',
termsUrl: 'https://tosdr.org/',
introMessage: 'This chat application does not collect any of your personal data or any data from your use of this service.',
agreementMessage: 'Do you want to continue?',
confirmationMessage: 'Waiting for a facilitator to join the chat...',
exitMessage: 'The chat is closed. You may close this window.',
chatUnavailableMessage: 'The chat service is not available right now. Please try again later.',
anonymousDisplayName: 'Anonymous',
}
describe('Chatbox', () => {
beforeEach(() => {
createClient.mockClear()
mockInitCrypto.mockClear()
mockStartClient.mockClear()
mockRegisterRequest.mockClear()
mockSetPowerLevel.mockClear()
mockCreateRoom.mockClear()
mockLeave.mockClear()
mockDeactivateAccount.mockClear()
mockStopClient.mockClear()
mockClearStores.mockClear()
mockOnce.mockClear()
mockOn.mockClear()
mockSendTextMessage.mockClear()
mockIsCryptoEnabled.mockClear()
mockIsRoomEncrypted.mockClear()
})
test('chat window should open and close', async () => {
const chatbox = mount(<Chatbox {...testConfig} />)
let dock = chatbox.find('button.dock')
let chatWindow = chatbox.find('.widget')
expect(dock.length).toEqual(1)
expect(chatWindow.hasClass('widget-exited')).toEqual(true)
// open chat window
dock.simulate('click')
const openChatWindow = await createWaitForElement('.widget-entered')(chatbox)
dock = chatbox.find('button.dock')
expect(openChatWindow.length).toEqual(1)
expect(dock.length).toEqual(0)
// close chat window
const closeButton = chatbox.find('button.widget-header-close')
closeButton.simulate('click')
chatWindow = chatbox.find('.widget')
dock = chatbox.find('button.dock')
expect(dock.length).toEqual(1)
expect(chatWindow.hasClass('widget-exited')).toEqual(true)
})
test('chat window should contain the right messages', () => {
const chatbox = mount(<Chatbox {...testConfig} />)
const props = chatbox.props()
const messages = chatbox.find('.messages')
expect(messages.text()).toContain(props.introMessage)
expect(messages.html()).toContain(props.termsUrl)
expect(messages.text()).toContain(props.agreementMessage)
});
test('agreeing to terms should start encrypted chat', async () => {
const chatbox = mount(<Chatbox {...testConfig} />)
const dock = chatbox.find('button.dock')
dock.simulate('click')
const openChatWindow = await createWaitForElement('.widget-entered')(chatbox)
let acceptButton = await createWaitForElement('button#accept')(chatbox)
acceptButton = chatbox.find('button#accept')
acceptButton.simulate('click')
const ready = await createWaitForElement('.loader')(chatbox)
expect(ready.length).toEqual(1)
expect(createClient).toHaveBeenCalled()
expect(mockInitCrypto).toHaveBeenCalled()
expect(mockStartClient).toHaveBeenCalled()
expect(mockCreateRoom).toHaveBeenCalled()
expect(mockSetPowerLevel).toHaveBeenCalled()
expect(mockOn).toHaveBeenCalled()
})
test('rejecting terms should not start chat', async () => {
const chatbox = mount(<Chatbox {...testConfig} />)
const dock = chatbox.find('button.dock')
dock.simulate('click')
const openChatWindow = await createWaitForElement('.widget-entered')(chatbox)
let rejectButton = await createWaitForElement('button#reject')(chatbox)
rejectButton = chatbox.find('button#reject')
rejectButton.simulate('click')
expect(createClient.mock.calls.length).toEqual(0)
})
test('submitted messages should be sent to matrix', async () => {
const chatbox = mount(<Chatbox {...testConfig} />)
const dock = chatbox.find('button.dock')
dock.simulate('click')
let acceptButton = await createWaitForElement('button#accept')(chatbox)
acceptButton = chatbox.find('button#accept')
acceptButton.simulate('click')
await waitForExpect(() => {
expect(mockCreateRoom).toHaveBeenCalled()
});
const input = chatbox.find('#message-input')
const form = chatbox.find('form')
const message = 'Hello'
input.simulate('change', { target: { value: message }})
await waitForExpect(() => {
chatbox.update()
expect(chatbox.state().inputValue).toEqual(message)
})
form.simulate('submit')
await waitForExpect(() => {
expect(mockSendTextMessage).toHaveBeenCalledWith(chatbox.state().roomId, message)
});
})
test('decryption failure should lead to a new unencrypted chat', async () => {
const chatbox = mount(<Chatbox {...testConfig} />)
const dock = chatbox.find('button.dock')
const instance = chatbox.instance()
dock.simulate('click')
const openChatWindow = await createWaitForElement('.widget-entered')(chatbox)
let acceptButton = await createWaitForElement('button#accept')(chatbox)
acceptButton = chatbox.find('button#accept')
acceptButton.simulate('click')
await waitForExpect(() => {
expect(mockCreateRoom).toHaveBeenCalled()
});
jest.spyOn(instance, 'initializeUnencryptedChat')
instance.handleDecryptionError()
await waitForExpect(() => {
expect(mockLeave).toHaveBeenCalled()
});
await waitForExpect(() => {
expect(mockStopClient).toHaveBeenCalled()
});
await waitForExpect(() => {
expect(mockClearStores).toHaveBeenCalled()
});
expect(instance.initializeUnencryptedChat).toHaveBeenCalled()
})
test('creating an unencrypted chat', async () => {
const chatbox = mount(<Chatbox {...testConfig} />)
const instance = chatbox.instance()
instance.initializeUnencryptedChat()
await waitForExpect(() => {
expect(createClient).toHaveBeenCalled()
})
await waitForExpect(() => {
expect(mockStartClient).toHaveBeenCalled()
})
await waitForExpect(() => {
expect(mockInitCrypto).not.toHaveBeenCalled()
})
})
test('exiting the chat should leave the room and destroy client', async () => {
const chatbox = mount(<Chatbox {...testConfig} />)
const dock = chatbox.find('button.dock')
dock.simulate('click')
const openChatWindow = await createWaitForElement('.widget-entered')(chatbox)
let acceptButton = await createWaitForElement('button#accept')(chatbox)
acceptButton = chatbox.find('button#accept')
acceptButton.simulate('click')
await waitForExpect(() => {
expect(mockCreateRoom).toHaveBeenCalled()
});
const exitButton = chatbox.find('button.widget-header-close')
exitButton.simulate('click')
let closed = await createWaitForElement('button.dock')
expect(closed.length).toEqual(1)
await waitForExpect(() => {
expect(mockLeave).toHaveBeenCalled()
});
await waitForExpect(() => {
expect(mockDeactivateAccount).toHaveBeenCalled()
});
await waitForExpect(() => {
expect(mockStopClient).toHaveBeenCalled()
});
await waitForExpect(() => {
expect(mockClearStores).toHaveBeenCalled()
});
})
test('notification should appear when facilitator joins chat', () => {
//
})
test('received messages should appear in chat window', () => {
//
})
});

22
src/components/dock.jsx Normal file
View File

@ -0,0 +1,22 @@
import React from "react"
import PropTypes from "prop-types"
const Dock = ({ handleToggleOpen }) => {
return(
<button
type="button"
className="dock"
onClick={handleToggleOpen}
onKeyPress={handleToggleOpen}
aria-labelledby="open-chatbox-label"
>
<div id="open-chatbox-label">Start a new chat</div>
<div className="label-icon">
<div className={`btn-icon`} aria-label={`Open support chat window`}>+</div>
</div>
</button>
)
}
export default Dock;

View File

@ -0,0 +1,50 @@
import React from "react"
import PropTypes from "prop-types"
import { Transition } from 'react-transition-group';
import EmojiPicker from 'emoji-picker-react';
import onClickOutside from "react-onclickoutside";
const SVG = () => <svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24"><path d="M0 0h24v24H0z" fill="none"/><path id="icon" fill="#828282" d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm3.5-9c.83 0 1.5-.67 1.5-1.5S16.33 8 15.5 8 14 8.67 14 9.5s.67 1.5 1.5 1.5zm-7 0c.83 0 1.5-.67 1.5-1.5S9.33 8 8.5 8 7 8.67 7 9.5 7.67 11 8.5 11zm3.5 6.5c2.33 0 4.31-1.46 5.11-3.5H6.89c.8 2.04 2.78 3.5 5.11 3.5z"/></svg>
class EmojiSelector extends React.Component {
constructor(props){
super(props)
}
handleClickOutside = e => {
this.props.closeEmojiSelector()
};
render() {
const { onEmojiClick, emojiSelectorOpen, toggleEmojiSelector } = this.props;
return(
<div className="emoji-button-container">
<div className="pos-relative">
<Transition in={emojiSelectorOpen} timeout={250}>
{
status => {
return(
<div className={`emoji-picker emoji-picker-${status}`} aria-hidden={!emojiSelectorOpen}>
<EmojiPicker
onEmojiClick={onEmojiClick}
emojiUrl="https://cdn.jsdelivr.net/gh/iamcal/emoji-data@master/img-apple-64"
/>
</div>
)
}
}
</Transition>
<button type="button" id="emoji-button" onClick={toggleEmojiSelector} aria-label="Emoji picker">
<SVG />
</button>
</div>
</div>
)
}
}
export default onClickOutside(EmojiSelector)

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 23.0.4, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 24 24" style="enable-background:new 0 0 24 24;" xml:space="preserve">
<path style="fill:none;" d="M0,0h24v24H0V0z"/>
<path style="fill:#828282;" d="M12,2C6.5,2,2,6.5,2,12s4.5,10,10,10c5.5,0,10-4.5,10-10S17.5,2,12,2z M12,20c-4.4,0-8-3.6-8-8
s3.6-8,8-8s8,3.6,8,8S16.4,20,12,20z M15.5,11c0.8,0,1.5-0.7,1.5-1.5S16.3,8,15.5,8S14,8.7,14,9.5S14.7,11,15.5,11z M8.5,11
c0.8,0,1.5-0.7,1.5-1.5S9.3,8,8.5,8S7,8.7,7,9.5S7.7,11,8.5,11z M12,17.5c2.3,0,4.3-1.5,5.1-3.5H6.9C7.7,16,9.7,17.5,12,17.5z"/>
</svg>

After

Width:  |  Height:  |  Size: 769 B

33
src/components/header.jsx Normal file
View File

@ -0,0 +1,33 @@
import React from "react"
import PropTypes from "prop-types"
const Header = ({ handleToggleOpen, handleExitChat, opened }) => {
return(
<div className="widget-header">
<button
type="button"
className={`widget-header-minimize`}
onClick={handleToggleOpen}
onKeyPress={handleToggleOpen}
aria-label="Minimize the chat window"
title="Minimize the chat window"
>
<span className={`btn-icon arrow ${opened ? "opened" : "closed"}`}></span>
<span>{`${opened ? "Hide" : "Show"} the chat`}</span>
</button>
<button
type="button"
className={`widget-header-close`}
onClick={handleExitChat}
onKeyPress={handleExitChat}
aria-label="Exit the chat"
title="Exit the chat"
>
<span className={`btn-icon`}>×</span>
</button>
</div>
)
}
export default Header;

View File

@ -0,0 +1,65 @@
import React from "react"
import PropTypes from "prop-types"
import Linkify from 'linkifyjs/react';
const Message = ({ message, userId, botId, client, placeholder }) => {
const senderClass = () => {
switch (message.sender) {
case 'from-me':
return 'from-me'
case userId:
return 'from-me'
case botId:
return 'from-bot'
default:
return 'from-support'
}
}
if (placeholder) {
return(
<div className={`message from-me placeholder`}>
<div className="text">
{ message.content.body }
</div>
</div>
)
}
if (message.content.msgtype === 'm.file') {
const url = client.mxcUrlToHttp(message.content.url);
return (
<div className={`message ${senderClass()}`}>
<div className="text">
<a href={url} target='_blank' rel='noopener noreferrer'>{ message.content.body }</a>
</div>
</div>
)
}
if (message.content.formatted_body) {
return (
<div className={`message ${senderClass()}`}>
<div className="text" dangerouslySetInnerHTML={{__html: message.content.formatted_body}} />
</div>
)
}
const linkifyOpts = {
linkAttributes: {
rel: 'noreferrer noopener',
},
}
return (
<div className={`message ${senderClass()}`}>
<div className="text">
<Linkify options={linkifyOpts}>{ message.content.body }</Linkify>
</div>
</div>
)
}
export default Message;

22
src/components/notice.jsx Normal file
View File

@ -0,0 +1,22 @@
import React from "react"
import PropTypes from "prop-types"
const Notice = ({ message, userId }) => {
const fromMe = message.sender === userId;
if (message.content.formatted_body) {
return (
<div className={`message ${fromMe ? "from-me" : "from-support"}`}>
<div className="text" dangerouslySetInnerHTML={{__html: message.content.formatted_body}} />
</div>
)
}
return (
<div className={`message ${fromMe ? "from-me" : "from-support"}`}>
<div className="text">{ message.content.body }</div>
</div>
)
}
export default Notice;

View File

@ -0,0 +1,4 @@
@import "variables";
@import "loader";
@import "chat";
@import "dark_mode";

View File

@ -0,0 +1,25 @@
import EmbeddableChatbox from './embeddable-chatbox';
const config = {
matrixServerUrl: 'https://matrix.safesupport.chat',
botId: '@help-bot:safesupport.chat',
roomName: 'Support Chat',
termsUrl: 'https://tosdr.org/',
introMessage: "This chat application does not collect any of your personal data or any data from your use of this service.",
agreementMessage: 'Do you want to continue?',
confirmationMessage: 'Waiting for a facilitator to join the chat...',
exitMessage: 'The chat is closed. You may close this window.',
chatUnavailableMessage: 'The chat service is not available right now. Please try again later.',
anonymousDisplayName: 'Anonymous',
}
export default function bookmarklet() {
if (window.EmbeddableChatbox) {
return;
}
window.EmbeddableChatbox = EmbeddableChatbox;
EmbeddableChatbox.mount(config);
}
bookmarklet();

View File

@ -0,0 +1,18 @@
import ReactDOM from 'react-dom';
import bookmarklet from './bookmarklet';
describe('bookmarklet', () => {
afterEach(() => {
const el = document.querySelectorAll('body > div');
ReactDOM.unmountComponentAtNode(el[0]);
el[0].parentNode.removeChild(el[0]);
window.EmbeddableChatbox = null;
});
test('#mount document becomes ready', async () => {
expect(window.EmbeddableChatbox).not.toBeNull();
bookmarklet();
const el = document.querySelectorAll('body > div');
expect(el).toHaveLength(1);
});
});

View File

@ -0,0 +1,49 @@
import React from 'react';
import ReactDOM from 'react-dom';
import Chatbox from '../components/chatbox';
import '../../vendor/cleanslate.css';
export default class EmbeddableChatbox {
static el;
static mount({ parentElement = null, ...props } = {}) {
const component = <Chatbox {...props} />; // eslint-disable-line
function doRender() {
if (EmbeddableChatbox.el) {
throw new Error('EmbeddableChatbox is already mounted, unmount first');
}
const el = document.createElement('div');
el.setAttribute('class', 'cleanslate');
if (parentElement) {
document.querySelector(parentElement).appendChild(el);
} else {
document.body.appendChild(el);
}
ReactDOM.render(
component,
el,
);
EmbeddableChatbox.el = el;
}
if (document.readyState === 'complete') {
doRender();
} else {
window.addEventListener('load', () => {
doRender();
});
}
}
static unmount() {
if (!EmbeddableChatbox.el) {
throw new Error('EmbeddableChatbox is not mounted, mount first');
}
ReactDOM.unmountComponentAtNode(EmbeddableChatbox.el);
EmbeddableChatbox.el.parentNode.removeChild(EmbeddableChatbox.el);
EmbeddableChatbox.el = null;
}
}

View File

@ -0,0 +1,65 @@
import EmbeddableChatbox from './embeddable-chatbox';
import { waitForSelection } from '../utils/test-helpers';
describe('EmbeddableChatbox', () => {
beforeAll(() => {
document.readyState = 'complete';
if (EmbeddableChatbox.el) {
EmbeddableChatbox.unmount();
}
});
afterEach(() => {
document.readyState = 'complete';
if (EmbeddableChatbox.el) {
EmbeddableChatbox.unmount();
}
});
test('#mount document becomes ready', async () => {
document.readyState = 'loading';
EmbeddableChatbox.mount();
window.dispatchEvent(new Event('load', {}));
await waitForSelection(document, 'div');
});
test('#mount document complete', async () => {
EmbeddableChatbox.mount();
await waitForSelection(document, 'div');
});
test('#mount to document element', async () => {
const newElement = document.createElement('span');
newElement.setAttribute('id', 'widget-mount');
document.body.appendChild(newElement);
EmbeddableChatbox.mount({
parentElement: '#widget-mount',
});
await waitForSelection(document, 'div');
expect(document.querySelectorAll('#widget-mount')).toHaveLength(1);
});
test('#mount twice', async () => {
EmbeddableChatbox.mount();
expect(() => EmbeddableChatbox.mount()).toThrow('already mounted');
});
test('#unmount', async () => {
const el = document.createElement('div');
document.body.appendChild(el);
expect(document.querySelectorAll('div')).toHaveLength(1);
EmbeddableChatbox.el = el;
EmbeddableChatbox.unmount();
expect(document.querySelectorAll('div')).toHaveLength(0);
});
test('#unmount without mounting', async () => {
expect(() => EmbeddableChatbox.unmount()).toThrow('not mounted');
});
});

46
src/utils/decryptFile.js Normal file
View File

@ -0,0 +1,46 @@
import encrypt from 'browser-encrypt-attachment';
import 'isomorphic-fetch';
const ALLOWED_BLOB_MIMETYPES = {
'image/jpeg': true,
'image/gif': true,
'image/png': true,
'video/mp4': true,
'video/webm': true,
'video/ogg': true,
'audio/mp4': true,
'audio/webm': true,
'audio/aac': true,
'audio/mpeg': true,
'audio/ogg': true,
'audio/wave': true,
'audio/wav': true,
'audio/x-wav': true,
'audio/x-pn-wav': true,
'audio/flac': true,
'audio/x-flac': true,
};
const decryptFile = (file, client) => {
const url = client.mxcUrlToHttp(file.url);
// Download the encrypted file as an array buffer.
return Promise.resolve(fetch(url))
.then((response) => response.arrayBuffer())
.then((responseData) => encrypt.decryptAttachment(responseData, file))
.then((dataArray) => {
// IMPORTANT: we must not allow scriptable mime-types into Blobs otherwise
// they introduce XSS attacks if the Blob URI is viewed directly in the
// browser (e.g. by copying the URI into a new tab or window.)
let mimetype = file.mimetype ? file.mimetype.split(';')[0].trim() : '';
if (!ALLOWED_BLOB_MIMETYPES[mimetype]) {
mimetype = 'application/octet-stream';
}
const blob = new Blob([dataArray], { type: mimetype });
return blob;
});
};
export default decryptFile;

30
src/utils/test-helpers.js Normal file
View File

@ -0,0 +1,30 @@
function checkFunc(dom, selector) {
if (typeof dom.update === 'function') {
const el = dom.update().find(selector);
if (el.exists()) {
return el;
}
return null;
}
const els = dom.querySelectorAll(selector);
if (els.length !== 0) {
return els;
}
return null;
}
export async function waitForSelection(dom, selector) {
let numSleep = 0;
for (;;) {
const el = checkFunc(dom, selector);
if (el) {
return el;
}
if (numSleep > 2) {
throw new Error(`could not find ${selector}`);
}
await new Promise(resolve => setTimeout(resolve, 250));
numSleep += 1;
}
}

453
vendor/cleanslate.css vendored Normal file
View File

@ -0,0 +1,453 @@
/*!
* CleanSlate
* github.com/premasagar/cleanslate
*
*//*
An extreme CSS reset stylesheet, for normalising the styling of a container element and its children.
by Premasagar Rose
dharmafly.com
license
opensource.org/licenses/mit-license.php
**
v0.10.1
*/
/* == BLANKET RESET RULES == */
/* HTML 4.01 */
.cleanslate, .cleanslate h1, .cleanslate h2, .cleanslate h3, .cleanslate h4, .cleanslate h5, .cleanslate h6, .cleanslate p, .cleanslate td, .cleanslate dl, .cleanslate tr, .cleanslate dt, .cleanslate ol, .cleanslate form, .cleanslate select, .cleanslate option, .cleanslate pre, .cleanslate div, .cleanslate table, .cleanslate th, .cleanslate tbody, .cleanslate tfoot, .cleanslate caption, .cleanslate thead, .cleanslate ul, .cleanslate li, .cleanslate address, .cleanslate blockquote, .cleanslate dd, .cleanslate fieldset, .cleanslate li, .cleanslate iframe, .cleanslate strong, .cleanslate legend, .cleanslate em, .cleanslate summary, .cleanslate cite, .cleanslate span, .cleanslate input, .cleanslate sup, .cleanslate label, .cleanslate dfn, .cleanslate object, .cleanslate big, .cleanslate q, .cleanslate samp, .cleanslate acronym, .cleanslate small, .cleanslate img, .cleanslate strike, .cleanslate code, .cleanslate sub, .cleanslate ins, .cleanslate textarea, .cleanslate button, .cleanslate var, .cleanslate a, .cleanslate abbr, .cleanslate applet, .cleanslate del, .cleanslate kbd, .cleanslate tt, .cleanslate b, .cleanslate i, .cleanslate hr,
/* HTML5 - Sept 2013 taken from MDN https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/HTML5/HTML5_element_list */
.cleanslate article, .cleanslate aside, .cleanslate figure, .cleanslate figcaption, .cleanslate footer, .cleanslate header, .cleanslate menu, .cleanslate nav, .cleanslate section, .cleanslate time, .cleanslate mark, .cleanslate audio, .cleanslate video, .cleanslate abbr, .cleanslate address, .cleanslate area, .cleanslate blockquote, .cleanslate canvas, .cleanslate caption, .cleanslate cite, .cleanslate code, .cleanslate colgroup, .cleanslate col, .cleanslate datalist, .cleanslate fieldset, .cleanslate main, .cleanslate map, .cleanslate meta, .cleanslate optgroup, .cleanslate output, .cleanslate progress, .cleanslate svg {
background-attachment:scroll !important;
background-color:transparent !important;
background-image:none !important; /* This rule affects the use of pngfix JavaScript http://dillerdesign.com/experiment/DD_BelatedPNG for IE6, which is used to force the browser to recognise alpha-transparent PNGs files that replace the IE6 lack of PNG transparency. (The rule overrides the VML image that is used to replace the given CSS background-image). If you don't know what that means, then you probably haven't used the pngfix script, and this comment may be ignored :) */
background-position:0 0 !important;
background-repeat:repeat !important;
border-color:black !important;
border-color:currentColor !important; /* `border-color` should match font color. Modern browsers (incl. IE9) allow the use of "currentColor" to match the current font 'color' value <http://www.w3.org/TR/css3-color/#currentcolor>. For older browsers, a default of 'black' is given before this rule. Guideline to support older browsers: if you haven't already declared a border-color for an element, be sure to do so, e.g. when you first declare the border-width. */
border-radius:0 !important;
border-style:none !important;
border-width:medium !important;
bottom:auto !important;
clear:none !important;
clip:auto !important;
color:inherit !important;
counter-increment:none !important;
counter-reset:none !important;
cursor:auto !important;
direction:inherit !important;
display:inline !important;
float:none !important;
font-family: inherit !important; /* As with other inherit values, this needs to be set on the root container element */
font-size: inherit !important;
font-style:inherit !important;
font-variant:normal !important;
font-weight:inherit !important;
height:auto !important;
left:auto !important;
letter-spacing:normal !important;
line-height:inherit !important;
list-style-type: inherit !important; /* Could set list-style-type to none */
list-style-position: outside !important;
list-style-image: none !important;
margin:0 !important;
max-height:none !important;
max-width:none !important;
min-height:0 !important;
min-width:0 !important;
opacity:1;
outline:invert none medium !important;
overflow:visible !important;
padding:0 !important;
position:static !important;
quotes: "" "" !important;
right:auto !important;
table-layout:auto !important;
text-align:inherit !important;
text-decoration:inherit !important;
text-indent:0 !important;
text-transform:none !important;
top:auto !important;
unicode-bidi:normal !important;
vertical-align:baseline !important;
visibility:inherit !important;
white-space:normal !important;
width:auto !important;
word-spacing:normal !important;
z-index:auto !important;
/* CSS3 */
/* Including all prefixes according to http://caniuse.com/ */
/* CSS Animations don't cascade, so don't require resetting */
-webkit-background-origin: padding-box !important;
background-origin: padding-box !important;
-webkit-background-clip: border-box !important;
background-clip: border-box !important;
-webkit-background-size: auto !important;
-moz-background-size: auto !important;
background-size: auto !important;
-webkit-border-image: none !important;
-moz-border-image: none !important;
-o-border-image: none !important;
border-image: none !important;
-webkit-border-radius:0 !important;
-moz-border-radius:0 !important;
border-radius: 0 !important;
-webkit-box-shadow: none !important;
box-shadow: none !important;
-webkit-box-sizing: content-box !important;
-moz-box-sizing: content-box !important;
box-sizing: content-box !important;
-webkit-column-count: auto !important;
-moz-column-count: auto !important;
column-count: auto !important;
-webkit-column-gap: normal !important;
-moz-column-gap: normal !important;
column-gap: normal !important;
-webkit-column-rule: medium none black !important;
-moz-column-rule: medium none black !important;
column-rule: medium none black !important;
-webkit-column-span: 1 !important;
-moz-column-span: 1 !important; /* doesn't exist yet but probably will */
column-span: 1 !important;
-webkit-column-width: auto !important;
-moz-column-width: auto !important;
column-width: auto !important;
font-feature-settings: normal !important;
overflow-x: visible !important;
overflow-y: visible !important;
-webkit-hyphens: manual !important;
-moz-hyphens: manual !important;
hyphens: manual !important;
-webkit-perspective: none !important;
-moz-perspective: none !important;
-ms-perspective: none !important;
-o-perspective: none !important;
perspective: none !important;
-webkit-perspective-origin: 50% 50% !important;
-moz-perspective-origin: 50% 50% !important;
-ms-perspective-origin: 50% 50% !important;
-o-perspective-origin: 50% 50% !important;
perspective-origin: 50% 50% !important;
-webkit-backface-visibility: visible !important;
-moz-backface-visibility: visible !important;
-ms-backface-visibility: visible !important;
-o-backface-visibility: visible !important;
backface-visibility: visible !important;
text-shadow: none !important;
-webkit-transition: all 0s ease 0s !important;
transition: all 0s ease 0s !important;
-webkit-transform: none !important;
-moz-transform: none !important;
-ms-transform: none !important;
-o-transform: none !important;
transform: none !important;
-webkit-transform-origin: 50% 50% !important;
-moz-transform-origin: 50% 50% !important;
-ms-transform-origin: 50% 50% !important;
-o-transform-origin: 50% 50% !important;
transform-origin: 50% 50% !important;
-webkit-transform-style: flat !important;
-moz-transform-style: flat !important;
-ms-transform-style: flat !important;
-o-transform-style: flat !important;
transform-style: flat !important;
word-break: normal !important;
}
/* == BLOCK-LEVEL == */
/* Actually, some of these should be inline-block and other values, but block works fine (TODO: rigorously verify this) */
/* HTML 4.01 */
.cleanslate, .cleanslate h3, .cleanslate h5, .cleanslate p, .cleanslate h1, .cleanslate dl, .cleanslate dt, .cleanslate h6, .cleanslate ol, .cleanslate form, .cleanslate option, .cleanslate pre, .cleanslate div, .cleanslate h2, .cleanslate caption, .cleanslate h4, .cleanslate ul, .cleanslate address, .cleanslate blockquote, .cleanslate dd, .cleanslate fieldset, .cleanslate hr,
/* HTML5 new elements */
.cleanslate article, .cleanslate dialog, .cleanslate figure, .cleanslate footer, .cleanslate header, .cleanslate hgroup, .cleanslate menu, .cleanslate nav, .cleanslate section, .cleanslate audio, .cleanslate video, .cleanslate address, .cleanslate blockquote, .cleanslate colgroup, .cleanslate main, .cleanslate progress, .cleanslate summary {
display:block !important;
}
.cleanslate h1, .cleanslate h2, .cleanslate h3, .cleanslate h4, .cleanslate h5, .cleanslate h6 {
font-weight: bold !important;
}
.cleanslate h1 {
font-size: 2em !important;
padding: .67em 0 !important;
}
.cleanslate h2 {
font-size: 1.5em !important;
padding: .83em 0 !important;
}
.cleanslate h3 {
font-size: 1.17em !important;
padding: .83em 0 !important;
}
.cleanslate h4 {
font-size: 1em !important;
}
.cleanslate h5 {
font-size: .83em !important;
}
.cleanslate p {
margin: 1em 0 !important;
}
.cleanslate table {
display: table !important;
}
.cleanslate thead {
display: table-header-group !important;
}
.cleanslate tbody {
display: table-row-group !important;
}
.cleanslate tfoot {
display: table-footer-group !important;
}
.cleanslate tr {
display: table-row !important;
}
.cleanslate th, .cleanslate td {
display: table-cell !important;
padding: 2px !important;
}
/* == SPECIFIC ELEMENTS == */
/* Some of these are browser defaults; some are just useful resets */
.cleanslate ol, .cleanslate ul {
margin: 1em 0 !important;
}
.cleanslate ul li, .cleanslate ul ul li, .cleanslate ul ul ul li, .cleanslate ol li, .cleanslate ol ol li, .cleanslate ol ol ol li, .cleanslate ul ol ol li, .cleanslate ul ul ol li, .cleanslate ol ul ul li, .cleanslate ol ol ul li {
list-style-position: inside !important;
margin-top: .08em !important;
}
.cleanslate ol ol, .cleanslate ol ol ol, .cleanslate ul ul, .cleanslate ul ul ul, .cleanslate ol ul, .cleanslate ol ul ul, .cleanslate ol ol ul, .cleanslate ul ol, .cleanslate ul ol ol, .cleanslate ul ul ol {
padding-left: 40px !important;
margin: 0 !important;
}
/* helper for general navigation */
.cleanslate nav ul, .cleanslate nav ol {
list-style-type:none !important;
}
.cleanslate ul, .cleanslate menu {
list-style-type:disc !important;
}
.cleanslate ol {
list-style-type:decimal !important;
}
.cleanslate ol ul, .cleanslate ul ul, .cleanslate menu ul, .cleanslate ol menu, .cleanslate ul menu, .cleanslate menu menu {
list-style-type:circle !important;
}
.cleanslate ol ol ul, .cleanslate ol ul ul, .cleanslate ol menu ul, .cleanslate ol ol menu, .cleanslate ol ul menu, .cleanslate ol menu menu, .cleanslate ul ol ul, .cleanslate ul ul ul, .cleanslate ul menu ul, .cleanslate ul ol menu, .cleanslate ul ul menu, .cleanslate ul menu menu, .cleanslate menu ol ul, .cleanslate menu ul ul, .cleanslate menu menu ul, .cleanslate menu ol menu, .cleanslate menu ul menu, .cleanslate menu menu menu {
list-style-type:square !important;
}
.cleanslate li {
display:list-item !important;
/* Fixes IE7 issue with positioning of nested bullets */
min-height:auto !important;
min-width:auto !important;
padding-left: 20px !important; /* replace -webkit-padding-start: 40px; */
}
.cleanslate strong {
font-weight:bold !important;
}
.cleanslate em {
font-style:italic !important;
}
.cleanslate kbd, .cleanslate samp, .cleanslate code, .cleanslate pre {
font-family:monospace !important;
}
.cleanslate a {
color: blue !important;
text-decoration: underline !important;
}
.cleanslate a:visited {
color: #529 !important;
}
.cleanslate a, .cleanslate a *, .cleanslate input[type=submit], .cleanslate input[type=button], .cleanslate input[type=radio], .cleanslate input[type=checkbox], .cleanslate select, .cleanslate button {
cursor:pointer !important;
}
.cleanslate button, .cleanslate input[type=submit] {
text-align: center !important;
padding: 2px 6px 3px !important;
border-radius: 4px !important;
text-decoration: none !important;
font-family: arial, helvetica, sans-serif !important;
font-size: small !important;
background: white !important;
-webkit-appearance: push-button !important;
color: buttontext !important;
border: 1px #a6a6a6 solid !important;
background: lightgrey !important; /* Old browsers */
background: rgb(255,255,255); /* Old browsers */
background: -moz-linear-gradient(top, rgba(255,255,255,1) 0%, rgba(221,221,221,1) 100%, rgba(209,209,209,1) 100%, rgba(221,221,221,1) 100%) !important; /* FF3.6+ */
background: -webkit-gradient(linear, left top, left bottom, color-stop(0%,rgba(255,255,255,1)), color-stop(100%,rgba(221,221,221,1)), color-stop(100%,rgba(209,209,209,1)), color-stop(100%,rgba(221,221,221,1))) !important; /* Chrome,Safari4+ */
background: -webkit-linear-gradient(top, rgba(255,255,255,1) 0%,rgba(221,221,221,1) 100%,rgba(209,209,209,1) 100%,rgba(221,221,221,1) 100%) !important; /* Chrome10+,Safari5.1+ */
background: -o-linear-gradient(top, rgba(255,255,255,1) 0%,rgba(221,221,221,1) 100%,rgba(209,209,209,1) 100%,rgba(221,221,221,1) 100%) !important; /* Opera 11.10+ */
background: -ms-linear-gradient(top, rgba(255,255,255,1) 0%,rgba(221,221,221,1) 100%,rgba(209,209,209,1) 100%,rgba(221,221,221,1) 100%) !important; /* IE10+ */
background: linear-gradient(to bottom, rgba(255,255,255,1) 0%,rgba(221,221,221,1) 100%,rgba(209,209,209,1) 100%,rgba(221,221,221,1) 100%) !important; /* W3C */
filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#ffffff', endColorstr='#dddddd',GradientType=0 ) !important; /* IE6-9 */
-webkit-box-shadow: 1px 1px 0px #eee !important;
-moz-box-shadow: 1px 1px 0px #eee !important;
-o-box-shadow: 1px 1px 0px #eee !important;
box-shadow: 1px 1px 0px #eee !important;
outline: initial !important;
}
.cleanslate button:active, .cleanslate input[type=submit]:active, .cleanslate input[type=button]:active, .cleanslate button:active {
background: rgb(59,103,158) !important; /* Old browsers */
background: -moz-linear-gradient(top, rgba(59,103,158,1) 0%, rgba(43,136,217,1) 50%, rgba(32,124,202,1) 51%, rgba(125,185,232,1) 100%) !important; /* FF3.6+ */
background: -webkit-gradient(linear, left top, left bottom, color-stop(0%,rgba(59,103,158,1)), color-stop(50%,rgba(43,136,217,1)), color-stop(51%,rgba(32,124,202,1)), color-stop(100%,rgba(125,185,232,1))) !important; /* Chrome,Safari4+ */
background: -webkit-linear-gradient(top, rgba(59,103,158,1) 0%,rgba(43,136,217,1) 50%,rgba(32,124,202,1) 51%,rgba(125,185,232,1) 100%) !important; /* Chrome10+,Safari5.1+ */
background: -o-linear-gradient(top, rgba(59,103,158,1) 0%,rgba(43,136,217,1) 50%,rgba(32,124,202,1) 51%,rgba(125,185,232,1) 100%) !important; /* Opera 11.10+ */
background: -ms-linear-gradient(top, rgba(59,103,158,1) 0%,rgba(43,136,217,1) 50%,rgba(32,124,202,1) 51%,rgba(125,185,232,1) 100%) !important; /* IE10+ */
background: linear-gradient(to bottom, rgba(59,103,158,1) 0%,rgba(43,136,217,1) 50%,rgba(32,124,202,1) 51%,rgba(125,185,232,1) 100%) !important; /* W3C */
border-color: #5259b0 !important;
}
.cleanslate button {
padding: 1px 6px 2px 6px !important;
margin-right: 5px !important;
}
.cleanslate input[type=hidden] {
display:none !important;
}
/* restore form defaults */
.cleanslate textarea {
-webkit-appearance: textarea !important;
background: white !important;
padding: 2px !important;
margin-left: 4px !important;
word-wrap: break-word !important;
white-space: pre-wrap !important;
font-size: 11px !important;
font-family: arial, helvetica, sans-serif !important;
line-height: 13px !important;
resize: both !important;
}
.cleanslate select, .cleanslate textarea, .cleanslate input {
border:1px solid #ccc !important;
}
.cleanslate select {
font-size: 11px !important;
font-family: helvetica, arial, sans-serif !important;
display: inline-block;
}
.cleanslate textarea:focus, .cleanslate input:focus {
outline: auto 5px -webkit-focus-ring-color !important;
outline: initial !important;
}
.cleanslate input[type=text] {
background: white !important;
padding: 1px !important;
font-family: initial !important;
font-size: small !important;
}
.cleanslate input[type=checkbox], .cleanslate input[type=radio] {
border: 1px #2b2b2b solid !important;
border-radius: 4px !important;
}
.cleanslate input[type=checkbox], .cleanslate input[type=radio] {
outline: initial !important;
}
.cleanslate input[type=radio] {
margin: 2px 2px 3px 2px !important;
}
.cleanslate abbr[title], .cleanslate acronym[title], .cleanslate dfn[title] {
cursor:help !important;
border-bottom-width:1px !important;
border-bottom-style:dotted !important;
}
.cleanslate ins {
background-color:#ff9 !important;
color:black !important;
}
.cleanslate del {
text-decoration: line-through !important;
}
.cleanslate blockquote, .cleanslate q {
quotes:none !important; /* HTML5 */
}
.cleanslate blockquote:before, .cleanslate blockquote:after, .cleanslate q:before, .cleanslate q:after, .cleanslate li:before, .cleanslate li:after {
content:"" !important;
}
.cleanslate input, .cleanslate select {
vertical-align:middle !important;
}
.cleanslate table {
border-collapse:collapse !important;
border-spacing:0 !important;
}
.cleanslate hr {
display:block !important;
height:1px !important;
border:0 !important;
border-top:1px solid #ccc !important;
margin:1em 0 !important;
}
.cleanslate *[dir=rtl] {
direction: rtl !important;
}
.cleanslate mark {
background-color:#ff9 !important;
color:black !important;
font-style:italic !important;
font-weight:bold !important;
}
.cleanslate menu {
padding-left: 40px !important;
padding-top: 8px !important;
}
/* additional helpers */
.cleanslate [hidden],
.cleanslate template {
display: none !important;
}
.cleanslate abbr[title] {
border-bottom: 1px dotted !important;
}
.cleanslate sub, .cleanslate sup {
font-size: 75% !important;
line-height: 0 !important;
position: relative !important;
vertical-align: baseline !important;
}
.cleanslate sup {
top: -0.5em !important;
}
.cleanslate sub {
bottom: -0.25em !important;
}
.cleanslate img {
border: 0 !important;
}
.cleanslate figure {
margin: 0 !important;
}
.cleanslate textarea {
overflow: auto !important;
vertical-align: top !important;
}
/* == ROOT CONTAINER ELEMENT == */
/* This contains default values for child elements to inherit */
.cleanslate {
font-size: medium !important;
line-height: 1 !important;
direction:ltr !important;
text-align: left !important; /* for IE, Opera */
text-align: start !important; /* recommended W3C Spec */
font-family: "Times New Roman", Times, serif !important; /* Override this with whatever font-family is required */
color: black !important;
font-style:normal !important;
font-weight:normal !important;
text-decoration:none !important;
list-style-type:disc !important;
}
.cleanslate pre {
white-space:pre !important;
}

97
webpack.config.js Normal file
View File

@ -0,0 +1,97 @@
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const increaseSpecificity = require('postcss-increase-specificity');
const autoprefixer = require('autoprefixer');
const CopyPlugin = require('copy-webpack-plugin');
const path = require('path');
const devMode = process.env.NODE_ENV !== 'production';
const publicDir = path.join(__dirname, 'public');
const distDir = path.join(__dirname, 'dist');
const defaultConfig = {
mode: process.env.NODE_ENV || 'development',
devServer: {
contentBase: publicDir,
port: 9000,
},
plugins: [
// new CleanWebpackPlugin({protectWebpackAssets: false}),
new MiniCssExtractPlugin({
// Options similar to the same options in webpackOptions.output
// both options are optional
filename: devMode ? '[name].css' : '[name].[hash].css',
chunkFilename: devMode ? '[id].css' : '[id].[hash].css',
}),
new CopyPlugin([
{ from: 'public', to: '.' },
]),
],
module: {
rules: [
{
test: /\.(js|jsx)$/,
exclude: /node_modules/,
use: ['babel-loader'],
},
{
test: /\.js$/,
exclude: /node_modules/,
loader: 'eslint-loader',
options: {
emitWarning: true,
},
},
{
test: /\.(scss|css)$/,
use: [
// fallback to style-loader in development
// devMode ? 'style-loader' : MiniCssExtractPlugin.loader,
'style-loader',
'css-loader',
'cssimportant-loader',
{
loader: 'postcss-loader',
options: {
ident: 'postcss',
plugins: [
increaseSpecificity({
stackableRoot: '.cleanslate',
repeat: 1,
}),
autoprefixer()
],
sourceMap: devMode,
},
},
'sass-loader',
],
},
],
},
resolve: {
extensions: ['*', '.js', '.jsx'],
},
node: { fs: 'empty' }
};
module.exports = [{
...defaultConfig,
entry: './src/outputs/embeddable-chatbox.js',
output: {
path: distDir,
publicPath: '/',
filename: 'chatbox.js',
library: 'EmbeddableChatbox',
libraryExport: 'default',
libraryTarget: 'window',
},
}, {
...defaultConfig,
entry: './src/outputs/bookmarklet.js',
output: {
path: distDir,
publicPath: '/',
filename: 'bookmarklet.js',
},
}];

10542
yarn.lock Normal file

File diff suppressed because it is too large Load Diff