diff --git a/CHANGELOG.md b/CHANGELOG.md index b5e596144e..090f5a49da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,53 @@ +Changes in [0.10.3](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.10.3) (2017-09-06) +===================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.10.3-rc.2...v0.10.3) + + * No changes + +Changes in [0.10.3-rc.2](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.10.3-rc.2) (2017-09-05) +=============================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.10.3-rc.1...v0.10.3-rc.2) + + * Fix plurals in translations + [\#1358](https://github.com/matrix-org/matrix-react-sdk/pull/1358) + * Fix typo + [\#1357](https://github.com/matrix-org/matrix-react-sdk/pull/1357) + * Update from Weblate. + [\#1356](https://github.com/matrix-org/matrix-react-sdk/pull/1356) + +Changes in [0.10.3-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.10.3-rc.1) (2017-09-01) +=============================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.10.2...v0.10.3-rc.1) + + * Fix room change sometimes being very slow + [\#1354](https://github.com/matrix-org/matrix-react-sdk/pull/1354) + * apply shouldHideEvent fn to onRoomTimeline for RoomStatusBar + [\#1346](https://github.com/matrix-org/matrix-react-sdk/pull/1346) + * text4event widget modified, used to show widget added each time. + [\#1345](https://github.com/matrix-org/matrix-react-sdk/pull/1345) + * separate concepts of showing and managing RRs to fix regression + [\#1352](https://github.com/matrix-org/matrix-react-sdk/pull/1352) + * Make staging widgets work with live and vice versa. + [\#1350](https://github.com/matrix-org/matrix-react-sdk/pull/1350) + * Avoid breaking /sync with uncaught exceptions + [\#1349](https://github.com/matrix-org/matrix-react-sdk/pull/1349) + * we need to pass whether it is an invite RoomSubList explicitly (i18n) + [\#1343](https://github.com/matrix-org/matrix-react-sdk/pull/1343) + * Percent encoding isn't a valid thing within _t + [\#1348](https://github.com/matrix-org/matrix-react-sdk/pull/1348) + * Fix spurious notifications + [\#1339](https://github.com/matrix-org/matrix-react-sdk/pull/1339) + * Unbreak password reset with a non-default HS + [\#1347](https://github.com/matrix-org/matrix-react-sdk/pull/1347) + * Remove unnecessary 'load' on notif audio element + [\#1341](https://github.com/matrix-org/matrix-react-sdk/pull/1341) + * _tJsx returns a React Object, the sub fn must return a React Object + [\#1340](https://github.com/matrix-org/matrix-react-sdk/pull/1340) + * Fix deprecation warning about promise.defer() + [\#1292](https://github.com/matrix-org/matrix-react-sdk/pull/1292) + * Fix click to insert completion + [\#1331](https://github.com/matrix-org/matrix-react-sdk/pull/1331) + Changes in [0.10.2](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.10.2) (2017-08-24) ===================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.10.1...v0.10.2) diff --git a/package.json b/package.json index 548349f802..38a647ff67 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "0.10.2", + "version": "0.10.3", "description": "SDK for matrix.org using React", "author": "matrix.org", "repository": { @@ -73,7 +73,6 @@ "react-addons-css-transition-group": "15.3.2", "react-dom": "^15.4.0", "react-gemini-scrollbar": "matrix-org/react-gemini-scrollbar#5e97aef", - "react-sticky": "^6.0.1", "sanitize-html": "^1.14.1", "text-encoding-utf-8": "^1.0.1", "url": "^0.11.0", diff --git a/src/DateUtils.js b/src/DateUtils.js index e7be394c17..78eef57eae 100644 --- a/src/DateUtils.js +++ b/src/DateUtils.js @@ -30,18 +30,6 @@ function getDaysArray() { ]; } -function getLongDaysArray() { - return [ - _t('Sunday'), - _t('Monday'), - _t('Tuesday'), - _t('Wednesday'), - _t('Thursday'), - _t('Friday'), - _t('Saturday'), - ]; -} - function getMonthsArray() { return [ _t('Jan'), @@ -108,38 +96,6 @@ module.exports = { }); }, - formatDateSeparator: function(date) { - const days = getDaysArray(); - const longDays = getLongDaysArray(); - const months = getMonthsArray(); - - const today = new Date(); - const yesterday = new Date(); - yesterday.setDate(today.getDate() - 1); - - if (date.toDateString() === today.toDateString()) { - return _t('Today'); - } else if (date.toDateString() === yesterday.toDateString()) { - return _t('Yesterday'); - } else if (today.getTime() - date.getTime() < 6 * 24 * 60 * 60 * 1000) { - return longDays[date.getDay()]; - } else if (today.getTime() - date.getTime() < 365 * 24 * 60 * 60 * 1000) { - return _t('%(weekDayName)s, %(monthName)s %(day)s', { - weekDayName: days[date.getDay()], - monthName: months[date.getMonth()], - day: date.getDate(), - fullYear: date.getFullYear(), - }); - } else { - return _t('%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s', { - weekDayName: days[date.getDay()], - monthName: months[date.getMonth()], - day: date.getDate(), - fullYear: date.getFullYear(), - }); - } - }, - formatTime: function(date, showTwelveHour=false) { if (showTwelveHour) { return twelveHourTime(date); diff --git a/src/HtmlUtils.js b/src/HtmlUtils.js index 87e714083b..63ee5fa480 100644 --- a/src/HtmlUtils.js +++ b/src/HtmlUtils.js @@ -1,5 +1,6 @@ /* Copyright 2015, 2016 OpenMarket Ltd +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. @@ -31,13 +32,28 @@ emojione.imagePathPNG = 'emojione/png/'; // Use SVGs for emojis emojione.imageType = 'svg'; +const SIMPLE_EMOJI_PATTERN = /([\ud800-\udbff])([\udc00-\udfff])/; const EMOJI_REGEX = new RegExp(emojione.unicodeRegexp+"+", "gi"); const COLOR_REGEX = /^#[0-9a-fA-F]{6}$/; +/* + * Return true if the given string contains emoji + * Uses a much, much simpler regex than emojione's so will give false + * positives, but useful for fast-path testing strings to see if they + * need emojification. + * unicodeToImage uses this function. + */ +export function containsEmoji(str) { + return SIMPLE_EMOJI_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; + let replaceWith, unicode, alt, short, fname; const mappedUnicode = emojione.mapUnicodeToShort(); @@ -393,7 +409,6 @@ export function bodyToHtml(content, highlights, opts) { } safeBody = sanitizeHtml(body, sanitizeHtmlParams); safeBody = unicodeToImage(safeBody); - safeBody = addCodeCopyButton(safeBody); } finally { delete sanitizeHtmlParams.textFilter; @@ -412,23 +427,6 @@ export function bodyToHtml(content, highlights, opts) { return ; } -function addCodeCopyButton(safeBody) { - // Adds 'copy' buttons to pre blocks - // Note that this only manipulates the markup to add the buttons: - // we need to add the event handlers once the nodes are in the DOM - // since we can't save functions in the markup. - // This is done in TextualBody - const el = document.createElement("div"); - el.innerHTML = safeBody; - const codeBlocks = Array.from(el.getElementsByTagName("pre")); - codeBlocks.forEach(p => { - const button = document.createElement("span"); - button.className = "mx_EventTile_copyButton"; - p.appendChild(button); - }); - return el.innerHTML; -} - export function emojifyText(text) { return { __html: unicodeToImage(escape(text)), diff --git a/src/Skinner.js b/src/Skinner.js index f47572ba01..1fe12f85ab 100644 --- a/src/Skinner.js +++ b/src/Skinner.js @@ -84,6 +84,9 @@ class Skinner { // behaviour with multiple copies of files etc. is erratic at best. // XXX: We can still end up with the same file twice in the resulting // JS bundle which is nonideal. +// See https://derickbailey.com/2016/03/09/creating-a-true-singleton-in-node-js-with-es6-symbols/ +// or https://nodejs.org/api/modules.html#modules_module_caching_caveats +// ("Modules are cached based on their resolved filename") if (global.mxSkinner === undefined) { global.mxSkinner = new Skinner(); } diff --git a/src/components/structures/LoggedInView.js b/src/components/structures/LoggedInView.js index 0790a5766e..147707b6fc 100644 --- a/src/components/structures/LoggedInView.js +++ b/src/components/structures/LoggedInView.js @@ -81,10 +81,6 @@ export default React.createClass({ // stash the MatrixClient in case we log out before we are unmounted this._matrixClient = this.props.matrixClient; - // _scrollStateMap is a map from room id to the scroll state returned by - // RoomView.getScrollState() - this._scrollStateMap = {}; - CallMediaHandler.loadDevices(); document.addEventListener('keydown', this._onKeyDown); @@ -116,10 +112,6 @@ export default React.createClass({ return Boolean(MatrixClientPeg.get()); }, - getScrollStateForRoom: function(roomId) { - return this._scrollStateMap[roomId]; - }, - canResetTimelineInRoom: function(roomId) { if (!this.refs.roomView) { return true; @@ -248,7 +240,6 @@ export default React.createClass({ opacity={this.props.middleOpacity} collapsedRhs={this.props.collapse_rhs} ConferenceHandler={this.props.ConferenceHandler} - scrollStateMap={this._scrollStateMap} />; if (!this.props.collapse_rhs) right_panel = ; break; diff --git a/src/components/structures/MatrixChat.js b/src/components/structures/MatrixChat.js index bbe345933e..c142d6958c 100644 --- a/src/components/structures/MatrixChat.js +++ b/src/components/structures/MatrixChat.js @@ -38,7 +38,6 @@ import linkifyMatrix from "../../linkify-matrix"; import * as Lifecycle from '../../Lifecycle'; // LifecycleStore is not used but does listen to and dispatch actions require('../../stores/LifecycleStore'); -import RoomViewStore from '../../stores/RoomViewStore'; import PageTypes from '../../PageTypes'; import createRoom from "../../createRoom"; @@ -214,9 +213,6 @@ module.exports = React.createClass({ componentWillMount: function() { SdkConfig.put(this.props.config); - this._roomViewStoreToken = RoomViewStore.addListener(this._onRoomViewStoreUpdated); - this._onRoomViewStoreUpdated(); - if (!UserSettingsStore.getLocalSetting('analyticsOptOut', false)) Analytics.enable(); // Used by _viewRoom before getting state from sync @@ -353,7 +349,6 @@ module.exports = React.createClass({ UDEHandler.stopListening(); window.removeEventListener("focus", this.onFocus); window.removeEventListener('resize', this.handleResize); - this._roomViewStoreToken.remove(); }, componentDidUpdate: function() { @@ -587,10 +582,6 @@ module.exports = React.createClass({ } }, - _onRoomViewStoreUpdated: function() { - this.setState({ currentRoomId: RoomViewStore.getRoomId() }); - }, - _setPage: function(pageType) { this.setState({ page_type: pageType, @@ -677,6 +668,7 @@ module.exports = React.createClass({ this.focusComposer = true; const newState = { + currentRoomId: roomInfo.room_id || null, page_type: PageTypes.RoomView, thirdPartyInvite: roomInfo.third_party_invite, roomOobData: roomInfo.oob_data, diff --git a/src/components/structures/MessagePanel.js b/src/components/structures/MessagePanel.js index aad4e1957a..e5884973c6 100644 --- a/src/components/structures/MessagePanel.js +++ b/src/components/structures/MessagePanel.js @@ -61,6 +61,9 @@ module.exports = React.createClass({ // for pending messages. ourUserId: React.PropTypes.string, + // true to suppress the date at the start of the timeline + suppressFirstDateSeparator: React.PropTypes.bool, + // whether to show read receipts showReadReceipts: React.PropTypes.bool, @@ -514,10 +517,10 @@ module.exports = React.createClass({ _wantsDateSeparator: function(prevEvent, nextEventDate) { if (prevEvent == null) { - // First event in the panel always wants a DateSeparator - return true; + // first event in the panel: depends if we could back-paginate from + // here. + return !this.props.suppressFirstDateSeparator; } - const prevEventDate = prevEvent.getDate(); if (!nextEventDate || !prevEventDate) { return false; diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index 87bed1ed08..8a0eeb50b9 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -47,6 +47,7 @@ import KeyCode from '../../KeyCode'; import UserProvider from '../../autocomplete/UserProvider'; import RoomViewStore from '../../stores/RoomViewStore'; +import RoomScrollStateStore from '../../stores/RoomScrollStateStore'; let DEBUG = false; let debuglog = function() {}; @@ -163,7 +164,6 @@ module.exports = React.createClass({ roomLoadError: RoomViewStore.getRoomLoadError(), joining: RoomViewStore.isJoining(), initialEventId: RoomViewStore.getInitialEventId(), - initialEventPixelOffset: RoomViewStore.getInitialEventPixelOffset(), isInitialEventHighlighted: RoomViewStore.isInitialEventHighlighted(), forwardingEvent: RoomViewStore.getForwardingEvent(), shouldPeek: RoomViewStore.shouldPeek(), @@ -189,6 +189,25 @@ module.exports = React.createClass({ // the RoomView instance if (initial) { newState.room = MatrixClientPeg.get().getRoom(newState.roomId); + if (newState.room) { + newState.unsentMessageError = this._getUnsentMessageError(newState.room); + newState.showApps = this._shouldShowApps(newState.room); + this._onRoomLoaded(newState.room); + } + } + + if (this.state.roomId === null && newState.roomId !== null) { + // Get the scroll state for the new room + + // If an event ID wasn't specified, default to the one saved for this room + // in the scroll state store. Assume initialEventPixelOffset should be set. + if (!newState.initialEventId) { + const roomScrollState = RoomScrollStateStore.getScrollState(newState.roomId); + if (roomScrollState) { + newState.initialEventId = roomScrollState.focussedEvent; + newState.initialEventPixelOffset = roomScrollState.pixelOffset; + } + } } // Clear the search results when clicking a search result (which changes the @@ -197,22 +216,20 @@ module.exports = React.createClass({ newState.searchResults = null; } - // Store the scroll state for the previous room so that we can return to this - // position when viewing this room in future. - if (this.state.roomId !== newState.roomId) { - this._updateScrollMap(this.state.roomId); - } + this.setState(newState); + // At this point, newState.roomId could be null (e.g. the alias might not + // have been resolved yet) so anything called here must handle this case. - this.setState(newState, () => { - // At this point, this.state.roomId could be null (e.g. the alias might not - // have been resolved yet) so anything called here must handle this case. - if (initial) { - this._onHaveRoom(); - } - }); + // We pass the new state into this function for it to read: it needs to + // observe the new state but we don't want to put it in the setState + // callback because this would prevent the setStates from being batched, + // ie. cause it to render RoomView twice rather than the once that is necessary. + if (initial) { + this._setupRoom(newState.room, newState.roomId, newState.joining, newState.shouldPeek); + } }, - _onHaveRoom: function() { + _setupRoom: function(room, roomId, joining, shouldPeek) { // if this is an unknown room then we're in one of three states: // - This is a room we can peek into (search engine) (we can /peek) // - This is a room we can publicly join or were invited to. (we can /join) @@ -228,23 +245,15 @@ module.exports = React.createClass({ // about it). We don't peek in the historical case where we were joined but are // now not joined because the js-sdk peeking API will clobber our historical room, // making it impossible to indicate a newly joined room. - const room = this.state.room; - if (room) { - this.setState({ - unsentMessageError: this._getUnsentMessageError(room), - showApps: this._shouldShowApps(room), - }); - this._onRoomLoaded(room); - } - if (!this.state.joining && this.state.roomId) { + if (!joining && roomId) { if (this.props.autoJoin) { this.onJoinButtonClicked(); - } else if (!room && this.state.shouldPeek) { - console.log("Attempting to peek into room %s", this.state.roomId); + } else if (!room && shouldPeek) { + console.log("Attempting to peek into room %s", roomId); this.setState({ peekLoading: true, }); - MatrixClientPeg.get().peekInRoom(this.state.roomId).then((room) => { + MatrixClientPeg.get().peekInRoom(roomId).then((room) => { this.setState({ room: room, peekLoading: false, @@ -340,7 +349,9 @@ module.exports = React.createClass({ this.unmounted = true; // update the scroll map before we get unmounted - this._updateScrollMap(this.state.roomId); + if (this.state.roomId) { + RoomScrollStateStore.setScrollState(this.state.roomId, this._getScrollState()); + } if (this.refs.roomView) { // disconnect the D&D event listeners from the room view. This @@ -617,18 +628,6 @@ module.exports = React.createClass({ }); }, - _updateScrollMap(roomId) { - // No point updating scroll state if the room ID hasn't been resolved yet - if (!roomId) { - return; - } - dis.dispatch({ - action: 'update_scroll_state', - room_id: roomId, - scroll_state: this._getScrollState(), - }); - }, - onRoom: function(room) { if (!room || room.roomId !== this.state.roomId) { return; diff --git a/src/components/structures/ScrollPanel.js b/src/components/structures/ScrollPanel.js index 3ea699798e..ae3ffe66e3 100644 --- a/src/components/structures/ScrollPanel.js +++ b/src/components/structures/ScrollPanel.js @@ -17,7 +17,6 @@ limitations under the License. var React = require("react"); var ReactDOM = require("react-dom"); var GeminiScrollbar = require('react-gemini-scrollbar'); -import { StickyContainer } from 'react-sticky'; import Promise from 'bluebird'; var KeyCode = require('../../KeyCode'); @@ -78,52 +77,111 @@ if (DEBUG_SCROLL) { * scroll down further. If stickyBottom is disabled, we just save the scroll * offset as normal. */ -export default class ScrollPanel extends StickyContainer { +module.exports = React.createClass({ + displayName: 'ScrollPanel', - constructor() { - super(); - this.onResize = this.onResize.bind(this); - this.onScroll = this.onScroll.bind(this); - } + propTypes: { + /* stickyBottom: if set to true, then once the user hits the bottom of + * the list, any new children added to the list will cause the list to + * scroll down to show the new element, rather than preserving the + * existing view. + */ + stickyBottom: React.PropTypes.bool, - componentWillMount() { + /* startAtBottom: if set to true, the view is assumed to start + * scrolled to the bottom. + * XXX: It's likley this is unecessary and can be derived from + * stickyBottom, but I'm adding an extra parameter to ensure + * behaviour stays the same for other uses of ScrollPanel. + * If so, let's remove this parameter down the line. + */ + startAtBottom: React.PropTypes.bool, + + /* onFillRequest(backwards): a callback which is called on scroll when + * the user nears the start (backwards = true) or end (backwards = + * false) of the list. + * + * This should return a promise; no more calls will be made until the + * promise completes. + * + * The promise should resolve to true if there is more data to be + * retrieved in this direction (in which case onFillRequest may be + * called again immediately), or false if there is no more data in this + * directon (at this time) - which will stop the pagination cycle until + * the user scrolls again. + */ + onFillRequest: React.PropTypes.func, + + /* onUnfillRequest(backwards): a callback which is called on scroll when + * there are children elements that are far out of view and could be removed + * without causing pagination to occur. + * + * This function should accept a boolean, which is true to indicate the back/top + * of the panel and false otherwise, and a scroll token, which refers to the + * first element to remove if removing from the front/bottom, and last element + * to remove if removing from the back/top. + */ + onUnfillRequest: React.PropTypes.func, + + /* onScroll: a callback which is called whenever any scroll happens. + */ + onScroll: React.PropTypes.func, + + /* onResize: a callback which is called whenever the Gemini scroll + * panel is resized + */ + onResize: React.PropTypes.func, + + /* className: classnames to add to the top-level div + */ + className: React.PropTypes.string, + + /* style: styles to add to the top-level div + */ + style: React.PropTypes.object, + }, + + getDefaultProps: function() { + return { + stickyBottom: true, + startAtBottom: true, + onFillRequest: function(backwards) { return Promise.resolve(false); }, + onUnfillRequest: function(backwards, scrollToken) {}, + onScroll: function() {}, + }; + }, + + componentWillMount: function() { this._pendingFillRequests = {b: null, f: null}; this.resetScrollState(); - } + }, - componentDidMount() { - this.checkFillState(); - } + componentDidMount: function() { + this.checkScroll(); + }, - componentDidUpdate() { + componentDidUpdate: function() { // after adding event tiles, we may need to tweak the scroll (either to // keep at the bottom of the timeline, or to maintain the view after // adding events to the top). // // This will also re-check the fill state, in case the paginate was inadequate this.checkScroll(); - } + }, - componentWillUnmount() { + componentWillUnmount: function() { // set a boolean to say we've been unmounted, which any pending // promises can use to throw away their results. // // (We could use isMounted(), but facebook have deprecated that.) this.unmounted = true; - } + }, - onScroll(ev) { + onScroll: function(ev) { var sn = this._getScrollNode(); debuglog("Scroll event: offset now:", sn.scrollTop, "_lastSetScroll:", this._lastSetScroll); - // Set the node and notify subscribers of the StickyContainer - // By extending StickyContainer, we can set the scroll node to be that of the - // ScrolPanel to allow any `` children to be sticky, namely DateSeparators. - this.node = sn; - // Update subscribers - arbitrarily nested `` children - this.notifySubscribers(ev); - // Sometimes we see attempts to write to scrollTop essentially being // ignored. (Or rather, it is successfully written, but on the next // scroll event, it's been reset again). @@ -159,27 +217,27 @@ export default class ScrollPanel extends StickyContainer { this.props.onScroll(ev); this.checkFillState(); - } + }, - onResize() { + onResize: function() { this.props.onResize(); this.checkScroll(); this.refs.geminiPanel.forceUpdate(); - } + }, // after an update to the contents of the panel, check that the scroll is // where it ought to be, and set off pagination requests if necessary. - checkScroll() { + checkScroll: function() { this._restoreSavedScrollState(); this.checkFillState(); - } + }, // return true if the content is fully scrolled down right now; else false. // // note that this is independent of the 'stuckAtBottom' state - it is simply // about whether the the content is scrolled down right now, irrespective of // whether it will stay that way when the children update. - isAtBottom() { + isAtBottom: function() { var sn = this._getScrollNode(); // there seems to be some bug with flexbox/gemini/chrome/richvdh's @@ -189,7 +247,7 @@ export default class ScrollPanel extends StickyContainer { // that we're at the bottom when we're still a few pixels off. return sn.scrollHeight - Math.ceil(sn.scrollTop) <= sn.clientHeight + 3; - } + }, // returns the vertical height in the given direction that can be removed from // the content box (which has a height of scrollHeight, see checkFillState) without @@ -222,17 +280,17 @@ export default class ScrollPanel extends StickyContainer { // |#########| - | // |#########| | // `---------' - - _getExcessHeight(backwards) { + _getExcessHeight: function(backwards) { var sn = this._getScrollNode(); if (backwards) { return sn.scrollTop - sn.clientHeight - UNPAGINATION_PADDING; } else { return sn.scrollHeight - (sn.scrollTop + 2*sn.clientHeight) - UNPAGINATION_PADDING; } - } + }, // check the scroll state and send out backfill requests if necessary. - checkFillState() { + checkFillState: function() { if (this.unmounted) { return; } @@ -271,10 +329,10 @@ export default class ScrollPanel extends StickyContainer { // need to forward-fill this._maybeFill(false); } - } + }, // check if unfilling is possible and send an unfill request if necessary - _checkUnfillState(backwards) { + _checkUnfillState: function(backwards) { let excessHeight = this._getExcessHeight(backwards); if (excessHeight <= 0) { return; @@ -315,10 +373,10 @@ export default class ScrollPanel extends StickyContainer { this.props.onUnfillRequest(backwards, markerScrollToken); }, UNFILL_REQUEST_DEBOUNCE_MS); } - } + }, // check if there is already a pending fill request. If not, set one off. - _maybeFill(backwards) { + _maybeFill: function(backwards) { var dir = backwards ? 'b' : 'f'; if (this._pendingFillRequests[dir]) { debuglog("ScrollPanel: Already a "+dir+" fill in progress - not starting another"); @@ -350,7 +408,7 @@ export default class ScrollPanel extends StickyContainer { this.checkFillState(); } }).done(); - } + }, /* get the current scroll state. This returns an object with the following * properties: @@ -366,9 +424,9 @@ export default class ScrollPanel extends StickyContainer { * the number of pixels the bottom of the tracked child is above the * bottom of the scroll panel. */ - getScrollState() { + getScrollState: function() { return this.scrollState; - } + }, /* reset the saved scroll state. * @@ -382,46 +440,46 @@ export default class ScrollPanel extends StickyContainer { * no use if no children exist yet, or if you are about to replace the * child list.) */ - resetScrollState() { + resetScrollState: function() { this.scrollState = {stuckAtBottom: this.props.startAtBottom}; - } + }, /** * jump to the top of the content. */ - scrollToTop() { + scrollToTop: function() { this._setScrollTop(0); this._saveScrollState(); - } + }, /** * jump to the bottom of the content. */ - scrollToBottom() { + scrollToBottom: function() { // the easiest way to make sure that the scroll state is correctly // saved is to do the scroll, then save the updated state. (Calculating // it ourselves is hard, and we can't rely on an onScroll callback // happening, since there may be no user-visible change here). this._setScrollTop(Number.MAX_VALUE); this._saveScrollState(); - } + }, /** * Page up/down. * * mult: -1 to page up, +1 to page down */ - scrollRelative(mult) { + scrollRelative: function(mult) { var scrollNode = this._getScrollNode(); var delta = mult * scrollNode.clientHeight * 0.5; this._setScrollTop(scrollNode.scrollTop + delta); this._saveScrollState(); - } + }, /** * Scroll up/down in response to a scroll key */ - handleScrollKey(ev) { + handleScrollKey: function(ev) { switch (ev.keyCode) { case KeyCode.PAGE_UP: if (!ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) { @@ -447,7 +505,7 @@ export default class ScrollPanel extends StickyContainer { } break; } - } + }, /* Scroll the panel to bring the DOM node with the scroll token * `scrollToken` into view. @@ -460,7 +518,7 @@ export default class ScrollPanel extends StickyContainer { * node (specifically, the bottom of it) will be positioned. If omitted, it * defaults to 0. */ - scrollToToken(scrollToken, pixelOffset, offsetBase) { + scrollToToken: function(scrollToken, pixelOffset, offsetBase) { pixelOffset = pixelOffset || 0; offsetBase = offsetBase || 0; @@ -482,11 +540,11 @@ export default class ScrollPanel extends StickyContainer { // ... then make it so. this._restoreSavedScrollState(); - } + }, // set the scrollTop attribute appropriately to position the given child at the // given offset in the window. A helper for _restoreSavedScrollState. - _scrollToToken(scrollToken, pixelOffset) { + _scrollToToken: function(scrollToken, pixelOffset) { /* find the dom node with the right scrolltoken */ var node; var messages = this.refs.itemlist.children; @@ -518,9 +576,9 @@ export default class ScrollPanel extends StickyContainer { this._setScrollTop(scrollNode.scrollTop + scrollDelta); } - } + }, - _saveScrollState() { + _saveScrollState: function() { if (this.props.stickyBottom && this.isAtBottom()) { this.scrollState = { stuckAtBottom: true }; debuglog("ScrollPanel: Saved scroll state", this.scrollState); @@ -558,9 +616,9 @@ export default class ScrollPanel extends StickyContainer { } else { debuglog("ScrollPanel: unable to save scroll state: found no children in the viewport"); } - } + }, - _restoreSavedScrollState() { + _restoreSavedScrollState: function() { var scrollState = this.scrollState; var scrollNode = this._getScrollNode(); @@ -570,9 +628,9 @@ export default class ScrollPanel extends StickyContainer { this._scrollToToken(scrollState.trackedScrollToken, scrollState.pixelOffset); } - } + }, - _setScrollTop(scrollTop) { + _setScrollTop: function(scrollTop) { var scrollNode = this._getScrollNode(); var prevScroll = scrollNode.scrollTop; @@ -594,12 +652,12 @@ export default class ScrollPanel extends StickyContainer { debuglog("ScrollPanel: set scrollTop:", scrollNode.scrollTop, "requested:", scrollTop, "_lastSetScroll:", this._lastSetScroll); - } + }, /* get the DOM node which has the scrollTop property we care about for our * message panel. */ - _getScrollNode() { + _getScrollNode: function() { if (this.unmounted) { // this shouldn't happen, but when it does, turn the NPE into // something more meaningful. @@ -607,91 +665,21 @@ export default class ScrollPanel extends StickyContainer { } return this.refs.geminiPanel.scrollbar.getViewElement(); - } + }, - render() { + render: function() { // TODO: the classnames on the div and ol could do with being updated to // reflect the fact that we don't necessarily contain a list of messages. // it's not obvious why we have a separate div and ol anyway. - return ( - -
-
    - {this.props.children} -
-
-
- ); - } -} - -ScrollPanel.propTypes = { - /* stickyBottom: if set to true, then once the user hits the bottom of - * the list, any new children added to the list will cause the list to - * scroll down to show the new element, rather than preserving the - * existing view. - */ - stickyBottom: React.PropTypes.bool, - - /* startAtBottom: if set to true, the view is assumed to start - * scrolled to the bottom. - * XXX: It's likley this is unecessary and can be derived from - * stickyBottom, but I'm adding an extra parameter to ensure - * behaviour stays the same for other uses of ScrollPanel. - * If so, let's remove this parameter down the line. - */ - startAtBottom: React.PropTypes.bool, - - /* onFillRequest(backwards): a callback which is called on scroll when - * the user nears the start (backwards = true) or end (backwards = - * false) of the list. - * - * This should return a promise; no more calls will be made until the - * promise completes. - * - * The promise should resolve to true if there is more data to be - * retrieved in this direction (in which case onFillRequest may be - * called again immediately), or false if there is no more data in this - * directon (at this time) - which will stop the pagination cycle until - * the user scrolls again. - */ - onFillRequest: React.PropTypes.func, - - /* onUnfillRequest(backwards): a callback which is called on scroll when - * there are children elements that are far out of view and could be removed - * without causing pagination to occur. - * - * This function should accept a boolean, which is true to indicate the back/top - * of the panel and false otherwise, and a scroll token, which refers to the - * first element to remove if removing from the front/bottom, and last element - * to remove if removing from the back/top. - */ - onUnfillRequest: React.PropTypes.func, - - /* onScroll: a callback which is called whenever any scroll happens. - */ - onScroll: React.PropTypes.func, - - /* onResize: a callback which is called whenever the Gemini scroll - * panel is resized - */ - onResize: React.PropTypes.func, - - /* className: classnames to add to the top-level div - */ - className: React.PropTypes.string, - - /* style: styles to add to the top-level div - */ - style: React.PropTypes.object, -}; - -ScrollPanel.defaultProps = { - stickyBottom: true, - startAtBottom: true, - onFillRequest: function(backwards) { return Promise.resolve(false); }, - onUnfillRequest: function(backwards, scrollToken) {}, - onScroll: function() {}, -}; + return ( +
+
    + {this.props.children} +
+
+
+ ); + }, +}); diff --git a/src/components/structures/TimelinePanel.js b/src/components/structures/TimelinePanel.js index ebd9784b6f..862c3f46d0 100644 --- a/src/components/structures/TimelinePanel.js +++ b/src/components/structures/TimelinePanel.js @@ -1147,6 +1147,7 @@ var TimelinePanel = React.createClass({ highlightedEventId={ this.props.highlightedEventId } readMarkerEventId={ this.state.readMarkerEventId } readMarkerVisible={ this.state.readMarkerVisible } + suppressFirstDateSeparator={ this.state.canBackPaginate } showUrlPreview={ this.props.showUrlPreview } showReadReceipts={ this.props.showReadReceipts } ourUserId={ MatrixClientPeg.get().credentials.userId } diff --git a/src/components/views/elements/EmojiText.js b/src/components/views/elements/EmojiText.js index cb6cd2ef5e..faab0241ae 100644 --- a/src/components/views/elements/EmojiText.js +++ b/src/components/views/elements/EmojiText.js @@ -1,5 +1,6 @@ /* Copyright 2016 Aviral Dasgupta + 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. @@ -15,12 +16,19 @@ */ import React from 'react'; -import {emojifyText} from '../../../HtmlUtils'; +import {emojifyText, containsEmoji} from '../../../HtmlUtils'; export default function EmojiText(props) { const {element, children, ...restProps} = props; - restProps.dangerouslySetInnerHTML = emojifyText(children); - return React.createElement(element, restProps); + + // fast path: simple regex to detect strings that don't contain + // emoji and just return them + if (containsEmoji(children)) { + restProps.dangerouslySetInnerHTML = emojifyText(children); + return React.createElement(element, restProps); + } else { + return React.createElement(element, restProps, children); + } } EmojiText.propTypes = { diff --git a/src/components/views/messages/TextualBody.js b/src/components/views/messages/TextualBody.js index 17bb9f90b5..aae46d343c 100644 --- a/src/components/views/messages/TextualBody.js +++ b/src/components/views/messages/TextualBody.js @@ -118,28 +118,7 @@ module.exports = React.createClass({ } }, 10); } - // add event handlers to the 'copy code' buttons - const buttons = ReactDOM.findDOMNode(this).getElementsByClassName("mx_EventTile_copyButton"); - for (let i = 0; i < buttons.length; i++) { - buttons[i].onclick = (e) => { - const copyCode = buttons[i].parentNode.getElementsByTagName("code")[0]; - const successful = this.copyToClipboard(copyCode.textContent); - - const GenericTextContextMenu = sdk.getComponent('context_menus.GenericTextContextMenu'); - const buttonRect = e.target.getBoundingClientRect(); - - // The window X and Y offsets are to adjust position when zoomed in to page - const x = buttonRect.right + window.pageXOffset; - const y = (buttonRect.top + (buttonRect.height / 2) + window.pageYOffset) - 19; - const {close} = ContextualMenu.createMenu(GenericTextContextMenu, { - chevronOffset: 10, - left: x, - top: y, - message: successful ? _t('Copied!') : _t('Failed to copy'), - }); - e.target.onmouseout = close; - }; - } + this._addCodeCopyButton(); } }, @@ -276,6 +255,33 @@ module.exports = React.createClass({ } }, + _addCodeCopyButton() { + // Add 'copy' buttons to pre blocks + ReactDOM.findDOMNode(this).querySelectorAll('.mx_EventTile_body pre').forEach((p) => { + const button = document.createElement("span"); + button.className = "mx_EventTile_copyButton"; + button.onclick = (e) => { + const copyCode = button.parentNode.getElementsByTagName("code")[0]; + const successful = this.copyToClipboard(copyCode.textContent); + + const GenericTextContextMenu = sdk.getComponent('context_menus.GenericTextContextMenu'); + const buttonRect = e.target.getBoundingClientRect(); + + // The window X and Y offsets are to adjust position when zoomed in to page + const x = buttonRect.right + window.pageXOffset; + const y = (buttonRect.top + (buttonRect.height / 2) + window.pageYOffset) - 19; + const {close} = ContextualMenu.createMenu(GenericTextContextMenu, { + chevronOffset: 10, + left: x, + top: y, + message: successful ? _t('Copied!') : _t('Failed to copy'), + }); + e.target.onmouseout = close; + }; + p.appendChild(button); + }); + }, + onCancelClick: function(event) { this.setState({ widgetHidden: true }); // FIXME: persist this somewhere smarter than local storage diff --git a/src/stores/RoomScrollStateStore.js b/src/stores/RoomScrollStateStore.js new file mode 100644 index 0000000000..07848283d1 --- /dev/null +++ b/src/stores/RoomScrollStateStore.js @@ -0,0 +1,50 @@ +/* +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. +*/ + +/** + * Stores where the user has scrolled to in each room + */ +class RoomScrollStateStore { + constructor() { + // A map from room id to scroll state. + // + // If there is no special scroll state (ie, we are following the live + // timeline), the scroll state is null. Otherwise, it is an object with + // the following properties: + // + // focussedEvent: the ID of the 'focussed' event. Typically this is + // the last event fully visible in the viewport, though if we + // have done an explicit scroll to an explicit event, it will be + // that event. + // + // pixelOffset: the number of pixels the window is scrolled down + // from the focussedEvent. + this._scrollStateMap = {}; + } + + getScrollState(roomId) { + return this._scrollStateMap[roomId]; + } + + setScrollState(roomId, scrollState) { + this._scrollStateMap[roomId] = scrollState; + } +} + +if (global.mx_RoomScrollStateStore === undefined) { + global.mx_RoomScrollStateStore = new RoomScrollStateStore(); +} +export default global.mx_RoomScrollStateStore; diff --git a/src/stores/RoomViewStore.js b/src/stores/RoomViewStore.js index bd9d3ea0fa..17fcc97160 100644 --- a/src/stores/RoomViewStore.js +++ b/src/stores/RoomViewStore.js @@ -30,8 +30,6 @@ const INITIAL_STATE = { // The event to scroll to when the room is first viewed initialEventId: null, - // The offset to display the initial event at (see scrollStateMap) - initialEventPixelOffset: null, // Whether to highlight the initial event isInitialEventHighlighted: false, @@ -41,20 +39,6 @@ const INITIAL_STATE = { roomLoading: false, // Any error that has occurred during loading roomLoadError: null, - // A map from room id to scroll state. - // - // If there is no special scroll state (ie, we are following the live - // timeline), the scroll state is null. Otherwise, it is an object with - // the following properties: - // - // focussedEvent: the ID of the 'focussed' event. Typically this is - // the last event fully visible in the viewport, though if we - // have done an explicit scroll to an explicit event, it will be - // that event. - // - // pixelOffset: the number of pixels the window is scrolled down - // from the focussedEvent. - scrollStateMap: {}, forwardingEvent: null, }; @@ -115,9 +99,6 @@ class RoomViewStore extends Store { case 'on_logged_out': this.reset(); break; - case 'update_scroll_state': - this._updateScrollState(payload); - break; case 'forward_event': this._setState({ forwardingEvent: payload.event, @@ -132,7 +113,6 @@ class RoomViewStore extends Store { roomId: payload.room_id, roomAlias: payload.room_alias, initialEventId: payload.event_id, - initialEventPixelOffset: undefined, isInitialEventHighlighted: payload.highlighted, forwardingEvent: null, roomLoading: false, @@ -145,16 +125,6 @@ class RoomViewStore extends Store { newState.joining = false; } - // If an event ID wasn't specified, default to the one saved for this room - // via update_scroll_state. Assume initialEventPixelOffset should be set. - if (!newState.initialEventId) { - const roomScrollState = this._state.scrollStateMap[payload.room_id]; - if (roomScrollState) { - newState.initialEventId = roomScrollState.focussedEvent; - newState.initialEventPixelOffset = roomScrollState.pixelOffset; - } - } - if (this._state.forwardingEvent) { dis.dispatch({ action: 'send_event', @@ -241,15 +211,6 @@ class RoomViewStore extends Store { }); } - _updateScrollState(payload) { - // Clobber existing scroll state for the given room ID - const newScrollStateMap = this._state.scrollStateMap; - newScrollStateMap[payload.room_id] = payload.scroll_state; - this._setState({ - scrollStateMap: newScrollStateMap, - }); - } - reset() { this._state = Object.assign({}, INITIAL_STATE); } @@ -264,11 +225,6 @@ class RoomViewStore extends Store { return this._state.initialEventId; } - // The offset to display the initial event at (see scrollStateMap) - getInitialEventPixelOffset() { - return this._state.initialEventPixelOffset; - } - // Whether to highlight the initial event isInitialEventHighlighted() { return this._state.isInitialEventHighlighted; diff --git a/test/components/structures/TimelinePanel-test.js b/test/components/structures/TimelinePanel-test.js index c13d149ed0..98ec65b8e8 100644 --- a/test/components/structures/TimelinePanel-test.js +++ b/test/components/structures/TimelinePanel-test.js @@ -234,6 +234,7 @@ describe('TimelinePanel', function() { // 5 times, and we should have given up paginating expect(client.paginateEventTimeline.callCount).toEqual(5); expect(messagePanel.props.backPaginating).toBe(false); + expect(messagePanel.props.suppressFirstDateSeparator).toBe(false); // now, if we update the events, there shouldn't be any // more requests. @@ -338,6 +339,7 @@ describe('TimelinePanel', function() { awaitScroll().then(() => { // we should now have loaded the first few events expect(messagePanel.props.backPaginating).toBe(false); + expect(messagePanel.props.suppressFirstDateSeparator).toBe(true); // back-paginate until we hit the start return backPaginate(); @@ -345,6 +347,7 @@ describe('TimelinePanel', function() { // hopefully, we got to the start of the timeline expect(messagePanel.props.backPaginating).toBe(false); + expect(messagePanel.props.suppressFirstDateSeparator).toBe(false); var events = scryEventTiles(panel); expect(events[0].props.mxEvent).toBe(timeline.getEvents()[0]);