├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── README.md ├── app.js ├── classes ├── markdown.js ├── message-handler.js └── sanitizer.js ├── components ├── avatar.jsx ├── client.jsx ├── event-tile.jsx ├── message-composer.jsx ├── message-toolbar.jsx ├── modal.jsx ├── reply-popup.jsx ├── room-header.jsx ├── room-tile.jsx ├── rooms-list.jsx ├── sign-in-form.jsx ├── theme-context.jsx └── timeline-panel.jsx ├── docs.md ├── index.html ├── package-lock.json ├── package.json ├── res ├── delete.svg ├── quote.svg ├── read.svg └── reply.svg ├── styles ├── colors.scss ├── layout.scss ├── main.scss └── themes.scss ├── test-page.html └── webpack.config.js /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | /dist -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'env': { 3 | 'browser': true, 4 | 'es6': true, 5 | 'node': true 6 | }, 7 | 'extends': [ 8 | 'eslint:recommended', 9 | 'plugin:react/recommended' 10 | ], 11 | 'globals': { 12 | 'Atomics': 'readonly', 13 | 'SharedArrayBuffer': 'readonly' 14 | }, 15 | 'parser': 'babel-eslint', 16 | 'parserOptions': { 17 | 'ecmaFeatures': { 18 | 'jsx': true 19 | }, 20 | 'ecmaVersion': 11, 21 | 'sourceType': 'module' 22 | }, 23 | 'plugins': [ 24 | 'react' 25 | ], 26 | 'rules': { 27 | 'strict': 0, 28 | 'indent': [ 29 | 'error', 30 | 4 31 | ], 32 | 'linebreak-style': [ 33 | 'error', 34 | 'unix' 35 | ], 36 | 'quotes': [ 37 | 'error', 38 | 'single' 39 | ], 40 | 'semi': [ 41 | 'error', 42 | 'always' 43 | ] 44 | } 45 | }; 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | config.js -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # riot-embedded 2 | Embedded version of Riot. Currently work in progress. 3 | ## Development workflow 4 | ### Installing dependencies 5 | ``` 6 | npm install 7 | ``` 8 | ### Creating a bundle 9 | #### For development: 10 | ``` 11 | npm run dev 12 | ``` 13 | #### For production: 14 | ``` 15 | npm run build 16 | ``` 17 | ### Deploying to `webpack-dev-server` with hot reloading enabled (deployed on port 9000) 18 | ``` 19 | npm start 20 | ``` 21 | ### Running the linter 22 | ``` 23 | npm run lint 24 | ``` 25 | ### Configuration 26 | Create `config.js` in the root directory of the repository with the following format. 27 | ```js 28 | export let config = { 29 | baseUrl: '', 30 | roomId: '', 31 | userId: '', 32 | accessToken: '' 33 | }; 34 | ``` 35 | Leave out `userId` and `accessToken` to attempt registration as guest. 36 | To set custom highlight colors, change the Sass variables `$color-highlight-custom` and `$color-txt-custom` in `styles/colors.scss` and set `highlight` to `'custom'` in the configuration. 37 | #### Complete list of options: 38 | * `baseUrl` (*string*) - Base URL of homeserver - **Required** 39 | * `roomId` (*string*) - The internal ID of default room - **Required** 40 | * `userId` (*string*) - The ID of default user 41 | Ignore to register as guest 42 | * `accessToken` (*string*) - Access token of default user 43 | Ignore to register as guest 44 | * `readOnly` (*boolean*) - If the client is in read-only mode 45 | - `true` 46 | - `false` (default) 47 | Disables `msgComposer` and `roomsList` (unless overriden) 48 | * `theme` (*string*) - Theme of the client 49 | - `'dark'` - Dark theme (default) 50 | - `'light'` - Light theme 51 | - `'auto'` - Use device theme 52 | * `highlight` (*string*) - Highlight color 53 | - `'pink'` - Pink highlights (default) 54 | - `'green'` - Green highlights 55 | - `'custom'` - Custom highlight color 56 | * `roomHeader` (*boolean*) - If room header should be displayed 57 | - `true` (default) 58 | - `false` 59 | * `roomsList` (*boolean*) - If rooms list should be displayed (overrides `readOnly`) 60 | - `true` (default) 61 | - `false` 62 | * `msgComposer` (*boolean*) - If message composer should be displayed (overrides `readOnly`) 63 | - `true` (default) 64 | - `false` 65 | * `whitelist` (*Array*) - Whitelisted origins 66 | Ignore to allow all origins 67 | * `signInPrompt` (*string*) - Show sign in prompts 68 | - `'none'` - Never show (default) 69 | - `'guests'` - Show if signed in as guest 70 | - `'all'` - Always show -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | import {config} from './config.js'; 2 | import React from 'react'; 3 | import ReactDOM from 'react-dom'; 4 | import Client from './components/client.jsx'; 5 | import ThemeContext from './components/theme-context.jsx'; 6 | require('./styles/main.scss'); 7 | 8 | ReactDOM.render( 9 | , 10 | document.getElementById('root') 11 | ); -------------------------------------------------------------------------------- /classes/markdown.js: -------------------------------------------------------------------------------- 1 | import commonmark from 'commonmark'; 2 | 3 | /** 4 | * Class for generating HTML from markdown messages 5 | * 6 | * @param {string} input - Message body containing markdown 7 | */ 8 | export default class Markdown { 9 | constructor(input) { 10 | // Replace < to escape HTML 11 | input = input.replace(/' 21 | }); 22 | 23 | return renderer.render(this.parsed); 24 | } 25 | } 26 | 27 | -------------------------------------------------------------------------------- /classes/message-handler.js: -------------------------------------------------------------------------------- 1 | import EventEmitter from 'events'; 2 | 3 | /** 4 | * Class for handling messages to and from parent frame 5 | * 6 | * @param {Array} origins - List of whitelisted origin domains 7 | */ 8 | export default class MessageHandler extends EventEmitter{ 9 | constructor(origins) { 10 | super(); 11 | this.origins = origins; 12 | 13 | this.onMessage = this.onMessage.bind(this); 14 | 15 | // Attach listener to window 16 | window.addEventListener('message', this.onMessage, false); 17 | } 18 | 19 | /** Callback for handling messages */ 20 | onMessage(event) { 21 | // Origin of message event 22 | let origin = event.origin; 23 | 24 | if (!origin) { 25 | // Chrome fix 26 | origin = event.originalEvent.origin; 27 | } 28 | 29 | // If origin is in whitelist or if whitelist is undefined 30 | if ( this.origins === undefined || this.origins.includes(origin) ) { 31 | let data = event.data; 32 | 33 | // Parse data 34 | this.parseMessage(data); 35 | } 36 | } 37 | 38 | /** Parse message and invoke a command */ 39 | parseMessage(data) { 40 | // Check if data is an object 41 | if (typeof data !== 'object') { 42 | parent.postMessage({ 43 | 'status' : 'error', 44 | 'message' : 'Invalid message format.' 45 | }, '*'); 46 | return; 47 | } 48 | 49 | let cmd = data.cmd; 50 | if (typeof cmd !== 'string') { 51 | parent.postMessage({ 52 | 'status' : 'error', 53 | 'message' : 'Invalid or missing command.' 54 | }, '*'); 55 | return; 56 | } 57 | 58 | // Invoke command 59 | let args = data.args; 60 | switch(cmd) { 61 | case 'setTheme': 62 | // Change theme 63 | if (typeof args !== 'object') { 64 | parent.postMessage({ 65 | 'status' : 'error', 66 | 'message' : 'Invalid or missing arguments.' 67 | }, '*'); 68 | return; 69 | } 70 | this.emit('setTheme', args); 71 | parent.postMessage({ 72 | 'status' : 'success', 73 | 'message' : 'Theme set.' 74 | }, '*'); 75 | break; 76 | 77 | case 'roomHeader': 78 | // Toggle room header 79 | if (typeof args !== 'boolean') { 80 | parent.postMessage({ 81 | 'status' : 'error', 82 | 'message' : 'Invalid or missing arguments.' 83 | }, '*'); 84 | return; 85 | } 86 | this.emit('roomHeader', args); 87 | parent.postMessage({ 88 | 'status' : 'success', 89 | 'message' : 'Toggled room header.' 90 | }, '*'); 91 | break; 92 | 93 | case 'roomsList': 94 | // Toggle rooms list 95 | if (typeof args !== 'boolean') { 96 | parent.postMessage({ 97 | 'status' : 'error', 98 | 'message' : 'Invalid or missing arguments.' 99 | }, '*'); 100 | return; 101 | } 102 | this.emit('roomsList', args); 103 | parent.postMessage({ 104 | 'status' : 'success', 105 | 'message' : 'Toggled rooms list.' 106 | }, '*'); 107 | break; 108 | 109 | case 'msgComposer': 110 | // Toggle message composer 111 | if (typeof args !== 'boolean') { 112 | parent.postMessage({ 113 | 'status' : 'error', 114 | 'message' : 'Invalid or missing arguments.' 115 | }, '*'); 116 | return; 117 | } 118 | this.emit('msgComposer', args); 119 | parent.postMessage({ 120 | 'status' : 'success', 121 | 'message' : 'Toggled message composer.' 122 | }, '*'); 123 | break; 124 | 125 | case 'login': 126 | // Sign in to account using password 127 | if (typeof args !== 'object') { 128 | parent.postMessage({ 129 | 'status' : 'error', 130 | 'message' : 'Invalid or missing arguments.' 131 | }, '*'); 132 | return; 133 | } 134 | this.emit('login', args); 135 | parent.postMessage({ 136 | 'status' : 'success', 137 | 'message' : 'Attempting sign in...' 138 | }, '*'); 139 | break; 140 | 141 | case 'switchRoom': 142 | // Switch to this room 143 | if (typeof args !== 'string') { 144 | parent.postMessage({ 145 | 'status' : 'error', 146 | 'message' : 'Invalid or missing arguments.' 147 | }, '*'); 148 | return; 149 | } 150 | this.emit('switchRoom', args); 151 | parent.postMessage({ 152 | 'status' : 'success', 153 | 'message' : 'Attempting to switch room...' 154 | }, '*'); 155 | break; 156 | 157 | default: 158 | // No matching command 159 | parent.postMessage({ 160 | 'status' : 'error', 161 | 'message' : 'Invalid command.' 162 | }, '*'); 163 | return; 164 | } 165 | } 166 | } -------------------------------------------------------------------------------- /classes/sanitizer.js: -------------------------------------------------------------------------------- 1 | import sanitizeHtml from 'sanitize-html'; 2 | 3 | /** 4 | * Class for sanitizing HTML 5 | * 6 | * @param {string} html - HTML to sanitize 7 | * @param {object} params - Custom parameters for sanitization (optional) 8 | */ 9 | export default class Sanitizer { 10 | constructor(html, params=null) { 11 | this.html = html; 12 | 13 | // Sanitizer Params copied from matrix-react-sdk 14 | this.sanitizeHtmlParams = { 15 | allowedTags: [ 16 | 'font', // custom to matrix for IRC-style font coloring 17 | 'del', // for markdown 18 | 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'p', 'a', 'ul', 'ol', 'sup', 'sub', 19 | 'nl', 'li', 'b', 'i', 'u', 'strong', 'em', 'strike', 'code', 'hr', 'br', 'div', 20 | 'table', 'thead', 'caption', 'tbody', 'tr', 'th', 'td', 'pre', 'span', 'img', 21 | ], 22 | allowedAttributes: { 23 | font: ['color', 'data-mx-bg-color', 'data-mx-color', 'style'], // custom to matrix 24 | span: ['data-mx-bg-color', 'data-mx-color', 'data-mx-spoiler', 'style'], // custom to matrix 25 | // a: ['href', 'name', 'target', 'rel'], // remote target: custom to matrix 26 | img: ['src', 'width', 'height', 'alt', 'title'], 27 | ol: ['start'], 28 | code: ['class'], 29 | }, 30 | selfClosing: ['img', 'br', 'hr', 'area', 'base', 'basefont', 'input', 'link', 'meta'], 31 | 32 | transformTags: { 33 | 'a': 'b', 34 | } 35 | }; 36 | if (params) this.sanitizeHtmlParams = params; 37 | 38 | this.sanitize = this.sanitize.bind(this); 39 | } 40 | 41 | /** Sanitize HTML for client */ 42 | sanitize() { 43 | return sanitizeHtml(this.html, this.sanitizeHtmlParams); 44 | } 45 | } -------------------------------------------------------------------------------- /components/avatar.jsx: -------------------------------------------------------------------------------- 1 | import React, {PureComponent} from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | /** 5 | * React component for an avatar icon 6 | * 7 | * @param {string} imgUrl - The avatar URL 8 | * @param {number} size - The height and width of avatar 9 | * @param {string} name - String to get initial letter from 10 | */ 11 | export default class Avatar extends PureComponent { 12 | static propTypes = { 13 | imgUrl: PropTypes.string, // The avatar URL 14 | size: PropTypes.number, // The height and width of avatar 15 | name: PropTypes.string, // String to get initial letter from 16 | }; 17 | 18 | /** Get first letter of name */ 19 | getInitialLetter() { 20 | let idx = 0; 21 | if (['@', '#', '+'].includes(this.props.name[0]) && this.props.name[1]) idx++; 22 | let char = String.fromCharCode(this.props.name.codePointAt(idx)); 23 | return char.toUpperCase(); 24 | } 25 | 26 | /** Hash function for names */ 27 | cyrb53(str, seed = 0) { 28 | let h1 = 0xdeadbeef ^ seed, h2 = 0x41c6ce57 ^ seed; 29 | for (let i = 0, ch; i < str.length; i++) { 30 | ch = str.charCodeAt(i); 31 | h1 = Math.imul(h1 ^ ch, 2654435761); 32 | h2 = Math.imul(h2 ^ ch, 1597334677); 33 | } 34 | h1 = Math.imul(h1 ^ h1>>>16, 2246822507) ^ Math.imul(h2 ^ h2>>>13, 3266489909); 35 | h2 = Math.imul(h2 ^ h2>>>16, 2246822507) ^ Math.imul(h1 ^ h1>>>13, 3266489909); 36 | return 4294967296 * (2097151 & h2) + (h1>>>0); 37 | } 38 | 39 | /** Generate HSL color from name */ 40 | generateHsl() { 41 | let h = this.cyrb53(this.props.name)%360; 42 | return `hsl(${h}, 100%, 30%)`; 43 | } 44 | 45 | render() { 46 | let imgUrl = this.props.imgUrl; 47 | 48 | // Placeholder avatar if imgUrl is falsy 49 | if (!this.props.imgUrl) return ( 50 |
57 | {this.getInitialLetter()} 58 |
59 | ); 60 | return ( 61 | 69 | ); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /components/client.jsx: -------------------------------------------------------------------------------- 1 | import React, {Component, createRef} from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import RoomsList from './rooms-list'; 4 | import TimelinePanel from './timeline-panel.jsx'; 5 | import RoomHeader from './room-header'; 6 | import MessageComposer from './message-composer'; 7 | import ThemeContext from './theme-context.jsx'; 8 | import Modal from './modal'; 9 | import SignInForm from './sign-in-form'; 10 | import ReplyPopup from './reply-popup'; 11 | import MessageHandler from '../classes/message-handler.js'; 12 | import Avatar from './avatar'; 13 | 14 | /** 15 | * React component for the client 16 | * 17 | * @param {string} roomId - The ID of default room 18 | * @param {string} userId - The ID of default user 19 | * @param {string} accessToken - Access token of default user 20 | * @param {string} baseUrl - Base URL of homeserver 21 | * @param {boolean} readOnly - If the client is in read-only mode 22 | * @param {string} theme - Theme (dark or light) 23 | * @param {string} highlight - Highlight color (green or pink) 24 | * @param {boolean} roomHeader - If room header should be displayed 25 | * @param {boolean} roomsList - If rooms list should be displayed 26 | * @param {boolean} msgComposer - If message composer should be displayed 27 | * @param {Array} whitelist - Whitelisted origins 28 | * @param {string} signInPrompt - Show sign in prompt for - none, guests, all 29 | */ 30 | export default class Client extends Component{ 31 | static propTypes = { 32 | roomId: PropTypes.string.isRequired, // The ID of default room 33 | userId: PropTypes.string, // The ID of default user 34 | accessToken: PropTypes.string, // The access token of default user 35 | baseUrl: PropTypes.string.isRequired, // The base URL of homeserver 36 | readOnly: PropTypes.bool, // Enable read only mode 37 | theme: PropTypes.string, // Theme - default dark 38 | highlight: PropTypes.string, // Highlight - default pink 39 | roomHeader: PropTypes.bool, // Enable roomHeader? 40 | roomsList: PropTypes.bool, // Enable roomsList? Overrides readOnly 41 | msgComposer: PropTypes.bool, // Enable msgComposer? Overrides readOnly 42 | whitelist: PropTypes.array, // Whitelisted origins - ignore to allow all 43 | signInPrompt: PropTypes.string // Show signInPrompt for - none, guests, all 44 | }; 45 | 46 | constructor(props) { 47 | super(props); 48 | this.state = { 49 | room: null, 50 | theme: props.theme === undefined ? 'dark' : 51 | (props.theme === 'auto' ? this.getDeviceTheme() : props.theme), // Client theme (dark/light) 52 | highlight: props.highlight === undefined ? 'pink' : props.highlight, // Client theme highlight (pink/green) 53 | roomHeader: props.roomHeader !== undefined ? props.roomHeader : true, // If room header should be displayed 54 | roomsList: props.roomsList !== undefined ? props.roomsList : 55 | props.readOnly !== undefined ? !props.readOnly : true, // If rooms list should be displayed 56 | msgComposer: props.msgComposer !== undefined ? props.msgComposer : 57 | props.readOnly !== undefined ? !props.readOnly : true, // If message composer should be displayed 58 | reply: null, // Event to reply to 59 | connectionError: false, // Display connection error message 60 | currentlyTyping: new Set(), // People currently typing in the room 61 | readOnly: props.readOnly // If client is still read only 62 | }; 63 | if (props.theme === 'auto') this.deviceTheme = true; 64 | this.sdk = require('matrix-js-sdk'); 65 | 66 | // TODO: Load from whitelist from config 67 | this.messageHandler = new MessageHandler(this.props.whitelist); 68 | 69 | this.init = this.init.bind(this); 70 | this.onSelectRoom = this.onSelectRoom.bind(this); 71 | this._onRoomTimeline = this._onRoomTimeline.bind(this); 72 | this.setTheme = this.setTheme.bind(this); 73 | this.setUser = this.setUser.bind(this); 74 | this.toggleRoomHeader = this.toggleRoomHeader.bind(this); 75 | this.toggleRoomsList = this.toggleRoomsList.bind(this); 76 | this.toggleMsgComposer = this.toggleMsgComposer.bind(this); 77 | this.switchRoom = this.switchRoom.bind(this); 78 | this.login = this.login.bind(this); 79 | this.replyTo = this.replyTo.bind(this); 80 | this.showReceipts = this.showReceipts.bind(this); 81 | this.checkConnectivity = this.checkConnectivity.bind(this); 82 | this.joinRoomConsentSafe = this.joinRoomConsentSafe.bind(this); 83 | 84 | // Consume events from MessageHandler 85 | this.messageHandler.on('setTheme', this.setTheme); 86 | this.messageHandler.on('roomHeader', this.toggleRoomHeader); 87 | this.messageHandler.on('roomsList', this.toggleRoomsList); 88 | this.messageHandler.on('msgComposer', this.toggleMsgComposer); 89 | this.messageHandler.on('switchRoom', this.switchRoom); 90 | this.messageHandler.on('login', this.login); 91 | 92 | // Refs 93 | this.signInModal = createRef(); 94 | this.receiptsModal = createRef(); 95 | this.continueModal = createRef(); 96 | this.consentModal = createRef(); 97 | this.msgComposer = createRef(); 98 | 99 | if (!props.accessToken || !props.userId) { 100 | // If either accessToken or userId is absent 101 | // Register as guest 102 | this.isGuest = true; 103 | 104 | this.client = this.sdk.createClient({ 105 | baseUrl: props.baseUrl 106 | }); 107 | 108 | this.client.registerGuest({}, (err, data) => { 109 | if (err) { 110 | console.log('ERR: ', err); 111 | return; 112 | } 113 | 114 | let userId = data.user_id; 115 | let accessToken = data.access_token; 116 | this.client = this.sdk.createClient({ 117 | baseUrl: props.baseUrl, 118 | accessToken: accessToken, 119 | userId: userId 120 | }); 121 | this.client.setGuest(true); 122 | if (props.readOnly) { 123 | this.client.peekInRoom(this.props.roomId, {syncRoom: true}).then(() => { 124 | this.init(); 125 | }); 126 | } else { 127 | this.joinRoomConsentSafe(this.props.roomId, () => { 128 | this.init(); 129 | }); 130 | } 131 | }); 132 | } else { 133 | this.isGuest = false; 134 | 135 | this.client = this.sdk.createClient({ 136 | baseUrl: props.baseUrl, 137 | accessToken: props.accessToken, 138 | userId: props.userId 139 | }); 140 | 141 | if (props.readOnly) { 142 | this.client.peekInRoom(this.props.roomId, {syncRoom: true}).then(() => { 143 | this.init(); 144 | }); 145 | } else { 146 | this.client.joinRoom(this.props.roomId, {syncRoom: true}).then(() => { 147 | this.init(); 148 | }); 149 | } 150 | } 151 | } 152 | 153 | /** Listener for timeline events */ 154 | _onRoomTimeline(event, room) { 155 | if (room === this.state.room) { 156 | // If event is from current room, update 157 | this.setState({ 158 | room: room 159 | }); 160 | } 161 | } 162 | 163 | /** Connect client to homeserver */ 164 | async init(callback=null) { 165 | this.client.startClient(); 166 | this.client.once('sync', (state) => { 167 | console.log(state); 168 | if (state === 'PREPARED') { 169 | this.setState({ 170 | room: this.client.getRoom(this.props.roomId) 171 | }); 172 | 173 | if (callback) callback(); 174 | 175 | // Add listeners 176 | this.client.on('Room.timeline', this._onRoomTimeline); 177 | this.client.on('sync', this.checkConnectivity); 178 | window.addEventListener('online', () => { 179 | // Force SDK to recheck 180 | this.client.retryImmediately(); 181 | }); 182 | window.addEventListener('offline', () => { 183 | // Manually show error 184 | this.setState({ 185 | connectionError: true 186 | }); 187 | this.client.retryImmediately(); 188 | }); 189 | window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', e => { 190 | // Watch for changes in device theme 191 | const newTheme = e.matches ? 'dark' : 'light'; 192 | if (this.deviceTheme) this.setState({ theme: newTheme }); 193 | }); 194 | this.client.on('RoomMember.typing', (_, member) => { 195 | // Add or remove member from currently typing members 196 | let roomId = member.roomId; 197 | let username = member.name; 198 | let typing = member.typing; 199 | if (roomId === (this.state.room ? this.state.room.roomId : null)) { 200 | // If event is in current room 201 | let currentlyTyping = this.state.currentlyTyping; 202 | if (typing) { 203 | // Add to list 204 | if (!currentlyTyping.has(username)) { 205 | currentlyTyping.add(username); 206 | this.setState({ 207 | currentlyTyping: currentlyTyping 208 | }); 209 | } 210 | } else { 211 | // Remove from list 212 | if (currentlyTyping.delete(username)) { 213 | this.setState({ 214 | currentlyTyping: currentlyTyping 215 | }); 216 | } 217 | } 218 | } 219 | }); 220 | } 221 | }); 222 | } 223 | 224 | /** Join room with consent */ 225 | joinRoomConsentSafe(roomId, callback=null) { 226 | this.client.joinRoom(roomId, {syncRoom: true}) 227 | .then(callback) 228 | .catch(e => { 229 | if (e.errcode === 'M_CONSENT_NOT_GIVEN') { 230 | if (this.signInModal.current) this.signInModal.current.close(); 231 | if (this.continueModal.current) this.continueModal.current.close(); 232 | this.consentModal.current.open(); 233 | document.querySelector('#consent-given').disabled = true; 234 | document.querySelector('#consent-error').style.display = 'none'; 235 | this.consentHref = e.data.consent_uri; 236 | this.consentCallback = callback; 237 | } else console.log('Unhandled exception!', e); 238 | }); 239 | } 240 | 241 | /** Get current device theme */ 242 | getDeviceTheme() { 243 | if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) return 'dark'; 244 | return 'light'; 245 | } 246 | 247 | /** Handle clicks from room list */ 248 | onSelectRoom(e) { 249 | // Unset reply 250 | this.replyTo(); 251 | 252 | let roomId = e.currentTarget.getAttribute('id'); 253 | this.setState({ 254 | room: this.client.getRoom(roomId), 255 | currentlyTyping: new Set() 256 | }); 257 | } 258 | 259 | /** Reinitialize client after login */ 260 | setUser(userId, accessToken, callback=null) { 261 | this.isGuest = false; 262 | 263 | this.client = this.sdk.createClient({ 264 | baseUrl: this.props.baseUrl, 265 | accessToken: accessToken, 266 | userId: userId, 267 | currentlyTyping: new Set() 268 | }); 269 | 270 | // Unset reply 271 | this.replyTo(); 272 | 273 | // Set R/W 274 | this.setState({ 275 | readOnly: false 276 | }); 277 | 278 | this.init(callback); 279 | } 280 | 281 | /** Set the reply */ 282 | replyTo(mxEvent=null) { 283 | this.setState({ 284 | reply: mxEvent 285 | }); 286 | } 287 | 288 | /** Consume setTheme event from MessageHandler */ 289 | setTheme(args) { 290 | this.setState({ 291 | theme: args.theme ? 292 | (args.theme === 'auto' ? this.getDeviceTheme() : args.theme) : this.state.theme, 293 | highlight: args.highlight ? args.highlight : this.state.highlight 294 | }); 295 | } 296 | 297 | /** Consume roomHeader event from MessageHandler */ 298 | toggleRoomHeader(args) { 299 | this.setState({ 300 | roomHeader: args 301 | }); 302 | } 303 | 304 | /** Consume roomsList event from MessageHandler */ 305 | toggleRoomsList(args) { 306 | this.setState({ 307 | roomsList: args 308 | }); 309 | } 310 | 311 | /** Consume msgComposer event from MessageHandler */ 312 | toggleMsgComposer(args) { 313 | this.setState({ 314 | msgComposer: args 315 | }); 316 | } 317 | 318 | /** Switch room */ 319 | switchRoom(args) { 320 | if (typeof args != 'string' && !this.isGuest) return; 321 | // Unset reply 322 | this.replyTo(); 323 | 324 | this.setState({ 325 | room: this.client.getRoom(args), 326 | currentlyTyping: new Set() 327 | }); 328 | } 329 | 330 | /** Attempt login with password */ 331 | login(args) { 332 | let user = args.user; 333 | let passwd = args.passwd; 334 | if (!user || !passwd) return; 335 | this.client.loginWithPassword(user, passwd, (err, data) => { 336 | if (err) { 337 | // Handle error 338 | console.log('ERROR: ', err); 339 | } else { 340 | console.log('SUCCESS: ', data); 341 | this.setUser(user, data.access_token); 342 | } 343 | }); 344 | } 345 | 346 | /** Show read receipts for event */ 347 | showReceipts(event) { 348 | // Construct read receipts for event from latest to current 349 | let receipts = []; 350 | let timeline = this.state.room.timeline; 351 | for (let i=timeline.length-1; i>=0; i--) { 352 | let evt = timeline[i]; 353 | receipts.push( 354 | ...this.state.room.getReceiptsForEvent(evt) 355 | ); 356 | if (event.getId() === evt.getId()) break; 357 | } 358 | 359 | // Sort in alphabetical order on userId 360 | // Can't sort on time as receipts are not available 361 | // for previous messages (hence no timestamp) 362 | receipts.sort((a,b) => a.userId.localeCompare(b.userId)); 363 | 364 | // Generate JSX for list 365 | this.list = []; 366 | for (let receipt of receipts) { 367 | const userId = receipt.userId; 368 | let user = this.client.getUser(userId); 369 | let avatarUrl = user.avatarUrl; 370 | if (avatarUrl) { 371 | avatarUrl = this.client.mxcUrlToHttp(avatarUrl, 32, 32); 372 | } 373 | this.list.push( 374 |
  • 376 | 377 |

    {user.displayName} {user.userId}

    378 |
  • 379 | ); 380 | } 381 | 382 | // Show modal 383 | this.receiptsModal.current.open(); 384 | this.forceUpdate(); 385 | } 386 | 387 | /** Check connection and show/hide error */ 388 | checkConnectivity() { 389 | // this.client.turnServer() 390 | // .then(() => { 391 | // this.setState({ 392 | // connectionError: false 393 | // }); 394 | // }) 395 | // .catch(() => { 396 | // this.setState({ 397 | // connectionError: true 398 | // }); 399 | // }); 400 | 401 | // Fetch state from client 402 | const syncState = this.client.getSyncState(); 403 | const syncStateData = this.client.getSyncStateData(); 404 | 405 | // Logic copied from RoomStatusBar in matrix-react-sdk 406 | const errorIsMauError = Boolean( 407 | syncStateData && 408 | syncStateData.error && 409 | syncStateData.error.errcode === 'M_RESOURCE_LIMIT_EXCEEDED', 410 | ); 411 | const connErr = Boolean(syncState === 'ERROR' && !errorIsMauError); 412 | 413 | // Show or hide error 414 | this.setState({ 415 | connectionError: connErr 416 | }); 417 | } 418 | 419 | render() { 420 | if (!this.client) return <>; 421 | 422 | // Get current room ID 423 | let currentRoomId = this.state.room ? this.state.room.roomId : ''; 424 | let homeserver = this.client.getHomeserverUrl(); 425 | 426 | // Sign-in prompt 427 | let siPrompt = false; 428 | if (this.props.signInPrompt === 'all') { 429 | // Show for everyone 430 | siPrompt = true; 431 | } else if (this.props.signInPrompt === 'guests' && this.isGuest) { 432 | // Show for guests and currently signed in as guest 433 | siPrompt = true; 434 | } 435 | 436 | // Generate typing text 437 | let typingText = ''; 438 | let setSize = this.state.currentlyTyping.size; 439 | if (setSize > 0 && setSize <= 3) { 440 | // Display names of currently typing users 441 | const verb = setSize == 1 ? 'is' : 'are'; 442 | typingText = 443 | `${Array.from(this.state.currentlyTyping).join(', ')} ${verb} typing...`; 444 | 445 | } else if (setSize > 3) { 446 | // Display number of currently typing users 447 | typingText = `${setSize} users are typing...`; 448 | } 449 | 450 | return ( 451 | 452 |
    453 | 454 | 455 | 456 | 457 | 458 |
    459 |
      460 | {this.list} 461 |
    462 |
    463 |
    464 | 465 | 466 |
    467 | 468 | Please open the privacy agreement and accept the terms and conditions to continue. 469 | 470 |
    471 | { 472 | let checked = event.target.checked; 473 | document.querySelector('#consent-given').disabled = !checked; 474 | }} /> I have accepted the terms given on the privacy agreement page 475 |
    476 | You need to accept the terms on the privacy agreement page to continue. 477 |
    478 | 482 | 503 |
    504 |
    505 |
    506 | 507 | 508 |
    509 | Please sign in or register a guest account to send a message. 510 |
    511 | 516 | 528 |
    529 |
    530 |
    531 | 532 | {this.state.roomHeader && ()} 534 | 535 | {this.state.connectionError &&
    536 | Lost connection to the server. 537 |
    } 538 | 539 |
    540 | {this.state.roomsList && ()} 543 | 547 | {this.state.reply && this.state.msgComposer ? 548 | : 551 | <>} 552 | {this.state.msgComposer ? : <>} 557 | 558 | 559 |
    560 |
    561 |
    562 | ); 563 | } 564 | } 565 | -------------------------------------------------------------------------------- /components/event-tile.jsx: -------------------------------------------------------------------------------- 1 | import React, {PureComponent} from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import Avatar from './avatar.jsx'; 4 | import MessageToolbar from './message-toolbar.jsx'; 5 | import ThemeContext from './theme-context.jsx'; 6 | import Sanitizer from '../classes/sanitizer.js'; 7 | import linkifyHtml from 'linkifyjs/html'; 8 | 9 | /** 10 | * React component for an event in the room timeline 11 | * 12 | * @param {string} homeserver - The homeserver URL 13 | * @param {object} mxEvent - The event object 14 | * @param {object} client - The matrix client object 15 | * @param {func} replyTo - Callback for setting reply 16 | * @param {boolean} canWrite - If client can send messages 17 | * @param {boolean} isGuest - If client is in guest mode 18 | * @param {func} showReceipts - Callback to show read receipts 19 | */ 20 | export default class EventTile extends PureComponent { 21 | static propTypes = { 22 | homeserver: PropTypes.string.isRequired, // Homeserver URL 23 | mxEvent: PropTypes.object.isRequired, // Event object 24 | client: PropTypes.object.isRequired, // Client object 25 | replyTo: PropTypes.func.isRequired, // Callback for setting reply 26 | canWrite: PropTypes.bool.isRequired, // If client can send messages 27 | isGuest: PropTypes.bool.isRequired, // If client is in guest mode 28 | showReceipts: PropTypes.func.isRequired // Callback to show read receipts 29 | }; 30 | 31 | constructor(props) { 32 | super(props); 33 | 34 | this.delete = this.delete.bind(this); 35 | } 36 | 37 | /** Delete this event */ 38 | delete() { 39 | const roomId = this.props.mxEvent.getRoomId(); 40 | const eventId = this.props.mxEvent.event.event_id; 41 | this.props.client.redactEvent(roomId, eventId, (err, data) => { 42 | if (err) console.log(err); 43 | console.log(data); 44 | }); 45 | } 46 | 47 | // Consume theme context 48 | static contextType = ThemeContext; 49 | render() { 50 | let theme = this.context; 51 | 52 | // Extract details from event 53 | let sender = this.props.mxEvent.sender; 54 | let avatarUrl = sender.getAvatarUrl(this.props.homeserver, 32, 32, 'scale', false); 55 | let {name, userId} = sender; 56 | let fmtBody = this.props.mxEvent.event.content.formatted_body; 57 | let mxBody; 58 | 59 | // If deleting is possible by current user 60 | let canDelete = this.props.client.getUserId() == userId && 61 | !this.props.isGuest; 62 | 63 | if (this.props.mxEvent.event.content.msgtype === 'm.image') { 64 | // Load images 65 | let content = this.props.mxEvent.event.content; 66 | let img_url; 67 | if (content.info && content.info.thumbnail_url) { 68 | // Usual format 69 | img_url = this.props.client.mxcUrlToHttp( 70 | content.info.thumbnail_url 71 | ); 72 | } else { 73 | // GIFs 74 | img_url = this.props.client.mxcUrlToHttp( 75 | content.url 76 | ); 77 | } 78 | mxBody = ( 79 | // eslint-disable-next-line react/jsx-no-target-blank 80 | 81 | 82 | 83 | ); 84 | } else if (this.props.mxEvent.event.content.msgtype === 'm.text') { 85 | // Load text only messages 86 | if (fmtBody) { 87 | let saneHtml = new Sanitizer(fmtBody).sanitize(); 88 | saneHtml = linkifyHtml(saneHtml, { 89 | defaultProtocol: 'https', 90 | ignoreTags: ['a', 'blockquote'] 91 | }); 92 | mxBody = ( 93 | 94 | ); 95 | } else mxBody = this.props.mxEvent.event.content.body; 96 | } else return <>; // Return empty message 97 | 98 | return ( 99 |
  • 100 |
    101 | 102 | 109 |
    110 |

    {name} {userId}

    111 |

    112 | {mxBody} 113 |

    114 |
    115 |
    116 |
  • 117 | ); 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /components/message-composer.jsx: -------------------------------------------------------------------------------- 1 | import React, {PureComponent} from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import ThemeContext from './theme-context.jsx'; 4 | import Markdown from '../classes/markdown.js'; 5 | import Sanitizer from '../classes/sanitizer.js'; 6 | 7 | /** 8 | * React component for composing and sending messages 9 | * 10 | * @param {string} roomId - The ID of current room 11 | * @param {MatrixClient} client - The client object 12 | * @param {object} mxEvent - Event to reply to 13 | * @param {func} unsetReply - Callback to unset reply 14 | * @param {func} openContinueModal - Callback to open continue dialog box 15 | */ 16 | export default class MessageComposer extends PureComponent { 17 | static propTypes = { 18 | roomId: PropTypes.string.isRequired, // Current room ID 19 | client: PropTypes.object.isRequired, // Client object 20 | mxEvent: PropTypes.object, // Event to reply to 21 | unsetReply: PropTypes.func.isRequired, // Callback to unset reply 22 | openContinueModal: PropTypes.func // Callback to open continue dialog box 23 | }; 24 | 25 | constructor(props) { 26 | super(props); 27 | this.state = { 28 | value: '', 29 | busy: false 30 | }; 31 | 32 | this._msgCallback = this._msgCallback.bind(this); 33 | this.onChange = this.onChange.bind(this); 34 | this.onKeyDown = this.onKeyDown.bind(this); 35 | this.onSubmit = this.onSubmit.bind(this); 36 | } 37 | 38 | /** Callback for sending a message */ 39 | _msgCallback(err) { 40 | if (err) { 41 | console.log(err); 42 | // TODO: Handle it by showing a prompt 43 | } 44 | 45 | // Re-enable message composer 46 | this.setState({ 47 | busy: false 48 | }); 49 | } 50 | 51 | /** Construct reply content */ 52 | _constructReply(htmlBody) { 53 | // Extract details from original event 54 | let eventId = this.props.mxEvent.event.event_id; 55 | let userId = this.props.mxEvent.sender.userId; 56 | let fmtBody = this.props.mxEvent.event.content.formatted_body || 57 | new Sanitizer(this.props.mxEvent.event.content.body).sanitize(); 58 | let body = this.props.mxEvent.event.content.body; 59 | 60 | // Generate reply bodies 61 | let finalFmtBody = `
    In reply to ${userId}` 62 | + `
    ${fmtBody}
    ` + htmlBody; 63 | let finalBody = `> <${userId}> ${body}\n\n${this.state.value}`; 64 | 65 | // Return final content object 66 | const content = { 67 | msgtype: 'm.text', 68 | format: 'org.matrix.custom.html', 69 | body: finalBody, 70 | formatted_body: finalFmtBody, 71 | 'm.relates_to': { 72 | 'm.in_reply_to': { 73 | event_id: eventId 74 | } 75 | } 76 | }; 77 | return content; 78 | } 79 | 80 | /** Send the value in composer */ 81 | sendMessage() { 82 | if (this.state.value.length <= 0) return; 83 | 84 | if (this.props.openContinueModal) { 85 | this.props.openContinueModal(); 86 | return; 87 | } 88 | this.setState({ busy: true }); 89 | 90 | const htmlBody = new Markdown(this.state.value).toHtml(); 91 | 92 | if (this.props.mxEvent == null) { 93 | // Message is not a reply 94 | this.props.client.sendHtmlMessage(this.props.roomId, this.state.value, 95 | htmlBody, this._msgCallback); 96 | } else { 97 | // Message is a reply 98 | this.props.client.sendMessage(this.props.roomId, 99 | this._constructReply(htmlBody), null, this._msgCallback); 100 | this.props.unsetReply(); 101 | } 102 | this.setState({ value: '' }); 103 | } 104 | 105 | /** Callback for updating text */ 106 | onChange(event) { 107 | if (this.busy) return; 108 | 109 | this.setState({ 110 | value: event.target.value 111 | }); 112 | } 113 | 114 | /** Callback for handling special keys */ 115 | onKeyDown(event) { 116 | let handled = false; 117 | const isMac = navigator.platform.indexOf('Mac') !== -1; 118 | 119 | if (event.key === 'Enter' && (event.shiftKey || (isMac && event.altKey))) { 120 | // Insert newline character on shift+enter 121 | this.setState({ 122 | value: this.state.value + '\n' 123 | }); 124 | handled = true; 125 | } else if (event.key === 'Enter') { 126 | // Send message 127 | this.sendMessage(); 128 | handled = true; 129 | } 130 | 131 | if (handled) { 132 | event.preventDefault(); 133 | event.stopPropagation(); 134 | } 135 | } 136 | 137 | /** Callback for handling clicks on button */ 138 | onSubmit(event) { 139 | // Send message 140 | this.sendMessage(); 141 | 142 | event.preventDefault(); 143 | event.stopPropagation(); 144 | } 145 | 146 | // Consume theme context 147 | static contextType = ThemeContext; 148 | render() { 149 | let theme = this.context; 150 | 151 | let placeholder = this.state.busy ? 'Sending...' : 'Send a message...'; 152 | let shouldSend = this.state.value.length > 0 && !this.state.busy; 153 | 154 | return ( 155 |
    156 |