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'; import defaultConfig from '../defaultConfig.js'; 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." 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 (resetState=true) => { if (this.state.client) { try { 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() } catch (err) { console.log("Error exiting chat", err) } finally { this.setState({ client: null, ready: true }) // no more loading animation window.clearInterval(this.state.waitIntervalId) // no more waiting messages } } if (this.state.localStorage) { this.state.localStorage.clear() } if (resetState) { 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() try { await client.initCrypto() } catch(err) { return this.restartWithoutCrypto() } await client.startClient() client.once('sync', async (state, prevState, data) => { if (state === "PREPARED") { this.setState({ client }) client.setDisplayName(this.props.displayName) this.setMatrixListeners(client) await this.createRoom(client) } }) } restartWithoutCrypto = 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) await client.startClient() client.once('sync', async (state, prevState, data) => { if (state === "PREPARED") { try { this.setState({ client }) client.setDisplayName(this.props.displayName) this.setMatrixListeners(client) await this.createRoom(client) this.displayBotMessage({ body: UNENCRYPTION_NOTICE }) } catch(err) { console.log("error", err) this.handleInitError(err) } } }) } initializeUnencryptedChat = async () => { this.setState({ ready: false }) const client = await this.createClientWithAccount() await client.startClient() client.once('sync', async (state, prevState, data) => { client.setDisplayName(this.props.displayName) this.setMatrixListeners(client) await this.createRoom(client) }) } handleInitError = (err) => { console.log("error", err) this.displayBotMessage({ body: this.props.chatUnavailableMessage }) this.setState({ ready: true }) } handleDecryptionError = async (event, err) => { const eventId = event.getId() this.handleMessageEvent(event) 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 msg = { id: messageId, type: 'm.room.message', sender: sender, roomId: this.state.roomId, content: content, timestamp: Date.now(), } this.setState({ messages: { ...this.state.messages, [messageId]: msg } }) } displayBotMessage = (content, roomId, messageId=uuid()) => { const msg = { id: messageId, type: 'm.room.message', sender: this.props.botId, roomId: roomId || this.state.roomId, content: content, timestamp: Date.now(), } this.setState({ messages: { ...this.state.messages, [messageId]: msg } }) } handleMessageEvent = event => { const message = { id: event.getId(), type: event.getType(), sender: event.getSender(), roomId: event.getRoomId(), content: event.getContent(), timestamp: event.getTs(), } // there's also event.getClearContent() which only works on encrypted messages // but not really sure when it should be used vs 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 }) } const decryptionErrors = {...this.state.decryptionErrors} delete decryptionErrors[message.id] this.setState({ messages: { ...this.state.messages, [message.id]: message, }, 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("sync", (state, prevState, data) => { switch (state) { case "ERROR": // update UI to say "Connection Lost" break; case "SYNCING": // update UI to remove any "Connection Lost" message break; case "PREPARED": // the client instance is ready to be queried. this.setState({ client: client }) break; } }); 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.waitIntervalId) } }); client.on("Event.decrypted", (event, err) => { if (err) { return this.handleDecryptionError(event, err) } if (event.getType() === "m.room.message") { 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 }) } }); client.on("event", (event) => { const eventType = event.getType() const content = event.getContent() if (eventType === 'm.bot.signal') { this.handleBotSignal(content.signal) } }) } handleBotSignal = (signal) => { switch (signal) { case 'END_CHAT': this.displayBotMessage({ body: this.props.exitMessage }) return this.exitChat(false); // keepg chat state case 'CHAT_OFFLINE': this.displayBotMessage({ body: this.props.chatOfflineMessage }) return this.exitChat(false); // keep chat state } } componentDidUpdate(prevProps, prevState) { if ((prevState.messages !== this.state.messages) || (prevState.messagesInFlight !== this.state.messagesInFlight) || (prevState.typingStatus !== this.state.typingStatus)) { 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 { if (this.props.isEncryptionDisabled) { this.initializeUnencryptedChat() } else { this.initializeChat() } } catch(err) { this.handleInitError(err) } } startWaitTimeForFacilitator = () => { const waitIntervalId = window.setInterval(() => { if (!this.state.facilitatorId && !this.state.ready) { this.displayBotMessage({ body: this.props.waitMessage }) } }, this.props.waitInterval) this.setState({ waitIntervalId }) } 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 && !(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() { console.log(this.props) if (!this.props.enabled || !this.props.isAvailable) { return null } const { ready, messages, messagesInFlight, inputValue, userId, roomId, typingStatus, opened, showDock, emojiSelectorOpen, isMobile, decryptionErrors } = this.state; const orderedMessages = Object.values(messages).sort((a,b) => a.timestamp - b.timestamp) const inputLabel = 'Send a message...' const [positionY, positionX] = this.props.position.split(' ') return (