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

547
src/components/_chat.scss Normal file

File diff suppressed because it is too large Load Diff

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

File diff suppressed because it is too large Load Diff

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";