diff --git a/.eslintignore.errorfiles b/.eslintignore.errorfiles index f66d2c69d3..f501f373cd 100644 --- a/.eslintignore.errorfiles +++ b/.eslintignore.errorfiles @@ -8,7 +8,6 @@ src/CallHandler.js src/component-index.js src/components/structures/ContextualMenu.js src/components/structures/CreateRoom.js -src/components/structures/FilePanel.js src/components/structures/LoggedInView.js src/components/structures/login/ForgotPassword.js src/components/structures/login/Login.js @@ -27,16 +26,10 @@ src/components/views/dialogs/ChatCreateOrReuseDialog.js src/components/views/dialogs/DeactivateAccountDialog.js src/components/views/dialogs/UnknownDeviceDialog.js src/components/views/elements/AddressSelector.js -src/components/views/elements/CreateRoomButton.js src/components/views/elements/DeviceVerifyButtons.js src/components/views/elements/DirectorySearchBox.js src/components/views/elements/EditableText.js -src/components/views/elements/HomeButton.js src/components/views/elements/MemberEventListSummary.js -src/components/views/elements/PowerSelector.js -src/components/views/elements/RoomDirectoryButton.js -src/components/views/elements/SettingsButton.js -src/components/views/elements/StartChatButton.js src/components/views/elements/TintableSvg.js src/components/views/elements/UserSelector.js src/components/views/login/CountryDropdown.js @@ -93,7 +86,6 @@ src/RichText.js src/Roles.js src/Rooms.js src/ScalarAuthClient.js -src/Tinter.js src/UiEffects.js src/Unread.js src/utils/DecryptFile.js diff --git a/.travis.yml b/.travis.yml index 4137d754bf..954f14a4da 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,7 +3,10 @@ dist: trusty # we don't need sudo, so can run in a container, which makes startup much # quicker. -sudo: false +# +# unfortunately we do temporarily require sudo as a workaround for +# https://github.com/travis-ci/travis-ci/issues/8836 +sudo: required language: node_js node_js: diff --git a/CHANGELOG.md b/CHANGELOG.md index 87459882c9..055e25b805 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +Changes in [0.11.4](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.11.4) (2018-02-09) +===================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.11.3...v0.11.4) + + * Add isUrlPermitted function to sanity check URLs + Changes in [0.11.3](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.11.3) (2017-12-04) ===================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.11.2...v0.11.3) diff --git a/package.json b/package.json index 943c443c59..bb8db64d28 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "0.11.3", + "version": "0.11.4", "description": "SDK for matrix.org using React", "author": "matrix.org", "repository": { @@ -56,7 +56,7 @@ "browser-encrypt-attachment": "^0.3.0", "browser-request": "^0.3.3", "classnames": "^2.1.2", - "commonmark": "^0.27.0", + "commonmark": "^0.28.1", "counterpart": "^0.18.0", "draft-js": "^0.11.0-alpha", "draft-js-export-html": "^0.6.0", @@ -77,8 +77,7 @@ "querystring": "^0.2.0", "react": "^15.4.0", "react-addons-css-transition-group": "15.3.2", - "react-dnd": "^2.1.4", - "react-dnd-html5-backend": "^2.1.2", + "react-beautiful-dnd": "^4.0.0", "react-dom": "^15.4.0", "react-gemini-scrollbar": "matrix-org/react-gemini-scrollbar#5e97aef", "sanitize-html": "^1.14.1", diff --git a/src/Analytics.js b/src/Analytics.js index 1b4f45bc6b..5c39b48a35 100644 --- a/src/Analytics.js +++ b/src/Analytics.js @@ -14,25 +14,54 @@ limitations under the License. */ -import { getCurrentLanguage } from './languageHandler'; +import { getCurrentLanguage, _t, _td } from './languageHandler'; import PlatformPeg from './PlatformPeg'; -import SdkConfig from './SdkConfig'; +import SdkConfig, { DEFAULTS } from './SdkConfig'; +import Modal from './Modal'; +import sdk from './index'; + +function getRedactedHash() { + return window.location.hash.replace(/#\/(group|room|user)\/(.+)/, "#/$1/"); +} function getRedactedUrl() { - const redactedHash = window.location.hash.replace(/#\/(group|room|user)\/(.+)/, "#/$1/"); // hardcoded url to make piwik happy - return 'https://riot.im/app/' + redactedHash; + return 'https://riot.im/app/' + getRedactedHash(); } const customVariables = { - 'App Platform': 1, - 'App Version': 2, - 'User Type': 3, - 'Chosen Language': 4, - 'Instance': 5, - 'RTE: Uses Richtext Mode': 6, - 'Homeserver URL': 7, - 'Identity Server URL': 8, + 'App Platform': { + id: 1, + expl: _td('The platform you\'re on'), + }, + 'App Version': { + id: 2, + expl: _td('The version of Riot.im'), + }, + 'User Type': { + id: 3, + expl: _td('Whether or not you\'re logged in (we don\'t record your user name)'), + }, + 'Chosen Language': { + id: 4, + expl: _td('Your language of choice'), + }, + 'Instance': { + id: 5, + expl: _td('Which officially provided instance you are using, if any'), + }, + 'RTE: Uses Richtext Mode': { + id: 6, + expl: _td('Whether or not you\'re using the Richtext mode of the Rich Text Editor'), + }, + 'Homeserver URL': { + id: 7, + expl: _td('Your homeserver\'s URL'), + }, + 'Identity Server URL': { + id: 8, + expl: _td('Your identity server\'s URL'), + }, }; function whitelistRedact(whitelist, str) { @@ -40,9 +69,6 @@ function whitelistRedact(whitelist, str) { return ''; } -const whitelistedHSUrls = ["https://matrix.org"]; -const whitelistedISUrls = ["https://vector.im"]; - class Analytics { constructor() { this._paq = null; @@ -140,11 +166,16 @@ class Analytics { } _setVisitVariable(key, value) { - this._paq.push(['setCustomVariable', customVariables[key], key, value, 'visit']); + this._paq.push(['setCustomVariable', customVariables[key].id, key, value, 'visit']); } setLoggedIn(isGuest, homeserverUrl, identityServerUrl) { if (this.disabled) return; + + const config = SdkConfig.get(); + const whitelistedHSUrls = config.piwik.whitelistedHSUrls || DEFAULTS.piwik.whitelistedHSUrls; + const whitelistedISUrls = config.piwik.whitelistedISUrls || DEFAULTS.piwik.whitelistedISUrls; + this._setVisitVariable('User Type', isGuest ? 'Guest' : 'Logged In'); this._setVisitVariable('Homeserver URL', whitelistRedact(whitelistedHSUrls, homeserverUrl)); this._setVisitVariable('Identity Server URL', whitelistRedact(whitelistedISUrls, identityServerUrl)); @@ -154,6 +185,44 @@ class Analytics { if (this.disabled) return; this._setVisitVariable('RTE: Uses Richtext Mode', state ? 'on' : 'off'); } + + showDetailsModal() { + const Tracker = window.Piwik.getAsyncTracker(); + const rows = Object.values(customVariables).map((v) => Tracker.getCustomVariable(v.id)).filter(Boolean); + + const resolution = `${window.screen.width}x${window.screen.height}`; + + const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog'); + Modal.createTrackedDialog('Analytics Details', '', ErrorDialog, { + title: _t('Analytics'), + description:
+
+ { _t('The information being sent to us to help make Riot.im better includes:') } +
+ + { rows.map((row) => + + + ) } +
{ _t(customVariables[row[0]].expl) }{ row[1] }
+
+
+ { _t('We also record each page you use in the app (currently ), your User Agent' + + ' () and your device resolution ().', + {}, + { + CurrentPageHash: { getRedactedHash() }, + CurrentUserAgent: { navigator.userAgent }, + CurrentDeviceResolution: { resolution }, + }, + ) } + + { _t('Where this page includes identifiable information, such as a room, ' + + 'user or group ID, that data is removed before being sent to the server.') } +
+
, + }); + } } if (!global.mxAnalytics) { diff --git a/src/DateUtils.js b/src/DateUtils.js index 77f3644f6f..108697238c 100644 --- a/src/DateUtils.js +++ b/src/DateUtils.js @@ -15,7 +15,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -'use strict'; import { _t } from './languageHandler'; function getDaysArray() { @@ -51,55 +50,89 @@ function pad(n) { return (n < 10 ? '0' : '') + n; } -function twelveHourTime(date) { +function twelveHourTime(date, showSeconds=false) { let hours = date.getHours() % 12; const minutes = pad(date.getMinutes()); const ampm = date.getHours() >= 12 ? _t('PM') : _t('AM'); hours = hours ? hours : 12; // convert 0 -> 12 + if (showSeconds) { + const seconds = pad(date.getSeconds()); + return `${hours}:${minutes}:${seconds}${ampm}`; + } return `${hours}:${minutes}${ampm}`; } -module.exports = { - formatDate: function(date, showTwelveHour=false) { - const now = new Date(); - const days = getDaysArray(); - const months = getMonthsArray(); - if (date.toDateString() === now.toDateString()) { - 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', { - weekDayName: days[date.getDay()], - time: this.formatTime(date, showTwelveHour), - }); - } else if (now.getFullYear() === date.getFullYear()) { - // TODO: use standard date localize function provided in counterpart - return _t('%(weekDayName)s, %(monthName)s %(day)s %(time)s', { - weekDayName: days[date.getDay()], - monthName: months[date.getMonth()], - day: date.getDate(), - time: this.formatTime(date, showTwelveHour), - }); - } - return this.formatFullDate(date, showTwelveHour); - }, - - formatFullDate: function(date, showTwelveHour=false) { - const days = getDaysArray(); - const months = getMonthsArray(); - return _t('%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s %(time)s', { +export function formatDate(date, showTwelveHour=false) { + const now = new Date(); + const days = getDaysArray(); + const months = getMonthsArray(); + if (date.toDateString() === now.toDateString()) { + return 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', { + weekDayName: days[date.getDay()], + time: formatTime(date, showTwelveHour), + }); + } else if (now.getFullYear() === date.getFullYear()) { + // TODO: use standard date localize function provided in counterpart + return _t('%(weekDayName)s, %(monthName)s %(day)s %(time)s', { weekDayName: days[date.getDay()], monthName: months[date.getMonth()], day: date.getDate(), - fullYear: date.getFullYear(), - time: this.formatTime(date, showTwelveHour), + time: formatTime(date, showTwelveHour), }); - }, + } + return formatFullDate(date, showTwelveHour); +} - formatTime: function(date, showTwelveHour=false) { - if (showTwelveHour) { - return twelveHourTime(date); - } - return pad(date.getHours()) + ':' + pad(date.getMinutes()); - }, -}; +export function formatFullDateNoTime(date) { + const days = getDaysArray(); + const months = getMonthsArray(); + return _t('%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s', { + weekDayName: days[date.getDay()], + monthName: months[date.getMonth()], + day: date.getDate(), + fullYear: date.getFullYear(), + }); +} + +export function formatFullDate(date, showTwelveHour=false) { + const days = getDaysArray(); + const months = getMonthsArray(); + return _t('%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s %(time)s', { + weekDayName: days[date.getDay()], + monthName: months[date.getMonth()], + day: date.getDate(), + fullYear: date.getFullYear(), + time: formatFullTime(date, showTwelveHour), + }); +} + +export function formatFullTime(date, showTwelveHour=false) { + if (showTwelveHour) { + return twelveHourTime(date, true); + } + return pad(date.getHours()) + ':' + pad(date.getMinutes()) + ':' + pad(date.getSeconds()); +} + +export function formatTime(date, showTwelveHour=false) { + if (showTwelveHour) { + return twelveHourTime(date); + } + return pad(date.getHours()) + ':' + pad(date.getMinutes()); +} + +const MILLIS_IN_DAY = 86400000; +export function wantsDateSeparator(prevEventDate, nextEventDate) { + if (!nextEventDate || !prevEventDate) { + return false; + } + // Return early for events that are > 24h apart + if (Math.abs(prevEventDate.getTime() - nextEventDate.getTime()) > MILLIS_IN_DAY) { + return true; + } + + // Compare weekdays + return prevEventDate.getDay() !== nextEventDate.getDay(); +} diff --git a/src/HtmlUtils.js b/src/HtmlUtils.js index 0c262fe89a..5c6cbd6c1b 100644 --- a/src/HtmlUtils.js +++ b/src/HtmlUtils.js @@ -1,6 +1,6 @@ /* Copyright 2015, 2016 OpenMarket Ltd -Copyright 2017 New Vector Ltd +Copyright 2017, 2018 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. @@ -25,6 +25,7 @@ import escape from 'lodash/escape'; import emojione from 'emojione'; import classNames from 'classnames'; import MatrixClientPeg from './MatrixClientPeg'; +import url from 'url'; emojione.imagePathSVG = 'emojione/svg/'; // Store PNG path for displaying many flags at once (for increased performance over SVG) @@ -44,6 +45,8 @@ const SYMBOL_PATTERN = /([\u2100-\u2bff])/; const EMOJI_REGEX = new RegExp(emojione.unicodeRegexp+"+", "gi"); const COLOR_REGEX = /^#[0-9a-fA-F]{6}$/; +const PERMITTED_URL_SCHEMES = ['http', 'https', 'ftp', 'mailto', 'magnet']; + /* * Return true if the given string contains emoji * Uses a much, much simpler regex than emojione's so will give false @@ -152,6 +155,25 @@ export function sanitizedHtmlNode(insaneHtml) { return
; } +/** + * Tests if a URL from an untrusted source may be safely put into the DOM + * The biggest threat here is javascript: URIs. + * Note that the HTML sanitiser library has its own internal logic for + * doing this, to which we pass the same list of schemes. This is used in + * other places we need to sanitise URLs. + * @return true if permitted, otherwise false + */ +export function isUrlPermitted(inputUrl) { + try { + const parsed = url.parse(inputUrl); + if (!parsed.protocol) return false; + // URL parser protocol includes the trailing colon + return PERMITTED_URL_SCHEMES.includes(parsed.protocol.slice(0, -1)); + } catch (e) { + return false; + } +} + const sanitizeHtmlParams = { allowedTags: [ 'font', // custom to matrix for IRC-style font coloring @@ -172,7 +194,7 @@ const sanitizeHtmlParams = { // Lots of these won't come up by default because we don't allow them selfClosing: ['img', 'br', 'hr', 'area', 'base', 'basefont', 'input', 'link', 'meta'], // URL schemes we permit - allowedSchemes: ['http', 'https', 'ftp', 'mailto', 'magnet'], + allowedSchemes: PERMITTED_URL_SCHEMES, allowProtocolRelative: false, diff --git a/src/Keyboard.js b/src/Keyboard.js index 9c872e1c66..bf83a1a05f 100644 --- a/src/Keyboard.js +++ b/src/Keyboard.js @@ -68,3 +68,12 @@ export function isOnlyCtrlOrCmdKeyEvent(ev) { return ev.ctrlKey && !ev.altKey && !ev.metaKey && !ev.shiftKey; } } + +export function isOnlyCtrlOrCmdIgnoreShiftKeyEvent(ev) { + const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0; + if (isMac) { + return ev.metaKey && !ev.altKey && !ev.ctrlKey; + } else { + return ev.ctrlKey && !ev.altKey && !ev.metaKey; + } +} diff --git a/src/Lifecycle.js b/src/Lifecycle.js index efd5c20d5c..ec1fca2bc6 100644 --- a/src/Lifecycle.js +++ b/src/Lifecycle.js @@ -362,7 +362,7 @@ async function _doSetLoggedIn(credentials, clearStorage) { dis.dispatch({action: 'on_logged_in', teamToken: teamToken}); }); - startMatrixClient(); + await startMatrixClient(); return MatrixClientPeg.get(); } @@ -423,7 +423,7 @@ export function logout() { * Starts the matrix client and all other react-sdk services that * listen for events while a session is logged in. */ -function startMatrixClient() { +async function startMatrixClient() { console.log(`Lifecycle: Starting MatrixClient`); // dispatch this before starting the matrix client: it's used @@ -437,7 +437,7 @@ function startMatrixClient() { Presence.start(); DMRoomMap.makeShared().start(); - MatrixClientPeg.start(); + await MatrixClientPeg.start(); // dispatch that we finished starting up to wire up any other bits // of the matrix client that cannot be set prior to starting up. diff --git a/src/Markdown.js b/src/Markdown.js index e05f163ba5..aa1c7e45b1 100644 --- a/src/Markdown.js +++ b/src/Markdown.js @@ -55,25 +55,6 @@ function is_multi_line(node) { return par.firstChild != par.lastChild; } -import linkifyMatrix from './linkify-matrix'; -import * as linkify from 'linkifyjs'; -linkifyMatrix(linkify); - -// Thieved from draft-js-export-markdown -function escapeMarkdown(s) { - return s.replace(/[*_`]/g, '\\$&'); -} - -// Replace URLs, room aliases and user IDs with md-escaped URLs -function linkifyMarkdown(s) { - const links = linkify.find(s); - links.forEach((l) => { - // This may replace several instances of `l.value` at once, but that's OK - s = s.replace(l.value, escapeMarkdown(l.value)); - }); - return s; -} - /** * Class that wraps commonmark, adding the ability to see whether * a given message actually uses any markdown syntax or whether @@ -81,7 +62,7 @@ function linkifyMarkdown(s) { */ export default class Markdown { constructor(input) { - this.input = linkifyMarkdown(input); + this.input = input; const parser = new commonmark.Parser(); this.parsed = parser.parse(this.input); diff --git a/src/Modal.js b/src/Modal.js index 68d75d1ff1..c9f08772e7 100644 --- a/src/Modal.js +++ b/src/Modal.js @@ -19,6 +19,7 @@ limitations under the License. const React = require('react'); const ReactDOM = require('react-dom'); +import PropTypes from 'prop-types'; import Analytics from './Analytics'; import sdk from './index'; @@ -33,7 +34,7 @@ const AsyncWrapper = React.createClass({ /** A function which takes a 'callback' argument which it will call * with the real component once it loads. */ - loader: React.PropTypes.func.isRequired, + loader: PropTypes.func.isRequired, }, getInitialState: function() { diff --git a/src/Notifier.js b/src/Notifier.js index 75b698862c..e69bdf4461 100644 --- a/src/Notifier.js +++ b/src/Notifier.js @@ -135,6 +135,10 @@ const Notifier = { const plaf = PlatformPeg.get(); if (!plaf) return; + // Dev note: We don't set the "notificationsEnabled" setting to true here because it is a + // calculated value. It is determined based upon whether or not the master rule is enabled + // and other flags. Setting it here would cause a circular reference. + Analytics.trackEvent('Notifier', 'Set Enabled', enable); // make sure that we persist the current setting audio_enabled setting @@ -168,7 +172,7 @@ const Notifier = { }); // clear the notifications_hidden flag, so that if notifications are // disabled again in the future, we will show the banner again. - this.setToolbarHidden(false); + this.setToolbarHidden(true); } else { dis.dispatch({ action: "notifier_enabled", diff --git a/src/RoomInvite.js b/src/RoomInvite.js index 1979c6d111..31541148d9 100644 --- a/src/RoomInvite.js +++ b/src/RoomInvite.js @@ -85,9 +85,7 @@ function _onStartChatFinished(shouldInvite, addrs) { if (rooms.length > 0) { // A Direct Message room already exists for this user, so select a // room from a list that is similar to the one in MemberInfo panel - const ChatCreateOrReuseDialog = sdk.getComponent( - "views.dialogs.ChatCreateOrReuseDialog", - ); + const ChatCreateOrReuseDialog = sdk.getComponent("views.dialogs.ChatCreateOrReuseDialog"); const close = Modal.createTrackedDialog('Create or Reuse', '', ChatCreateOrReuseDialog, { userId: addrTexts[0], onNewDMClick: () => { @@ -115,6 +113,15 @@ function _onStartChatFinished(shouldInvite, addrs) { }); }); } + } else if (addrTexts.length === 1) { + // Start a new DM chat + createRoom({dmUserId: addrTexts[0]}).catch((err) => { + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + Modal.createTrackedDialog('Failed to invite user', '', ErrorDialog, { + title: _t("Failed to invite user"), + description: ((err && err.message) ? err.message : _t("Operation failed")), + }); + }); } else { // Start multi user chat let room; diff --git a/src/RoomNotifs.js b/src/RoomNotifs.js index 5cc078dc59..91e49fe09b 100644 --- a/src/RoomNotifs.js +++ b/src/RoomNotifs.js @@ -34,7 +34,14 @@ export function getRoomNotifsState(roomId) { } // for everything else, look at the room rule. - const roomRule = MatrixClientPeg.get().getRoomPushRule('global', roomId); + let roomRule = null; + try { + roomRule = MatrixClientPeg.get().getRoomPushRule('global', roomId); + } catch (err) { + // Possible that the client doesn't have pushRules yet. If so, it + // hasn't started eiher, so indicate that this room is not notifying. + return null; + } // XXX: We have to assume the default is to notify for all messages // (in particular this will be 'wrong' for one to one rooms because @@ -130,6 +137,11 @@ function setRoomNotifsStateUnmuted(roomId, newState) { } function findOverrideMuteRule(roomId) { + if (!MatrixClientPeg.get().pushRules || + !MatrixClientPeg.get().pushRules['global'] || + !MatrixClientPeg.get().pushRules['global'].override) { + return null; + } for (const rule of MatrixClientPeg.get().pushRules['global'].override) { if (isRuleForRoom(roomId, rule)) { if (isMuteRule(rule) && rule.enabled) { diff --git a/src/Rooms.js b/src/Rooms.js index 6cc2d867a6..ffa39141ff 100644 --- a/src/Rooms.js +++ b/src/Rooms.js @@ -43,7 +43,7 @@ export function getOnlyOtherMember(room, me) { return null; } -export function isConfCallRoom(room, me, conferenceHandler) { +function _isConfCallRoom(room, me, conferenceHandler) { if (!conferenceHandler) return false; if (me.membership != "join") { @@ -58,6 +58,26 @@ export function isConfCallRoom(room, me, conferenceHandler) { if (conferenceHandler.isConferenceUser(otherMember.userId)) { return true; } + + return false; +} + +// Cache whether a room is a conference call. Assumes that rooms will always +// either will or will not be a conference call room. +const isConfCallRoomCache = { + // $roomId: bool +}; + +export function isConfCallRoom(room, me, conferenceHandler) { + if (isConfCallRoomCache[room.roomId] !== undefined) { + return isConfCallRoomCache[room.roomId]; + } + + const result = _isConfCallRoom(room, me, conferenceHandler); + + isConfCallRoomCache[room.roomId] = result; + + return result; } export function looksLikeDirectMessageRoom(room, me) { diff --git a/src/ScalarAuthClient.js b/src/ScalarAuthClient.js index 756965d41e..c3fcf80308 100644 --- a/src/ScalarAuthClient.js +++ b/src/ScalarAuthClient.js @@ -15,6 +15,7 @@ limitations under the License. */ import Promise from 'bluebird'; +import SettingsStore from "./settings/SettingsStore"; const request = require('browser-request'); const SdkConfig = require('./SdkConfig'); @@ -38,11 +39,53 @@ class ScalarAuthClient { // Returns a scalar_token string getScalarToken() { - const tok = window.localStorage.getItem("mx_scalar_token"); - if (tok) return Promise.resolve(tok); + const token = window.localStorage.getItem("mx_scalar_token"); - // No saved token, so do the dance to get one. First, we - // need an openid bearer token from the HS. + if (!token) { + return this.registerForToken(); + } else { + return this.validateToken(token).then(userId => { + const me = MatrixClientPeg.get().getUserId(); + if (userId !== me) { + throw new Error("Scalar token is owned by someone else: " + me); + } + return token; + }).catch(err => { + console.error(err); + + // Something went wrong - try to get a new token. + console.warn("Registering for new scalar token"); + return this.registerForToken(); + }) + } + } + + validateToken(token) { + let url = SdkConfig.get().integrations_rest_url + "/account"; + + const defer = Promise.defer(); + request({ + method: "GET", + uri: url, + qs: {scalar_token: token}, + json: true, + }, (err, response, body) => { + if (err) { + defer.reject(err); + } else if (response.statusCode / 100 !== 2) { + defer.reject({statusCode: response.statusCode}); + } else if (!body || !body.user_id) { + defer.reject(new Error("Missing user_id in response")); + } else { + defer.resolve(body.user_id); + } + }); + + return defer.promise; + } + + registerForToken() { + // Get openid bearer token from the HS as the first part of our dance return MatrixClientPeg.get().getOpenIdToken().then((token_object) => { // Now we can send that to scalar and exchange it for a scalar token return this.exchangeForScalarToken(token_object); @@ -112,6 +155,7 @@ class ScalarAuthClient { url += "?scalar_token=" + encodeURIComponent(this.scalarToken); url += "&room_id=" + encodeURIComponent(roomId); url += "&room_name=" + encodeURIComponent(roomName); + url += "&theme=" + encodeURIComponent(SettingsStore.getValue("theme")); if (id) { url += '&integ_id=' + encodeURIComponent(id); } diff --git a/src/ScalarMessaging.js b/src/ScalarMessaging.js index abd1caaec1..abdea55506 100644 --- a/src/ScalarMessaging.js +++ b/src/ScalarMessaging.js @@ -602,8 +602,16 @@ const onMessage = function(event) { // // All strings start with the empty string, so for sanity return if the length // of the event origin is 0. + // + // TODO -- Scalar postMessage API should be namespaced with event.data.api field + // Fix following "if" statement to respond only to specific API messages. const url = SdkConfig.get().integrations_ui_url; - if (event.origin.length === 0 || !url.startsWith(event.origin) || !event.data.action) { + if ( + event.origin.length === 0 || + !url.startsWith(event.origin + '/') || + !event.data.action || + event.data.api // Ignore messages with specific API set + ) { return; // don't log this - debugging APIs like to spam postMessage which floods the log otherwise } diff --git a/src/SdkConfig.js b/src/SdkConfig.js index 8df725a913..64bf21ecf8 100644 --- a/src/SdkConfig.js +++ b/src/SdkConfig.js @@ -21,6 +21,13 @@ const DEFAULTS = { integrations_rest_url: "https://scalar.vector.im/api", // Where to send bug reports. If not specified, bugs cannot be sent. bug_report_endpoint_url: null, + + piwik: { + url: "https://piwik.riot.im/", + whitelistedHSUrls: ["https://matrix.org"], + whitelistedISUrls: ["https://vector.im", "https://matrix.org"], + siteId: 1, + }, }; class SdkConfig { @@ -45,3 +52,4 @@ class SdkConfig { } module.exports = SdkConfig; +module.exports.DEFAULTS = DEFAULTS; diff --git a/src/SlashCommands.js b/src/SlashCommands.js index 344bac1ddb..d45e45e84c 100644 --- a/src/SlashCommands.js +++ b/src/SlashCommands.js @@ -96,6 +96,8 @@ const commands = { colorScheme.primary_color = matches[1]; if (matches[4]) { colorScheme.secondary_color = matches[4]; + } else { + colorScheme.secondary_color = colorScheme.primary_color; } return success( SettingsStore.setValue("roomColor", roomId, SettingLevel.ROOM_ACCOUNT, colorScheme), @@ -295,7 +297,7 @@ const commands = { // Define the power level of a user op: new Command("op", " []", function(roomId, args) { if (args) { - const matches = args.match(/^(\S+?)( +(\d+))?$/); + const matches = args.match(/^(\S+?)( +(-?\d+))?$/); let powerLevel = 50; // default power level for op if (matches) { const userId = matches[1]; diff --git a/src/TextForEvent.js b/src/TextForEvent.js index 1bdf5ad90c..e60bde4094 100644 --- a/src/TextForEvent.js +++ b/src/TextForEvent.js @@ -52,8 +52,7 @@ function textForMemberEvent(ev) { case 'join': if (prevContent && prevContent.membership === 'join') { if (prevContent.displayname && content.displayname && prevContent.displayname !== content.displayname) { - return _t('%(senderName)s changed their display name from %(oldDisplayName)s to %(displayName)s.', { - senderName, + return _t('%(oldDisplayName)s changed their display name to %(displayName)s.', { oldDisplayName: prevContent.displayname, displayName: content.displayname, }); diff --git a/src/Unread.js b/src/Unread.js index 383b5c2e5a..55e60f2a9a 100644 --- a/src/Unread.js +++ b/src/Unread.js @@ -28,6 +28,8 @@ module.exports = { return false; } else if (ev.getType() == 'm.room.member') { return false; + } else if (ev.getType() == 'm.room.third_party_invite') { + return false; } else if (ev.getType() == 'm.call.answer' || ev.getType() == 'm.call.hangup') { return false; } else if (ev.getType == 'm.room.message' && ev.getContent().msgtype == 'm.notify') { diff --git a/src/Velociraptor.js b/src/Velociraptor.js index 9a674d4f09..af4e6dcb60 100644 --- a/src/Velociraptor.js +++ b/src/Velociraptor.js @@ -1,5 +1,6 @@ const React = require('react'); const ReactDom = require('react-dom'); +import PropTypes from 'prop-types'; const Velocity = require('velocity-vector'); /** @@ -14,16 +15,16 @@ module.exports = React.createClass({ propTypes: { // either a list of child nodes, or a single child. - children: React.PropTypes.any, + children: PropTypes.any, // optional transition information for changing existing children - transition: React.PropTypes.object, + transition: PropTypes.object, // a list of state objects to apply to each child node in turn - startStyles: React.PropTypes.array, + startStyles: PropTypes.array, // a list of transition options from the corresponding startStyle - enterTransitionOpts: React.PropTypes.array, + enterTransitionOpts: PropTypes.array, }, getDefaultProps: function() { diff --git a/src/actions/MatrixActionCreators.js b/src/actions/MatrixActionCreators.js index 33bdb53799..6e1d52a88f 100644 --- a/src/actions/MatrixActionCreators.js +++ b/src/actions/MatrixActionCreators.js @@ -62,6 +62,127 @@ function createAccountDataAction(matrixClient, accountDataEvent) { }; } +/** + * @typedef RoomAction + * @type {Object} + * @property {string} action 'MatrixActions.Room'. + * @property {Room} room the Room that was stored. + */ + +/** + * Create a MatrixActions.Room action that represents a MatrixClient `Room` + * matrix event, emitted when a Room is stored in the client. + * + * @param {MatrixClient} matrixClient the matrix client. + * @param {Room} room the Room that was stored. + * @returns {RoomAction} an action of type `MatrixActions.Room`. + */ +function createRoomAction(matrixClient, room) { + return { action: 'MatrixActions.Room', room }; +} + +/** + * @typedef RoomTagsAction + * @type {Object} + * @property {string} action 'MatrixActions.Room.tags'. + * @property {Room} room the Room whose tags changed. + */ + +/** + * Create a MatrixActions.Room.tags action that represents a MatrixClient + * `Room.tags` matrix event, emitted when the m.tag room account data + * event is updated. + * + * @param {MatrixClient} matrixClient the matrix client. + * @param {MatrixEvent} roomTagsEvent the m.tag event. + * @param {Room} room the Room whose tags were changed. + * @returns {RoomTagsAction} an action of type `MatrixActions.Room.tags`. + */ +function createRoomTagsAction(matrixClient, roomTagsEvent, room) { + return { action: 'MatrixActions.Room.tags', room }; +} + +/** + * @typedef RoomTimelineAction + * @type {Object} + * @property {string} action 'MatrixActions.Room.timeline'. + * @property {boolean} isLiveEvent whether the event was attached to a + * live timeline. + * @property {boolean} isLiveUnfilteredRoomTimelineEvent whether the + * event was attached to a timeline in the set of unfiltered timelines. + * @property {Room} room the Room whose tags changed. + */ + +/** + * Create a MatrixActions.Room.timeline action that represents a + * MatrixClient `Room.timeline` matrix event, emitted when an event + * is added to or removed from a timeline of a room. + * + * @param {MatrixClient} matrixClient the matrix client. + * @param {MatrixEvent} timelineEvent the event that was added/removed. + * @param {Room} room the Room that was stored. + * @param {boolean} toStartOfTimeline whether the event is being added + * to the start (and not the end) of the timeline. + * @param {boolean} removed whether the event was removed from the + * timeline. + * @param {Object} data + * @param {boolean} data.liveEvent whether the event is a live event, + * belonging to a live timeline. + * @param {EventTimeline} data.timeline the timeline being altered. + * @returns {RoomTimelineAction} an action of type `MatrixActions.Room.timeline`. + */ +function createRoomTimelineAction(matrixClient, timelineEvent, room, toStartOfTimeline, removed, data) { + return { + action: 'MatrixActions.Room.timeline', + event: timelineEvent, + isLiveEvent: data.liveEvent, + isLiveUnfilteredRoomTimelineEvent: + room && data.timeline.getTimelineSet() === room.getUnfilteredTimelineSet(), + }; +} + +/** + * @typedef RoomMembershipAction + * @type {Object} + * @property {string} action 'MatrixActions.RoomMember.membership'. + * @property {RoomMember} member the member whose membership was updated. + */ + +/** + * Create a MatrixActions.RoomMember.membership action that represents + * a MatrixClient `RoomMember.membership` matrix event, emitted when a + * member's membership is updated. + * + * @param {MatrixClient} matrixClient the matrix client. + * @param {MatrixEvent} membershipEvent the m.room.member event. + * @param {RoomMember} member the member whose membership was updated. + * @param {string} oldMembership the member's previous membership. + * @returns {RoomMembershipAction} an action of type `MatrixActions.RoomMember.membership`. + */ +function createRoomMembershipAction(matrixClient, membershipEvent, member, oldMembership) { + return { action: 'MatrixActions.RoomMember.membership', member }; +} + +/** + * @typedef EventDecryptedAction + * @type {Object} + * @property {string} action 'MatrixActions.Event.decrypted'. + * @property {MatrixEvent} event the matrix event that was decrypted. + */ + +/** + * Create a MatrixActions.Event.decrypted action that represents + * a MatrixClient `Event.decrypted` matrix event, emitted when a + * matrix event is decrypted. + * + * @param {MatrixClient} matrixClient the matrix client. + * @param {MatrixEvent} event the matrix event that was decrypted. + * @returns {EventDecryptedAction} an action of type `MatrixActions.Event.decrypted`. + */ +function createEventDecryptedAction(matrixClient, event) { + return { action: 'MatrixActions.Event.decrypted', event }; +} + /** * This object is responsible for dispatching actions when certain events are emitted by * the given MatrixClient. @@ -78,6 +199,11 @@ export default { start(matrixClient) { this._addMatrixClientListener(matrixClient, 'sync', createSyncAction); this._addMatrixClientListener(matrixClient, 'accountData', createAccountDataAction); + this._addMatrixClientListener(matrixClient, 'Room', createRoomAction); + this._addMatrixClientListener(matrixClient, 'Room.tags', createRoomTagsAction); + this._addMatrixClientListener(matrixClient, 'Room.timeline', createRoomTimelineAction); + this._addMatrixClientListener(matrixClient, 'RoomMember.membership', createRoomMembershipAction); + this._addMatrixClientListener(matrixClient, 'Event.decrypted', createEventDecryptedAction); }, /** @@ -91,7 +217,7 @@ export default { */ _addMatrixClientListener(matrixClient, eventName, actionCreator) { const listener = (...args) => { - dis.dispatch(actionCreator(matrixClient, ...args)); + dis.dispatch(actionCreator(matrixClient, ...args), true); }; matrixClient.on(eventName, listener); this._matrixClientListenersStop.push(() => { diff --git a/src/actions/RoomListActions.js b/src/actions/RoomListActions.js new file mode 100644 index 0000000000..a92bd1ebaf --- /dev/null +++ b/src/actions/RoomListActions.js @@ -0,0 +1,146 @@ +/* +Copyright 2018 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 { asyncAction } from './actionCreators'; +import RoomListStore from '../stores/RoomListStore'; + +import Modal from '../Modal'; +import Rooms from '../Rooms'; +import { _t } from '../languageHandler'; +import sdk from '../index'; + +const RoomListActions = {}; + +/** + * Creates an action thunk that will do an asynchronous request to + * tag room. + * + * @param {MatrixClient} matrixClient the matrix client to set the + * account data on. + * @param {Room} room the room to tag. + * @param {string} oldTag the tag to remove (unless oldTag ==== newTag) + * @param {string} newTag the tag with which to tag the room. + * @param {?number} oldIndex the previous position of the room in the + * list of rooms. + * @param {?number} newIndex the new position of the room in the list + * of rooms. + * @returns {function} an action thunk. + * @see asyncAction + */ +RoomListActions.tagRoom = function(matrixClient, room, oldTag, newTag, oldIndex, newIndex) { + let metaData = null; + + // Is the tag ordered manually? + if (newTag && !newTag.match(/^(m\.lowpriority|im\.vector\.fake\.(invite|recent|direct|archived))$/)) { + const lists = RoomListStore.getRoomLists(); + const newList = [...lists[newTag]]; + + newList.sort((a, b) => a.tags[newTag].order - b.tags[newTag].order); + + // If the room was moved "down" (increasing index) in the same list we + // need to use the orders of the tiles with indices shifted by +1 + const offset = ( + newTag === oldTag && oldIndex < newIndex + ) ? 1 : 0; + + const indexBefore = offset + newIndex - 1; + const indexAfter = offset + newIndex; + + const prevOrder = indexBefore <= 0 ? + 0 : newList[indexBefore].tags[newTag].order; + const nextOrder = indexAfter >= newList.length ? + 1 : newList[indexAfter].tags[newTag].order; + + metaData = { + order: (prevOrder + nextOrder) / 2.0, + }; + } + + return asyncAction('RoomListActions.tagRoom', () => { + const promises = []; + const roomId = room.roomId; + + // Evil hack to get DMs behaving + if ((oldTag === undefined && newTag === 'im.vector.fake.direct') || + (oldTag === 'im.vector.fake.direct' && newTag === undefined) + ) { + return Rooms.guessAndSetDMRoom( + room, newTag === 'im.vector.fake.direct', + ).catch((err) => { + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + console.error("Failed to set direct chat tag " + err); + Modal.createTrackedDialog('Failed to set direct chat tag', '', ErrorDialog, { + title: _t('Failed to set direct chat tag'), + description: ((err && err.message) ? err.message : _t('Operation failed')), + }); + }); + } + + const hasChangedSubLists = oldTag !== newTag; + + // More evilness: We will still be dealing with moving to favourites/low prio, + // but we avoid ever doing a request with 'im.vector.fake.direct`. + // + // if we moved lists, remove the old tag + if (oldTag && oldTag !== 'im.vector.fake.direct' && + hasChangedSubLists + ) { + const promiseToDelete = matrixClient.deleteRoomTag( + roomId, oldTag, + ).catch(function(err) { + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + console.error("Failed to remove tag " + oldTag + " from room: " + err); + Modal.createTrackedDialog('Failed to remove tag from room', '', ErrorDialog, { + title: _t('Failed to remove tag %(tagName)s from room', {tagName: oldTag}), + description: ((err && err.message) ? err.message : _t('Operation failed')), + }); + }); + + promises.push(promiseToDelete); + } + + // if we moved lists or the ordering changed, add the new tag + if (newTag && newTag !== 'im.vector.fake.direct' && + (hasChangedSubLists || metaData) + ) { + // metaData is the body of the PUT to set the tag, so it must + // at least be an empty object. + metaData = metaData || {}; + + const promiseToAdd = matrixClient.setRoomTag(roomId, newTag, metaData).catch(function(err) { + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + console.error("Failed to add tag " + newTag + " to room: " + err); + Modal.createTrackedDialog('Failed to add tag to room', '', ErrorDialog, { + title: _t('Failed to add tag %(tagName)s to room', {tagName: newTag}), + description: ((err && err.message) ? err.message : _t('Operation failed')), + }); + + throw err; + }); + + promises.push(promiseToAdd); + } + + return Promise.all(promises); + }, () => { + // For an optimistic update + return { + room, oldTag, newTag, metaData, + }; + }); +}; + +export default RoomListActions; diff --git a/src/actions/TagOrderActions.js b/src/actions/TagOrderActions.js index 60946ea7f1..a257ff16d8 100644 --- a/src/actions/TagOrderActions.js +++ b/src/actions/TagOrderActions.js @@ -22,25 +22,87 @@ const TagOrderActions = {}; /** * Creates an action thunk that will do an asynchronous request to - * commit TagOrderStore.getOrderedTags() to account data and dispatch - * actions to indicate the status of the request. + * move a tag in TagOrderStore to destinationIx. * * @param {MatrixClient} matrixClient the matrix client to set the * account data on. + * @param {string} tag the tag to move. + * @param {number} destinationIx the new position of the tag. * @returns {function} an action thunk that will dispatch actions * indicating the status of the request. * @see asyncAction */ -TagOrderActions.commitTagOrdering = function(matrixClient) { - return asyncAction('TagOrderActions.commitTagOrdering', () => { - // Only commit tags if the state is ready, i.e. not null - const tags = TagOrderStore.getOrderedTags(); - if (!tags) { - return; - } +TagOrderActions.moveTag = function(matrixClient, tag, destinationIx) { + // Only commit tags if the state is ready, i.e. not null + let tags = TagOrderStore.getOrderedTags(); + let removedTags = TagOrderStore.getRemovedTagsAccountData() || []; + if (!tags) { + return; + } + tags = tags.filter((t) => t !== tag); + tags = [...tags.slice(0, destinationIx), tag, ...tags.slice(destinationIx)]; + + removedTags = removedTags.filter((t) => t !== tag); + + const storeId = TagOrderStore.getStoreId(); + + return asyncAction('TagOrderActions.moveTag', () => { Analytics.trackEvent('TagOrderActions', 'commitTagOrdering'); - return matrixClient.setAccountData('im.vector.web.tag_ordering', {tags}); + return matrixClient.setAccountData( + 'im.vector.web.tag_ordering', + {tags, removedTags, _storeId: storeId}, + ); + }, () => { + // For an optimistic update + return {tags, removedTags}; + }); +}; + +/** + * Creates an action thunk that will do an asynchronous request to + * label a tag as removed in im.vector.web.tag_ordering account data. + * + * The reason this is implemented with new state `removedTags` is that + * we incrementally and initially populate `tags` with groups that + * have been joined. If we remove a group from `tags`, it will just + * get added (as it looks like a group we've recently joined). + * + * NB: If we ever support adding of tags (which is planned), we should + * take special care to remove the tag from `removedTags` when we add + * it. + * + * @param {MatrixClient} matrixClient the matrix client to set the + * account data on. + * @param {string} tag the tag to remove. + * @returns {function} an action thunk that will dispatch actions + * indicating the status of the request. + * @see asyncAction + */ +TagOrderActions.removeTag = function(matrixClient, tag) { + // Don't change tags, just removedTags + const tags = TagOrderStore.getOrderedTags(); + const removedTags = TagOrderStore.getRemovedTagsAccountData() || []; + + if (removedTags.includes(tag)) { + // Return a thunk that doesn't do anything, we don't even need + // an asynchronous action here, the tag is already removed. + return () => {}; + } + + removedTags.push(tag); + + const storeId = TagOrderStore.getStoreId(); + + return asyncAction('TagOrderActions.removeTag', () => { + Analytics.trackEvent('TagOrderActions', 'removeTag'); + return matrixClient.setAccountData( + 'im.vector.web.tag_ordering', + {tags, removedTags, _storeId: storeId}, + ); + }, () => { + // For an optimistic update + return {removedTags}; }); }; diff --git a/src/actions/actionCreators.js b/src/actions/actionCreators.js index bddfbc7c63..967ce609e7 100644 --- a/src/actions/actionCreators.js +++ b/src/actions/actionCreators.js @@ -22,16 +22,32 @@ limitations under the License. * suffix determining whether it is pending, successful or * a failure. * @param {function} fn a function that returns a Promise. + * @param {function?} pendingFn a function that returns an object to assign + * to the `request` key of the ${id}.pending + * payload. * @returns {function} an action thunk - a function that uses its single * argument as a dispatch function to dispatch the * following actions: * `${id}.pending` and either * `${id}.success` or * `${id}.failure`. + * + * The shape of each are: + * { action: '${id}.pending', request } + * { action: '${id}.success', result } + * { action: '${id}.failure', err } + * + * where `request` is returned by `pendingFn` and + * result is the result of the promise returned by + * `fn`. */ -export function asyncAction(id, fn) { +export function asyncAction(id, fn, pendingFn) { return (dispatch) => { - dispatch({action: id + '.pending'}); + dispatch({ + action: id + '.pending', + request: + typeof pendingFn === 'function' ? pendingFn() : undefined, + }); fn().then((result) => { dispatch({action: id + '.success', result}); }).catch((err) => { diff --git a/src/async-components/views/dialogs/EncryptedEventDialog.js b/src/async-components/views/dialogs/EncryptedEventDialog.js index a8f588d39a..5db8b2365f 100644 --- a/src/async-components/views/dialogs/EncryptedEventDialog.js +++ b/src/async-components/views/dialogs/EncryptedEventDialog.js @@ -15,6 +15,7 @@ limitations under the License. */ const React = require("react"); +import PropTypes from 'prop-types'; import { _t } from '../../../languageHandler'; const sdk = require('../../../index'); const MatrixClientPeg = require("../../../MatrixClientPeg"); @@ -23,8 +24,8 @@ module.exports = React.createClass({ displayName: 'EncryptedEventDialog', propTypes: { - event: React.PropTypes.object.isRequired, - onFinished: React.PropTypes.func.isRequired, + event: PropTypes.object.isRequired, + onFinished: PropTypes.func.isRequired, }, getInitialState: function() { diff --git a/src/async-components/views/dialogs/ExportE2eKeysDialog.js b/src/async-components/views/dialogs/ExportE2eKeysDialog.js index 04274442c2..06fb0668d5 100644 --- a/src/async-components/views/dialogs/ExportE2eKeysDialog.js +++ b/src/async-components/views/dialogs/ExportE2eKeysDialog.js @@ -16,6 +16,7 @@ limitations under the License. import FileSaver from 'file-saver'; import React from 'react'; +import PropTypes from 'prop-types'; import { _t } from '../../../languageHandler'; import * as Matrix from 'matrix-js-sdk'; @@ -29,8 +30,8 @@ export default React.createClass({ displayName: 'ExportE2eKeysDialog', propTypes: { - matrixClient: React.PropTypes.instanceOf(Matrix.MatrixClient).isRequired, - onFinished: React.PropTypes.func.isRequired, + matrixClient: PropTypes.instanceOf(Matrix.MatrixClient).isRequired, + onFinished: PropTypes.func.isRequired, }, getInitialState: function() { diff --git a/src/async-components/views/dialogs/ImportE2eKeysDialog.js b/src/async-components/views/dialogs/ImportE2eKeysDialog.js index a01b6580f1..10744a8911 100644 --- a/src/async-components/views/dialogs/ImportE2eKeysDialog.js +++ b/src/async-components/views/dialogs/ImportE2eKeysDialog.js @@ -15,6 +15,7 @@ limitations under the License. */ import React from 'react'; +import PropTypes from 'prop-types'; import * as Matrix from 'matrix-js-sdk'; import * as MegolmExportEncryption from '../../../utils/MegolmExportEncryption'; @@ -40,8 +41,8 @@ export default React.createClass({ displayName: 'ImportE2eKeysDialog', propTypes: { - matrixClient: React.PropTypes.instanceOf(Matrix.MatrixClient).isRequired, - onFinished: React.PropTypes.func.isRequired, + matrixClient: PropTypes.instanceOf(Matrix.MatrixClient).isRequired, + onFinished: PropTypes.func.isRequired, }, getInitialState: function() { diff --git a/src/autocomplete/Components.js b/src/autocomplete/Components.js index a27533f7c2..b09f4e963e 100644 --- a/src/autocomplete/Components.js +++ b/src/autocomplete/Components.js @@ -15,6 +15,7 @@ limitations under the License. */ import React from 'react'; +import PropTypes from 'prop-types'; import classNames from 'classnames'; /* These were earlier stateless functional components but had to be converted @@ -42,10 +43,10 @@ export class TextualCompletion extends React.Component { } } TextualCompletion.propTypes = { - title: React.PropTypes.string, - subtitle: React.PropTypes.string, - description: React.PropTypes.string, - className: React.PropTypes.string, + title: PropTypes.string, + subtitle: PropTypes.string, + description: PropTypes.string, + className: PropTypes.string, }; export class PillCompletion extends React.Component { @@ -69,9 +70,9 @@ export class PillCompletion extends React.Component { } } PillCompletion.propTypes = { - title: React.PropTypes.string, - subtitle: React.PropTypes.string, - description: React.PropTypes.string, - initialComponent: React.PropTypes.element, - className: React.PropTypes.string, + title: PropTypes.string, + subtitle: PropTypes.string, + description: PropTypes.string, + initialComponent: PropTypes.element, + className: PropTypes.string, }; diff --git a/src/autocomplete/RoomProvider.js b/src/autocomplete/RoomProvider.js index 1e1928a1ee..31599703c2 100644 --- a/src/autocomplete/RoomProvider.js +++ b/src/autocomplete/RoomProvider.js @@ -25,6 +25,7 @@ import {PillCompletion} from './Components'; import {getDisplayAliasForRoom} from '../Rooms'; import sdk from '../index'; import _sortBy from 'lodash/sortBy'; +import {makeRoomPermalink} from "../matrix-to"; const ROOM_REGEX = /(?=#)(\S*)/g; @@ -78,7 +79,7 @@ export default class RoomProvider extends AutocompleteProvider { return { completion: displayAlias, suffix: ' ', - href: 'https://matrix.to/#/' + displayAlias, + href: makeRoomPermalink(displayAlias), component: ( } title={room.name} description={displayAlias} /> ), diff --git a/src/autocomplete/UserProvider.js b/src/autocomplete/UserProvider.js index 794f507d21..bceec3f144 100644 --- a/src/autocomplete/UserProvider.js +++ b/src/autocomplete/UserProvider.js @@ -28,6 +28,7 @@ import _sortBy from 'lodash/sortBy'; import MatrixClientPeg from '../MatrixClientPeg'; import type {Room, RoomMember} from 'matrix-js-sdk'; +import {makeUserPermalink} from "../matrix-to"; const USER_REGEX = /@\S*/g; @@ -106,7 +107,7 @@ export default class UserProvider extends AutocompleteProvider { // relies on the length of the entity === length of the text in the decoration. completion: user.rawDisplayName.replace(' (IRC)', ''), suffix: range.start === 0 ? ': ' : ' ', - href: 'https://matrix.to/#/' + user.userId, + href: makeUserPermalink(user.userId), component: ( } @@ -157,7 +158,7 @@ export default class UserProvider extends AutocompleteProvider { } renderCompletions(completions: [React.Component]): ?React.Component { - return
+ return
{ completions }
; } diff --git a/src/components/structures/ContextualMenu.js b/src/components/structures/ContextualMenu.js index 3c23e0ceae..59a68181c3 100644 --- a/src/components/structures/ContextualMenu.js +++ b/src/components/structures/ContextualMenu.js @@ -20,6 +20,7 @@ limitations under the License. const classNames = require('classnames'); const React = require('react'); const ReactDOM = require('react-dom'); +import PropTypes from 'prop-types'; // Shamelessly ripped off Modal.js. There's probably a better way // of doing reusable widgets like dialog boxes & menus where we go and @@ -29,11 +30,11 @@ module.exports = { ContextualMenuContainerId: "mx_ContextualMenu_Container", propTypes: { - menuWidth: React.PropTypes.number, - menuHeight: React.PropTypes.number, - chevronOffset: React.PropTypes.number, - menuColour: React.PropTypes.string, - chevronFace: React.PropTypes.string, // top, bottom, left, right + menuWidth: PropTypes.number, + menuHeight: PropTypes.number, + chevronOffset: PropTypes.number, + menuColour: PropTypes.string, + chevronFace: PropTypes.string, // top, bottom, left, right }, getOrCreateContainer: function() { diff --git a/src/components/structures/CreateRoom.js b/src/components/structures/CreateRoom.js index 26454c5ea6..2bb9adb544 100644 --- a/src/components/structures/CreateRoom.js +++ b/src/components/structures/CreateRoom.js @@ -17,6 +17,7 @@ limitations under the License. 'use strict'; import React from 'react'; +import PropTypes from 'prop-types'; import { _t } from '../../languageHandler'; import sdk from '../../index'; import MatrixClientPeg from '../../MatrixClientPeg'; @@ -30,8 +31,8 @@ module.exports = React.createClass({ displayName: 'CreateRoom', propTypes: { - onRoomCreated: React.PropTypes.func, - collapsedRhs: React.PropTypes.bool, + onRoomCreated: PropTypes.func, + collapsedRhs: PropTypes.bool, }, phases: { diff --git a/src/components/structures/FilePanel.js b/src/components/structures/FilePanel.js index 6a8d0c5bdd..bd03683830 100644 --- a/src/components/structures/FilePanel.js +++ b/src/components/structures/FilePanel.js @@ -15,6 +15,7 @@ limitations under the License. */ import React from 'react'; +import PropTypes from 'prop-types'; import Matrix from 'matrix-js-sdk'; import sdk from '../../index'; @@ -28,7 +29,7 @@ const FilePanel = React.createClass({ displayName: 'FilePanel', propTypes: { - roomId: React.PropTypes.string.isRequired, + roomId: PropTypes.string.isRequired, }, getInitialState: function() { diff --git a/src/components/structures/GroupView.js b/src/components/structures/GroupView.js index 5ffb97c6ed..de96935838 100644 --- a/src/components/structures/GroupView.js +++ b/src/components/structures/GroupView.js @@ -31,6 +31,7 @@ import GroupStoreCache from '../../stores/GroupStoreCache'; import GroupStore from '../../stores/GroupStore'; import { showGroupAddRoomDialog } from '../../GroupAddressPicker'; import GeminiScrollbar from 'react-gemini-scrollbar'; +import {makeGroupPermalink, makeUserPermalink} from "../../matrix-to"; const LONG_DESC_PLACEHOLDER = _td( `

HTML for your community's page

@@ -209,7 +210,7 @@ const FeaturedRoom = React.createClass({ let permalink = null; if (this.props.summaryInfo.profile && this.props.summaryInfo.profile.canonical_alias) { - permalink = 'https://matrix.to/#/' + this.props.summaryInfo.profile.canonical_alias; + permalink = makeGroupPermalink(this.props.summaryInfo.profile.canonical_alias); } let roomNameNode = null; @@ -366,7 +367,7 @@ const FeaturedUser = React.createClass({ const BaseAvatar = sdk.getComponent("avatars.BaseAvatar"); const name = this.props.summaryInfo.displayname || this.props.summaryInfo.user_id; - const permalink = 'https://matrix.to/#/' + this.props.summaryInfo.user_id; + const permalink = makeUserPermalink(this.props.summaryInfo.user_id); const userNameNode = { name }; const httpUrl = MatrixClientPeg.get() .mxcUrlToHttp(this.props.summaryInfo.avatar_url, 64, 64); @@ -390,7 +391,7 @@ const FeaturedUser = React.createClass({ }); const GroupContext = { - groupStore: React.PropTypes.instanceOf(GroupStore).isRequired, + groupStore: PropTypes.instanceOf(GroupStore).isRequired, }; CategoryRoomList.contextTypes = GroupContext; @@ -408,7 +409,7 @@ export default React.createClass({ }, childContextTypes: { - groupStore: React.PropTypes.instanceOf(GroupStore), + groupStore: PropTypes.instanceOf(GroupStore), }, getChildContext: function() { diff --git a/src/components/structures/InteractiveAuth.js b/src/components/structures/InteractiveAuth.js index 8a2c1b8c79..8428e3c714 100644 --- a/src/components/structures/InteractiveAuth.js +++ b/src/components/structures/InteractiveAuth.js @@ -18,6 +18,7 @@ import Matrix from 'matrix-js-sdk'; const InteractiveAuth = Matrix.InteractiveAuth; import React from 'react'; +import PropTypes from 'prop-types'; import {getEntryComponentForLoginType} from '../views/login/InteractiveAuthEntryComponents'; @@ -26,18 +27,18 @@ export default React.createClass({ propTypes: { // matrix client to use for UI auth requests - matrixClient: React.PropTypes.object.isRequired, + matrixClient: PropTypes.object.isRequired, // response from initial request. If not supplied, will do a request on // mount. - authData: React.PropTypes.shape({ - flows: React.PropTypes.array, - params: React.PropTypes.object, - session: React.PropTypes.string, + authData: PropTypes.shape({ + flows: PropTypes.array, + params: PropTypes.object, + session: PropTypes.string, }), // callback - makeRequest: React.PropTypes.func.isRequired, + makeRequest: PropTypes.func.isRequired, // callback called when the auth process has finished, // successfully or unsuccessfully. @@ -51,22 +52,22 @@ export default React.createClass({ // the auth session. // * clientSecret {string} The client secret used in auth // sessions with the ID server. - onAuthFinished: React.PropTypes.func.isRequired, + onAuthFinished: PropTypes.func.isRequired, // Inputs provided by the user to the auth process // and used by various stages. As passed to js-sdk // interactive-auth - inputs: React.PropTypes.object, + inputs: PropTypes.object, // As js-sdk interactive-auth - makeRegistrationUrl: React.PropTypes.func, - sessionId: React.PropTypes.string, - clientSecret: React.PropTypes.string, - emailSid: React.PropTypes.string, + makeRegistrationUrl: PropTypes.func, + sessionId: PropTypes.string, + clientSecret: PropTypes.string, + emailSid: PropTypes.string, // If true, poll to see if the auth flow has been completed // out-of-band - poll: React.PropTypes.bool, + poll: PropTypes.bool, }, getInitialState: function() { diff --git a/src/components/structures/LoggedInView.js b/src/components/structures/LoggedInView.js index 38b7634edb..f6bbfd247b 100644 --- a/src/components/structures/LoggedInView.js +++ b/src/components/structures/LoggedInView.js @@ -18,8 +18,8 @@ limitations under the License. import * as Matrix from 'matrix-js-sdk'; import React from 'react'; -import { DragDropContext } from 'react-dnd'; -import HTML5Backend from 'react-dnd-html5-backend'; +import PropTypes from 'prop-types'; +import { DragDropContext } from 'react-beautiful-dnd'; import { KeyCode, isOnlyCtrlOrCmdKeyEvent } from '../../Keyboard'; import Notifier from '../../Notifier'; @@ -31,6 +31,9 @@ import sessionStore from '../../stores/SessionStore'; import MatrixClientPeg from '../../MatrixClientPeg'; import SettingsStore from "../../settings/SettingsStore"; +import TagOrderActions from '../../actions/TagOrderActions'; +import RoomListActions from '../../actions/RoomListActions'; + /** * This is what our MatrixChat shows when we are logged in. The precise view is * determined by the page_type property. @@ -44,23 +47,23 @@ const LoggedInView = React.createClass({ displayName: 'LoggedInView', propTypes: { - matrixClient: React.PropTypes.instanceOf(Matrix.MatrixClient).isRequired, - page_type: React.PropTypes.string.isRequired, - onRoomCreated: React.PropTypes.func, - onUserSettingsClose: React.PropTypes.func, + matrixClient: PropTypes.instanceOf(Matrix.MatrixClient).isRequired, + page_type: PropTypes.string.isRequired, + onRoomCreated: PropTypes.func, + onUserSettingsClose: PropTypes.func, // Called with the credentials of a registered user (if they were a ROU that // transitioned to PWLU) - onRegistered: React.PropTypes.func, + onRegistered: PropTypes.func, - teamToken: React.PropTypes.string, + teamToken: PropTypes.string, // and lots and lots of other stuff. }, childContextTypes: { - matrixClient: React.PropTypes.instanceOf(Matrix.MatrixClient), - authCache: React.PropTypes.object, + matrixClient: PropTypes.instanceOf(Matrix.MatrixClient), + authCache: PropTypes.object, }, getChildContext: function() { @@ -208,8 +211,51 @@ const LoggedInView = React.createClass({ } }, + _onDragEnd: function(result) { + // Dragged to an invalid destination, not onto a droppable + if (!result.destination) { + return; + } + + const dest = result.destination.droppableId; + + if (dest === 'tag-panel-droppable') { + // Could be "GroupTile +groupId:domain" + const draggableId = result.draggableId.split(' ').pop(); + + // Dispatch synchronously so that the TagPanel receives an + // optimistic update from TagOrderStore before the previous + // state is shown. + dis.dispatch(TagOrderActions.moveTag( + this._matrixClient, + draggableId, + result.destination.index, + ), true); + } else if (dest.startsWith('room-sub-list-droppable_')) { + this._onRoomTileEndDrag(result); + } + }, + + _onRoomTileEndDrag: function(result) { + let newTag = result.destination.droppableId.split('_')[1]; + let prevTag = result.source.droppableId.split('_')[1]; + if (newTag === 'undefined') newTag = undefined; + if (prevTag === 'undefined') prevTag = undefined; + + const roomId = result.draggableId.split('_')[1]; + + const oldIndex = result.source.index; + const newIndex = result.destination.index; + + dis.dispatch(RoomListActions.tagRoom( + this._matrixClient, + this._matrixClient.getRoom(roomId), + prevTag, newTag, + oldIndex, newIndex, + ), true); + }, + render: function() { - const TagPanel = sdk.getComponent('structures.TagPanel'); const LeftPanel = sdk.getComponent('structures.LeftPanel'); const RightPanel = sdk.getComponent('structures.RightPanel'); const RoomView = sdk.getComponent('structures.RoomView'); @@ -330,21 +376,21 @@ const LoggedInView = React.createClass({ return (
{ topBar } -
- { SettingsStore.isFeatureEnabled("feature_tag_panel") ? :
} - -
- { page_element } -
- { right_panel } -
+ +
+ +
+ { page_element } +
+ { right_panel } +
+
); }, }); -export default DragDropContext(HTML5Backend)(LoggedInView); +export default LoggedInView; diff --git a/src/components/structures/MatrixChat.js b/src/components/structures/MatrixChat.js index 69b737cb7e..b37da0144f 100644 --- a/src/components/structures/MatrixChat.js +++ b/src/components/structures/MatrixChat.js @@ -19,6 +19,7 @@ limitations under the License. import Promise from 'bluebird'; import React from 'react'; +import PropTypes from 'prop-types'; import Matrix from "matrix-js-sdk"; import Analytics from "../../Analytics"; @@ -92,38 +93,38 @@ export default React.createClass({ displayName: 'MatrixChat', propTypes: { - config: React.PropTypes.object, - ConferenceHandler: React.PropTypes.any, - onNewScreen: React.PropTypes.func, - registrationUrl: React.PropTypes.string, - enableGuest: React.PropTypes.bool, + config: PropTypes.object, + ConferenceHandler: PropTypes.any, + onNewScreen: PropTypes.func, + registrationUrl: PropTypes.string, + enableGuest: PropTypes.bool, // the queryParams extracted from the [real] query-string of the URI - realQueryParams: React.PropTypes.object, + realQueryParams: PropTypes.object, // the initial queryParams extracted from the hash-fragment of the URI - startingFragmentQueryParams: React.PropTypes.object, + startingFragmentQueryParams: PropTypes.object, // called when we have completed a token login - onTokenLoginCompleted: React.PropTypes.func, + onTokenLoginCompleted: PropTypes.func, // Represents the screen to display as a result of parsing the initial // window.location - initialScreenAfterLogin: React.PropTypes.shape({ - screen: React.PropTypes.string.isRequired, - params: React.PropTypes.object, + initialScreenAfterLogin: PropTypes.shape({ + screen: PropTypes.string.isRequired, + params: PropTypes.object, }), // displayname, if any, to set on the device when logging // in/registering. - defaultDeviceDisplayName: React.PropTypes.string, + defaultDeviceDisplayName: PropTypes.string, // A function that makes a registration URL - makeRegistrationUrl: React.PropTypes.func.isRequired, + makeRegistrationUrl: PropTypes.func.isRequired, }, childContextTypes: { - appConfig: React.PropTypes.object, + appConfig: PropTypes.object, }, AuxPanel: { @@ -617,18 +618,26 @@ export default React.createClass({ }, _startRegistration: function(params) { - this.setStateForNewView({ + const newState = { view: VIEWS.REGISTER, - // these params may be undefined, but if they are, - // unset them from our state: we don't want to - // resume a previous registration session if the - // user just clicked 'register' - register_client_secret: params.client_secret, - register_session_id: params.session_id, - register_hs_url: params.hs_url, - register_is_url: params.is_url, - register_id_sid: params.sid, - }); + }; + + // Only honour params if they are all present, otherwise we reset + // HS and IS URLs when switching to registration. + if (params.client_secret && + params.session_id && + params.hs_url && + params.is_url && + params.sid + ) { + newState.register_client_secret = params.client_secret; + newState.register_session_id = params.session_id; + newState.register_hs_url = params.hs_url; + newState.register_is_url = params.is_url; + newState.register_id_sid = params.sid; + } + + this.setStateForNewView(newState); this.notifyNewScreen('register'); }, @@ -846,16 +855,36 @@ export default React.createClass({ }).close; }, + _leaveRoomWarnings: function(roomId) { + const roomToLeave = MatrixClientPeg.get().getRoom(roomId); + // Show a warning if there are additional complications. + const joinRules = roomToLeave.currentState.getStateEvents('m.room.join_rules', ''); + const warnings = []; + if (joinRules) { + const rule = joinRules.getContent().join_rule; + if (rule !== "public") { + warnings.push(( + + { _t("This room is not public. You will not be able to rejoin without an invite.") } + + )); + } + } + return warnings; + }, + _leaveRoom: function(roomId) { const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - const roomToLeave = MatrixClientPeg.get().getRoom(roomId); + const warnings = this._leaveRoomWarnings(roomId); + Modal.createTrackedDialog('Leave room', '', QuestionDialog, { title: _t("Leave room"), description: ( { _t("Are you sure you want to leave the room '%(roomName)s'?", {roomName: roomToLeave.name}) } + { warnings } ), onFinished: (shouldLeave) => { @@ -1065,10 +1094,10 @@ export default React.createClass({ // this if we are not scrolled up in the view. To find out, delegate to // the timeline panel. If the timeline panel doesn't exist, then we assume // it is safe to reset the timeline. - if (!self.refs.loggedInView) { + if (!self._loggedInView || !self._loggedInView.child) { return true; } - return self.refs.loggedInView.canResetTimelineInRoom(roomId); + return self._loggedInView.child.canResetTimelineInRoom(roomId); }); cli.on('sync', function(state, prevState) { @@ -1480,6 +1509,17 @@ export default React.createClass({ } }, + onServerConfigChange(config) { + const newState = {}; + if (config.hsUrl) { + newState.register_hs_url = config.hsUrl; + } + if (config.isUrl) { + newState.register_is_url = config.isUrl; + } + this.setState(newState); + }, + _makeRegistrationUrl: function(params) { if (this.props.startingFragmentQueryParams.referrer) { params.referrer = this.props.startingFragmentQueryParams.referrer; @@ -1487,6 +1527,10 @@ export default React.createClass({ return this.props.makeRegistrationUrl(params); }, + _collectLoggedInView: function(ref) { + this._loggedInView = ref; + }, + render: function() { // console.log(`Rendering MatrixChat with view ${this.state.view}`); @@ -1519,7 +1563,7 @@ export default React.createClass({ */ const LoggedInView = sdk.getComponent('structures.LoggedInView'); return ( - ); } @@ -1598,6 +1643,7 @@ export default React.createClass({ onForgotPasswordClick={this.onForgotPasswordClick} enableGuest={this.props.enableGuest} onCancelClick={MatrixClientPeg.get() ? this.onReturnToAppClick : null} + onServerConfigChange={this.onServerConfigChange} /> ); } diff --git a/src/components/structures/MessagePanel.js b/src/components/structures/MessagePanel.js index 30ded948a4..248c874fa8 100644 --- a/src/components/structures/MessagePanel.js +++ b/src/components/structures/MessagePanel.js @@ -16,15 +16,15 @@ limitations under the License. import React from 'react'; import ReactDOM from 'react-dom'; +import PropTypes from 'prop-types'; import classNames from 'classnames'; import shouldHideEvent from '../../shouldHideEvent'; +import {wantsDateSeparator} from '../../DateUtils'; import dis from "../../dispatcher"; import sdk from '../../index'; import MatrixClientPeg from '../../MatrixClientPeg'; -const MILLIS_IN_DAY = 86400000; - /* (almost) stateless UI component which builds the event tiles in the room timeline. */ module.exports = React.createClass({ @@ -32,63 +32,63 @@ module.exports = React.createClass({ propTypes: { // true to give the component a 'display: none' style. - hidden: React.PropTypes.bool, + hidden: PropTypes.bool, // true to show a spinner at the top of the timeline to indicate // back-pagination in progress - backPaginating: React.PropTypes.bool, + backPaginating: PropTypes.bool, // true to show a spinner at the end of the timeline to indicate // forward-pagination in progress - forwardPaginating: React.PropTypes.bool, + forwardPaginating: PropTypes.bool, // the list of MatrixEvents to display - events: React.PropTypes.array.isRequired, + events: PropTypes.array.isRequired, // ID of an event to highlight. If undefined, no event will be highlighted. - highlightedEventId: React.PropTypes.string, + highlightedEventId: PropTypes.string, // Should we show URL Previews - showUrlPreview: React.PropTypes.bool, + showUrlPreview: PropTypes.bool, // event after which we should show a read marker - readMarkerEventId: React.PropTypes.string, + readMarkerEventId: PropTypes.string, // whether the read marker should be visible - readMarkerVisible: React.PropTypes.bool, + readMarkerVisible: PropTypes.bool, // the userid of our user. This is used to suppress the read marker // for pending messages. - ourUserId: React.PropTypes.string, + ourUserId: PropTypes.string, // true to suppress the date at the start of the timeline - suppressFirstDateSeparator: React.PropTypes.bool, + suppressFirstDateSeparator: PropTypes.bool, // whether to show read receipts - showReadReceipts: React.PropTypes.bool, + showReadReceipts: PropTypes.bool, // true if updates to the event list should cause the scroll panel to // scroll down when we are at the bottom of the window. See ScrollPanel // for more details. - stickyBottom: React.PropTypes.bool, + stickyBottom: PropTypes.bool, // callback which is called when the panel is scrolled. - onScroll: React.PropTypes.func, + onScroll: PropTypes.func, // callback which is called when more content is needed. - onFillRequest: React.PropTypes.func, + onFillRequest: PropTypes.func, // className for the panel - className: React.PropTypes.string.isRequired, + className: PropTypes.string.isRequired, // shape parameter to be passed to EventTiles - tileShape: React.PropTypes.string, + tileShape: PropTypes.string, // show twelve hour timestamps - isTwelveHour: React.PropTypes.bool, + isTwelveHour: PropTypes.bool, // show timestamps always - alwaysShowTimestamps: React.PropTypes.bool, + alwaysShowTimestamps: PropTypes.bool, }, componentWillMount: function() { @@ -325,7 +325,7 @@ module.exports = React.createClass({ const key = "membereventlistsummary-" + (prevEvent ? mxEv.getId() : "initial"); if (this._wantsDateSeparator(prevEvent, mxEv.getDate())) { - const dateSeparator =
  • ; + const dateSeparator =
  • ; ret.push(dateSeparator); } @@ -479,7 +479,7 @@ module.exports = React.createClass({ // do we need a date separator since the last event? if (this._wantsDateSeparator(prevEvent, eventDate)) { - const dateSeparator =
  • ; + const dateSeparator =
  • ; ret.push(dateSeparator); continuation = false; } @@ -522,17 +522,7 @@ module.exports = React.createClass({ // here. return !this.props.suppressFirstDateSeparator; } - const prevEventDate = prevEvent.getDate(); - if (!nextEventDate || !prevEventDate) { - return false; - } - // Return early for events that are > 24h apart - if (Math.abs(prevEvent.getTs() - nextEventDate.getTime()) > MILLIS_IN_DAY) { - return true; - } - - // Compare weekdays - return prevEventDate.getDay() !== nextEventDate.getDay(); + return wantsDateSeparator(prevEvent.getDate(), nextEventDate); }, // get a list of read receipts that should be shown next to this event diff --git a/src/components/structures/MyGroups.js b/src/components/structures/MyGroups.js index 9281fb199e..116607fb08 100644 --- a/src/components/structures/MyGroups.js +++ b/src/components/structures/MyGroups.js @@ -15,6 +15,7 @@ limitations under the License. */ import React from 'react'; +import PropTypes from 'prop-types'; import GeminiScrollbar from 'react-gemini-scrollbar'; import sdk from '../../index'; import { _t } from '../../languageHandler'; @@ -26,7 +27,7 @@ export default withMatrixClient(React.createClass({ displayName: 'MyGroups', propTypes: { - matrixClient: React.PropTypes.object.isRequired, + matrixClient: PropTypes.object.isRequired, }, getInitialState: function() { @@ -72,8 +73,10 @@ export default withMatrixClient(React.createClass({ }); contentHeader = groupNodes.length > 0 ?

    { _t('Your Communities') }

    :
    ; content = groupNodes.length > 0 ? - - { groupNodes } + +
    + { groupNodes } +
    :
    { _t( diff --git a/src/components/structures/RoomStatusBar.js b/src/components/structures/RoomStatusBar.js index 77d506d9af..8034923158 100644 --- a/src/components/structures/RoomStatusBar.js +++ b/src/components/structures/RoomStatusBar.js @@ -16,6 +16,7 @@ limitations under the License. */ import React from 'react'; +import PropTypes from 'prop-types'; import Matrix from 'matrix-js-sdk'; import { _t } from '../../languageHandler'; import sdk from '../../index'; @@ -23,7 +24,7 @@ import WhoIsTyping from '../../WhoIsTyping'; import MatrixClientPeg from '../../MatrixClientPeg'; import MemberAvatar from '../views/avatars/MemberAvatar'; import Resend from '../../Resend'; -import { showUnknownDeviceDialogForMessages } from '../../cryptodevices'; +import * as cryptodevices from '../../cryptodevices'; const STATUS_BAR_HIDDEN = 0; const STATUS_BAR_EXPANDED = 1; @@ -41,59 +42,59 @@ module.exports = React.createClass({ propTypes: { // the room this statusbar is representing. - room: React.PropTypes.object.isRequired, + room: PropTypes.object.isRequired, // the number of messages which have arrived since we've been scrolled up - numUnreadMessages: React.PropTypes.number, + numUnreadMessages: PropTypes.number, // this is true if we are fully scrolled-down, and are looking at // the end of the live timeline. - atEndOfLiveTimeline: React.PropTypes.bool, + atEndOfLiveTimeline: PropTypes.bool, // This is true when the user is alone in the room, but has also sent a message. // Used to suggest to the user to invite someone - sentMessageAndIsAlone: React.PropTypes.bool, + sentMessageAndIsAlone: PropTypes.bool, // true if there is an active call in this room (means we show // the 'Active Call' text in the status bar if there is nothing // more interesting) - hasActiveCall: React.PropTypes.bool, + hasActiveCall: PropTypes.bool, // Number of names to display in typing indication. E.g. set to 3, will // result in "X, Y, Z and 100 others are typing." - whoIsTypingLimit: React.PropTypes.number, + whoIsTypingLimit: PropTypes.number, // callback for when the user clicks on the 'resend all' button in the // 'unsent messages' bar - onResendAllClick: React.PropTypes.func, + onResendAllClick: PropTypes.func, // callback for when the user clicks on the 'cancel all' button in the // 'unsent messages' bar - onCancelAllClick: React.PropTypes.func, + onCancelAllClick: PropTypes.func, // callback for when the user clicks on the 'invite others' button in the // 'you are alone' bar - onInviteClick: React.PropTypes.func, + onInviteClick: PropTypes.func, // callback for when the user clicks on the 'stop warning me' button in the // 'you are alone' bar - onStopWarningClick: React.PropTypes.func, + onStopWarningClick: PropTypes.func, // callback for when the user clicks on the 'scroll to bottom' button - onScrollToBottomClick: React.PropTypes.func, + onScrollToBottomClick: PropTypes.func, // callback for when we do something that changes the size of the // status bar. This is used to trigger a re-layout in the parent // component. - onResize: React.PropTypes.func, + onResize: PropTypes.func, // callback for when the status bar can be hidden from view, as it is // not displaying anything - onHidden: React.PropTypes.func, + onHidden: PropTypes.func, // callback for when the status bar is displaying something and should // be visible - onVisible: React.PropTypes.func, + onVisible: PropTypes.func, }, getDefaultProps: function() { @@ -147,6 +148,13 @@ module.exports = React.createClass({ }); }, + _onSendWithoutVerifyingClick: function() { + cryptodevices.getUnknownDevicesForRoom(MatrixClientPeg.get(), this.props.room).then((devices) => { + cryptodevices.markAllDevicesKnown(MatrixClientPeg.get(), devices); + Resend.resendUnsentEvents(this.props.room); + }); + }, + _onResendAllClick: function() { Resend.resendUnsentEvents(this.props.room); }, @@ -156,7 +164,7 @@ module.exports = React.createClass({ }, _onShowDevicesClick: function() { - showUnknownDeviceDialogForMessages(MatrixClientPeg.get(), this.props.room); + cryptodevices.showUnknownDeviceDialogForMessages(MatrixClientPeg.get(), this.props.room); }, _onRoomLocalEchoUpdated: function(event, room, oldEventId, oldStatus) { @@ -169,8 +177,10 @@ module.exports = React.createClass({ // Check whether current size is greater than 0, if yes call props.onVisible _checkSize: function() { - if (this.props.onVisible && this._getSize()) { - this.props.onVisible(); + if (this._getSize()) { + if (this.props.onVisible) this.props.onVisible(); + } else { + if (this.props.onHidden) this.props.onHidden(); } }, @@ -286,10 +296,11 @@ module.exports = React.createClass({ if (hasUDE) { title = _t("Message not sent due to unknown devices being present"); content = _t( - "Show devices or cancel all.", + "Show devices, send anyway or cancel.", {}, { 'showDevicesText': (sub) => { sub }, + 'sendAnywayText': (sub) => { sub }, 'cancelText': (sub) => { sub }, }, ); @@ -302,11 +313,11 @@ module.exports = React.createClass({ ) { title = unsentMessages[0].error.data.error; } else { - title = _t("Some of your messages have not been sent."); + title = _t('%(count)s of your messages have not been sent.', { count: unsentMessages.length }); } - content = _t("Resend all or cancel all now. " + + content = _t("%(count)s Resend all or cancel all now. " + "You can also select individual messages to resend or cancel.", - {}, + { count: unsentMessages.length }, { 'resendText': (sub) => { sub }, diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index 4873bbc961..a6b4c272b4 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -24,6 +24,7 @@ import shouldHideEvent from "../../shouldHideEvent"; const React = require("react"); const ReactDOM = require("react-dom"); +import PropTypes from 'prop-types'; import Promise from 'bluebird'; const classNames = require("classnames"); import { _t } from '../../languageHandler'; @@ -58,18 +59,18 @@ if (DEBUG) { module.exports = React.createClass({ displayName: 'RoomView', propTypes: { - ConferenceHandler: React.PropTypes.any, + ConferenceHandler: PropTypes.any, // Called with the credentials of a registered user (if they were a ROU that // transitioned to PWLU) - onRegistered: React.PropTypes.func, + onRegistered: PropTypes.func, // An object representing a third party invite to join this room // Fields: // * inviteSignUrl (string) The URL used to join this room from an email invite // (given as part of the link in the invite email) // * invitedEmail (string) The email address that was invited to this room - thirdPartyInvite: React.PropTypes.object, + thirdPartyInvite: PropTypes.object, // Any data about the room that would normally come from the Home Server // but has been passed out-of-band, eg. the room name and avatar URL @@ -80,10 +81,10 @@ module.exports = React.createClass({ // * avatarUrl (string) The mxc:// avatar URL for the room // * inviterName (string) The display name of the person who // * invited us tovthe room - oobData: React.PropTypes.object, + oobData: PropTypes.object, // is the RightPanel collapsed? - collapsedRhs: React.PropTypes.bool, + collapsedRhs: PropTypes.bool, }, getInitialState: function() { @@ -263,12 +264,19 @@ module.exports = React.createClass({ isPeeking: true, // this will change to false if peeking fails }); MatrixClientPeg.get().peekInRoom(roomId).then((room) => { + if (this.unmounted) { + return; + } this.setState({ room: room, peekLoading: false, }); this._onRoomLoaded(room); }, (err) => { + if (this.unmounted) { + return; + } + // Stop peeking if anything went wrong this.setState({ isPeeking: false, @@ -285,7 +293,7 @@ module.exports = React.createClass({ } else { throw err; } - }).done(); + }); } } else if (room) { // Stop peeking because we have joined this room previously @@ -628,8 +636,8 @@ module.exports = React.createClass({ const room = this.state.room; if (!room) return; - const color_scheme = SettingsStore.getValue("roomColor", room.room_id); console.log("Tinter.tint from updateTint"); + const color_scheme = SettingsStore.getValue("roomColor", room.roomId); Tinter.tint(color_scheme.primary_color, color_scheme.secondary_color); }, @@ -678,23 +686,7 @@ module.exports = React.createClass({ // a member state changed in this room // refresh the conf call notification state this._updateConfCallNotification(); - - // if we are now a member of the room, where we were not before, that - // means we have finished joining a room we were previously peeking - // into. - const me = MatrixClientPeg.get().credentials.userId; - if (this.state.joining && this.state.room.hasMembershipState(me, "join")) { - // Having just joined a room, check to see if it looks like a DM room, and if so, - // mark it as one. This is to work around the fact that some clients don't support - // is_direct. We should remove this once they do. - const me = this.state.room.getMember(MatrixClientPeg.get().credentials.userId); - if (Rooms.looksLikeDirectMessageRoom(this.state.room, me)) { - // XXX: There's not a whole lot we can really do if this fails: at best - // perhaps we could try a couple more times, but since it's a temporary - // compatability workaround, let's not bother. - Rooms.setDMRoom(this.state.room.roomId, me.events.member.getSender()).done(); - } - } + this._updateDMState(); }, 500), _checkIfAlone: function(room) { @@ -735,6 +727,44 @@ module.exports = React.createClass({ }); }, + _updateDMState() { + const me = this.state.room.getMember(MatrixClientPeg.get().credentials.userId); + if (!me || me.membership !== "join") { + return; + } + + // The user may have accepted an invite with is_direct set + if (me.events.member.getPrevContent().membership === "invite" && + me.events.member.getPrevContent().is_direct + ) { + // This is a DM with the sender of the invite event (which we assume + // preceded the join event) + Rooms.setDMRoom( + this.state.room.roomId, + me.events.member.getUnsigned().prev_sender, + ); + return; + } + + const invitedMembers = this.state.room.getMembersWithMembership("invite"); + const joinedMembers = this.state.room.getMembersWithMembership("join"); + + // There must be one invited member and one joined member + if (invitedMembers.length !== 1 || joinedMembers.length !== 1) { + return; + } + + // The user may have sent an invite with is_direct sent + const other = invitedMembers[0]; + if (other && + other.membership === "invite" && + other.events.member.getContent().is_direct + ) { + Rooms.setDMRoom(this.state.room.roomId, other.userId); + return; + } + }, + onSearchResultsResize: function() { dis.dispatch({ action: 'timeline_resize' }, true); }, @@ -827,18 +857,6 @@ module.exports = React.createClass({ action: 'join_room', opts: { inviteSignUrl: signUrl }, }); - - // if this is an invite and has the 'direct' hint set, mark it as a DM room now. - if (this.state.room) { - const me = this.state.room.getMember(MatrixClientPeg.get().credentials.userId); - if (me && me.membership == 'invite') { - if (me.events.member.getContent().is_direct) { - // The 'direct' hint is there, so declare that this is a DM room for - // whoever invited us. - return Rooms.setDMRoom(this.state.room.roomId, me.events.member.getSender()); - } - } - } return Promise.resolve(); }); }, @@ -863,9 +881,13 @@ module.exports = React.createClass({ ev.dataTransfer.dropEffect = 'none'; - const items = ev.dataTransfer.items; - if (items.length == 1) { - if (items[0].kind == 'file') { + const items = [...ev.dataTransfer.items]; + if (items.length >= 1) { + const isDraggingFiles = items.every(function(item) { + return item.kind == 'file'; + }); + + if (isDraggingFiles) { this.setState({ draggingFile: true }); ev.dataTransfer.dropEffect = 'copy'; } @@ -876,10 +898,8 @@ module.exports = React.createClass({ ev.stopPropagation(); ev.preventDefault(); this.setState({ draggingFile: false }); - const files = ev.dataTransfer.files; - if (files.length == 1) { - this.uploadFile(files[0]); - } + const files = [...ev.dataTransfer.files]; + files.forEach(this.uploadFile); }, onDragLeaveOrEnd: function(ev) { @@ -1369,10 +1389,12 @@ module.exports = React.createClass({ }, onStatusBarHidden: function() { - if (this.unmounted) return; + // This is currently not desired as it is annoying if it keeps expanding and collapsing + // TODO: Find a less annoying way of hiding the status bar + /*if (this.unmounted) return; this.setState({ statusBarVisible: false, - }); + });*/ }, showSettings: function(show) { diff --git a/src/components/structures/ScrollPanel.js b/src/components/structures/ScrollPanel.js index 37cb2977aa..cbb6001d5f 100644 --- a/src/components/structures/ScrollPanel.js +++ b/src/components/structures/ScrollPanel.js @@ -16,6 +16,7 @@ limitations under the License. const React = require("react"); const ReactDOM = require("react-dom"); +import PropTypes from 'prop-types'; const GeminiScrollbar = require('react-gemini-scrollbar'); import Promise from 'bluebird'; import { KeyCode } from '../../Keyboard'; @@ -86,7 +87,7 @@ module.exports = React.createClass({ * scroll down to show the new element, rather than preserving the * existing view. */ - stickyBottom: React.PropTypes.bool, + stickyBottom: PropTypes.bool, /* startAtBottom: if set to true, the view is assumed to start * scrolled to the bottom. @@ -95,7 +96,7 @@ module.exports = React.createClass({ * behaviour stays the same for other uses of ScrollPanel. * If so, let's remove this parameter down the line. */ - startAtBottom: React.PropTypes.bool, + startAtBottom: PropTypes.bool, /* onFillRequest(backwards): a callback which is called on scroll when * the user nears the start (backwards = true) or end (backwards = @@ -110,7 +111,7 @@ module.exports = React.createClass({ * directon (at this time) - which will stop the pagination cycle until * the user scrolls again. */ - onFillRequest: React.PropTypes.func, + onFillRequest: 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 @@ -121,24 +122,24 @@ module.exports = React.createClass({ * 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, + onUnfillRequest: PropTypes.func, /* onScroll: a callback which is called whenever any scroll happens. */ - onScroll: React.PropTypes.func, + onScroll: PropTypes.func, /* onResize: a callback which is called whenever the Gemini scroll * panel is resized */ - onResize: React.PropTypes.func, + onResize: PropTypes.func, /* className: classnames to add to the top-level div */ - className: React.PropTypes.string, + className: PropTypes.string, /* style: styles to add to the top-level div */ - style: React.PropTypes.object, + style: PropTypes.object, }, getDefaultProps: function() { diff --git a/src/components/structures/TagPanel.js b/src/components/structures/TagPanel.js index 49d22d8e52..3d76a967a2 100644 --- a/src/components/structures/TagPanel.js +++ b/src/components/structures/TagPanel.js @@ -17,15 +17,16 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; import { MatrixClient } from 'matrix-js-sdk'; -import FilterStore from '../../stores/FilterStore'; -import FlairStore from '../../stores/FlairStore'; +import GeminiScrollbar from 'react-gemini-scrollbar'; import TagOrderStore from '../../stores/TagOrderStore'; import GroupActions from '../../actions/GroupActions'; -import TagOrderActions from '../../actions/TagOrderActions'; import sdk from '../../index'; import dis from '../../dispatcher'; +import { _t } from '../../languageHandler'; + +import { Droppable } from 'react-beautiful-dnd'; const TagPanel = React.createClass({ displayName: 'TagPanel', @@ -36,17 +37,7 @@ const TagPanel = React.createClass({ getInitialState() { return { - // A list of group profiles for tags that are group IDs. The intention in future - // is to allow arbitrary tags to be selected in the TagPanel, not just groups. - // For now, it suffices to maintain a list of ordered group profiles. - orderedGroupTagProfiles: [ - // { - // groupId: '+awesome:foo.bar',{ - // name: 'My Awesome Community', - // avatarUrl: 'mxc://...', - // shortDescription: 'Some description...', - // }, - ], + orderedTags: [], selectedTags: [], }; }, @@ -54,28 +45,15 @@ const TagPanel = React.createClass({ componentWillMount: function() { this.unmounted = false; this.context.matrixClient.on("Group.myMembership", this._onGroupMyMembership); + this.context.matrixClient.on("sync", this.onClientSync); - this._filterStoreToken = FilterStore.addListener(() => { - if (this.unmounted) { - return; - } - this.setState({ - selectedTags: FilterStore.getSelectedTags(), - }); - }); this._tagOrderStoreToken = TagOrderStore.addListener(() => { if (this.unmounted) { return; } - - const orderedTags = TagOrderStore.getOrderedTags() || []; - const orderedGroupTags = orderedTags.filter((t) => t[0] === '+'); - // XXX: One profile lookup failing will bring the whole lot down - Promise.all(orderedGroupTags.map( - (groupId) => FlairStore.getGroupProfileCached(this.context.matrixClient, groupId), - )).then((orderedGroupTagProfiles) => { - if (this.unmounted) return; - this.setState({orderedGroupTagProfiles}); + this.setState({ + orderedTags: TagOrderStore.getOrderedTags() || [], + selectedTags: TagOrderStore.getSelectedTags(), }); }); // This could be done by anything with a matrix client @@ -85,6 +63,7 @@ const TagPanel = React.createClass({ componentWillUnmount() { this.unmounted = true; this.context.matrixClient.removeListener("Group.myMembership", this._onGroupMyMembership); + this.context.matrixClient.removeListener("sync", this.onClientSync); if (this._filterStoreToken) { this._filterStoreToken.remove(); } @@ -95,7 +74,17 @@ const TagPanel = React.createClass({ dis.dispatch(GroupActions.fetchJoinedGroups(this.context.matrixClient)); }, - onClick() { + onClientSync(syncState, prevState) { + // Consider the client reconnected if there is no error with syncing. + // This means the state could be RECONNECTING, SYNCING or PREPARED. + const reconnected = syncState !== "ERROR" && prevState !== syncState; + if (reconnected) { + // Load joined groups + dis.dispatch(GroupActions.fetchJoinedGroups(this.context.matrixClient)); + } + }, + + onClick(e) { dis.dispatch({action: 'deselect_tags'}); }, @@ -104,30 +93,62 @@ const TagPanel = React.createClass({ dis.dispatch({action: 'view_create_group'}); }, - onTagTileEndDrag() { - dis.dispatch(TagOrderActions.commitTagOrdering(this.context.matrixClient)); + onClearFilterClick(ev) { + dis.dispatch({action: 'deselect_tags'}); }, render() { - const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); - const TintableSvg = sdk.getComponent('elements.TintableSvg'); + const GroupsButton = sdk.getComponent('elements.GroupsButton'); const DNDTagTile = sdk.getComponent('elements.DNDTagTile'); + const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); - const tags = this.state.orderedGroupTagProfiles.map((groupProfile, index) => { + const tags = this.state.orderedTags.map((tag, index) => { return ; }); - return
    -
    - { tags } -
    - - + + const clearButton = this.state.selectedTags.length > 0 ? + {_t("Clear : +
    ; + + return
    + + { clearButton } +
    + + + { (provided, snapshot) => ( +
    + { tags } + { provided.placeholder } +
    + ) } +
    +
    +
    +
    + +
    ; }, }); diff --git a/src/components/structures/TimelinePanel.js b/src/components/structures/TimelinePanel.js index 98f57a60b5..12f745146e 100644 --- a/src/components/structures/TimelinePanel.js +++ b/src/components/structures/TimelinePanel.js @@ -19,6 +19,7 @@ import SettingsStore from "../../settings/SettingsStore"; const React = require('react'); const ReactDOM = require("react-dom"); +import PropTypes from 'prop-types'; import Promise from 'bluebird'; const Matrix = require("matrix-js-sdk"); @@ -58,49 +59,49 @@ var TimelinePanel = React.createClass({ // representing. This may or may not have a room, depending on what it's // a timeline representing. If it has a room, we maintain RRs etc for // that room. - timelineSet: React.PropTypes.object.isRequired, + timelineSet: PropTypes.object.isRequired, - showReadReceipts: React.PropTypes.bool, + showReadReceipts: PropTypes.bool, // Enable managing RRs and RMs. These require the timelineSet to have a room. - manageReadReceipts: React.PropTypes.bool, - manageReadMarkers: React.PropTypes.bool, + manageReadReceipts: PropTypes.bool, + manageReadMarkers: PropTypes.bool, // true to give the component a 'display: none' style. - hidden: React.PropTypes.bool, + hidden: PropTypes.bool, // ID of an event to highlight. If undefined, no event will be highlighted. // typically this will be either 'eventId' or undefined. - highlightedEventId: React.PropTypes.string, + highlightedEventId: PropTypes.string, // id of an event to jump to. If not given, will go to the end of the // live timeline. - eventId: React.PropTypes.string, + eventId: PropTypes.string, // where to position the event given by eventId, in pixels from the // bottom of the viewport. If not given, will try to put the event // half way down the viewport. - eventPixelOffset: React.PropTypes.number, + eventPixelOffset: PropTypes.number, // Should we show URL Previews - showUrlPreview: React.PropTypes.bool, + showUrlPreview: PropTypes.bool, // callback which is called when the panel is scrolled. - onScroll: React.PropTypes.func, + onScroll: PropTypes.func, // callback which is called when the read-up-to mark is updated. - onReadMarkerUpdated: React.PropTypes.func, + onReadMarkerUpdated: PropTypes.func, // maximum number of events to show in a timeline - timelineCap: React.PropTypes.number, + timelineCap: PropTypes.number, // classname to use for the messagepanel - className: React.PropTypes.string, + className: PropTypes.string, // shape property to be passed to EventTiles - tileShape: React.PropTypes.string, + tileShape: PropTypes.string, // placeholder text to use if the timeline is empty - empty: React.PropTypes.string, + empty: PropTypes.string, }, statics: { @@ -301,6 +302,8 @@ var TimelinePanel = React.createClass({ // set off a pagination request. onMessageListFillRequest: function(backwards) { + if (!this._shouldPaginate()) return Promise.resolve(false); + const dir = backwards ? EventTimeline.BACKWARDS : EventTimeline.FORWARDS; const canPaginateKey = backwards ? 'canBackPaginate' : 'canForwardPaginate'; const paginatingKey = backwards ? 'backPaginating' : 'forwardPaginating'; @@ -1090,6 +1093,17 @@ var TimelinePanel = React.createClass({ }, this.props.onReadMarkerUpdated); }, + _shouldPaginate: function() { + // don't try to paginate while events in the timeline are + // still being decrypted. We don't render events while they're + // being decrypted, so they don't take up space in the timeline. + // This means we can pull quite a lot of events into the timeline + // and end up trying to render a lot of events. + return !this.state.events.some((e) => { + return e.isBeingDecrypted(); + }); + }, + render: function() { const MessagePanel = sdk.getComponent("structures.MessagePanel"); const Loader = sdk.getComponent("elements.Spinner"); @@ -1107,9 +1121,9 @@ var TimelinePanel = React.createClass({ // exist. if (this.state.timelineLoading) { return ( -
    - -
    +
    + +
    ); } diff --git a/src/components/structures/UploadBar.js b/src/components/structures/UploadBar.js index ca566d3a64..fed4ff33b3 100644 --- a/src/components/structures/UploadBar.js +++ b/src/components/structures/UploadBar.js @@ -15,6 +15,7 @@ limitations under the License. */ const React = require('react'); +import PropTypes from 'prop-types'; const ContentMessages = require('../../ContentMessages'); const dis = require('../../dispatcher'); const filesize = require('filesize'); @@ -22,7 +23,7 @@ import { _t } from '../../languageHandler'; module.exports = React.createClass({displayName: 'UploadBar', propTypes: { - room: React.PropTypes.object, + room: PropTypes.object, }, componentDidMount: function() { diff --git a/src/components/structures/UserSettings.js b/src/components/structures/UserSettings.js index 09844c3d63..b1eedd1a90 100644 --- a/src/components/structures/UserSettings.js +++ b/src/components/structures/UserSettings.js @@ -19,6 +19,7 @@ import SettingsStore, {SettingLevel} from "../../settings/SettingsStore"; const React = require('react'); const ReactDOM = require('react-dom'); +import PropTypes from 'prop-types'; const sdk = require('../../index'); const MatrixClientPeg = require("../../MatrixClientPeg"); const PlatformPeg = require("../../PlatformPeg"); @@ -125,8 +126,8 @@ const THEMES = [ const IgnoredUser = React.createClass({ propTypes: { - userId: React.PropTypes.string.isRequired, - onUnignored: React.PropTypes.func.isRequired, + userId: PropTypes.string.isRequired, + onUnignored: PropTypes.func.isRequired, }, _onUnignoreClick: function() { @@ -155,16 +156,16 @@ module.exports = React.createClass({ displayName: 'UserSettings', propTypes: { - onClose: React.PropTypes.func, + onClose: PropTypes.func, // The brand string given when creating email pushers - brand: React.PropTypes.string, + brand: PropTypes.string, // The base URL to use in the referral link. Defaults to window.location.origin. - referralBaseUrl: React.PropTypes.string, + referralBaseUrl: PropTypes.string, // Team token for the referral link. If falsy, the referral section will // not appear - teamToken: React.PropTypes.string, + teamToken: PropTypes.string, }, getDefaultProps: function() { @@ -375,7 +376,7 @@ module.exports = React.createClass({ { _t("For security, logging out will delete any end-to-end " + "encryption keys from this browser. If you want to be able " + "to decrypt your conversation history from future Riot sessions, " + - "please export your room keys for safe-keeping.") }. + "please export your room keys for safe-keeping.") }
    , button: _t("Sign out"), extraButtons: [ @@ -811,6 +812,12 @@ module.exports = React.createClass({

    { _t('Analytics') }

    { _t('Riot collects anonymous analytics to allow us to improve the application.') } +
    + { _t('Privacy is important to us, so we don\'t collect any personal' + + ' or identifiable data for our analytics.') } +
    + { _t('Learn more about how we use analytics.') } +
    { ANALYTICS_SETTINGS.map( this._renderDeviceSetting ) }
    ; diff --git a/src/components/structures/login/ForgotPassword.js b/src/components/structures/login/ForgotPassword.js index 43753bfd38..53688ee6c3 100644 --- a/src/components/structures/login/ForgotPassword.js +++ b/src/components/structures/login/ForgotPassword.js @@ -18,6 +18,7 @@ limitations under the License. 'use strict'; import React from 'react'; +import PropTypes from 'prop-types'; import { _t } from '../../../languageHandler'; import sdk from '../../../index'; import Modal from "../../../Modal"; @@ -29,13 +30,13 @@ module.exports = React.createClass({ displayName: 'ForgotPassword', propTypes: { - defaultHsUrl: React.PropTypes.string, - defaultIsUrl: React.PropTypes.string, - customHsUrl: React.PropTypes.string, - customIsUrl: React.PropTypes.string, - onLoginClick: React.PropTypes.func, - onRegisterClick: React.PropTypes.func, - onComplete: React.PropTypes.func.isRequired, + defaultHsUrl: PropTypes.string, + defaultIsUrl: PropTypes.string, + customHsUrl: PropTypes.string, + customIsUrl: PropTypes.string, + onLoginClick: PropTypes.func, + onRegisterClick: PropTypes.func, + onComplete: PropTypes.func.isRequired, }, getInitialState: function() { diff --git a/src/components/structures/login/Login.js b/src/components/structures/login/Login.js index 9ed710534b..7f4aa0325a 100644 --- a/src/components/structures/login/Login.js +++ b/src/components/structures/login/Login.js @@ -18,6 +18,7 @@ limitations under the License. 'use strict'; import React from 'react'; +import PropTypes from 'prop-types'; import { _t } from '../../../languageHandler'; import * as languageHandler from '../../../languageHandler'; import sdk from '../../../index'; @@ -36,27 +37,28 @@ module.exports = React.createClass({ displayName: 'Login', propTypes: { - onLoggedIn: React.PropTypes.func.isRequired, + onLoggedIn: PropTypes.func.isRequired, - enableGuest: React.PropTypes.bool, + enableGuest: PropTypes.bool, - customHsUrl: React.PropTypes.string, - customIsUrl: React.PropTypes.string, - defaultHsUrl: React.PropTypes.string, - defaultIsUrl: React.PropTypes.string, + customHsUrl: PropTypes.string, + customIsUrl: PropTypes.string, + defaultHsUrl: PropTypes.string, + defaultIsUrl: PropTypes.string, // Secondary HS which we try to log into if the user is using // the default HS but login fails. Useful for migrating to a // different home server without confusing users. - fallbackHsUrl: React.PropTypes.string, + fallbackHsUrl: PropTypes.string, - defaultDeviceDisplayName: React.PropTypes.string, + defaultDeviceDisplayName: PropTypes.string, // login shouldn't know or care how registration is done. - onRegisterClick: React.PropTypes.func.isRequired, + onRegisterClick: PropTypes.func.isRequired, // login shouldn't care how password recovery is done. - onForgotPasswordClick: React.PropTypes.func, - onCancelClick: React.PropTypes.func, + onForgotPasswordClick: PropTypes.func, + onCancelClick: PropTypes.func, + onServerConfigChange: PropTypes.func.isRequired, }, getInitialState: function() { @@ -217,6 +219,8 @@ module.exports = React.createClass({ if (config.isUrl !== undefined) { newState.enteredIdentityServerUrl = config.isUrl; } + + this.props.onServerConfigChange(config); this.setState(newState, function() { self._initLoginLogic(config.hsUrl || null, config.isUrl); }); @@ -427,10 +431,10 @@ module.exports = React.createClass({ // FIXME: remove status.im theme tweaks const theme = SettingsStore.getValue("theme"); if (theme !== "status") { - header =

    { _t('Sign in') }

    ; + header =

    { _t('Sign in') } { loader }

    ; } else { if (!this.state.errorText) { - header =

    { _t('Sign in to get started') }

    ; + header =

    { _t('Sign in to get started') } { loader }

    ; } } diff --git a/src/components/structures/login/PostRegistration.js b/src/components/structures/login/PostRegistration.js index 184356e852..f6165348bd 100644 --- a/src/components/structures/login/PostRegistration.js +++ b/src/components/structures/login/PostRegistration.js @@ -17,6 +17,7 @@ limitations under the License. 'use strict'; import React from 'react'; +import PropTypes from 'prop-types'; import sdk from '../../../index'; import MatrixClientPeg from '../../../MatrixClientPeg'; import { _t } from '../../../languageHandler'; @@ -25,7 +26,7 @@ module.exports = React.createClass({ displayName: 'PostRegistration', propTypes: { - onComplete: React.PropTypes.func.isRequired, + onComplete: PropTypes.func.isRequired, }, getInitialState: function() { diff --git a/src/components/structures/login/Registration.js b/src/components/structures/login/Registration.js index e57b7fd0c2..62a3ee4f68 100644 --- a/src/components/structures/login/Registration.js +++ b/src/components/structures/login/Registration.js @@ -19,6 +19,7 @@ import Matrix from 'matrix-js-sdk'; import Promise from 'bluebird'; import React from 'react'; +import PropTypes from 'prop-types'; import sdk from '../../../index'; import ServerConfig from '../../views/login/ServerConfig'; @@ -35,31 +36,32 @@ module.exports = React.createClass({ displayName: 'Registration', propTypes: { - onLoggedIn: React.PropTypes.func.isRequired, - clientSecret: React.PropTypes.string, - sessionId: React.PropTypes.string, - makeRegistrationUrl: React.PropTypes.func.isRequired, - idSid: React.PropTypes.string, - customHsUrl: React.PropTypes.string, - customIsUrl: React.PropTypes.string, - defaultHsUrl: React.PropTypes.string, - defaultIsUrl: React.PropTypes.string, - brand: React.PropTypes.string, - email: React.PropTypes.string, - referrer: React.PropTypes.string, - teamServerConfig: React.PropTypes.shape({ + onLoggedIn: PropTypes.func.isRequired, + clientSecret: PropTypes.string, + sessionId: PropTypes.string, + makeRegistrationUrl: PropTypes.func.isRequired, + idSid: PropTypes.string, + customHsUrl: PropTypes.string, + customIsUrl: PropTypes.string, + defaultHsUrl: PropTypes.string, + defaultIsUrl: PropTypes.string, + brand: PropTypes.string, + email: PropTypes.string, + referrer: PropTypes.string, + teamServerConfig: PropTypes.shape({ // Email address to request new teams - supportEmail: React.PropTypes.string.isRequired, + supportEmail: PropTypes.string.isRequired, // URL of the riot-team-server to get team configurations and track referrals - teamServerURL: React.PropTypes.string.isRequired, + teamServerURL: PropTypes.string.isRequired, }), - teamSelected: React.PropTypes.object, + teamSelected: PropTypes.object, - defaultDeviceDisplayName: React.PropTypes.string, + defaultDeviceDisplayName: PropTypes.string, // registration shouldn't know or care how login is done. - onLoginClick: React.PropTypes.func.isRequired, - onCancelClick: React.PropTypes.func, + onLoginClick: PropTypes.func.isRequired, + onCancelClick: PropTypes.func, + onServerConfigChange: PropTypes.func.isRequired, }, getInitialState: function() { @@ -130,6 +132,7 @@ module.exports = React.createClass({ if (config.isUrl !== undefined) { newState.isUrl = config.isUrl; } + this.props.onServerConfigChange(config); this.setState(newState, function() { this._replaceClient(); }); diff --git a/src/components/views/avatars/BaseAvatar.js b/src/components/views/avatars/BaseAvatar.js index f68e98ec3d..5735a99125 100644 --- a/src/components/views/avatars/BaseAvatar.js +++ b/src/components/views/avatars/BaseAvatar.js @@ -15,6 +15,8 @@ limitations under the License. */ import React from 'react'; +import PropTypes from 'prop-types'; +import { MatrixClient } from 'matrix-js-sdk'; import AvatarLogic from '../../../Avatar'; import sdk from '../../../index'; import AccessibleButton from '../elements/AccessibleButton'; @@ -23,16 +25,20 @@ module.exports = React.createClass({ displayName: 'BaseAvatar', propTypes: { - name: React.PropTypes.string.isRequired, // The name (first initial used as default) - idName: React.PropTypes.string, // ID for generating hash colours - title: React.PropTypes.string, // onHover title text - url: React.PropTypes.string, // highest priority of them all, shortcut to set in urls[0] - urls: React.PropTypes.array, // [highest_priority, ... , lowest_priority] - width: React.PropTypes.number, - height: React.PropTypes.number, + name: PropTypes.string.isRequired, // The name (first initial used as default) + idName: PropTypes.string, // ID for generating hash colours + title: PropTypes.string, // onHover title text + url: PropTypes.string, // highest priority of them all, shortcut to set in urls[0] + urls: PropTypes.array, // [highest_priority, ... , lowest_priority] + width: PropTypes.number, + height: PropTypes.number, // XXX resizeMethod not actually used. - resizeMethod: React.PropTypes.string, - defaultToInitialLetter: React.PropTypes.bool, // true to add default url + resizeMethod: PropTypes.string, + defaultToInitialLetter: PropTypes.bool, // true to add default url + }, + + contextTypes: { + matrixClient: PropTypes.instanceOf(MatrixClient), }, getDefaultProps: function() { @@ -48,6 +54,16 @@ module.exports = React.createClass({ return this._getState(this.props); }, + componentWillMount() { + this.unmounted = false; + this.context.matrixClient.on('sync', this.onClientSync); + }, + + componentWillUnmount() { + this.unmounted = true; + this.context.matrixClient.removeListener('sync', this.onClientSync); + }, + componentWillReceiveProps: function(nextProps) { // work out if we need to call setState (if the image URLs array has changed) const newState = this._getState(nextProps); @@ -66,6 +82,23 @@ module.exports = React.createClass({ } }, + onClientSync(syncState, prevState) { + if (this.unmounted) return; + + // Consider the client reconnected if there is no error with syncing. + // This means the state could be RECONNECTING, SYNCING or PREPARED. + const reconnected = syncState !== "ERROR" && prevState !== syncState; + if (reconnected && + // Did we fall back? + this.state.urlsIndex > 0 + ) { + // Start from the highest priority URL again + this.setState({ + urlsIndex: 0, + }); + } + }, + _getState: function(props) { // work out the full set of urls to try to load. This is formed like so: // imageUrls: [ props.url, props.urls, default image ] diff --git a/src/components/views/avatars/MemberAvatar.js b/src/components/views/avatars/MemberAvatar.js index 89047cd69c..a4fe5e280f 100644 --- a/src/components/views/avatars/MemberAvatar.js +++ b/src/components/views/avatars/MemberAvatar.js @@ -17,6 +17,7 @@ limitations under the License. 'use strict'; const React = require('react'); +import PropTypes from 'prop-types'; const Avatar = require('../../../Avatar'); const sdk = require("../../../index"); const dispatcher = require("../../../dispatcher"); @@ -25,15 +26,15 @@ module.exports = React.createClass({ displayName: 'MemberAvatar', propTypes: { - member: React.PropTypes.object.isRequired, - width: React.PropTypes.number, - height: React.PropTypes.number, - resizeMethod: React.PropTypes.string, + member: PropTypes.object.isRequired, + width: PropTypes.number, + height: PropTypes.number, + resizeMethod: PropTypes.string, // The onClick to give the avatar - onClick: React.PropTypes.func, + onClick: PropTypes.func, // Whether the onClick of the avatar should be overriden to dispatch 'view_user' - viewUserOnClick: React.PropTypes.bool, - title: React.PropTypes.string, + viewUserOnClick: PropTypes.bool, + title: PropTypes.string, }, getDefaultProps: function() { diff --git a/src/components/views/avatars/MemberPresenceAvatar.js b/src/components/views/avatars/MemberPresenceAvatar.js index 49cfee2cff..aa6def00ae 100644 --- a/src/components/views/avatars/MemberPresenceAvatar.js +++ b/src/components/views/avatars/MemberPresenceAvatar.js @@ -17,6 +17,7 @@ 'use strict'; import React from "react"; +import PropTypes from 'prop-types'; import * as sdk from "../../../index"; import MatrixClientPeg from "../../../MatrixClientPeg"; import AccessibleButton from '../elements/AccessibleButton'; @@ -30,10 +31,10 @@ module.exports = React.createClass({ displayName: 'MemberPresenceAvatar', propTypes: { - member: React.PropTypes.object.isRequired, - width: React.PropTypes.number, - height: React.PropTypes.number, - resizeMethod: React.PropTypes.string, + member: PropTypes.object.isRequired, + width: PropTypes.number, + height: PropTypes.number, + resizeMethod: PropTypes.string, }, getDefaultProps: function() { diff --git a/src/components/views/avatars/RoomAvatar.js b/src/components/views/avatars/RoomAvatar.js index 11554b2379..cae02ac408 100644 --- a/src/components/views/avatars/RoomAvatar.js +++ b/src/components/views/avatars/RoomAvatar.js @@ -14,6 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ import React from "react"; +import PropTypes from 'prop-types'; import {ContentRepo} from "matrix-js-sdk"; import MatrixClientPeg from "../../../MatrixClientPeg"; import sdk from "../../../index"; @@ -25,11 +26,11 @@ module.exports = React.createClass({ // oobData.avatarUrl should be set (else there // would be nowhere to get the avatar from) propTypes: { - room: React.PropTypes.object, - oobData: React.PropTypes.object, - width: React.PropTypes.number, - height: React.PropTypes.number, - resizeMethod: React.PropTypes.string, + room: PropTypes.object, + oobData: PropTypes.object, + width: PropTypes.number, + height: PropTypes.number, + resizeMethod: PropTypes.string, }, getDefaultProps: function() { diff --git a/src/components/views/create_room/CreateRoomButton.js b/src/components/views/create_room/CreateRoomButton.js index 8a5f00d942..25f71f542d 100644 --- a/src/components/views/create_room/CreateRoomButton.js +++ b/src/components/views/create_room/CreateRoomButton.js @@ -17,11 +17,12 @@ limitations under the License. 'use strict'; import React from 'react'; +import PropTypes from 'prop-types'; import { _t } from '../../../languageHandler'; module.exports = React.createClass({ displayName: 'CreateRoomButton', propTypes: { - onCreateRoom: React.PropTypes.func, + onCreateRoom: PropTypes.func, }, getDefaultProps: function() { diff --git a/src/components/views/create_room/Presets.js b/src/components/views/create_room/Presets.js index 2073896d87..c9607c0082 100644 --- a/src/components/views/create_room/Presets.js +++ b/src/components/views/create_room/Presets.js @@ -17,6 +17,7 @@ limitations under the License. 'use strict'; const React = require('react'); +import PropTypes from 'prop-types'; import { _t } from '../../../languageHandler'; const Presets = { @@ -28,8 +29,8 @@ const Presets = { module.exports = React.createClass({ displayName: 'CreateRoomPresets', propTypes: { - onChange: React.PropTypes.func, - preset: React.PropTypes.string, + onChange: PropTypes.func, + preset: PropTypes.string, }, Presets: Presets, diff --git a/src/components/views/create_room/RoomAlias.js b/src/components/views/create_room/RoomAlias.js index d4228a8bca..6262db7833 100644 --- a/src/components/views/create_room/RoomAlias.js +++ b/src/components/views/create_room/RoomAlias.js @@ -15,6 +15,7 @@ limitations under the License. */ const React = require('react'); +import PropTypes from 'prop-types'; import { _t } from '../../../languageHandler'; module.exports = React.createClass({ @@ -22,9 +23,9 @@ module.exports = React.createClass({ propTypes: { // Specifying a homeserver will make magical things happen when you, // e.g. start typing in the room alias box. - homeserver: React.PropTypes.string, - alias: React.PropTypes.string, - onChange: React.PropTypes.func, + homeserver: PropTypes.string, + alias: PropTypes.string, + onChange: PropTypes.func, }, getDefaultProps: function() { diff --git a/src/components/views/dialogs/AddressPickerDialog.js b/src/components/views/dialogs/AddressPickerDialog.js index 837d2f5349..685c4fcde3 100644 --- a/src/components/views/dialogs/AddressPickerDialog.js +++ b/src/components/views/dialogs/AddressPickerDialog.js @@ -20,7 +20,6 @@ import PropTypes from 'prop-types'; import { _t } from '../../../languageHandler'; import sdk from '../../../index'; import MatrixClientPeg from '../../../MatrixClientPeg'; -import AccessibleButton from '../elements/AccessibleButton'; import Promise from 'bluebird'; import { addressTypes, getAddressType } from '../../../UserAddress.js'; import GroupStoreCache from '../../../stores/GroupStoreCache'; @@ -507,7 +506,8 @@ module.exports = React.createClass({ }, render: function() { - const TintableSvg = sdk.getComponent("elements.TintableSvg"); + const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); + const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); const AddressSelector = sdk.getComponent("elements.AddressSelector"); this.scrollElement = null; @@ -580,14 +580,8 @@ module.exports = React.createClass({ } return ( -
    -
    - { this.props.title } -
    - - - +
    @@ -597,12 +591,10 @@ module.exports = React.createClass({ { addressSelector } { this.props.extraNode }
    -
    - -
    -
    + + ); }, }); diff --git a/src/components/views/dialogs/BaseDialog.js b/src/components/views/dialogs/BaseDialog.js index b88a6c026e..66e5fcb0c0 100644 --- a/src/components/views/dialogs/BaseDialog.js +++ b/src/components/views/dialogs/BaseDialog.js @@ -15,10 +15,14 @@ limitations under the License. */ import React from 'react'; +import PropTypes from 'prop-types'; + +import { MatrixClient } from 'matrix-js-sdk'; import { KeyCode } from '../../../Keyboard'; import AccessibleButton from '../elements/AccessibleButton'; import sdk from '../../../index'; +import MatrixClientPeg from '../../../MatrixClientPeg'; /** * Basic container for modal dialogs. @@ -31,23 +35,43 @@ export default React.createClass({ propTypes: { // onFinished callback to call when Escape is pressed - onFinished: React.PropTypes.func.isRequired, + onFinished: PropTypes.func.isRequired, // callback to call when Enter is pressed - onEnterPressed: React.PropTypes.func, + onEnterPressed: PropTypes.func, + + // called when a key is pressed + onKeyDown: PropTypes.func, // CSS class to apply to dialog div - className: React.PropTypes.string, + className: PropTypes.string, // Title for the dialog. // (could probably actually be something more complicated than a string if desired) - title: React.PropTypes.string.isRequired, + title: PropTypes.string.isRequired, // children should be the content of the dialog - children: React.PropTypes.node, + children: PropTypes.node, + }, + + childContextTypes: { + matrixClient: PropTypes.instanceOf(MatrixClient), + }, + + getChildContext: function() { + return { + matrixClient: this._matrixClient, + }; + }, + + componentWillMount() { + this._matrixClient = MatrixClientPeg.get(); }, _onKeyDown: function(e) { + if (this.props.onKeyDown) { + this.props.onKeyDown(e); + } if (e.keyCode === KeyCode.ESCAPE) { e.stopPropagation(); e.preventDefault(); @@ -75,7 +99,7 @@ export default React.createClass({ >
    -
    +
    { this.props.title }
    { this.props.children } diff --git a/src/components/views/dialogs/ChatCreateOrReuseDialog.js b/src/components/views/dialogs/ChatCreateOrReuseDialog.js index e0578f3b53..dc4f3f77db 100644 --- a/src/components/views/dialogs/ChatCreateOrReuseDialog.js +++ b/src/components/views/dialogs/ChatCreateOrReuseDialog.js @@ -15,6 +15,7 @@ limitations under the License. */ import React from 'react'; +import PropTypes from 'prop-types'; import sdk from '../../../index'; import { _t } from '../../../languageHandler'; import MatrixClientPeg from '../../../MatrixClientPeg'; @@ -137,6 +138,7 @@ export default class ChatCreateOrReuseDialog extends React.Component { } else { // Show the avatar, name and a button to confirm that a new chat is requested const BaseAvatar = sdk.getComponent('avatars.BaseAvatar'); + const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); const Spinner = sdk.getComponent('elements.Spinner'); title = _t('Start chatting'); @@ -166,11 +168,8 @@ export default class ChatCreateOrReuseDialog extends React.Component {

    { profile }
    -
    - -
    +
    ; } @@ -187,9 +186,9 @@ export default class ChatCreateOrReuseDialog extends React.Component { } ChatCreateOrReuseDialog.propTyps = { - userId: React.PropTypes.string.isRequired, + userId: PropTypes.string.isRequired, // Called when clicking outside of the dialog - onFinished: React.PropTypes.func.isRequired, - onNewDMClick: React.PropTypes.func.isRequired, - onExistingRoomSelected: React.PropTypes.func.isRequired, + onFinished: PropTypes.func.isRequired, + onNewDMClick: PropTypes.func.isRequired, + onExistingRoomSelected: PropTypes.func.isRequired, }; diff --git a/src/components/views/dialogs/ConfirmUserActionDialog.js b/src/components/views/dialogs/ConfirmUserActionDialog.js index 78d084b709..f347261470 100644 --- a/src/components/views/dialogs/ConfirmUserActionDialog.js +++ b/src/components/views/dialogs/ConfirmUserActionDialog.js @@ -15,10 +15,10 @@ limitations under the License. */ import React from 'react'; +import PropTypes from 'prop-types'; import { MatrixClient } from 'matrix-js-sdk'; import sdk from '../../../index'; import { _t } from '../../../languageHandler'; -import classnames from 'classnames'; import { GroupMemberType } from '../../../groups'; /* @@ -33,20 +33,20 @@ export default React.createClass({ displayName: 'ConfirmUserActionDialog', propTypes: { // matrix-js-sdk (room) member object. Supply either this or 'groupMember' - member: React.PropTypes.object, + member: PropTypes.object, // group member object. Supply either this or 'member' groupMember: GroupMemberType, // needed if a group member is specified - matrixClient: React.PropTypes.instanceOf(MatrixClient), - action: React.PropTypes.string.isRequired, // eg. 'Ban' - title: React.PropTypes.string.isRequired, // eg. 'Ban this user?' + matrixClient: PropTypes.instanceOf(MatrixClient), + action: PropTypes.string.isRequired, // eg. 'Ban' + title: PropTypes.string.isRequired, // eg. 'Ban this user?' // Whether to display a text field for a reason // If true, the second argument to onFinished will // be the string entered. - askReason: React.PropTypes.bool, - danger: React.PropTypes.bool, - onFinished: React.PropTypes.func.isRequired, + askReason: PropTypes.bool, + danger: PropTypes.bool, + onFinished: PropTypes.func.isRequired, }, defaultProps: { @@ -76,13 +76,11 @@ export default React.createClass({ render: function() { const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); + const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); const MemberAvatar = sdk.getComponent("views.avatars.MemberAvatar"); const BaseAvatar = sdk.getComponent("views.avatars.BaseAvatar"); - const confirmButtonClass = classnames({ - 'mx_Dialog_primary': true, - 'danger': this.props.danger, - }); + const confirmButtonClass = this.props.danger ? 'danger' : ''; let reasonBox; if (this.props.askReason) { @@ -127,17 +125,11 @@ export default React.createClass({
    { userId }
    { reasonBox } -
    - - - -
    + ); }, diff --git a/src/components/views/dialogs/CreateGroupDialog.js b/src/components/views/dialogs/CreateGroupDialog.js index 168fe75947..86a2b2498c 100644 --- a/src/components/views/dialogs/CreateGroupDialog.js +++ b/src/components/views/dialogs/CreateGroupDialog.js @@ -55,11 +55,15 @@ export default React.createClass({ _checkGroupId: function(e) { let error = null; - if (!/^[a-z0-9=_\-\.\/]*$/.test(this.state.groupId)) { + if (!this.state.groupId) { + error = _t("Community IDs cannot not be empty."); + } else if (!/^[a-z0-9=_\-\.\/]*$/.test(this.state.groupId)) { error = _t("Community IDs may only contain characters a-z, 0-9, or '=_-./'"); } this.setState({ groupIdError: error, + // Reset createError to get rid of now stale error message + createError: null, }); return error; }, @@ -159,10 +163,10 @@ export default React.createClass({ { createErrorNode }
    + -
    diff --git a/src/components/views/dialogs/CreateRoomDialog.js b/src/components/views/dialogs/CreateRoomDialog.js index f7be47b3eb..d9287d23da 100644 --- a/src/components/views/dialogs/CreateRoomDialog.js +++ b/src/components/views/dialogs/CreateRoomDialog.js @@ -15,6 +15,7 @@ limitations under the License. */ import React from 'react'; +import PropTypes from 'prop-types'; import sdk from '../../../index'; import SdkConfig from '../../../SdkConfig'; import { _t } from '../../../languageHandler'; @@ -22,7 +23,7 @@ import { _t } from '../../../languageHandler'; export default React.createClass({ displayName: 'CreateRoomDialog', propTypes: { - onFinished: React.PropTypes.func.isRequired, + onFinished: PropTypes.func.isRequired, }, componentDidMount: function() { @@ -41,6 +42,7 @@ export default React.createClass({ render: function() { const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); + const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); return (
    -
    - - -
    + ); }, diff --git a/src/components/views/dialogs/DeactivateAccountDialog.js b/src/components/views/dialogs/DeactivateAccountDialog.js index c45e072d72..87228b4733 100644 --- a/src/components/views/dialogs/DeactivateAccountDialog.js +++ b/src/components/views/dialogs/DeactivateAccountDialog.js @@ -15,6 +15,7 @@ limitations under the License. */ import React from 'react'; +import PropTypes from 'prop-types'; import sdk from '../../../index'; import Analytics from '../../../Analytics'; @@ -77,6 +78,7 @@ export default class DeactivateAccountDialog extends React.Component { } render() { + const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const Loader = sdk.getComponent("elements.Spinner"); let passwordBoxClass = ''; @@ -99,10 +101,11 @@ export default class DeactivateAccountDialog extends React.Component { } return ( -
    -
    - { _t("Deactivate Account") } -
    +

    { _t("This will make your account permanently unusable. You will not be able to re-register the same user ID.") }

    @@ -130,11 +133,11 @@ export default class DeactivateAccountDialog extends React.Component { { cancelButton }
    -
    + ); } } DeactivateAccountDialog.propTypes = { - onFinished: React.PropTypes.func.isRequired, + onFinished: PropTypes.func.isRequired, }; diff --git a/src/components/views/dialogs/DeviceVerifyDialog.js b/src/components/views/dialogs/DeviceVerifyDialog.js index ba31d2a8c2..6bec933389 100644 --- a/src/components/views/dialogs/DeviceVerifyDialog.js +++ b/src/components/views/dialogs/DeviceVerifyDialog.js @@ -16,6 +16,7 @@ limitations under the License. */ import React from 'react'; +import PropTypes from 'prop-types'; import MatrixClientPeg from '../../../MatrixClientPeg'; import sdk from '../../../index'; import * as FormattingUtils from '../../../utils/FormattingUtils'; @@ -71,7 +72,7 @@ export default function DeviceVerifyDialog(props) { } DeviceVerifyDialog.propTypes = { - userId: React.PropTypes.string.isRequired, - device: React.PropTypes.object.isRequired, - onFinished: React.PropTypes.func.isRequired, + userId: PropTypes.string.isRequired, + device: PropTypes.object.isRequired, + onFinished: PropTypes.func.isRequired, }; diff --git a/src/components/views/dialogs/ErrorDialog.js b/src/components/views/dialogs/ErrorDialog.js index 97ed47e10f..2af2d6214f 100644 --- a/src/components/views/dialogs/ErrorDialog.js +++ b/src/components/views/dialogs/ErrorDialog.js @@ -26,20 +26,21 @@ limitations under the License. */ import React from 'react'; +import PropTypes from 'prop-types'; import sdk from '../../../index'; import { _t } from '../../../languageHandler'; export default React.createClass({ displayName: 'ErrorDialog', propTypes: { - title: React.PropTypes.string, - description: React.PropTypes.oneOfType([ - React.PropTypes.element, - React.PropTypes.string, + title: PropTypes.string, + description: PropTypes.oneOfType([ + PropTypes.element, + PropTypes.string, ]), - button: React.PropTypes.string, - focus: React.PropTypes.bool, - onFinished: React.PropTypes.func.isRequired, + button: PropTypes.string, + focus: PropTypes.bool, + onFinished: PropTypes.func.isRequired, }, getDefaultProps: function() { diff --git a/src/components/views/dialogs/InteractiveAuthDialog.js b/src/components/views/dialogs/InteractiveAuthDialog.js index 59de7c7f59..a47702305c 100644 --- a/src/components/views/dialogs/InteractiveAuthDialog.js +++ b/src/components/views/dialogs/InteractiveAuthDialog.js @@ -16,6 +16,7 @@ limitations under the License. */ import React from 'react'; +import PropTypes from 'prop-types'; import sdk from '../../../index'; import { _t } from '../../../languageHandler'; @@ -27,22 +28,22 @@ export default React.createClass({ propTypes: { // matrix client to use for UI auth requests - matrixClient: React.PropTypes.object.isRequired, + matrixClient: PropTypes.object.isRequired, // response from initial request. If not supplied, will do a request on // mount. - authData: React.PropTypes.shape({ - flows: React.PropTypes.array, - params: React.PropTypes.object, - session: React.PropTypes.string, + authData: PropTypes.shape({ + flows: PropTypes.array, + params: PropTypes.object, + session: PropTypes.string, }), // callback - makeRequest: React.PropTypes.func.isRequired, + makeRequest: PropTypes.func.isRequired, - onFinished: React.PropTypes.func.isRequired, + onFinished: PropTypes.func.isRequired, - title: React.PropTypes.string, + title: PropTypes.string, }, getInitialState: function() { diff --git a/src/components/views/dialogs/KeyShareDialog.js b/src/components/views/dialogs/KeyShareDialog.js index 9c8be27c89..00bcc942a1 100644 --- a/src/components/views/dialogs/KeyShareDialog.js +++ b/src/components/views/dialogs/KeyShareDialog.js @@ -16,6 +16,7 @@ limitations under the License. import Modal from '../../../Modal'; import React from 'react'; +import PropTypes from 'prop-types'; import sdk from '../../../index'; import { _t, _td } from '../../../languageHandler'; @@ -30,10 +31,10 @@ import { _t, _td } from '../../../languageHandler'; */ export default React.createClass({ propTypes: { - matrixClient: React.PropTypes.object.isRequired, - userId: React.PropTypes.string.isRequired, - deviceId: React.PropTypes.string.isRequired, - onFinished: React.PropTypes.func.isRequired, + matrixClient: PropTypes.object.isRequired, + userId: PropTypes.string.isRequired, + deviceId: PropTypes.string.isRequired, + onFinished: PropTypes.func.isRequired, }, getInitialState: function() { diff --git a/src/components/views/dialogs/QuestionDialog.js b/src/components/views/dialogs/QuestionDialog.js index 339b284e2f..6cfe0babcb 100644 --- a/src/components/views/dialogs/QuestionDialog.js +++ b/src/components/views/dialogs/QuestionDialog.js @@ -16,20 +16,20 @@ limitations under the License. */ import React from 'react'; +import PropTypes from 'prop-types'; import sdk from '../../../index'; import { _t } from '../../../languageHandler'; -import classnames from 'classnames'; export default React.createClass({ displayName: 'QuestionDialog', propTypes: { - title: React.PropTypes.string, - description: React.PropTypes.node, - extraButtons: React.PropTypes.node, - button: React.PropTypes.string, - danger: React.PropTypes.bool, - focus: React.PropTypes.bool, - onFinished: React.PropTypes.func.isRequired, + title: PropTypes.string, + description: PropTypes.node, + extraButtons: PropTypes.node, + button: PropTypes.string, + danger: PropTypes.bool, + focus: PropTypes.bool, + onFinished: PropTypes.func.isRequired, }, getDefaultProps: function() { @@ -53,15 +53,11 @@ export default React.createClass({ render: function() { const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); - const cancelButton = this.props.hasCancelButton ? ( - - ) : null; - const buttonClasses = classnames({ - mx_Dialog_primary: true, - danger: this.props.danger, - }); + const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); + let primaryButtonClass = ""; + if (this.props.danger) { + primaryButtonClass = "danger"; + } return ( { this.props.description }
    -
    - + { this.props.extraButtons } - { cancelButton } -
    + ); }, diff --git a/src/components/views/dialogs/SessionRestoreErrorDialog.js b/src/components/views/dialogs/SessionRestoreErrorDialog.js index 75ae0eda17..77f31a8d80 100644 --- a/src/components/views/dialogs/SessionRestoreErrorDialog.js +++ b/src/components/views/dialogs/SessionRestoreErrorDialog.js @@ -15,6 +15,7 @@ limitations under the License. */ import React from 'react'; +import PropTypes from 'prop-types'; import sdk from '../../../index'; import SdkConfig from '../../../SdkConfig'; import Modal from '../../../Modal'; @@ -25,8 +26,8 @@ export default React.createClass({ displayName: 'SessionRestoreErrorDialog', propTypes: { - error: React.PropTypes.string.isRequired, - onFinished: React.PropTypes.func.isRequired, + error: PropTypes.string.isRequired, + onFinished: PropTypes.func.isRequired, }, _sendBugReport: function() { @@ -40,6 +41,7 @@ export default React.createClass({ render: function() { const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); + const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); let bugreport; if (SdkConfig.get().bug_report_endpoint_url) { @@ -68,11 +70,9 @@ export default React.createClass({ { bugreport }
    -
    - -
    + ); }, diff --git a/src/components/views/dialogs/SetEmailDialog.js b/src/components/views/dialogs/SetEmailDialog.js index 2dd996953d..c00cc1122b 100644 --- a/src/components/views/dialogs/SetEmailDialog.js +++ b/src/components/views/dialogs/SetEmailDialog.js @@ -15,6 +15,7 @@ limitations under the License. */ import React from 'react'; +import PropTypes from 'prop-types'; import sdk from '../../../index'; import Email from '../../../email'; import AddThreepid from '../../../AddThreepid'; @@ -30,7 +31,7 @@ import Modal from '../../../Modal'; export default React.createClass({ displayName: 'SetEmailDialog', propTypes: { - onFinished: React.PropTypes.func.isRequired, + onFinished: PropTypes.func.isRequired, }, getInitialState: function() { diff --git a/src/components/views/dialogs/SetMxIdDialog.js b/src/components/views/dialogs/SetMxIdDialog.js index 3ffafb0659..6ebc2eb87f 100644 --- a/src/components/views/dialogs/SetMxIdDialog.js +++ b/src/components/views/dialogs/SetMxIdDialog.js @@ -17,6 +17,7 @@ limitations under the License. import Promise from 'bluebird'; import React from 'react'; +import PropTypes from 'prop-types'; import sdk from '../../../index'; import MatrixClientPeg from '../../../MatrixClientPeg'; import classnames from 'classnames'; @@ -35,11 +36,11 @@ const USERNAME_CHECK_DEBOUNCE_MS = 250; export default React.createClass({ displayName: 'SetMxIdDialog', propTypes: { - onFinished: React.PropTypes.func.isRequired, + onFinished: PropTypes.func.isRequired, // Called when the user requests to register with a different homeserver - onDifferentServerClicked: React.PropTypes.func.isRequired, + onDifferentServerClicked: PropTypes.func.isRequired, // Called if the user wants to switch to login instead - onLoginClick: React.PropTypes.func.isRequired, + onLoginClick: PropTypes.func.isRequired, }, getInitialState: function() { diff --git a/src/components/views/dialogs/TextInputDialog.js b/src/components/views/dialogs/TextInputDialog.js index 5ea4191e5e..dadf7b7beb 100644 --- a/src/components/views/dialogs/TextInputDialog.js +++ b/src/components/views/dialogs/TextInputDialog.js @@ -15,21 +15,21 @@ limitations under the License. */ import React from 'react'; +import PropTypes from 'prop-types'; import sdk from '../../../index'; -import { _t } from '../../../languageHandler'; export default React.createClass({ displayName: 'TextInputDialog', propTypes: { - title: React.PropTypes.string, - description: React.PropTypes.oneOfType([ - React.PropTypes.element, - React.PropTypes.string, + title: PropTypes.string, + description: PropTypes.oneOfType([ + PropTypes.element, + PropTypes.string, ]), - value: React.PropTypes.string, - button: React.PropTypes.string, - focus: React.PropTypes.bool, - onFinished: React.PropTypes.func.isRequired, + value: PropTypes.string, + button: PropTypes.string, + focus: PropTypes.bool, + onFinished: PropTypes.func.isRequired, }, getDefaultProps: function() { @@ -58,6 +58,7 @@ export default React.createClass({ render: function() { const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); + const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); return ( -
    - - -
    +
    ); }, diff --git a/src/components/views/dialogs/UnknownDeviceDialog.js b/src/components/views/dialogs/UnknownDeviceDialog.js index 9c19ee6eca..eaf2537355 100644 --- a/src/components/views/dialogs/UnknownDeviceDialog.js +++ b/src/components/views/dialogs/UnknownDeviceDialog.js @@ -23,14 +23,7 @@ import GeminiScrollbar from 'react-gemini-scrollbar'; import Resend from '../../../Resend'; import { _t } from '../../../languageHandler'; import SettingsStore from "../../../settings/SettingsStore"; - -function markAllDevicesKnown(devices) { - Object.keys(devices).forEach((userId) => { - Object.keys(devices[userId]).map((deviceId) => { - MatrixClientPeg.get().setDeviceKnown(userId, deviceId, true); - }); - }); -} +import { markAllDevicesKnown } from '../../../cryptodevices'; function DeviceListEntry(props) { const {userId, device} = props; @@ -141,7 +134,7 @@ export default React.createClass({ }, _onSendAnywayClicked: function() { - markAllDevicesKnown(this.props.devices); + markAllDevicesKnown(MatrixClientPeg.get(), this.props.devices); this.props.onFinished(); this.props.onSend(); @@ -187,18 +180,11 @@ export default React.createClass({ } }); }); - let sendButton; - if (haveUnknownDevices) { - sendButton = ; - } else { - sendButton = ; - } + const sendButtonOnClick = haveUnknownDevices ? this._onSendAnywayClicked : this._onSendClicked; + const sendButtonLabel = haveUnknownDevices ? this.props.sendAnywayLabel : this.props.sendAnywayLabel; const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); + const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); return ( -
    - {sendButton} - -
    +
    ); // XXX: do we want to give the user the option to enable blacklistUnverifiedDevices for this room (or globally) at this point? diff --git a/src/components/views/elements/AccessibleButton.js b/src/components/views/elements/AccessibleButton.js index 794e0a4dd7..c6a973270a 100644 --- a/src/components/views/elements/AccessibleButton.js +++ b/src/components/views/elements/AccessibleButton.js @@ -15,6 +15,7 @@ */ import React from 'react'; +import PropTypes from 'prop-types'; /** * AccessibleButton is a generic wrapper for any element that should be treated @@ -44,9 +45,9 @@ export default function AccessibleButton(props) { * implemented exactly like a normal onClick handler. */ AccessibleButton.propTypes = { - children: React.PropTypes.node, - element: React.PropTypes.string, - onClick: React.PropTypes.func.isRequired, + children: PropTypes.node, + element: PropTypes.string, + onClick: PropTypes.func.isRequired, }; AccessibleButton.defaultProps = { diff --git a/src/components/views/elements/AddressSelector.js b/src/components/views/elements/AddressSelector.js index 9330206a39..b4279c7f70 100644 --- a/src/components/views/elements/AddressSelector.js +++ b/src/components/views/elements/AddressSelector.js @@ -18,6 +18,7 @@ limitations under the License. 'use strict'; import React from 'react'; +import PropTypes from 'prop-types'; import sdk from '../../../index'; import classNames from 'classnames'; import { UserAddressType } from '../../../UserAddress'; @@ -26,17 +27,17 @@ export default React.createClass({ displayName: 'AddressSelector', propTypes: { - onSelected: React.PropTypes.func.isRequired, + onSelected: PropTypes.func.isRequired, // List of the addresses to display - addressList: React.PropTypes.arrayOf(UserAddressType).isRequired, + addressList: PropTypes.arrayOf(UserAddressType).isRequired, // Whether to show the address on the address tiles - showAddress: React.PropTypes.bool, - truncateAt: React.PropTypes.number.isRequired, - selected: React.PropTypes.number, + showAddress: PropTypes.bool, + truncateAt: PropTypes.number.isRequired, + selected: PropTypes.number, // Element to put as a header on top of the list - header: React.PropTypes.node, + header: PropTypes.node, }, getInitialState: function() { diff --git a/src/components/views/elements/AddressTile.js b/src/components/views/elements/AddressTile.js index c8ea4062b1..16e340756a 100644 --- a/src/components/views/elements/AddressTile.js +++ b/src/components/views/elements/AddressTile.js @@ -16,6 +16,7 @@ limitations under the License. */ import React from 'react'; +import PropTypes from 'prop-types'; import classNames from 'classnames'; import sdk from "../../../index"; import MatrixClientPeg from "../../../MatrixClientPeg"; @@ -28,9 +29,9 @@ export default React.createClass({ propTypes: { address: UserAddressType.isRequired, - canDismiss: React.PropTypes.bool, - onDismissed: React.PropTypes.func, - justified: React.PropTypes.bool, + canDismiss: PropTypes.bool, + onDismissed: PropTypes.func, + justified: PropTypes.bool, }, getDefaultProps: function() { diff --git a/src/components/views/elements/AppTile.js b/src/components/views/elements/AppTile.js index 07bb811667..8abd5961bc 100644 --- a/src/components/views/elements/AppTile.js +++ b/src/components/views/elements/AppTile.js @@ -19,6 +19,7 @@ limitations under the License. import url from 'url'; import qs from 'querystring'; import React from 'react'; +import PropTypes from 'prop-types'; import MatrixClientPeg from '../../../MatrixClientPeg'; import PlatformPeg from '../../../PlatformPeg'; import ScalarAuthClient from '../../../ScalarAuthClient'; @@ -463,6 +464,10 @@ export default class AppTile extends React.Component { const sandboxFlags = "allow-forms allow-popups allow-popups-to-escape-sandbox "+ "allow-same-origin allow-scripts allow-presentation"; + // Additional iframe feature pemissions + // (see - https://sites.google.com/a/chromium.org/dev/Home/chromium-security/deprecating-permissions-in-cross-origin-iframes and https://wicg.github.io/feature-policy/) + const iframeFeatures = "microphone; camera; encrypted-media;"; + if (this.props.show) { const loadingElement = (
    @@ -482,7 +487,13 @@ export default class AppTile extends React.Component { appTileBody = (
    { this.state.loading && loadingElement } + { /* + The "is" attribute in the following iframe tag is needed in order to enable rendering of the + "allow" attribute, which is unknown to react 15. + */ }