diff --git a/src/ActiveRoomObserver.js b/src/ActiveRoomObserver.js new file mode 100644 index 0000000000..d6fbb460b5 --- /dev/null +++ b/src/ActiveRoomObserver.js @@ -0,0 +1,77 @@ +/* +Copyright 2017 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import RoomViewStore from './stores/RoomViewStore'; + +/** + * Consumes changes from the RoomViewStore and notifies specific things + * about when the active room changes. Unlike listening for RoomViewStore + * changes, you can subscribe to only changes relevant to a particular + * room. + * + * TODO: If we introduce an observer for something else, factor out + * the adding / removing of listeners & emitting into a common class. + */ +class ActiveRoomObserver { + constructor() { + this._listeners = {}; + + this._activeRoomId = RoomViewStore.getRoomId(); + // TODO: We could self-destruct when the last listener goes away, or at least + // stop listening. + this._roomStoreToken = RoomViewStore.addListener(this._onRoomViewStoreUpdate.bind(this)); + } + + addListener(roomId, listener) { + if (!this._listeners[roomId]) this._listeners[roomId] = []; + this._listeners[roomId].push(listener); + } + + removeListener(roomId, listener) { + if (this._listeners[roomId]) { + const i = this._listeners[roomId].indexOf(listener); + if (i > -1) { + this._listeners[roomId].splice(i, 1); + } + } else { + console.warn("Unregistering unrecognised listener (roomId=" + roomId + ")"); + } + } + + _emit(roomId) { + if (!this._listeners[roomId]) return; + + for (const l of this._listeners[roomId]) { + l.call(); + } + } + + _onRoomViewStoreUpdate() { + // emit for the old room ID + if (this._activeRoomId) this._emit(this._activeRoomId); + + // update our cache + this._activeRoomId = RoomViewStore.getRoomId(); + + // and emit for the new one + if (this._activeRoomId) this._emit(this._activeRoomId); + } +} + +if (global.mx_ActiveRoomObserver === undefined) { + global.mx_ActiveRoomObserver = new ActiveRoomObserver(); +} +export default global.mx_ActiveRoomObserver; diff --git a/src/DateUtils.js b/src/DateUtils.js index 78eef57eae..77f3644f6f 100644 --- a/src/DateUtils.js +++ b/src/DateUtils.js @@ -65,7 +65,7 @@ module.exports = { const days = getDaysArray(); const months = getMonthsArray(); if (date.toDateString() === now.toDateString()) { - return this.formatTime(date); + return this.formatTime(date, showTwelveHour); } else if (now.getTime() - date.getTime() < 6 * 24 * 60 * 60 * 1000) { // TODO: use standard date localize function provided in counterpart return _t('%(weekDayName)s %(time)s', { @@ -78,7 +78,7 @@ module.exports = { weekDayName: days[date.getDay()], monthName: months[date.getMonth()], day: date.getDate(), - time: this.formatTime(date), + time: this.formatTime(date, showTwelveHour), }); } return this.formatFullDate(date, showTwelveHour); @@ -92,13 +92,13 @@ module.exports = { monthName: months[date.getMonth()], day: date.getDate(), fullYear: date.getFullYear(), - time: showTwelveHour ? twelveHourTime(date) : this.formatTime(date), + time: this.formatTime(date, showTwelveHour), }); }, formatTime: function(date, showTwelveHour=false) { if (showTwelveHour) { - return twelveHourTime(date); + return twelveHourTime(date); } return pad(date.getHours()) + ':' + pad(date.getMinutes()); }, diff --git a/src/HtmlUtils.js b/src/HtmlUtils.js index 63ee5fa480..ee2bcd2b0f 100644 --- a/src/HtmlUtils.js +++ b/src/HtmlUtils.js @@ -32,7 +32,15 @@ emojione.imagePathPNG = 'emojione/png/'; // Use SVGs for emojis emojione.imageType = 'svg'; -const SIMPLE_EMOJI_PATTERN = /([\ud800-\udbff])([\udc00-\udfff])/; +// Anything outside the basic multilingual plane will be a surrogate pair +const SURROGATE_PAIR_PATTERN = /([\ud800-\udbff])([\udc00-\udfff])/; +// And there a bunch more symbol characters that emojione has within the +// BMP, so this includes the ranges from 'letterlike symbols' to +// 'miscellaneous symbols and arrows' which should catch all of them +// (with plenty of false positives, but that's OK) +const SYMBOL_PATTERN = /([\u2100-\u2bff])/; + +// And this is emojione's complete regex const EMOJI_REGEX = new RegExp(emojione.unicodeRegexp+"+", "gi"); const COLOR_REGEX = /^#[0-9a-fA-F]{6}$/; @@ -44,16 +52,13 @@ const COLOR_REGEX = /^#[0-9a-fA-F]{6}$/; * unicodeToImage uses this function. */ export function containsEmoji(str) { - return SIMPLE_EMOJI_PATTERN.test(str); + return SURROGATE_PAIR_PATTERN.test(str) || SYMBOL_PATTERN.test(str); } /* modified from https://github.com/Ranks/emojione/blob/master/lib/js/emojione.js * because we want to include emoji shortnames in title text */ -export function unicodeToImage(str) { - // fast path - if (!containsEmoji(str)) return str; - +function unicodeToImage(str) { let replaceWith, unicode, alt, short, fname; const mappedUnicode = emojione.mapUnicodeToShort(); @@ -143,7 +148,7 @@ export function processHtmlForSending(html: string): string { * of that HTML. */ export function sanitizedHtmlNode(insaneHtml) { - const saneHtml = sanitizeHtml(insaneHtml, sanitizeHtmlParams); + const saneHtml = sanitizeHtml(insaneHtml, sanitizeHtmlParams); return
; } @@ -152,7 +157,7 @@ const sanitizeHtmlParams = { allowedTags: [ 'font', // custom to matrix for IRC-style font coloring 'del', // for markdown - 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'p', 'a', 'ul', 'ol', + 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'p', 'a', 'ul', 'ol', 'sup', 'sub', 'nl', 'li', 'b', 'i', 'u', 'strong', 'em', 'strike', 'code', 'hr', 'br', 'div', 'table', 'thead', 'caption', 'tbody', 'tr', 'th', 'td', 'pre', 'span', 'img', ], @@ -391,6 +396,8 @@ export function bodyToHtml(content, highlights, opts) { var isHtml = (content.format === "org.matrix.custom.html"); let body = isHtml ? content.formatted_body : escape(content.body); + let bodyHasEmoji = false; + var safeBody; // XXX: We sanitize the HTML whilst also highlighting its text nodes, to avoid accidentally trying // to highlight HTML tags themselves. However, this does mean that we don't highlight textnodes which @@ -408,16 +415,20 @@ export function bodyToHtml(content, highlights, opts) { }; } safeBody = sanitizeHtml(body, sanitizeHtmlParams); - safeBody = unicodeToImage(safeBody); + bodyHasEmoji = containsEmoji(body); + if (bodyHasEmoji) safeBody = unicodeToImage(safeBody); } finally { delete sanitizeHtmlParams.textFilter; } - EMOJI_REGEX.lastIndex = 0; - let contentBodyTrimmed = content.body !== undefined ? content.body.trim() : ''; - let match = EMOJI_REGEX.exec(contentBodyTrimmed); - let emojiBody = match && match[0] && match[0].length === contentBodyTrimmed.length; + let emojiBody = false; + if (bodyHasEmoji) { + EMOJI_REGEX.lastIndex = 0; + let contentBodyTrimmed = content.body !== undefined ? content.body.trim() : ''; + let match = EMOJI_REGEX.exec(contentBodyTrimmed); + emojiBody = match && match[0] && match[0].length === contentBodyTrimmed.length; + } const className = classNames({ 'mx_EventTile_body': true, diff --git a/src/Invite.js b/src/Invite.js index b8e33d318a..9be3da53e4 100644 --- a/src/Invite.js +++ b/src/Invite.js @@ -127,7 +127,7 @@ function _onRoomInviteFinished(roomId, shouldInvite, addrs) { } function _isDmChat(addrTexts) { - if (addrTexts.length === 1 && getAddressType(addrTexts[0])) { + if (addrTexts.length === 1 && getAddressType(addrTexts[0]) === 'mx') { return true; } else { return false; diff --git a/src/Markdown.js b/src/Markdown.js index 6e735c6f0e..455d5e95bd 100644 --- a/src/Markdown.js +++ b/src/Markdown.js @@ -17,7 +17,7 @@ limitations under the License. import commonmark from 'commonmark'; import escape from 'lodash/escape'; -const ALLOWED_HTML_TAGS = ['del', 'u']; +const ALLOWED_HTML_TAGS = ['sub', 'sup', 'del', 'u']; // These types of node are definitely text const TEXT_NODES = ['text', 'softbreak', 'linebreak', 'paragraph', 'document']; diff --git a/src/components/structures/MatrixChat.js b/src/components/structures/MatrixChat.js index c142d6958c..1bdfb3e5f9 100644 --- a/src/components/structures/MatrixChat.js +++ b/src/components/structures/MatrixChat.js @@ -672,7 +672,6 @@ module.exports = React.createClass({ page_type: PageTypes.RoomView, thirdPartyInvite: roomInfo.third_party_invite, roomOobData: roomInfo.oob_data, - autoJoin: roomInfo.auto_join, }; if (roomInfo.room_alias) { diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index 8a0eeb50b9..2a6cf0aee4 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -157,6 +157,22 @@ module.exports = React.createClass({ if (this.unmounted) { return; } + + if (!initial && this.state.roomId !== RoomViewStore.getRoomId()) { + // RoomView explicitly does not support changing what room + // is being viewed: instead it should just be re-mounted when + // switching rooms. Therefore, if the room ID changes, we + // ignore this. We either need to do this or add code to handle + // saving the scroll position (otherwise we end up saving the + // scroll position against the wrong room). + + // Given that doing the setState here would cause a bunch of + // unnecessary work, we just ignore the change since we know + // that if the current room ID has changed from what we thought + // it was, it means we're about to be unmounted. + return; + } + const newState = { roomId: RoomViewStore.getRoomId(), roomAlias: RoomViewStore.getRoomAlias(), diff --git a/src/components/structures/UserSettings.js b/src/components/structures/UserSettings.js index 3c139f77a6..e67991ac12 100644 --- a/src/components/structures/UserSettings.js +++ b/src/components/structures/UserSettings.js @@ -729,6 +729,7 @@ module.exports = React.createClass({ // to rebind the onChange each time we render const onChange = (e) => { if (e.target.checked) { + this._syncedSettings[setting.id] = setting.value; UserSettingsStore.setSyncedSetting(setting.id, setting.value); } dis.dispatch({ @@ -741,7 +742,7 @@ module.exports = React.createClass({ type="radio" name={ setting.id } value={ setting.value } - defaultChecked={ this._syncedSettings[setting.id] === setting.value } + checked={ this._syncedSettings[setting.id] === setting.value } onChange={ onChange } />