diff --git a/__mocks__/matrix-js-sdk.js b/__mocks__/matrix-js-sdk.js new file mode 100644 index 0000000..d6bd58a --- /dev/null +++ b/__mocks__/matrix-js-sdk.js @@ -0,0 +1,11 @@ +export const mockCreateClient = jest.fn(); +export const mockStartClient = jest.fn(); + +const mockMatrix = jest.fn().mockImplementation(() => { + return { + createClient: mockCreateClient, + startClient: mockStartClient + }; +}); + +export default mockMatrix; \ No newline at end of file diff --git a/package.json b/package.json index 64b6627..fdd314a 100644 --- a/package.json +++ b/package.json @@ -88,9 +88,10 @@ "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": "^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", @@ -120,6 +121,7 @@ "prop-types": "^15.6.2", "react": "^16.8.6", "react-dom": "^16.8.6", + "react-test-renderer": "^16.13.0", "react-transition-group": "^4.0.0", "uuidv4": "^6.0.2" } diff --git a/public/index.html b/public/index.html index 8784007..bbebf00 100644 --- a/public/index.html +++ b/public/index.html @@ -18,9 +18,9 @@ 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? Type yes or no.', + agreementMessage: 'Do you want to continue?', confirmationMessage: 'Waiting for a facilitator to join the chat...', - exitMessage: 'The chat was not started.', + 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', } diff --git a/src/components/__snapshots__/widget.test.js.snap b/src/components/__snapshots__/widget.test.js.snap deleted file mode 100644 index 4041c21..0000000 --- a/src/components/__snapshots__/widget.test.js.snap +++ /dev/null @@ -1,197 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[` open/close 1`] = ` - -
- -
-
-
- Header -
- -
-
- Body -
-
- Footer -
-
-
- -
-
-`; - -exports[` open/close 2`] = ` - -
- -
-
-
- Header -
- -
-
- Body -
-
- Footer -
-
-
-
-
-`; - -exports[` open/close 3`] = ` - -
- -
-
-
- Header -
- -
-
- Body -
-
- Footer -
-
-
- -
-
-`; diff --git a/src/components/_chat.scss b/src/components/_chat.scss index a28153b..fe30bdc 100644 --- a/src/components/_chat.scss +++ b/src/components/_chat.scss @@ -270,6 +270,42 @@ width: fit-content; } + .buttons { + display: flex; + align-items: center; + font-size: 1rem; + + button { + background-color: transparent; + padding: 0.25rem 0.5rem; + font-size: 0.9rem; + 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: 10px; + margin-left: 4px; + + &: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.9rem; diff --git a/src/components/chatbox.jsx b/src/components/chatbox.jsx index 528482c..c01ac8d 100644 --- a/src/components/chatbox.jsx +++ b/src/components/chatbox.jsx @@ -24,11 +24,14 @@ const ENCRYPTION_NOTICE = "Messages in this chat are secured with end-to-end enc const UNENCRYPTION_NOTICE = "End-to-end message encryption is not available on this browser." const RESTARTING_UNENCRYPTED_CHAT_MESSAGE = "Restarting chat without encryption." +const DEFAULT_MATRIX_SERVER = "https://matrix.rhok.space/" +const DEFAULT_BOT_USERNAME = "@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? Type yes or no." +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 was not started." +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." @@ -36,7 +39,6 @@ const DEFAULT_CHAT_UNAVAILABLE_MESSAGE = "The chat service is not available righ class ChatBox extends React.Component { constructor(props) { super(props) - const client = matrix.createClient(this.props.matrixServerUrl) this.initialState = { opened: false, showDock: true, @@ -46,38 +48,17 @@ class ChatBox extends React.Component { userId: null, password: null, localStorage: null, - messages: [ - { - id: 'intro-msg-id', - type: 'm.room.message', - sender: this.props.botUsername, - content: { body: this.props.introMessage }, - }, - { - id: 'terms-msg-id', - type: 'm.room.message', - sender: this.props.botUsername, - content: { - body: `Please read the full terms and conditions at ${this.props.termsUrl}.`, - formatted_body: `Please read the full terms and conditions.` - } - }, - { - id: 'agreement-msg-id', - type: 'm.room.message', - sender: this.props.botUsername, - content: { body: this.props.agreementMessage }, }, - ], + messages: [], inputValue: "", errors: [], roomId: null, typingStatus: null, awaitingAgreement: true, - awaitingFacilitator: false, } this.state = this.initialState this.chatboxInput = React.createRef(); this.messageWindow = React.createRef(); + this.termsUrl = React.createRef(); } handleToggleOpen = () => { @@ -100,7 +81,11 @@ class ChatBox extends React.Component { } handleWidgetEnter = () => { - this.chatboxInput.current.focus() + if (this.state.awaitingAgreement) { + this.termsUrl.current.focus() + } else { + this.chatboxInput.current.focus() + } } handleExitChat = () => { @@ -442,24 +427,20 @@ class ChatBox extends React.Component { this.setState({ inputValue: e.currentTarget.value }) } + handleAcceptTerms = () => { + this.setState({ awaitingAgreement: false }) + this.initializeChat() + } + + handleRejectTerms = () => { + this.exitChat() + this.displayBotMessage({ body: this.props.exitMessage }) + } + handleSubmit = e => { e.preventDefault() if (!Boolean(this.state.inputValue)) return null; - if (this.state.awaitingAgreement && !this.state.client) { - if (this.state.inputValue.toLowerCase() === 'yes') { - this.displayFakeMessage({ body: this.state.inputValue }, 'from-me') - this.setState({ inputValue: "" }) - - return this.initializeChat() - - } else { - this.displayFakeMessage({ body: this.state.inputValue }, 'from-me') - this.displayBotMessage({ body: this.props.exitMessage }) - return this.setState({ inputValue: "" }) - } - } - if (this.state.client && this.state.roomId) { return this.sendMessage() } @@ -480,6 +461,26 @@ class ChatBox extends React.Component {
+
+
{ this.props.introMessage }
+
+ +
+
Please read the full terms and conditions. By using this chat, you agree to these terms.
+
+ +
+
{ this.props.agreementMessage }
+
+ +
+
+ {`👉`} + + +
+
+ { messages.map((message, index) => { return( @@ -537,6 +538,9 @@ ChatBox.propTypes = { } ChatBox.defaultProps = { + matrixServerUrl: DEFAULT_MATRIX_SERVER, + botUsername: DEFAULT_BOT_USERNAME, + termsUrl: DEFAULT_TERMS_URL, roomName: DEFAULT_ROOM_NAME, introMessage: DEFAULT_INTRO_MESSAGE, agreementMessage: DEFAULT_AGREEMENT_MESSAGE, diff --git a/src/components/chatbox.test.js b/src/components/chatbox.test.js new file mode 100644 index 0000000..a84a36e --- /dev/null +++ b/src/components/chatbox.test.js @@ -0,0 +1,167 @@ +import React from 'react'; +import Chatbox from './chatbox'; +import mockMatrix, { mockCreateClient } from "matrix-js-sdk"; +import { mount, shallow } from 'enzyme'; +import { createWaitForElement } from 'enzyme-wait'; +import { config } from 'react-transition-group'; + +config.disabled = true + +const testConfig = { + matrixServerUrl: 'https://matrix.rhok.space', + botUsername: '@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? Type yes or no.', + confirmationMessage: 'Waiting for a facilitator to join the chat...', + exitMessage: 'The chat was not started.', + chatUnavailableMessage: 'The chat service is not available right now. Please try again later.', + anonymousDisplayName: 'Anonymous', +} + + +describe('Chatbox', () => { + + test('chat window should open and close', async () => { + const chatbox = mount() + + 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() + 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('#handleExitChat should call exitChat if the client has been initialized', () => { + + }) + + test('#exitChat should leave the room and destroy client', () => { + // leave room + // deactivate account + // stop client + // clear stores + // reset initial state + }) + + test('agreeing to terms should start encrypted chat', async () => { + const chatbox = mount() + const dock = chatbox.find('button.dock') + + dock.simulate('click') + + const yesButton = chatbox.find('#accept') + yesButton.simulate('click') + + expect(mockCreateClient).toHaveBeenCalled() + }) + + // test('rejecting terms should not start chat', async () => { + // const chatbox = mount() + // const dock = chatbox.find('button.dock') + + // dock.simulate('click') + + // const noButton = chatbox.find('#reject') + // noButton.simulate('click') + + // expect(mockMatrix.mockCreateClient.mock.calls.length).toEqual(0) + // }) + + test('#initializeChat should notify user if client fails to initialize', () => { + // handleInitError + }) + + test('#initializeChat should create unencypted chat if initCrypto fails', () => { + // initializeUnencryptedChat + }) + + test('#initializeUnencryptedChat should initialize an unencrypted client', () => { + // initializeUnencryptedChat + }) + + test('#handleDecryptionError should restart client without encryption and notify user', () => { + // initializeUnencryptedChat + }) + + test('#verifyAllRoomDevices should mark all devices in the room as verified devices', () => { + + }) + + test('#createRoom should create a new encrypted room with bot as admin', () => { + + }) + + test('#createRoom should create a new unencrypted room if encryption is not enabled', () => { + + }) + + test('#sendMessage should send text message with input value', () => { + + }) + + test('#sendMessage should mark devices as known and retry sending on UnknownDeviceError', () => { + + }) + + test('#sendMessage should mark devices as known and retry sending on UnknownDeviceError', () => { + + }) + + test('#displayFakeMessage should add a message object to message list', () => { + + }) + + test('#displayBotMessage should add a message object with bot as sender to message list', () => { + + }) + + test('#handleMessageEvent should add received message to message list', () => { + + }) + + test('#componentDidUpdate should set state listeners', () => { + + }) + + test('#handleSubmit should listen for yes if awaiting agreement and initialize client', () => { + + }) + + test('#handleSubmit should listen for no if awaiting agreement and do nothing', () => { + + }) + + test('#handleSubmit should send message if awaitingAgreement is false', () => { + + }) +}); diff --git a/src/outputs/bookmarklet.js b/src/outputs/bookmarklet.js index 9698bb0..de4df6a 100644 --- a/src/outputs/bookmarklet.js +++ b/src/outputs/bookmarklet.js @@ -6,12 +6,20 @@ export default function bookmarklet() { } window.EmbeddableChatbox = EmbeddableChatbox; - EmbeddableChatbox.mount({ - termsUrl: 'https://tosdr.org/', - privacyStatement: 'This chat application does not collect any of your personal data or any data from your use of this service.', + var config = { matrixServerUrl: 'https://matrix.rhok.space', + botUsername: '@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? Type yes or no.', + confirmationMessage: 'Waiting for a facilitator to join the chat...', + exitMessage: 'The chat was not started.', + chatUnavailableMessage: 'The chat service is not available right now. Please try again later.', + anonymousDisplayName: 'Anonymous', + } + + EmbeddableChatbox.mount(config); } bookmarklet(); diff --git a/src/outputs/bookmarklet.test.js b/src/outputs/bookmarklet.test.js index 89c8d9b..3f7f3e3 100644 --- a/src/outputs/bookmarklet.test.js +++ b/src/outputs/bookmarklet.test.js @@ -6,11 +6,11 @@ describe('bookmarklet', () => { const el = document.querySelectorAll('body > div'); ReactDOM.unmountComponentAtNode(el[0]); el[0].parentNode.removeChild(el[0]); - window.EmbeddableWidget = null; + window.EmbeddableChatbox = null; }); test('#mount document becomes ready', async () => { - expect(window.EmbeddableWidget).not.toBeNull(); + expect(window.EmbeddableChatbox).not.toBeNull(); bookmarklet(); const el = document.querySelectorAll('body > div'); expect(el).toHaveLength(1); diff --git a/src/outputs/embeddable-widget.test.js b/src/outputs/embeddable-chatbox.test.js similarity index 70% rename from src/outputs/embeddable-widget.test.js rename to src/outputs/embeddable-chatbox.test.js index d28f958..3578a2d 100644 --- a/src/outputs/embeddable-widget.test.js +++ b/src/outputs/embeddable-chatbox.test.js @@ -1,23 +1,24 @@ -import EmbeddableWidget from './embeddable-widget'; +import EmbeddableChatbox from './embeddable-chatbox'; import { waitForSelection } from '../test-helpers'; -describe('EmbeddableWidget', () => { + +describe('EmbeddableChatbox', () => { afterEach(() => { document.readyState = 'complete'; - if (EmbeddableWidget.el) { - EmbeddableWidget.unmount(); + if (EmbeddableChatbox.el) { + EmbeddableChatbox.unmount(); } }); test('#mount document becomes ready', async () => { document.readyState = 'loading'; - EmbeddableWidget.mount(); + EmbeddableChatbox.mount(); window.dispatchEvent(new Event('load', {})); await waitForSelection(document, 'div'); }); test('#mount document complete', async () => { - EmbeddableWidget.mount(); + EmbeddableChatbox.mount(); await waitForSelection(document, 'div'); }); @@ -26,7 +27,7 @@ describe('EmbeddableWidget', () => { newElement.setAttribute('id', 'widget-mount'); document.body.appendChild(newElement); - EmbeddableWidget.mount({ + EmbeddableChatbox.mount({ parentElement: '#widget-mount', }); @@ -36,8 +37,8 @@ describe('EmbeddableWidget', () => { }); test('#mount twice', async () => { - EmbeddableWidget.mount(); - expect(() => EmbeddableWidget.mount()).toThrow('already mounted'); + EmbeddableChatbox.mount(); + expect(() => EmbeddableChatbox.mount()).toThrow('already mounted'); }); test('#unmount', async () => { @@ -45,13 +46,13 @@ describe('EmbeddableWidget', () => { document.body.appendChild(el); expect(document.querySelectorAll('div')).toHaveLength(1); - EmbeddableWidget.el = el; - EmbeddableWidget.unmount(); + EmbeddableChatbox.el = el; + EmbeddableChatbox.unmount(); expect(document.querySelectorAll('div')).toHaveLength(0); }); test('#unmount without mounting', async () => { - expect(() => EmbeddableWidget.unmount()).toThrow('not mounted'); + expect(() => EmbeddableChatbox.unmount()).toThrow('not mounted'); }); }); diff --git a/yarn.lock b/yarn.lock index e5bd495..0fcf7e4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2598,6 +2598,11 @@ assert@^1.1.1: object-assign "^4.1.1" util "0.10.3" +assertion-error@^1.0.2: + version "1.1.0" + resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-1.1.0.tgz#e60b6b0e8f301bd97e5375215bda406c85118c0b" + integrity sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw== + assign-symbols@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/assign-symbols/-/assign-symbols-1.0.0.tgz#59667f41fadd4f20ccbc2bb96b8d4f7f78ec0367" @@ -4828,7 +4833,7 @@ entities@^2.0.0: resolved "https://registry.yarnpkg.com/entities/-/entities-2.0.0.tgz#68d6084cab1b079767540d80e56a39b423e4abf4" integrity sha512-D9f7V0JSRwIxlRI2mjMqufDrRDnx8p+eEOz7aUM9SuvF8gsBzra0/6tbjl1m8eQHrZlYj6PxqE00hZ1SAIKPLw== -enzyme-adapter-react-16@1.15.2: +enzyme-adapter-react-16@^1.15.2: version "1.15.2" resolved "https://registry.yarnpkg.com/enzyme-adapter-react-16/-/enzyme-adapter-react-16-1.15.2.tgz#b16db2f0ea424d58a808f9df86ab6212895a4501" integrity sha512-SkvDrb8xU3lSxID8Qic9rB8pvevDbLybxPK6D/vW7PrT0s2Cl/zJYuXvsd1EBTz0q4o3iqG3FJhpYz3nUNpM2Q== @@ -4870,7 +4875,14 @@ enzyme-to-json@3.4.3: dependencies: lodash "^4.17.15" -enzyme@3.11.0: +enzyme-wait@^1.0.9: + version "1.0.9" + resolved "https://registry.yarnpkg.com/enzyme-wait/-/enzyme-wait-1.0.9.tgz#f7a08bf49c7047358fa03e1f411a565c3a15101a" + integrity sha1-96CL9JxwRzWPoD4fQRpWXDoVEBo= + dependencies: + assertion-error "^1.0.2" + +enzyme@^3.11.0: version "3.11.0" resolved "https://registry.yarnpkg.com/enzyme/-/enzyme-3.11.0.tgz#71d680c580fe9349f6f5ac6c775bc3e6b7a79c28" integrity sha512-Dw8/Gs4vRjxY6/6i9wU0V+utmQO9kvh9XLnz3LIudviOnVYDEe2ec+0k+NQoMamn1VrjKgCUOWj5jG/5M5M0Qw== @@ -10089,6 +10101,16 @@ react-test-renderer@^16.0.0-0: react-is "^16.8.6" scheduler "^0.18.0" +react-test-renderer@^16.13.0: + version "16.13.0" + resolved "https://registry.yarnpkg.com/react-test-renderer/-/react-test-renderer-16.13.0.tgz#39ba3bf72cedc8210c3f81983f0bb061b14a3014" + integrity sha512-NQ2S9gdMUa7rgPGpKGyMcwl1d6D9MCF0lftdI3kts6kkiX+qvpC955jNjAZXlIDTjnN9jwFI8A8XhRh/9v0spA== + dependencies: + object-assign "^4.1.1" + prop-types "^15.6.2" + react-is "^16.8.6" + scheduler "^0.19.0" + react-textarea-autosize@^7.1.0: version "7.1.2" resolved "https://registry.yarnpkg.com/react-textarea-autosize/-/react-textarea-autosize-7.1.2.tgz#70fdb333ef86bcca72717e25e623e90c336e2cda" @@ -10706,6 +10728,14 @@ scheduler@^0.18.0: loose-envify "^1.1.0" object-assign "^4.1.1" +scheduler@^0.19.0: + version "0.19.0" + resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.19.0.tgz#a715d56302de403df742f4a9be11975b32f5698d" + integrity sha512-xowbVaTPe9r7y7RUejcK73/j8tt2jfiyTednOvHbA8JoClvMYCp+r8QegLwK/n8zWQAtZb1fFnER4XLBZXrCxA== + dependencies: + loose-envify "^1.1.0" + object-assign "^4.1.1" + schema-utils@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-1.0.0.tgz#0b79a93204d7b600d4b2850d1f66c2a34951c770"