mirror of
https://github.com/nomadic-labs/safesupport-chatbox
synced 2025-12-15 15:03:23 +00:00
cleaned up for github
This commit is contained in:
547
src/components/_chat.scss
Normal file
547
src/components/_chat.scss
Normal file
File diff suppressed because it is too large
Load Diff
187
src/components/_dark_mode.scss
Normal file
187
src/components/_dark_mode.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
48
src/components/_loader.scss
Normal file
48
src/components/_loader.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
30
src/components/_variables.scss
Normal file
30
src/components/_variables.scss
Normal 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
733
src/components/chatbox.jsx
Normal file
File diff suppressed because it is too large
Load Diff
274
src/components/chatbox.test.js
Normal file
274
src/components/chatbox.test.js
Normal 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
22
src/components/dock.jsx
Normal 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;
|
||||
50
src/components/emoji-selector.jsx
Normal file
50
src/components/emoji-selector.jsx
Normal 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)
|
||||
|
||||
9
src/components/emoji_icon.svg
Normal file
9
src/components/emoji_icon.svg
Normal 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
33
src/components/header.jsx
Normal 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;
|
||||
65
src/components/message.jsx
Normal file
65
src/components/message.jsx
Normal 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
22
src/components/notice.jsx
Normal 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;
|
||||
4
src/components/styles.scss
Normal file
4
src/components/styles.scss
Normal file
@@ -0,0 +1,4 @@
|
||||
@import "variables";
|
||||
@import "loader";
|
||||
@import "chat";
|
||||
@import "dark_mode";
|
||||
25
src/outputs/bookmarklet.js
Normal file
25
src/outputs/bookmarklet.js
Normal 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();
|
||||
18
src/outputs/bookmarklet.test.js
Normal file
18
src/outputs/bookmarklet.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
49
src/outputs/embeddable-chatbox.js
Normal file
49
src/outputs/embeddable-chatbox.js
Normal 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;
|
||||
}
|
||||
}
|
||||
65
src/outputs/embeddable-chatbox.test.js
Normal file
65
src/outputs/embeddable-chatbox.test.js
Normal 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
46
src/utils/decryptFile.js
Normal 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
30
src/utils/test-helpers.js
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user