diff --git a/jenkins.sh b/jenkins.sh index a0e8d2e893..0979edfa13 100755 --- a/jenkins.sh +++ b/jenkins.sh @@ -4,7 +4,7 @@ set -e export NVM_DIR="$HOME/.nvm" [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" -nvm use 4 +nvm use 6 set -x diff --git a/karma.conf.js b/karma.conf.js index d8a6c25cc6..164cd9ce59 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -93,7 +93,18 @@ module.exports = function (config) { // test results reporter to use // possible values: 'dots', 'progress' // available reporters: https://npmjs.org/browse/keyword/karma-reporter - reporters: ['progress', 'junit'], + reporters: ['logcapture', 'spec', 'junit', 'summary'], + + specReporter: { + suppressErrorSummary: false, // do print error summary + suppressFailed: false, // do print information about failed tests + suppressPassed: false, // do print information about passed tests + showSpecTiming: true, // print the time elapsed for each spec + }, + + client: { + captureLogs: true, + }, // web server port port: 9876, @@ -104,7 +115,10 @@ module.exports = function (config) { // level of logging // possible values: config.LOG_DISABLE || config.LOG_ERROR || // config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG - logLevel: config.LOG_INFO, + // + // This is strictly for logs that would be generated by the browser itself and we + // don't want to log about missing images, which are emitted on LOG_WARN. + logLevel: config.LOG_ERROR, // enable / disable watching file and executing tests whenever any file // changes diff --git a/package.json b/package.json index 9daaa38da7..661db4b6bc 100644 --- a/package.json +++ b/package.json @@ -53,9 +53,9 @@ "classnames": "^2.1.2", "commonmark": "^0.27.0", "counterpart": "^0.18.0", - "draft-js": "^0.9.1", - "draft-js-export-html": "^0.5.0", - "draft-js-export-markdown": "^0.2.0", + "draft-js": "^0.11.0-alpha", + "draft-js-export-html": "^0.6.0", + "draft-js-export-markdown": "^0.3.0", "emojione": "2.2.7", "file-saver": "^1.3.3", "filesize": "3.5.6", @@ -102,12 +102,15 @@ "eslint-plugin-react": "^6.9.0", "expect": "^1.16.0", "json-loader": "^0.5.3", - "karma": "^0.13.22", + "karma": "^1.7.0", "karma-chrome-launcher": "^0.2.3", "karma-cli": "^0.1.2", "karma-junit-reporter": "^0.4.1", + "karma-logcapture-reporter": "0.0.1", "karma-mocha": "^0.2.2", "karma-sourcemap-loader": "^0.3.7", + "karma-spec-reporter": "^0.0.31", + "karma-summary-reporter": "^1.3.3", "karma-webpack": "^1.7.0", "matrix-react-test-utils": "^0.1.1", "mocha": "^2.4.5", diff --git a/src/Analytics.js b/src/Analytics.js index 92691da1ea..a82f57a144 100644 --- a/src/Analytics.js +++ b/src/Analytics.js @@ -15,7 +15,6 @@ */ import { getCurrentLanguage } from './languageHandler'; -import MatrixClientPeg from './MatrixClientPeg'; import PlatformPeg from './PlatformPeg'; import SdkConfig from './SdkConfig'; @@ -31,8 +30,18 @@ const customVariables = { 'User Type': 3, 'Chosen Language': 4, 'Instance': 5, + 'RTE: Uses Richtext Mode': 6, + 'Homeserver URL': 7, + 'Identity Server URL': 8, }; +function whitelistRedact(whitelist, str) { + if (whitelist.includes(str)) return str; + return ''; +} + +const whitelistedHSUrls = ["https://matrix.org"]; +const whitelistedISUrls = ["https://vector.im"]; class Analytics { constructor() { @@ -76,7 +85,7 @@ class Analytics { this._paq.push(['trackAllContentImpressions']); this._paq.push(['discardHashTag', false]); this._paq.push(['enableHeartBeatTimer']); - this._paq.push(['enableLinkTracking', true]); + // this._paq.push(['enableLinkTracking', true]); const platform = PlatformPeg.get(); this._setVisitVariable('App Platform', platform.getHumanReadableName()); @@ -130,20 +139,20 @@ class Analytics { this._paq.push(['deleteCookies']); } - login() { // not used currently - const cli = MatrixClientPeg.get(); - if (this.disabled || !cli) return; - - this._paq.push(['setUserId', `@${cli.getUserIdLocalpart()}:${cli.getDomain()}`]); - } - _setVisitVariable(key, value) { this._paq.push(['setCustomVariable', customVariables[key], key, value, 'visit']); } - setGuest(guest) { + setLoggedIn(isGuest, homeserverUrl, identityServerUrl) { if (this.disabled) return; - this._setVisitVariable('User Type', guest ? 'Guest' : 'Logged In'); + this._setVisitVariable('User Type', isGuest ? 'Guest' : 'Logged In'); + this._setVisitVariable('Homeserver URL', whitelistRedact(whitelistedHSUrls, homeserverUrl)); + this._setVisitVariable('Identity Server URL', whitelistRedact(whitelistedISUrls, identityServerUrl)); + } + + setRichtextMode(state) { + if (this.disabled) return; + this._setVisitVariable('RTE: Uses Richtext Mode', state ? 'on' : 'off'); } } diff --git a/src/CallHandler.js b/src/CallHandler.js index e3fbe9e5e3..8331d579df 100644 --- a/src/CallHandler.js +++ b/src/CallHandler.js @@ -143,7 +143,7 @@ function _setCallListeners(call) { pause("ringbackAudio"); play("busyAudio"); var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - Modal.createDialog(ErrorDialog, { + Modal.createTrackedDialog('Call Handler', 'Call Timeout', ErrorDialog, { title: _t('Call Timeout'), description: _t('The remote side failed to pick up') + '.', }); @@ -205,7 +205,7 @@ function _onAction(payload) { _setCallState(undefined, newCall.roomId, "ended"); console.log("Can't capture screen: " + screenCapErrorString); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - Modal.createDialog(ErrorDialog, { + Modal.createTrackedDialog('Call Handler', 'Unable to capture screen', ErrorDialog, { title: _t('Unable to capture screen'), description: screenCapErrorString, }); @@ -225,7 +225,7 @@ function _onAction(payload) { case 'place_call': if (module.exports.getAnyActiveCall()) { const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - Modal.createDialog(ErrorDialog, { + Modal.createTrackedDialog('Call Handler', 'Existing Call', ErrorDialog, { title: _t('Existing Call'), description: _t('You are already in a call.'), }); @@ -235,7 +235,7 @@ function _onAction(payload) { // if the runtime env doesn't do VoIP, whine. if (!MatrixClientPeg.get().supportsVoip()) { const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - Modal.createDialog(ErrorDialog, { + Modal.createTrackedDialog('Call Handler', 'VoIP is unsupported', ErrorDialog, { title: _t('VoIP is unsupported'), description: _t('You cannot place VoIP calls in this browser.'), }); @@ -251,7 +251,7 @@ function _onAction(payload) { var members = room.getJoinedMembers(); if (members.length <= 1) { const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - Modal.createDialog(ErrorDialog, { + Modal.createTrackedDialog('Call Handler', 'Cannot place call with self', ErrorDialog, { description: _t('You cannot place a call with yourself.'), }); return; @@ -277,13 +277,13 @@ function _onAction(payload) { console.log("Place conference call in %s", payload.room_id); if (!ConferenceHandler) { const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - Modal.createDialog(ErrorDialog, { + Modal.createTrackedDialog('Call Handler', 'Conference call unsupported client', ErrorDialog, { description: _t('Conference calls are not supported in this client'), }); } else if (!MatrixClientPeg.get().supportsVoip()) { const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - Modal.createDialog(ErrorDialog, { + Modal.createTrackedDialog('Call Handler', 'VoIP is unsupported', ErrorDialog, { title: _t('VoIP is unsupported'), description: _t('You cannot place VoIP calls in this browser.'), }); @@ -296,13 +296,13 @@ function _onAction(payload) { // participant. // Therefore we disable conference calling in E2E rooms. const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - Modal.createDialog(ErrorDialog, { + Modal.createTrackedDialog('Call Handler', 'Conference calls unsupported e2e', ErrorDialog, { description: _t('Conference calls are not supported in encrypted rooms'), }); } else { var QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); - Modal.createDialog(QuestionDialog, { + Modal.createTrackedDialog('Call Handler', 'Conference calling in development', QuestionDialog, { title: _t('Warning!'), description: _t('Conference calling is in development and may not be reliable.'), onFinished: confirm=>{ @@ -314,7 +314,7 @@ function _onAction(payload) { }, function(err) { const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); console.error("Conference call failed: " + err); - Modal.createDialog(ErrorDialog, { + Modal.createTrackedDialog('Call Handler', 'Failed to set up conference call', ErrorDialog, { title: _t('Failed to set up conference call'), description: _t('Conference call failed.') + ' ' + ((err && err.message) ? err.message : ''), }); diff --git a/src/ContentMessages.js b/src/ContentMessages.js index 9239de9d8f..93057fafed 100644 --- a/src/ContentMessages.js +++ b/src/ContentMessages.js @@ -360,7 +360,7 @@ class ContentMessages { desc = _t('The file \'%(fileName)s\' exceeds this home server\'s size limit for uploads', {fileName: upload.fileName}); } var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - Modal.createDialog(ErrorDialog, { + Modal.createTrackedDialog('Upload failed', '', ErrorDialog, { title: _t('Upload Failed'), description: desc, }); diff --git a/src/HtmlUtils.js b/src/HtmlUtils.js index 20b444b8da..87e714083b 100644 --- a/src/HtmlUtils.js +++ b/src/HtmlUtils.js @@ -145,7 +145,7 @@ const sanitizeHtmlParams = { font: ['color', 'data-mx-bg-color', 'data-mx-color', 'style'], // custom to matrix span: ['data-mx-bg-color', 'data-mx-color', 'style'], // custom to matrix a: ['href', 'name', 'target', 'rel'], // remote target: custom to matrix - img: ['src'], + img: ['src', 'width', 'height', 'alt', 'title'], ol: ['start'], code: ['class'], // We don't actually allow all classes, we filter them in transformTags }, diff --git a/src/KeyRequestHandler.js b/src/KeyRequestHandler.js index 1da4922153..0b54d88e5f 100644 --- a/src/KeyRequestHandler.js +++ b/src/KeyRequestHandler.js @@ -125,7 +125,7 @@ export default class KeyRequestHandler { }; const KeyShareDialog = sdk.getComponent("dialogs.KeyShareDialog"); - Modal.createDialog(KeyShareDialog, { + Modal.createTrackedDialog('Key Share', 'Process Next Request', KeyShareDialog, { matrixClient: this._matrixClient, userId: userId, deviceId: deviceId, diff --git a/src/Lifecycle.js b/src/Lifecycle.js index eb2156e780..4d8911f7a6 100644 --- a/src/Lifecycle.js +++ b/src/Lifecycle.js @@ -240,7 +240,7 @@ function _handleRestoreFailure(e) { const SessionRestoreErrorDialog = sdk.getComponent('views.dialogs.SessionRestoreErrorDialog'); - Modal.createDialog(SessionRestoreErrorDialog, { + Modal.createTrackedDialog('Session Restore Error', '', SessionRestoreErrorDialog, { error: e.message, onFinished: (success) => { def.resolve(success); @@ -318,7 +318,7 @@ async function _doSetLoggedIn(credentials, clearStorage) { await _clearStorage(); } - Analytics.setGuest(credentials.guest); + Analytics.setLoggedIn(credentials.guest, credentials.homeserverUrl, credentials.identityServerUrl); // Resolves by default let teamPromise = Promise.resolve(null); diff --git a/src/Markdown.js b/src/Markdown.js index 5730e42a09..6e735c6f0e 100644 --- a/src/Markdown.js +++ b/src/Markdown.js @@ -55,6 +55,25 @@ 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 @@ -62,7 +81,7 @@ function is_multi_line(node) { */ export default class Markdown { constructor(input) { - this.input = input; + this.input = linkifyMarkdown(input); const parser = new commonmark.Parser(); this.parsed = parser.parse(this.input); diff --git a/src/Modal.js b/src/Modal.js index e100105a88..79fcaaefd1 100644 --- a/src/Modal.js +++ b/src/Modal.js @@ -103,13 +103,20 @@ class ModalManager { return container; } + createTrackedDialog(analyticsAction, analyticsInfo, Element, props, className) { + Analytics.trackEvent('Modal', analyticsAction, analyticsInfo); + return this.createDialog(Element, props, className); + } + createDialog(Element, props, className) { - if (props && props.title) { - Analytics.trackEvent('Modal', props.title, 'createDialog'); - } return this.createDialogAsync((cb) => {cb(Element);}, props, className); } + createTrackedDialogAsync(analyticsId, loader, props, className) { + Analytics.trackEvent('Modal', analyticsId); + return this.createDialogAsync(loader, props, className); + } + /** * Open a modal view. * diff --git a/src/Notifier.js b/src/Notifier.js index 40a65d4106..1bb435307d 100644 --- a/src/Notifier.js +++ b/src/Notifier.js @@ -142,7 +142,7 @@ const Notifier = { ? _t('Riot does not have permission to send you notifications - please check your browser settings') : _t('Riot was not given permission to send notifications - please try again'); const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog'); - Modal.createDialog(ErrorDialog, { + Modal.createTrackedDialog('Unable to enable Notifications', result, ErrorDialog, { title: _t('Unable to enable Notifications'), description, }); diff --git a/src/RichText.js b/src/RichText.js index c060565e2f..9876fcc93f 100644 --- a/src/RichText.js +++ b/src/RichText.js @@ -51,7 +51,8 @@ export const contentStateToHTML = (contentState: ContentState) => { }; export function htmlToContentState(html: string): ContentState { - return ContentState.createFromBlockArray(convertFromHTML(html)); + const blockArray = convertFromHTML(html).contentBlocks; + return ContentState.createFromBlockArray(blockArray); } function unicodeToEmojiUri(str) { @@ -90,7 +91,7 @@ function findWithRegex(regex, contentBlock: ContentBlock, callback: (start: numb // Workaround for https://github.com/facebook/draft-js/issues/414 let emojiDecorator = { - strategy: (contentBlock, callback) => { + strategy: (contentState, contentBlock, callback) => { findWithRegex(EMOJI_REGEX, contentBlock, callback); }, component: (props) => { @@ -119,7 +120,7 @@ export function getScopedRTDecorators(scope: any): CompositeDecorator { export function getScopedMDDecorators(scope: any): CompositeDecorator { let markdownDecorators = ['HR', 'BOLD', 'ITALIC', 'CODE', 'STRIKETHROUGH'].map( (style) => ({ - strategy: (contentBlock, callback) => { + strategy: (contentState, contentBlock, callback) => { return findWithRegex(MARKDOWN_REGEX[style], contentBlock, callback); }, component: (props) => ( @@ -130,7 +131,7 @@ export function getScopedMDDecorators(scope: any): CompositeDecorator { })); markdownDecorators.push({ - strategy: (contentBlock, callback) => { + strategy: (contentState, contentBlock, callback) => { return findWithRegex(MARKDOWN_REGEX.LINK, contentBlock, callback); }, component: (props) => ( @@ -201,10 +202,8 @@ export function selectionStateToTextOffsets(selectionState: SelectionState, export function textOffsetsToSelectionState({start, end}: SelectionRange, contentBlocks: Array): SelectionState { let selectionState = SelectionState.createEmpty(); - - for (let block of contentBlocks) { - let blockLength = block.getLength(); - + for (const block of contentBlocks) { + const blockLength = block.getLength(); if (start !== -1 && start < blockLength) { selectionState = selectionState.merge({ anchorKey: block.getKey(), @@ -212,9 +211,8 @@ export function textOffsetsToSelectionState({start, end}: SelectionRange, }); start = -1; } else { - start -= blockLength; + start -= blockLength + 1; // +1 to account for newline between blocks } - if (end !== -1 && end <= blockLength) { selectionState = selectionState.merge({ focusKey: block.getKey(), @@ -222,10 +220,9 @@ export function textOffsetsToSelectionState({start, end}: SelectionRange, }); end = -1; } else { - end -= blockLength; + end -= blockLength + 1; // +1 to account for newline between blocks } } - return selectionState; } @@ -242,7 +239,7 @@ export function attachImmutableEntitiesToEmoji(editorState: EditorState): Editor const existingEntityKey = block.getEntityAt(start); if (existingEntityKey) { // avoid manipulation in case the emoji already has an entity - const entity = Entity.get(existingEntityKey); + const entity = newContentState.getEntity(existingEntityKey); if (entity && entity.get('type') === 'emoji') { return; } @@ -252,7 +249,10 @@ export function attachImmutableEntitiesToEmoji(editorState: EditorState): Editor .set('anchorOffset', start) .set('focusOffset', end); const emojiText = plainText.substring(start, end); - const entityKey = Entity.create('emoji', 'IMMUTABLE', { emojiUnicode: emojiText }); + newContentState = newContentState.createEntity( + 'emoji', 'IMMUTABLE', { emojiUnicode: emojiText } + ); + const entityKey = newContentState.getLastCreatedEntityKey(); newContentState = Modifier.replaceText( newContentState, selection, diff --git a/src/SlashCommands.js b/src/SlashCommands.js index dea3d27751..e5378d4347 100644 --- a/src/SlashCommands.js +++ b/src/SlashCommands.js @@ -68,7 +68,7 @@ const commands = { ddg: new Command("ddg", "", function(roomId, args) { const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog'); // TODO Don't explain this away, actually show a search UI here. - Modal.createDialog(ErrorDialog, { + Modal.createTrackedDialog('Slash Commands', '/ddg is not a command', ErrorDialog, { title: _t('/ddg is not a command'), description: _t('To use it, just wait for autocomplete results to load and tab through them.'), }); @@ -326,13 +326,11 @@ const commands = { {deviceId: deviceId, fprint: fprint, userId: userId, fingerprint: fingerprint})); } - return MatrixClientPeg.get().setDeviceVerified( - userId, deviceId, true, - ); + return MatrixClientPeg.get().setDeviceVerified(userId, deviceId, true); }).then(() => { // Tell the user we verified everything const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); - Modal.createDialog(QuestionDialog, { + Modal.createTrackedDialog('Slash Commands', 'Verified key', QuestionDialog, { title: _t("Verified key"), description: (
diff --git a/src/UnknownDeviceErrorHandler.js b/src/UnknownDeviceErrorHandler.js index 2b1cf23380..e7d77b3b66 100644 --- a/src/UnknownDeviceErrorHandler.js +++ b/src/UnknownDeviceErrorHandler.js @@ -24,7 +24,7 @@ const onAction = function(payload) { if (payload.action === 'unknown_device_error' && !isDialogOpen) { const UnknownDeviceDialog = sdk.getComponent('dialogs.UnknownDeviceDialog'); isDialogOpen = true; - Modal.createDialog(UnknownDeviceDialog, { + Modal.createTrackedDialog('Unknown Device Error', '', UnknownDeviceDialog, { devices: payload.err.devices, room: payload.room, onFinished: (r) => { diff --git a/src/Unread.js b/src/Unread.js index 8a70291cf2..8fffc2a429 100644 --- a/src/Unread.js +++ b/src/Unread.js @@ -15,6 +15,8 @@ limitations under the License. */ var MatrixClientPeg = require('./MatrixClientPeg'); +import UserSettingsStore from './UserSettingsStore'; +import shouldHideEvent from './shouldHideEvent'; var sdk = require('./index'); module.exports = { @@ -63,6 +65,7 @@ module.exports = { // we have and the read receipt. We could fetch more history to try & find out, // but currently we just guess. + const syncedSettings = UserSettingsStore.getSyncedSettings(); // Loop through messages, starting with the most recent... for (var i = room.timeline.length - 1; i >= 0; --i) { var ev = room.timeline[i]; @@ -72,7 +75,7 @@ module.exports = { // that counts and we can stop looking because the user's read // this and everything before. return false; - } else if (this.eventTriggersUnreadCount(ev)) { + } else if (!shouldHideEvent(ev, syncedSettings) && this.eventTriggersUnreadCount(ev)) { // We've found a message that counts before we hit // the read marker, so this room is definitely unread. return true; diff --git a/src/UserSettingsStore.js b/src/UserSettingsStore.js index 4c66c90598..796dab4d60 100644 --- a/src/UserSettingsStore.js +++ b/src/UserSettingsStore.js @@ -29,6 +29,9 @@ export default { name: "-", id: 'matrix_apps', default: false, + + // XXX: Always use default, ignore localStorage and remove from labs + override: true, }, ], @@ -171,22 +174,36 @@ export default { localStorage.setItem('mx_local_settings', JSON.stringify(settings)); }, - isFeatureEnabled: function(feature: string): boolean { + getFeatureById(feature: string) { + for (let i = 0; i < this.LABS_FEATURES.length; i++) { + const f = this.LABS_FEATURES[i]; + if (f.id === feature) { + return f; + } + } + return null; + }, + + isFeatureEnabled: function(featureId: string): boolean { // Disable labs for guests. if (MatrixClientPeg.get().isGuest()) return false; - if (localStorage.getItem(`mx_labs_feature_${feature}`) === null) { - for (let i = 0; i < this.LABS_FEATURES.length; i++) { - const f = this.LABS_FEATURES[i]; - if (f.id === feature) { - return f.default; - } - } + const feature = this.getFeatureById(featureId); + if (!feature) { + console.warn(`Unknown feature "${featureId}"`); + return false; } - return localStorage.getItem(`mx_labs_feature_${feature}`) === 'true'; + // Return the default if this feature has an override to be the default value or + // if the feature has never been toggled and is therefore not in localStorage + if (Object.keys(feature).includes('override') || + localStorage.getItem(`mx_labs_feature_${featureId}`) === null + ) { + return feature.default; + } + return localStorage.getItem(`mx_labs_feature_${featureId}`) === 'true'; }, - setFeatureEnabled: function(feature: string, enabled: boolean) { - localStorage.setItem(`mx_labs_feature_${feature}`, enabled); + setFeatureEnabled: function(featureId: string, enabled: boolean) { + localStorage.setItem(`mx_labs_feature_${featureId}`, enabled); }, }; diff --git a/src/WidgetUtils.js b/src/WidgetUtils.js new file mode 100644 index 0000000000..34c998978d --- /dev/null +++ b/src/WidgetUtils.js @@ -0,0 +1,58 @@ +/* +Copyright 2017 Vector Creations 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 MatrixClientPeg from './MatrixClientPeg'; + +export default class WidgetUtils { + + /* Returns true if user is able to send state events to modify widgets in this room + * @param roomId -- The ID of the room to check + * @return Boolean -- true if the user can modify widgets in this room + * @throws Error -- specifies the error reason + */ + static canUserModifyWidgets(roomId) { + if (!roomId) { + console.warn('No room ID specified'); + return false; + } + + const client = MatrixClientPeg.get(); + if (!client) { + console.warn('User must be be logged in'); + return false; + } + + const room = client.getRoom(roomId); + if (!room) { + console.warn(`Room ID ${roomId} is not recognised`); + return false; + } + + const me = client.credentials.userId; + if (!me) { + console.warn('Failed to get user ID'); + return false; + } + + const member = room.getMember(me); + if (!member || member.membership !== "join") { + console.warn(`User ${me} is not in room ${roomId}`); + return false; + } + + return room.currentState.maySendStateEvent('im.vector.modular.widgets', me); + } +} diff --git a/src/autocomplete/EmojiProvider.js b/src/autocomplete/EmojiProvider.js index d58587d423..16e0347a5b 100644 --- a/src/autocomplete/EmojiProvider.js +++ b/src/autocomplete/EmojiProvider.js @@ -71,6 +71,15 @@ const EMOJI_SHORTNAMES = Object.keys(EmojiData).map((key) => EmojiData[key]).sor let instance = null; +function score(query, space) { + const index = space.indexOf(query); + if (index === -1) { + return Infinity; + } else { + return index; + } +} + export default class EmojiProvider extends AutocompleteProvider { constructor() { super(EMOJI_REGEX); @@ -104,8 +113,20 @@ export default class EmojiProvider extends AutocompleteProvider { // Do second match with shouldMatchWordsOnly in order to match against 'name' completions = completions.concat(this.nameMatcher.match(matchedString)); - // Reinstate original order - completions = _sortBy(_uniq(completions), '_orderBy'); + + const sorters = []; + // First, sort by score (Infinity if matchedString not in shortname) + sorters.push((c) => score(matchedString, c.shortname)); + // If the matchedString is not empty, sort by length of shortname. Example: + // matchedString = ":bookmark" + // completions = [":bookmark:", ":bookmark_tabs:", ...] + if (matchedString.length > 1) { + sorters.push((c) => c.shortname.length); + } + // Finally, sort by original ordering + sorters.push((c) => c._orderBy); + completions = _sortBy(_uniq(completions), sorters); + completions = completions.map((result) => { const {shortname} = result; const unicode = shortnameToUnicode(shortname); diff --git a/src/autocomplete/RoomProvider.js b/src/autocomplete/RoomProvider.js index 09d2d79833..1770089eb2 100644 --- a/src/autocomplete/RoomProvider.js +++ b/src/autocomplete/RoomProvider.js @@ -23,35 +23,58 @@ import FuzzyMatcher from './FuzzyMatcher'; import {PillCompletion} from './Components'; import {getDisplayAliasForRoom} from '../Rooms'; import sdk from '../index'; +import _sortBy from 'lodash/sortBy'; const ROOM_REGEX = /(?=#)(\S*)/g; let instance = null; +function score(query, space) { + const index = space.indexOf(query); + if (index === -1) { + return Infinity; + } else { + return index; + } +} + export default class RoomProvider extends AutocompleteProvider { constructor() { super(ROOM_REGEX); this.matcher = new FuzzyMatcher([], { - keys: ['name', 'roomId', 'aliases'], + keys: ['displayedAlias', 'name'], }); } async getCompletions(query: string, selection: {start: number, end: number}, force = false) { const RoomAvatar = sdk.getComponent('views.avatars.RoomAvatar'); - let client = MatrixClientPeg.get(); + // Disable autocompletions when composing commands because of various issues + // (see https://github.com/vector-im/riot-web/issues/4762) + if (/^(\/join|\/leave)/.test(query)) { + return []; + } + + const client = MatrixClientPeg.get(); let completions = []; const {command, range} = this.getCurrentCommand(query, selection, force); if (command) { // the only reason we need to do this is because Fuse only matches on properties - this.matcher.setObjects(client.getRooms().filter(room => !!room && !!getDisplayAliasForRoom(room)).map(room => { + this.matcher.setObjects(client.getRooms().filter( + (room) => !!room && !!getDisplayAliasForRoom(room), + ).map((room) => { return { room: room, name: room.name, - aliases: room.getAliases(), + displayedAlias: getDisplayAliasForRoom(room), }; })); - completions = this.matcher.match(command[0]).map(room => { + const matchedString = command[0]; + completions = this.matcher.match(matchedString); + completions = _sortBy(completions, [ + (c) => score(matchedString, c.displayedAlias), + (c) => c.displayedAlias.length, + ]).map((room) => { const displayAlias = getDisplayAliasForRoom(room.room) || room.roomId; return { completion: displayAlias, @@ -62,7 +85,9 @@ export default class RoomProvider extends AutocompleteProvider { ), range, }; - }).filter(completion => !!completion.completion && completion.completion.length > 0).slice(0, 4); + }) + .filter((completion) => !!completion.completion && completion.completion.length > 0) + .slice(0, 4); } return completions; } diff --git a/src/autocomplete/UserProvider.js b/src/autocomplete/UserProvider.js index 9c93cf537f..69b80dade4 100644 --- a/src/autocomplete/UserProvider.js +++ b/src/autocomplete/UserProvider.js @@ -48,13 +48,21 @@ export default class UserProvider extends AutocompleteProvider { async getCompletions(query: string, selection: {start: number, end: number}, force = false) { const MemberAvatar = sdk.getComponent('views.avatars.MemberAvatar'); + // Disable autocompletions when composing commands because of various issues + // (see https://github.com/vector-im/riot-web/issues/4762) + if (/^(\/ban|\/unban|\/op|\/deop|\/invite|\/kick|\/verify)/.test(query)) { + return []; + } + let completions = []; let {command, range} = this.getCurrentCommand(query, selection, force); if (command) { completions = this.matcher.match(command[0]).map((user) => { const displayName = (user.name || user.userId || '').replace(' (IRC)', ''); // FIXME when groups are done return { - completion: displayName, + // Length of completion should equal length of text in decorator. draft-js + // 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, component: ( diff --git a/src/components/structures/GroupView.js b/src/components/structures/GroupView.js index 5f7866773d..20fc4841ba 100644 --- a/src/components/structures/GroupView.js +++ b/src/components/structures/GroupView.js @@ -266,7 +266,7 @@ export default React.createClass({ this.setState({uploadingAvatar: false}); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); console.error("Failed to upload avatar image", e); - Modal.createDialog(ErrorDialog, { + Modal.createTrackedDialog('Failed to upload image', '', ErrorDialog, { title: _t('Error'), description: _t('Failed to upload image'), }); @@ -288,7 +288,7 @@ export default React.createClass({ }); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); console.error("Failed to save group profile", e); - Modal.createDialog(ErrorDialog, { + Modal.createTrackedDialog('Failed to update group', '', ErrorDialog, { title: _t('Error'), description: _t('Failed to update group'), }); diff --git a/src/components/structures/LoggedInView.js b/src/components/structures/LoggedInView.js index 093fae5d7b..0790a5766e 100644 --- a/src/components/structures/LoggedInView.js +++ b/src/components/structures/LoggedInView.js @@ -301,13 +301,13 @@ export default React.createClass({ case PageTypes.UserView: page_element = null; // deliberately null for now - right_panel = ; + right_panel = ; break; case PageTypes.GroupView: page_element = ; - //right_panel = ; + //right_panel = ; break; } diff --git a/src/components/structures/MatrixChat.js b/src/components/structures/MatrixChat.js index b90cb53435..8cb111bf82 100644 --- a/src/components/structures/MatrixChat.js +++ b/src/components/structures/MatrixChat.js @@ -131,9 +131,6 @@ module.exports = React.createClass({ // the master view we are showing. view: VIEWS.LOADING, - // a thing to call showScreen with once login completes. - screenAfterLogin: this.props.initialScreenAfterLogin, - // What the LoggedInView would be showing if visible page_type: null, @@ -147,8 +144,6 @@ module.exports = React.createClass({ collapse_lhs: false, collapse_rhs: false, - ready: false, - width: 10000, leftOpacity: 1.0, middleOpacity: 1.0, rightOpacity: 1.0, @@ -274,6 +269,15 @@ module.exports = React.createClass({ register_hs_url: paramHs, }); } + + // a thing to call showScreen with once login completes. this is kept + // outside this.state because updating it should never trigger a + // rerender. + this._screenAfterLogin = this.props.initialScreenAfterLogin; + + this._windowWidth = 10000; + this.handleResize(); + window.addEventListener('resize', this.handleResize); }, componentDidMount: function() { @@ -294,9 +298,6 @@ module.exports = React.createClass({ linkifyMatrix.onGroupClick = this.onGroupClick; } - window.addEventListener('resize', this.handleResize); - this.handleResize(); - const teamServerConfig = this.props.config.teamServerConfig || {}; Lifecycle.initRtsClient(teamServerConfig.teamServerURL); @@ -312,13 +313,12 @@ module.exports = React.createClass({ // if the user has followed a login or register link, don't reanimate // the old creds, but rather go straight to the relevant page - const firstScreen = this.state.screenAfterLogin ? - this.state.screenAfterLogin.screen : null; + const firstScreen = this._screenAfterLogin ? + this._screenAfterLogin.screen : null; if (firstScreen === 'login' || firstScreen === 'register' || firstScreen === 'forgot_password') { - this.setState({loading: false}); this._showScreenAfterLogin(); return; } @@ -367,9 +367,9 @@ module.exports = React.createClass({ } const newState = { viewUserId: null, - }; - Object.assign(newState, state); - this.setState(newState); + }; + Object.assign(newState, state); + this.setState(newState); }, onAction: function(payload) { @@ -410,7 +410,7 @@ module.exports = React.createClass({ this._leaveRoom(payload.room_id); break; case 'reject_invite': - Modal.createDialog(QuestionDialog, { + Modal.createTrackedDialog('Reject invitation', '', QuestionDialog, { title: _t('Reject invitation'), description: _t('Are you sure you want to reject the invitation?'), onFinished: (confirm) => { @@ -426,7 +426,7 @@ module.exports = React.createClass({ } }, (err) => { modal.close(); - Modal.createDialog(ErrorDialog, { + Modal.createTrackedDialog('Failed to reject invitation', '', ErrorDialog, { title: _t('Failed to reject invitation'), description: err.toString(), }); @@ -728,7 +728,7 @@ module.exports = React.createClass({ _setMxId: function(payload) { const SetMxIdDialog = sdk.getComponent('views.dialogs.SetMxIdDialog'); - const close = Modal.createDialog(SetMxIdDialog, { + const close = Modal.createTrackedDialog('Set MXID', '', SetMxIdDialog, { homeserverUrl: MatrixClientPeg.get().getHomeserverUrl(), onFinished: (submitted, credentials) => { if (!submitted) { @@ -767,7 +767,7 @@ module.exports = React.createClass({ return; } const ChatInviteDialog = sdk.getComponent("dialogs.ChatInviteDialog"); - Modal.createDialog(ChatInviteDialog, { + Modal.createTrackedDialog('Start a chat', '', ChatInviteDialog, { title: _t('Start a chat'), description: _t("Who would you like to communicate with?"), placeholder: _t("Email, name or matrix ID"), @@ -787,7 +787,7 @@ module.exports = React.createClass({ return; } const TextInputDialog = sdk.getComponent("dialogs.TextInputDialog"); - Modal.createDialog(TextInputDialog, { + Modal.createTrackedDialog('Create Room', '', TextInputDialog, { title: _t('Create Room'), description: _t('Room name (optional)'), button: _t('Create Room'), @@ -831,7 +831,7 @@ module.exports = React.createClass({ return; } - const close = Modal.createDialog(ChatCreateOrReuseDialog, { + const close = Modal.createTrackedDialog('Chat create or reuse', '', ChatCreateOrReuseDialog, { userId: userId, onFinished: (success) => { if (!success && goHomeOnCancel) { @@ -859,7 +859,7 @@ module.exports = React.createClass({ _invite: function(roomId) { const ChatInviteDialog = sdk.getComponent("dialogs.ChatInviteDialog"); - Modal.createDialog(ChatInviteDialog, { + Modal.createTrackedDialog('Chat Invite', '', ChatInviteDialog, { title: _t('Invite new room members'), description: _t('Who would you like to add to this room?'), button: _t('Send Invites'), @@ -873,7 +873,7 @@ module.exports = React.createClass({ const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); const roomToLeave = MatrixClientPeg.get().getRoom(roomId); - Modal.createDialog(QuestionDialog, { + Modal.createTrackedDialog('Leave room', '', QuestionDialog, { title: _t("Leave room"), description: ( @@ -896,7 +896,7 @@ module.exports = React.createClass({ }, (err) => { modal.close(); console.error("Failed to leave room " + roomId + " " + err); - Modal.createDialog(ErrorDialog, { + Modal.createTrackedDialog('Failed to leave room', '', ErrorDialog, { title: _t("Failed to leave room"), description: (err && err.message ? err.message : _t("Server may be unavailable, overloaded, or you hit a bug.")), @@ -992,14 +992,12 @@ module.exports = React.createClass({ _showScreenAfterLogin: function() { // If screenAfterLogin is set, use that, then null it so that a second login will // result in view_home_page, _user_settings or _room_directory - if (this.state.screenAfterLogin && this.state.screenAfterLogin.screen) { + if (this._screenAfterLogin && this._screenAfterLogin.screen) { this.showScreen( - this.state.screenAfterLogin.screen, - this.state.screenAfterLogin.params, + this._screenAfterLogin.screen, + this._screenAfterLogin.params, ); - // XXX: is this necessary? `showScreen` should do it for us. - this.notifyNewScreen(this.state.screenAfterLogin.screen); - this.setState({screenAfterLogin: null}); + this._screenAfterLogin = null; } else if (localStorage && localStorage.getItem('mx_last_room_id')) { // Before defaulting to directory, show the last viewed room dis.dispatch({ @@ -1092,7 +1090,7 @@ module.exports = React.createClass({ }); cli.on('Session.logged_out', function(call) { const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - Modal.createDialog(ErrorDialog, { + Modal.createTrackedDialog('Signed out', '', ErrorDialog, { title: _t('Signed Out'), description: _t('For security, this session has been signed out. Please sign in again.'), }); @@ -1205,21 +1203,24 @@ module.exports = React.createClass({ } else if (screen.indexOf('user/') == 0) { const userId = screen.substring(5); - if (params.action === 'chat') { - this._chatCreateOrReuse(userId); - return; - } + // Wait for the first sync so that `getRoom` gives us a room object if it's + // in the sync response + const waitFor = this.firstSyncPromise ? + this.firstSyncPromise.promise : Promise.resolve(); + waitFor.then(() => { + if (params.action === 'chat') { + this._chatCreateOrReuse(userId); + return; + } - this.setState({ viewUserId: userId }); - this._setPage(PageTypes.UserView); - this.notifyNewScreen('user/' + userId); - const member = new Matrix.RoomMember(null, userId); - if (member) { + this._setPage(PageTypes.UserView); + this.notifyNewScreen('user/' + userId); + const member = new Matrix.RoomMember(null, userId); dis.dispatch({ action: 'view_user', member: member, }); - } + }); } else if (screen.indexOf('group/') == 0) { const groupId = screen.substring(6); @@ -1276,20 +1277,20 @@ module.exports = React.createClass({ const hideRhsThreshold = 820; const showRhsThreshold = 820; - if (this.state.width > hideLhsThreshold && window.innerWidth <= hideLhsThreshold) { + if (this._windowWidth > hideLhsThreshold && window.innerWidth <= hideLhsThreshold) { dis.dispatch({ action: 'hide_left_panel' }); } - if (this.state.width <= showLhsThreshold && window.innerWidth > showLhsThreshold) { + if (this._windowWidth <= showLhsThreshold && window.innerWidth > showLhsThreshold) { dis.dispatch({ action: 'show_left_panel' }); } - if (this.state.width > hideRhsThreshold && window.innerWidth <= hideRhsThreshold) { + if (this._windowWidth > hideRhsThreshold && window.innerWidth <= hideRhsThreshold) { dis.dispatch({ action: 'hide_right_panel' }); } - if (this.state.width <= showRhsThreshold && window.innerWidth > showRhsThreshold) { + if (this._windowWidth <= showRhsThreshold && window.innerWidth > showRhsThreshold) { dis.dispatch({ action: 'show_right_panel' }); } - this.setState({width: window.innerWidth}); + this._windowWidth = window.innerWidth; }, onRoomCreated: function(roomId) { diff --git a/src/components/structures/MessagePanel.js b/src/components/structures/MessagePanel.js index 9251ff2bdb..02f224e942 100644 --- a/src/components/structures/MessagePanel.js +++ b/src/components/structures/MessagePanel.js @@ -14,12 +14,14 @@ See the License for the specific language governing permissions and limitations under the License. */ -var React = require('react'); -var ReactDOM = require("react-dom"); -var dis = require("../../dispatcher"); -var sdk = require('../../index'); +import React from 'react'; +import ReactDOM from 'react-dom'; +import UserSettingsStore from '../../UserSettingsStore'; +import shouldHideEvent from '../../shouldHideEvent'; +import dis from "../../dispatcher"; +import sdk from '../../index'; -var MatrixClientPeg = require('../../MatrixClientPeg'); +import MatrixClientPeg from '../../MatrixClientPeg'; const MILLIS_IN_DAY = 86400000; @@ -90,9 +92,6 @@ module.exports = React.createClass({ // show timestamps always alwaysShowTimestamps: React.PropTypes.bool, - - // hide redacted events as per old behaviour - hideRedactions: React.PropTypes.bool, }, componentWillMount: function() { @@ -113,6 +112,8 @@ module.exports = React.createClass({ // Velocity requires this._readMarkerGhostNode = null; + this._syncedSettings = UserSettingsStore.getSyncedSettings(); + this._isMounted = true; }, @@ -238,8 +239,20 @@ module.exports = React.createClass({ return !this._isMounted; }, - _getEventTiles: function() { + // TODO: Implement granular (per-room) hide options + _shouldShowEvent: function(mxEv) { const EventTile = sdk.getComponent('rooms.EventTile'); + if (!EventTile.haveTileForEvent(mxEv)) { + return false; // no tile = no show + } + + // Always show highlighted event + if (this.props.highlightedEventId === mxEv.getId()) return true; + + return !shouldHideEvent(mxEv, this._syncedSettings); + }, + + _getEventTiles: function() { const DateSeparator = sdk.getComponent('messages.DateSeparator'); const MemberEventListSummary = sdk.getComponent('views.elements.MemberEventListSummary'); @@ -249,20 +262,21 @@ module.exports = React.createClass({ // first figure out which is the last event in the list which we're // actually going to show; this allows us to behave slightly - // differently for the last event in the list. + // differently for the last event in the list. (eg show timestamp) // // we also need to figure out which is the last event we show which isn't // a local echo, to manage the read-marker. - var lastShownEventIndex = -1; + let lastShownEvent; + var lastShownNonLocalEchoIndex = -1; for (i = this.props.events.length-1; i >= 0; i--) { var mxEv = this.props.events[i]; - if (!EventTile.haveTileForEvent(mxEv)) { + if (!this._shouldShowEvent(mxEv)) { continue; } - if (lastShownEventIndex < 0) { - lastShownEventIndex = i; + if (lastShownEvent === undefined) { + lastShownEvent = mxEv; } if (mxEv.status) { @@ -288,25 +302,18 @@ module.exports = React.createClass({ this.currentGhostEventId = null; } - var isMembershipChange = (e) => e.getType() === 'm.room.member'; + const isMembershipChange = (e) => e.getType() === 'm.room.member'; for (i = 0; i < this.props.events.length; i++) { let mxEv = this.props.events[i]; - let wantTile = true; let eventId = mxEv.getId(); - let readMarkerInMels = false; + let last = (mxEv === lastShownEvent); - if (!EventTile.haveTileForEvent(mxEv)) { - wantTile = false; - } - - let last = (i == lastShownEventIndex); + const wantTile = this._shouldShowEvent(mxEv); // Wrap consecutive member events in a ListSummary, ignore if redacted - if (isMembershipChange(mxEv) && - EventTile.haveTileForEvent(mxEv) && - !mxEv.isRedacted() - ) { + if (isMembershipChange(mxEv) && wantTile) { + let readMarkerInMels = false; let ts1 = mxEv.getTs(); // Ensure that the key of the MemberEventListSummary does not change with new // member events. This will prevent it from being re-created unnecessarily, and @@ -323,37 +330,43 @@ module.exports = React.createClass({ ret.push(dateSeparator); } + // If RM event is the first in the MELS, append the RM after MELS + if (mxEv.getId() === this.props.readMarkerEventId) { + readMarkerInMels = true; + } + let summarisedEvents = [mxEv]; for (;i + 1 < this.props.events.length; i++) { - let collapsedMxEv = this.props.events[i + 1]; - - // Ignore redacted member events - if (!EventTile.haveTileForEvent(collapsedMxEv)) { - continue; - } + const collapsedMxEv = this.props.events[i + 1]; if (!isMembershipChange(collapsedMxEv) || this._wantsDateSeparator(this.props.events[i], collapsedMxEv.getDate())) { break; } + + // If RM event is in MELS mark it as such and the RM will be appended after MELS. + if (collapsedMxEv.getId() === this.props.readMarkerEventId) { + readMarkerInMels = true; + } + + // Ignore redacted/hidden member events + if (!this._shouldShowEvent(collapsedMxEv)) { + continue; + } + summarisedEvents.push(collapsedMxEv); } - // At this point, i = the index of the last event in the summary sequence - let eventTiles = summarisedEvents.map( - (e) => { - if (e.getId() === this.props.readMarkerEventId) { - readMarkerInMels = true; - } - // In order to prevent DateSeparators from appearing in the expanded form - // of MemberEventListSummary, render each member event as if the previous - // one was itself. This way, the timestamp of the previous event === the - // timestamp of the current event, and no DateSeperator is inserted. - let ret = this._getTilesForEvent(e, e); - prevEvent = e; - return ret; - } - ).reduce((a, b) => a.concat(b)); + // At this point, i = the index of the last event in the summary sequence + let eventTiles = summarisedEvents.map((e) => { + // In order to prevent DateSeparators from appearing in the expanded form + // of MemberEventListSummary, render each member event as if the previous + // one was itself. This way, the timestamp of the previous event === the + // timestamp of the current event, and no DateSeperator is inserted. + const ret = this._getTilesForEvent(e, e, e === lastShownEvent); + prevEvent = e; + return ret; + }).reduce((a, b) => a.concat(b)); if (eventTiles.length === 0) { eventTiles = null; @@ -466,8 +479,6 @@ module.exports = React.createClass({ continuation = false; } - if (mxEv.isRedacted() && this.props.hideRedactions) return ret; - var eventId = mxEv.getId(); var highlight = (eventId == this.props.highlightedEventId); diff --git a/src/components/structures/MyGroups.js b/src/components/structures/MyGroups.js index 3eb694acce..0b8055beda 100644 --- a/src/components/structures/MyGroups.js +++ b/src/components/structures/MyGroups.js @@ -63,7 +63,7 @@ export default withMatrixClient(React.createClass({ _onCreateGroupClick: function() { const CreateGroupDialog = sdk.getComponent("dialogs.CreateGroupDialog"); - Modal.createDialog(CreateGroupDialog); + Modal.createTrackedDialog('Create Group', '', CreateGroupDialog); }, _fetch: function() { diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index 094251f4c1..f825d1efbb 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -544,7 +544,7 @@ module.exports = React.createClass({ } if (!userHasUsedEncryption) { const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); - Modal.createDialog(QuestionDialog, { + Modal.createTrackedDialog('E2E Warning', '', QuestionDialog, { title: _t("Warning!"), hasCancelButton: false, description: ( @@ -820,7 +820,7 @@ module.exports = React.createClass({ }); const SetMxIdDialog = sdk.getComponent('views.dialogs.SetMxIdDialog'); - const close = Modal.createDialog(SetMxIdDialog, { + const close = Modal.createTrackedDialog('Set MXID', '', SetMxIdDialog, { homeserverUrl: cli.getHomeserverUrl(), onFinished: (submitted, credentials) => { if (submitted) { @@ -934,7 +934,7 @@ module.exports = React.createClass({ } const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); console.error("Failed to upload file " + file + " " + error); - Modal.createDialog(ErrorDialog, { + Modal.createTrackedDialog('Failed to upload file', '', ErrorDialog, { title: _t('Failed to upload file'), description: ((error && error.message) ? error.message : _t("Server may be unavailable, overloaded, or the file too big")), }); @@ -1021,7 +1021,7 @@ module.exports = React.createClass({ }, function(error) { var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); console.error("Search failed: " + error); - Modal.createDialog(ErrorDialog, { + Modal.createTrackedDialog('Search failed', '', ErrorDialog, { title: _t("Search failed"), description: ((error && error.message) ? error.message : _t("Server may be unavailable, overloaded, or search timed out :(")), }); @@ -1148,7 +1148,7 @@ module.exports = React.createClass({ console.error(result.reason); }); var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - Modal.createDialog(ErrorDialog, { + Modal.createTrackedDialog('Failed to save room settings', '', ErrorDialog, { title: _t("Failed to save settings"), description: fails.map(function(result) { return result.reason; }).join("\n"), }); @@ -1195,7 +1195,7 @@ module.exports = React.createClass({ }, function(err) { var errCode = err.errcode || _t("unknown error code"); var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - Modal.createDialog(ErrorDialog, { + Modal.createTrackedDialog('Failed to forget room', '', ErrorDialog, { title: _t("Error"), description: _t("Failed to forget room %(errCode)s", { errCode: errCode }), }); @@ -1217,7 +1217,7 @@ module.exports = React.createClass({ var msg = error.message ? error.message : JSON.stringify(error); var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - Modal.createDialog(ErrorDialog, { + Modal.createTrackedDialog('Failed to reject invite', '', ErrorDialog, { title: _t("Failed to reject invite"), description: msg, }); diff --git a/src/components/structures/TimelinePanel.js b/src/components/structures/TimelinePanel.js index dc9c0fc9ba..6be31361dd 100644 --- a/src/components/structures/TimelinePanel.js +++ b/src/components/structures/TimelinePanel.js @@ -181,9 +181,6 @@ var TimelinePanel = React.createClass({ // always show timestamps on event tiles? alwaysShowTimestamps: syncedSettings.alwaysShowTimestamps, - - // hide redacted events as per old behaviour - hideRedactions: syncedSettings.hideRedactions, }; }, @@ -926,7 +923,7 @@ var TimelinePanel = React.createClass({ var message = (error.errcode == 'M_FORBIDDEN') ? _t("Tried to load a specific point in this room's timeline, but you do not have permission to view the message in question.") : _t("Tried to load a specific point in this room's timeline, but was unable to find it."); - Modal.createDialog(ErrorDialog, { + Modal.createTrackedDialog('Failed to load timeline position', '', ErrorDialog, { title: _t("Failed to load timeline position"), description: message, onFinished: onFinished, @@ -1122,7 +1119,6 @@ var TimelinePanel = React.createClass({ return (
); }); + + // No labs section when there are no features in labs + if (features.length === 0) { + return null; + } + return (

{ _t("Labs") }

@@ -1000,7 +1019,7 @@ module.exports = React.createClass({ this._refreshMediaDevices, function() { const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog'); - Modal.createDialog(ErrorDialog, { + Modal.createTrackedDialog('No media permissions', '', ErrorDialog, { title: _t('No media permissions'), description: _t('You may need to manually permit Riot to access your microphone/webcam'), }); @@ -1136,7 +1155,7 @@ module.exports = React.createClass({ const threepidsSection = this.state.threepids.map((val, pidIndex) => { const id = "3pid-" + val.address; - // TODO; make a separate component to avoid having to rebind onClick + // TODO: make a separate component to avoid having to rebind onClick // each time we render const onRemoveClick = (e) => this.onRemoveThreepidClicked(val); return ( diff --git a/src/components/structures/login/ForgotPassword.js b/src/components/structures/login/ForgotPassword.js index 18a9dca5dd..320d21f5b4 100644 --- a/src/components/structures/login/ForgotPassword.js +++ b/src/components/structures/login/ForgotPassword.js @@ -89,14 +89,14 @@ module.exports = React.createClass({ } else { var QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); - Modal.createDialog(QuestionDialog, { + Modal.createTrackedDialog('Forgot Password Warning', '', QuestionDialog, { title: _t('Warning!'), description:
{ _t( 'Resetting password will currently reset any ' + 'end-to-end encryption keys on all devices, ' + - 'making encrypted chat history unreadable, ' + + 'making encrypted chat history unreadable, ' + 'unless you first export your room keys and re-import ' + 'them afterwards. In future this will be improved.' ) } @@ -121,15 +121,13 @@ module.exports = React.createClass({ }, _onExportE2eKeysClicked: function() { - Modal.createDialogAsync( - (cb) => { - require.ensure(['../../../async-components/views/dialogs/ExportE2eKeysDialog'], () => { - cb(require('../../../async-components/views/dialogs/ExportE2eKeysDialog')); - }, "e2e-export"); - }, { - matrixClient: MatrixClientPeg.get(), - } - ); + Modal.createTrackedDialogAsync('Export E2E Keys', 'Forgot Password', (cb) => { + require.ensure(['../../../async-components/views/dialogs/ExportE2eKeysDialog'], () => { + cb(require('../../../async-components/views/dialogs/ExportE2eKeysDialog')); + }, "e2e-export"); + }, { + matrixClient: MatrixClientPeg.get(), + }); }, onInputChanged: function(stateKey, ev) { @@ -152,7 +150,7 @@ module.exports = React.createClass({ showErrorDialog: function(body, title) { var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - Modal.createDialog(ErrorDialog, { + Modal.createTrackedDialog('Forgot Password Error', '', ErrorDialog, { title: title, description: body, }); diff --git a/src/components/structures/login/Login.js b/src/components/structures/login/Login.js index a081d2a205..a6c0a70c66 100644 --- a/src/components/structures/login/Login.js +++ b/src/components/structures/login/Login.js @@ -19,8 +19,11 @@ limitations under the License. import React from 'react'; import { _t, _tJsx } from '../../../languageHandler'; +import * as languageHandler from '../../../languageHandler'; import sdk from '../../../index'; import Login from '../../../Login'; +import UserSettingsStore from '../../../UserSettingsStore'; +import PlatformPeg from '../../../PlatformPeg'; // For validating phone numbers without country codes const PHONE_NUMBER_REGEX = /^[0-9\(\)\-\s]*$/; @@ -306,6 +309,23 @@ module.exports = React.createClass({ } }, + _onLanguageChange: function(newLang) { + if(languageHandler.getCurrentLanguage() !== newLang) { + UserSettingsStore.setLocalSetting('language', newLang); + PlatformPeg.get().reload(); + } + }, + + _renderLanguageSetting: function() { + const LanguageDropdown = sdk.getComponent('views.elements.LanguageDropdown'); + return
+ +
; + }, + render: function() { const Loader = sdk.getComponent("elements.Spinner"); const LoginHeader = sdk.getComponent("login.LoginHeader"); @@ -354,6 +374,7 @@ module.exports = React.createClass({ { loginAsGuestJsx } { returnToAppJsx } + { this._renderLanguageSetting() }
diff --git a/src/components/views/dialogs/ChatInviteDialog.js b/src/components/views/dialogs/ChatInviteDialog.js index d3a208a785..728860edec 100644 --- a/src/components/views/dialogs/ChatInviteDialog.js +++ b/src/components/views/dialogs/ChatInviteDialog.js @@ -103,7 +103,7 @@ module.exports = React.createClass({ const ChatCreateOrReuseDialog = sdk.getComponent( "views.dialogs.ChatCreateOrReuseDialog", ); - const close = Modal.createDialog(ChatCreateOrReuseDialog, { + const close = Modal.createTrackedDialog('Create or Reuse', '', ChatCreateOrReuseDialog, { userId: userId, onFinished: (success) => { this.props.onFinished(success); @@ -367,7 +367,7 @@ module.exports = React.createClass({ .catch(function(err) { console.error(err.stack); var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - Modal.createDialog(ErrorDialog, { + Modal.createTrackedDialog('Failed to invite', '', ErrorDialog, { title: _t("Failed to invite"), description: ((err && err.message) ? err.message : _t("Operation failed")), }); @@ -380,7 +380,7 @@ module.exports = React.createClass({ .catch(function(err) { console.error(err.stack); var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - Modal.createDialog(ErrorDialog, { + Modal.createTrackedDialog('Failed to invite user', '', ErrorDialog, { title: _t("Failed to invite user"), description: ((err && err.message) ? err.message : _t("Operation failed")), }); @@ -401,7 +401,7 @@ module.exports = React.createClass({ .catch(function(err) { console.error(err.stack); var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - Modal.createDialog(ErrorDialog, { + Modal.createTrackedDialog('Failed to invite', '', ErrorDialog, { title: _t("Failed to invite"), description: ((err && err.message) ? err.message : _t("Operation failed")), }); @@ -448,7 +448,7 @@ module.exports = React.createClass({ if (errorList.length > 0) { var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - Modal.createDialog(ErrorDialog, { + Modal.createTrackedDialog('Failed to invite the following users to the room', '', ErrorDialog, { title: _t("Failed to invite the following users to the %(roomName)s room:", {roomName: room.name}), description: errorList.join(", "), }); diff --git a/src/components/views/dialogs/DeactivateAccountDialog.js b/src/components/views/dialogs/DeactivateAccountDialog.js index e3b7cca078..0ee264b69b 100644 --- a/src/components/views/dialogs/DeactivateAccountDialog.js +++ b/src/components/views/dialogs/DeactivateAccountDialog.js @@ -17,6 +17,7 @@ limitations under the License. import React from 'react'; import sdk from '../../../index'; +import Analytics from '../../../Analytics'; import MatrixClientPeg from '../../../MatrixClientPeg'; import * as Lifecycle from '../../../Lifecycle'; import Velocity from 'velocity-vector'; @@ -54,6 +55,7 @@ export default class DeactivateAccountDialog extends React.Component { user: MatrixClientPeg.get().credentials.userId, password: this._passwordField.value, }).done(() => { + Analytics.trackEvent('Account', 'Deactivate Account'); Lifecycle.onLoggedOut(); this.props.onFinished(false); }, (err) => { diff --git a/src/components/views/dialogs/ErrorDialog.js b/src/components/views/dialogs/ErrorDialog.js index bf48d1757b..beca107252 100644 --- a/src/components/views/dialogs/ErrorDialog.js +++ b/src/components/views/dialogs/ErrorDialog.js @@ -16,7 +16,7 @@ limitations under the License. /* * Usage: - * Modal.createDialog(ErrorDialog, { + * Modal.createTrackedDialog('An Identifier', 'some detail', ErrorDialog, { * title: "some text", (default: "Error") * description: "some more text", * button: "Button Text", diff --git a/src/components/views/dialogs/KeyShareDialog.js b/src/components/views/dialogs/KeyShareDialog.js index 61391d281c..aed8e6a5af 100644 --- a/src/components/views/dialogs/KeyShareDialog.js +++ b/src/components/views/dialogs/KeyShareDialog.js @@ -88,7 +88,7 @@ export default React.createClass({ const DeviceVerifyDialog = sdk.getComponent('views.dialogs.DeviceVerifyDialog'); console.log("KeyShareDialog: Starting verify dialog"); - Modal.createDialog(DeviceVerifyDialog, { + Modal.createTrackedDialog('Key Share', 'Starting dialog', DeviceVerifyDialog, { userId: this.props.userId, device: this.state.deviceInfo, onFinished: (verified) => { diff --git a/src/components/views/dialogs/SessionRestoreErrorDialog.js b/src/components/views/dialogs/SessionRestoreErrorDialog.js index a3eb7c6962..010072e8c6 100644 --- a/src/components/views/dialogs/SessionRestoreErrorDialog.js +++ b/src/components/views/dialogs/SessionRestoreErrorDialog.js @@ -31,7 +31,7 @@ export default React.createClass({ _sendBugReport: function() { const BugReportDialog = sdk.getComponent("dialogs.BugReportDialog"); - Modal.createDialog(BugReportDialog, {}); + Modal.createTrackedDialog('Session Restore Error', 'Send Bug Report Dialog', BugReportDialog, {}); }, _continueClicked: function() { diff --git a/src/components/views/dialogs/SetEmailDialog.js b/src/components/views/dialogs/SetEmailDialog.js index 3c38064ee1..ed5cef2f67 100644 --- a/src/components/views/dialogs/SetEmailDialog.js +++ b/src/components/views/dialogs/SetEmailDialog.js @@ -55,7 +55,7 @@ export default React.createClass({ const emailAddress = this.state.emailAddress; if (!Email.looksValid(emailAddress)) { - Modal.createDialog(ErrorDialog, { + Modal.createTrackedDialog('Invalid Email Address', '', ErrorDialog, { title: _t("Invalid Email Address"), description: _t("This doesn't appear to be a valid email address"), }); @@ -65,7 +65,7 @@ export default React.createClass({ // we always bind emails when registering, so let's do the // same here. this._addThreepid.addEmailAddress(emailAddress, true).done(() => { - Modal.createDialog(QuestionDialog, { + Modal.createTrackedDialog('Verification Pending', '', QuestionDialog, { title: _t("Verification Pending"), description: _t( "Please check your email and click on the link it contains. Once this " + @@ -77,7 +77,7 @@ export default React.createClass({ }, (err) => { this.setState({emailBusy: false}); console.error("Unable to add email address " + emailAddress + " " + err); - Modal.createDialog(ErrorDialog, { + Modal.createTrackedDialog('Unable to add email address', '', ErrorDialog, { title: _t("Unable to add email address"), description: ((err && err.message) ? err.message : _t("Operation failed")), }); @@ -106,7 +106,7 @@ export default React.createClass({ const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); const message = _t("Unable to verify email address.") + " " + _t("Please check your email and click on the link it contains. Once this is done, click continue."); - Modal.createDialog(QuestionDialog, { + Modal.createTrackedDialog('Verification Pending', '3pid Auth Failed', QuestionDialog, { title: _t("Verification Pending"), description: message, button: _t('Continue'), @@ -115,7 +115,7 @@ export default React.createClass({ } else { const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); console.error("Unable to verify email address: " + err); - Modal.createDialog(ErrorDialog, { + Modal.createTrackedDialog('Unable to verify email address', '', ErrorDialog, { title: _t("Unable to verify email address."), description: ((err && err.message) ? err.message : _t("Operation failed")), }); diff --git a/src/components/views/dialogs/SetMxIdDialog.js b/src/components/views/dialogs/SetMxIdDialog.js index 4d4f672f2b..554a244358 100644 --- a/src/components/views/dialogs/SetMxIdDialog.js +++ b/src/components/views/dialogs/SetMxIdDialog.js @@ -106,6 +106,16 @@ export default React.createClass({ }, _doUsernameCheck: function() { + // XXX: SPEC-1 + // Check if username is valid + // Naive impl copied from https://github.com/matrix-org/matrix-react-sdk/blob/66c3a6d9ca695780eb6b662e242e88323053ff33/src/components/views/login/RegistrationForm.js#L190 + if (encodeURIComponent(this.state.username) !== this.state.username) { + this.setState({ + usernameError: _t('User names may only contain letters, numbers, dots, hyphens and underscores.'), + }); + return Promise.reject(); + } + // Check if username is available return this._matrixClient.isUsernameAvailable(this.state.username).then( (isAvailable) => { @@ -242,7 +252,7 @@ export default React.createClass({ return (
diff --git a/src/components/views/elements/AppPermission.js b/src/components/views/elements/AppPermission.js new file mode 100644 index 0000000000..083a7cd9c7 --- /dev/null +++ b/src/components/views/elements/AppPermission.js @@ -0,0 +1,75 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import url from 'url'; +import { _t } from '../../../languageHandler'; + +export default class AppPermission extends React.Component { + constructor(props) { + super(props); + + const curlBase = this.getCurlBase(); + this.state = { curlBase: curlBase}; + } + + // Return string representation of content URL without query parameters + getCurlBase() { + const wurl = url.parse(this.props.url); + let curl; + let curlString; + + const searchParams = new URLSearchParams(wurl.search); + + if(this.isScalarWurl(wurl) && searchParams && searchParams.get('url')) { + curl = url.parse(searchParams.get('url')); + if(curl) { + curl.search = curl.query = ""; + curlString = curl.format(); + } + } + if (!curl && wurl) { + wurl.search = wurl.query = ""; + curlString = wurl.format(); + } + return curlString; + } + + isScalarWurl(wurl) { + if(wurl && wurl.hostname && ( + wurl.hostname === 'scalar.vector.im' || + wurl.hostname === 'scalar-staging.riot.im' || + wurl.hostname === 'scalar-develop.riot.im' || + wurl.hostname === 'demo.riot.im' || + wurl.hostname === 'localhost' + )) { + return true; + } + return false; + } + + render() { + return ( +
+
+ {_t('Warning!')}/ +
+
+ Do you want to load widget from URL: {this.state.curlBase} +
+ +
+ ); + } +} + +AppPermission.propTypes = { + url: PropTypes.string.isRequired, + onPermissionGranted: PropTypes.func.isRequired, +}; +AppPermission.defaultProps = { + onPermissionGranted: function() {}, +}; diff --git a/src/components/views/elements/AppTile.js b/src/components/views/elements/AppTile.js index 9573b9fd9f..a78b802ad7 100644 --- a/src/components/views/elements/AppTile.js +++ b/src/components/views/elements/AppTile.js @@ -24,6 +24,10 @@ import SdkConfig from '../../../SdkConfig'; import Modal from '../../../Modal'; import { _t } from '../../../languageHandler'; import sdk from '../../../index'; +import AppPermission from './AppPermission'; +import AppWarning from './AppWarning'; +import MessageSpinner from './MessageSpinner'; +import WidgetUtils from '../../../WidgetUtils'; const ALLOWED_APP_URL_SCHEMES = ['https:', 'http:']; const betaHelpMsg = 'This feature is currently experimental and is intended for beta testing only'; @@ -37,6 +41,9 @@ export default React.createClass({ name: React.PropTypes.string.isRequired, room: React.PropTypes.object.isRequired, type: React.PropTypes.string.isRequired, + // Specifying 'fullWidth' as true will render the app tile to fill the width of the app drawer continer. + // This should be set to true when there is only one widget in the app drawer, otherwise it should be false. + fullWidth: React.PropTypes.bool, }, getDefaultProps: function() { @@ -46,9 +53,13 @@ export default React.createClass({ }, getInitialState: function() { + const widgetPermissionId = [this.props.room.roomId, encodeURIComponent(this.props.url)].join('_'); + const hasPermissionToLoad = localStorage.getItem(widgetPermissionId); return { loading: false, widgetUrl: this.props.url, + widgetPermissionId: widgetPermissionId, + hasPermissionToLoad: Boolean(hasPermissionToLoad === 'true'), error: null, deleting: false, }; @@ -60,6 +71,18 @@ export default React.createClass({ return scalarUrl && this.props.url.startsWith(scalarUrl); }, + isMixedContent: function() { + const parentContentProtocol = window.location.protocol; + const u = url.parse(this.props.url); + const childContentProtocol = u.protocol; + if (parentContentProtocol === 'https:' && childContentProtocol !== 'https:') { + console.warn("Refusing to load mixed-content app:", + parentContentProtocol, childContentProtocol, window.location, this.props.url); + return true; + } + return false; + }, + componentWillMount: function() { if (!this.isScalarUrl()) { return; @@ -71,6 +94,7 @@ export default React.createClass({ this._scalarClient = new ScalarAuthClient(); this._scalarClient.getScalarToken().done((token) => { // Append scalar_token as a query param + this._scalarClient.scalarToken = token; const u = url.parse(this.props.url); if (!u.search) { u.search = "?scalar_token=" + encodeURIComponent(token); @@ -91,29 +115,62 @@ export default React.createClass({ }); }, + _canUserModify: function() { + return WidgetUtils.canUserModifyWidgets(this.props.room.roomId); + }, + _onEditClick: function(e) { console.log("Edit widget ID ", this.props.id); const IntegrationsManager = sdk.getComponent("views.settings.IntegrationsManager"); const src = this._scalarClient.getScalarInterfaceUrlForRoom(this.props.room.roomId, 'type_' + this.props.type); - Modal.createDialog(IntegrationsManager, { + Modal.createTrackedDialog('Integrations Manager', '', IntegrationsManager, { src: src, }, "mx_IntegrationsManager"); }, + /* If user has permission to modify widgets, delete the widget, otherwise revoke access for the widget to load in the user's browser + */ _onDeleteClick: function() { - console.log("Delete widget %s", this.props.id); - this.setState({deleting: true}); - MatrixClientPeg.get().sendStateEvent( - this.props.room.roomId, - 'im.vector.modular.widgets', - {}, // empty content - this.props.id, - ).then(() => { - console.log('Deleted widget'); - }, (e) => { - console.error('Failed to delete widget', e); - this.setState({deleting: false}); - }); + if (this._canUserModify()) { + console.log("Delete widget %s", this.props.id); + this.setState({deleting: true}); + MatrixClientPeg.get().sendStateEvent( + this.props.room.roomId, + 'im.vector.modular.widgets', + {}, // empty content + this.props.id, + ).then(() => { + console.log('Deleted widget'); + }, (e) => { + console.error('Failed to delete widget', e); + this.setState({deleting: false}); + }); + } else { + console.log("Revoke widget permissions - %s", this.props.id); + this._revokeWidgetPermission(); + } + }, + + // Widget labels to render, depending upon user permissions + // These strings are translated at the point that they are inserted in to the DOM, in the render method + _deleteWidgetLabel() { + if (this._canUserModify()) { + return 'Delete widget'; + } + return 'Revoke widget access'; + }, + + /* TODO -- Store permission in account data so that it is persisted across multiple devices */ + _grantWidgetPermission() { + console.warn('Granting permission to load widget - ', this.state.widgetUrl); + localStorage.setItem(this.state.widgetPermissionId, true); + this.setState({hasPermissionToLoad: true}); + }, + + _revokeWidgetPermission() { + console.warn('Revoking permission to load widget - ', this.state.widgetUrl); + localStorage.removeItem(this.state.widgetPermissionId); + this.setState({hasPermissionToLoad: false}); }, formatAppTileName: function() { @@ -133,34 +190,66 @@ export default React.createClass({ return
; } + // Note that there is advice saying allow-scripts shouldn't be used with allow-same-origin + // because that would allow the iframe to prgramatically remove the sandbox attribute, but + // this would only be for content hosted on the same origin as the riot client: anything + // hosted on the same origin as the client will get the same access as if you clicked + // a link to it. + const sandboxFlags = "allow-forms allow-popups allow-popups-to-escape-sandbox "+ + "allow-same-origin allow-scripts allow-presentation"; + const parsedWidgetUrl = url.parse(this.state.widgetUrl); + let safeWidgetUrl = ''; + if (ALLOWED_APP_URL_SCHEMES.indexOf(parsedWidgetUrl.protocol) !== -1) { + safeWidgetUrl = url.format(parsedWidgetUrl); + } + if (this.state.loading) { appTileBody = ( -
Loading...
+
+ +
); - } else { - // Note that there is advice saying allow-scripts shouldn't be used with allow-same-origin - // because that would allow the iframe to prgramatically remove the sandbox attribute, but - // this would only be for content hosted on the same origin as the riot client: anything - // hosted on the same origin as the client will get the same access as if you clicked - // a link to it. - const sandboxFlags = "allow-forms allow-popups allow-popups-to-escape-sandbox "+ - "allow-same-origin allow-scripts"; - const parsedWidgetUrl = url.parse(this.state.widgetUrl); - let safeWidgetUrl = ''; - if (ALLOWED_APP_URL_SCHEMES.indexOf(parsedWidgetUrl.protocol) !== -1) { - safeWidgetUrl = url.format(parsedWidgetUrl); + } else if (this.state.hasPermissionToLoad == true) { + if (this.isMixedContent()) { + appTileBody = ( +
+ +
+ ); + } else { + appTileBody = ( +
+ +
+ ); } + } else { appTileBody = (
- +
); } // editing is done in scalar - const showEditButton = Boolean(this._scalarClient); + const showEditButton = Boolean(this._scalarClient && this._canUserModify()); + const deleteWidgetLabel = this._deleteWidgetLabel(); + let deleteIcon = 'img/cancel.svg'; + let deleteClasses = 'mx_filterFlipColor mx_AppTileMenuBarWidget'; + if(this._canUserModify()) { + deleteIcon = 'img/cancel-red.svg'; + deleteClasses += ' mx_AppTileMenuBarWidgetDelete'; + } return (
@@ -172,14 +261,18 @@ export default React.createClass({ {showEditButton && Edit} {/* Delete widget */} - {_t("Cancel")} diff --git a/src/components/views/elements/AppWarning.js b/src/components/views/elements/AppWarning.js new file mode 100644 index 0000000000..944f1422e6 --- /dev/null +++ b/src/components/views/elements/AppWarning.js @@ -0,0 +1,25 @@ +import React from 'react'; // eslint-disable-line no-unused-vars +import PropTypes from 'prop-types'; +import { _t } from '../../../languageHandler'; + +const AppWarning = (props) => { + return ( +
+
+ {_t('Warning!')}/ +
+
+ {props.errorMsg} +
+
+ ); +}; + +AppWarning.propTypes = { + errorMsg: PropTypes.string, +}; +AppWarning.defaultProps = { + errorMsg: 'Error', +}; + +export default AppWarning; diff --git a/src/components/views/elements/DeviceVerifyButtons.js b/src/components/views/elements/DeviceVerifyButtons.js index dfca7e2600..bfe45905a1 100644 --- a/src/components/views/elements/DeviceVerifyButtons.js +++ b/src/components/views/elements/DeviceVerifyButtons.js @@ -52,7 +52,7 @@ export default React.createClass({ onVerifyClick: function() { const DeviceVerifyDialog = sdk.getComponent('views.dialogs.DeviceVerifyDialog'); - Modal.createDialog(DeviceVerifyDialog, { + Modal.createTrackedDialog('Device Verify Dialog', '', DeviceVerifyDialog, { userId: this.props.userId, device: this.state.device, }); diff --git a/src/components/views/elements/MessageSpinner.js b/src/components/views/elements/MessageSpinner.js new file mode 100644 index 0000000000..bc0a326338 --- /dev/null +++ b/src/components/views/elements/MessageSpinner.js @@ -0,0 +1,34 @@ +/* +Copyright 2017 Vector Creations 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 React from 'react'; + +module.exports = React.createClass({ + displayName: 'MessageSpinner', + + render: function() { + const w = this.props.w || 32; + const h = this.props.h || 32; + const imgClass = this.props.imgClassName || ""; + const msg = this.props.msg || "Loading..."; + return ( +
+
{msg}
  + +
+ ); + }, +}); diff --git a/src/components/views/elements/Pill.js b/src/components/views/elements/Pill.js index fdd657470e..b5fa163608 100644 --- a/src/components/views/elements/Pill.js +++ b/src/components/views/elements/Pill.js @@ -25,8 +25,8 @@ import { getDisplayAliasForRoom } from '../../../Rooms'; const REGEX_MATRIXTO = new RegExp(MATRIXTO_URL_PATTERN); // For URLs of matrix.to links in the timeline which have been reformatted by -// HttpUtils transformTags to relative links -const REGEX_LOCAL_MATRIXTO = /^#\/(?:user|room)\/(([\#\!\@\+]).*)$/; +// HttpUtils transformTags to relative links. This excludes event URLs (with `[^\/]*`) +const REGEX_LOCAL_MATRIXTO = /^#\/(?:user|room)\/(([\#\!\@\+])[^\/]*)$/; const Pill = React.createClass({ statics: { @@ -47,6 +47,8 @@ const Pill = React.createClass({ inMessage: PropTypes.bool, // The room in which this pill is being rendered room: PropTypes.instanceOf(Room), + // Whether to include an avatar in the pill + shouldShowPillAvatar: PropTypes.bool, }, getInitialState() { @@ -63,16 +65,15 @@ const Pill = React.createClass({ }; }, - componentWillMount() { - this._unmounted = false; + componentWillReceiveProps(nextProps) { let regex = REGEX_MATRIXTO; - if (this.props.inMessage) { + if (nextProps.inMessage) { regex = REGEX_LOCAL_MATRIXTO; } // Default to the empty array if no match for simplicity // resource and prefix will be undefined instead of throwing - const matrixToMatch = regex.exec(this.props.url) || []; + const matrixToMatch = regex.exec(nextProps.url) || []; const resourceId = matrixToMatch[1]; // The room/user ID const prefix = matrixToMatch[2]; // The first character of prefix @@ -87,7 +88,7 @@ const Pill = React.createClass({ let room; switch (pillType) { case Pill.TYPE_USER_MENTION: { - const localMember = this.props.room.getMember(resourceId); + const localMember = nextProps.room.getMember(resourceId); member = localMember; if (!localMember) { member = new RoomMember(null, resourceId); @@ -112,6 +113,11 @@ const Pill = React.createClass({ this.setState({resourceId, pillType, member, room}); }, + componentWillMount() { + this._unmounted = false; + this.componentWillReceiveProps(this.props); + }, + componentWillUnmount() { this._unmounted = true; }, @@ -150,8 +156,10 @@ const Pill = React.createClass({ const member = this.state.member; if (member) { userId = member.userId; - linkText = member.name; - avatar = ; + linkText = member.rawDisplayName.replace(' (IRC)', ''); // FIXME when groups are done + if (this.props.shouldShowPillAvatar) { + avatar = ; + } pillClass = 'mx_UserPill'; } } @@ -160,7 +168,9 @@ const Pill = React.createClass({ const room = this.state.room; if (room) { linkText = (room ? getDisplayAliasForRoom(room) : null) || resource; - avatar = ; + if (this.props.shouldShowPillAvatar) { + avatar = ; + } pillClass = 'mx_RoomPill'; } } diff --git a/src/components/views/login/RegistrationForm.js b/src/components/views/login/RegistrationForm.js index ff07cd36e5..d5b7bcf46a 100644 --- a/src/components/views/login/RegistrationForm.js +++ b/src/components/views/login/RegistrationForm.js @@ -95,7 +95,7 @@ module.exports = React.createClass({ if (this.allFieldsValid()) { if (this.refs.email.value == '') { var QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); - Modal.createDialog(QuestionDialog, { + Modal.createTrackedDialog('If you don\'t specify an email address...', '', QuestionDialog, { title: _t("Warning!"), description:
diff --git a/src/components/views/login/ServerConfig.js b/src/components/views/login/ServerConfig.js index a63d02416c..0042ab5e9f 100644 --- a/src/components/views/login/ServerConfig.js +++ b/src/components/views/login/ServerConfig.js @@ -122,7 +122,7 @@ module.exports = React.createClass({ showHelpPopup: function() { var CustomServerDialog = sdk.getComponent('login.CustomServerDialog'); - Modal.createDialog(CustomServerDialog); + Modal.createTrackedDialog('Custom Server Dialog', '', CustomServerDialog); }, render: function() { diff --git a/src/components/views/messages/MFileBody.js b/src/components/views/messages/MFileBody.js index bccae923eb..53c36f234c 100644 --- a/src/components/views/messages/MFileBody.js +++ b/src/components/views/messages/MFileBody.js @@ -282,8 +282,8 @@ module.exports = React.createClass({ }); }).catch((err) => { console.warn("Unable to decrypt attachment: ", err); - Modal.createDialog(ErrorDialog, { - title: _t("Error"), + Modal.createTrackedDialog('Error decrypting attachment', '', ErrorDialog, { + title: _t("Error"), description: _t("Error decrypting attachment"), }); }).finally(() => { diff --git a/src/components/views/messages/TextualBody.js b/src/components/views/messages/TextualBody.js index 6d4d01a196..58273bee67 100644 --- a/src/components/views/messages/TextualBody.js +++ b/src/components/views/messages/TextualBody.js @@ -170,6 +170,7 @@ module.exports = React.createClass({ }, pillifyLinks: function(nodes) { + const shouldShowPillAvatar = !UserSettingsStore.getSyncedSetting("Pill.shouldHidePillAvatar", false); for (let i = 0; i < nodes.length; i++) { const node = nodes[i]; if (node.tagName === "A" && node.getAttribute("href")) { @@ -181,7 +182,12 @@ module.exports = React.createClass({ const pillContainer = document.createElement('span'); const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId()); - const pill = ; + const pill = ; ReactDOM.render(pill, pillContainer); node.parentNode.replaceChild(pillContainer, node); @@ -269,18 +275,21 @@ module.exports = React.createClass({ }, getEventTileOps: function() { - var self = this; return { - isWidgetHidden: function() { - return self.state.widgetHidden; + isWidgetHidden: () => { + return this.state.widgetHidden; }, - unhideWidget: function() { - self.setState({ widgetHidden: false }); + unhideWidget: () => { + this.setState({ widgetHidden: false }); if (global.localStorage) { - global.localStorage.removeItem("hide_preview_" + self.props.mxEvent.getId()); + global.localStorage.removeItem("hide_preview_" + this.props.mxEvent.getId()); } }, + + getInnerText: () => { + return this.refs.content.innerText; + } }; }, @@ -299,7 +308,7 @@ module.exports = React.createClass({ let completeUrl = scalarClient.getStarterLink(starterLink); let QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); let integrationsUrl = SdkConfig.get().integrations_ui_url; - Modal.createDialog(QuestionDialog, { + Modal.createTrackedDialog('Add an integration', '', QuestionDialog, { title: _t("Add an Integration"), description:
diff --git a/src/components/views/room_settings/AliasSettings.js b/src/components/views/room_settings/AliasSettings.js index ba0663153e..f37bd4271a 100644 --- a/src/components/views/room_settings/AliasSettings.js +++ b/src/components/views/room_settings/AliasSettings.js @@ -154,7 +154,7 @@ module.exports = React.createClass({ } else { var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - Modal.createDialog(ErrorDialog, { + Modal.createTrackedDialog('Invalid alias format', '', ErrorDialog, { title: _t('Invalid alias format'), description: _t('\'%(alias)s\' is not a valid format for an alias', { alias: alias }), }); @@ -170,7 +170,7 @@ module.exports = React.createClass({ } else { var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - Modal.createDialog(ErrorDialog, { + Modal.createTrackedDialog('Invalid address format', '', ErrorDialog, { title: _t('Invalid address format'), description: _t('\'%(alias)s\' is not a valid format for an address', { alias: alias }), }); diff --git a/src/components/views/rooms/AppsDrawer.js b/src/components/views/rooms/AppsDrawer.js index 3b8acc3f40..4bc98abb6f 100644 --- a/src/components/views/rooms/AppsDrawer.js +++ b/src/components/views/rooms/AppsDrawer.js @@ -26,6 +26,7 @@ import SdkConfig from '../../../SdkConfig'; import ScalarAuthClient from '../../../ScalarAuthClient'; import ScalarMessaging from '../../../ScalarMessaging'; import { _t } from '../../../languageHandler'; +import WidgetUtils from '../../../WidgetUtils'; module.exports = React.createClass({ @@ -147,6 +148,15 @@ module.exports = React.createClass({ }); }, + _canUserModify: function() { + try { + return WidgetUtils.canUserModifyWidgets(this.props.room.roomId); + } catch(err) { + console.error(err); + return false; + } + }, + onClickAddWidget: function(e) { if (e) { e.preventDefault(); @@ -156,7 +166,7 @@ module.exports = React.createClass({ const src = (this.scalarClient !== null && this.scalarClient.hasCredentials()) ? this.scalarClient.getScalarInterfaceUrlForRoom(this.props.room.roomId, 'add_integ') : null; - Modal.createDialog(IntegrationsManager, { + Modal.createTrackedDialog('Integrations Manager', '', IntegrationsManager, { src: src, }, "mx_IntegrationsManager"); }, @@ -164,7 +174,7 @@ module.exports = React.createClass({ render: function() { const apps = this.state.apps.map( (app, index, arr) => { - return ; + />); }); - const addWidget = this.state.apps && this.state.apps.length < 2 && + const addWidget = this.state.apps && this.state.apps.length < 2 && this._canUserModify() && (
{ + Modal.createTrackedDialogAsync('Encrypted Event Dialog', '', (cb) => { require(['../../../async-components/views/dialogs/EncryptedEventDialog'], cb); }, { event: event, @@ -386,6 +388,36 @@ module.exports = withMatrixClient(React.createClass({ }); }, + _renderE2EPadlock: function() { + const ev = this.props.mxEvent; + const props = {onClick: this.onCryptoClicked}; + + + if (ev.getContent().msgtype === 'm.bad.encrypted') { + return ; + } else if (ev.isEncrypted()) { + if (this.state.verified) { + return ; + } else { + return ; + } + } else { + // XXX: if the event is being encrypted (ie eventSendStatus === + // encrypting), it might be nice to show something other than the + // open padlock? + + // if the event is not encrypted, but it's an e2e room, show the + // open padlock + const e2eEnabled = this.props.matrixClient.isRoomEncrypted(ev.getRoomId()); + if (e2eEnabled) { + return ; + } + } + + // no padlock needed + return null; + }, + render: function() { var MessageTimestamp = sdk.getComponent('messages.MessageTimestamp'); var SenderProfile = sdk.getComponent('messages.SenderProfile'); @@ -407,7 +439,6 @@ module.exports = withMatrixClient(React.createClass({ throw new Error("Event type not supported"); } - var e2eEnabled = this.props.matrixClient.isRoomEncrypted(this.props.mxEvent.getRoomId()); var isSending = (['sending', 'queued', 'encrypting'].indexOf(this.props.eventSendStatus) !== -1); const isRedacted = (eventType === 'm.room.message') && this.props.isRedacted; @@ -485,26 +516,7 @@ module.exports = withMatrixClient(React.createClass({ const editButton = ( ); - let e2e; - // cosmetic padlocks: - if ((e2eEnabled && this.props.eventSendStatus) || this.props.mxEvent.getType() === 'm.room.encryption') { - e2e = {_t("Encrypted; - } - // real padlocks - else if (this.props.mxEvent.isEncrypted() || (e2eEnabled && this.props.eventSendStatus)) { - if (this.props.mxEvent.getContent().msgtype === 'm.bad.encrypted') { - e2e = {_t("Undecryptable")}; - } - else if (this.state.verified == true || (e2eEnabled && this.props.eventSendStatus)) { - e2e = {_t("Encrypted; - } - else { - e2e = {_t("Encrypted; - } - } - else if (e2eEnabled) { - e2e = {_t("Unencrypted; - } + const timestamp = this.props.mxEvent.getTs() ? : null; @@ -572,7 +584,7 @@ module.exports = withMatrixClient(React.createClass({ { timestamp } - { e2e } + { this._renderE2EPadlock() } + ); +} + +function E2ePadlockVerified(props) { + return ( + + ); +} + +function E2ePadlockUnverified(props) { + return ( + + ); +} + +function E2ePadlockUnencrypted(props) { + return ( + + ); +} + +function E2ePadlock(props) { + return ; +} diff --git a/src/components/views/rooms/MemberInfo.js b/src/components/views/rooms/MemberInfo.js index 290bd35483..64eeddb406 100644 --- a/src/components/views/rooms/MemberInfo.js +++ b/src/components/views/rooms/MemberInfo.js @@ -229,7 +229,7 @@ module.exports = withMatrixClient(React.createClass({ const membership = this.props.member.membership; const kickLabel = membership === "invite" ? _t("Disinvite") : _t("Kick"); const ConfirmUserActionDialog = sdk.getComponent("dialogs.ConfirmUserActionDialog"); - Modal.createDialog(ConfirmUserActionDialog, { + Modal.createTrackedDialog('Confirm User Action Dialog', 'onKick', ConfirmUserActionDialog, { member: this.props.member, action: kickLabel, askReason: membership == "join", @@ -248,7 +248,7 @@ module.exports = withMatrixClient(React.createClass({ }, function(err) { const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); console.error("Kick error: " + err); - Modal.createDialog(ErrorDialog, { + Modal.createTrackedDialog('Failed to kick', '', ErrorDialog, { title: _t("Failed to kick"), description: ((err && err.message) ? err.message : "Operation failed"), }); @@ -262,7 +262,7 @@ module.exports = withMatrixClient(React.createClass({ onBanOrUnban: function() { const ConfirmUserActionDialog = sdk.getComponent("dialogs.ConfirmUserActionDialog"); - Modal.createDialog(ConfirmUserActionDialog, { + Modal.createTrackedDialog('Confirm User Action Dialog', 'onBanOrUnban', ConfirmUserActionDialog, { member: this.props.member, action: this.props.member.membership == 'ban' ? _t("Unban") : _t("Ban"), askReason: this.props.member.membership != 'ban', @@ -290,7 +290,7 @@ module.exports = withMatrixClient(React.createClass({ }, function(err) { const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); console.error("Ban error: " + err); - Modal.createDialog(ErrorDialog, { + Modal.createTrackedDialog('Failed to ban user', '', ErrorDialog, { title: _t("Error"), description: _t("Failed to ban user"), }); @@ -340,7 +340,7 @@ module.exports = withMatrixClient(React.createClass({ console.log("Mute toggle success"); }, function(err) { console.error("Mute error: " + err); - Modal.createDialog(ErrorDialog, { + Modal.createTrackedDialog('Failed to mute user', '', ErrorDialog, { title: _t("Error"), description: _t("Failed to mute user"), }); @@ -385,7 +385,7 @@ module.exports = withMatrixClient(React.createClass({ dis.dispatch({action: 'view_set_mxid'}); } else { console.error("Toggle moderator error:" + err); - Modal.createDialog(ErrorDialog, { + Modal.createTrackedDialog('Failed to toggle moderator status', '', ErrorDialog, { title: _t("Error"), description: _t("Failed to toggle moderator status"), }); @@ -406,7 +406,7 @@ module.exports = withMatrixClient(React.createClass({ }, function(err) { const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); console.error("Failed to change power level " + err); - Modal.createDialog(ErrorDialog, { + Modal.createTrackedDialog('Failed to change power level', '', ErrorDialog, { title: _t("Error"), description: _t("Failed to change power level"), }); @@ -435,7 +435,7 @@ module.exports = withMatrixClient(React.createClass({ var myPower = powerLevelEvent.getContent().users[this.props.matrixClient.credentials.userId]; if (parseInt(myPower) === parseInt(powerLevel)) { var QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); - Modal.createDialog(QuestionDialog, { + Modal.createTrackedDialog('Promote to PL100 Warning', '', QuestionDialog, { title: _t("Warning!"), description:
diff --git a/src/components/views/rooms/MemberList.js b/src/components/views/rooms/MemberList.js index 37a9bcd0cd..e5f4f08572 100644 --- a/src/components/views/rooms/MemberList.js +++ b/src/components/views/rooms/MemberList.js @@ -1,5 +1,6 @@ /* Copyright 2015, 2016 OpenMarket Ltd +Copyright 2017 Vector Creations Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -200,11 +201,9 @@ module.exports = React.createClass({ _createOverflowTile: function(overflowCount, totalCount) { // For now we'll pretend this is any entity. It should probably be a separate tile. - var EntityTile = sdk.getComponent("rooms.EntityTile"); - var BaseAvatar = sdk.getComponent("avatars.BaseAvatar"); - var text = (overflowCount > 1) - ? _t("and %(overflowCount)s others...", { overflowCount: overflowCount }) - : _t("and one other..."); + const EntityTile = sdk.getComponent("rooms.EntityTile"); + const BaseAvatar = sdk.getComponent("avatars.BaseAvatar"); + const text = _t("and %(count)s others...", { count: overflowCount }); return ( diff --git a/src/components/views/rooms/MessageComposer.js b/src/components/views/rooms/MessageComposer.js index 14f52706ec..51b595eab0 100644 --- a/src/components/views/rooms/MessageComposer.js +++ b/src/components/views/rooms/MessageComposer.js @@ -99,7 +99,7 @@ export default class MessageComposer extends React.Component { ); } - Modal.createDialog(QuestionDialog, { + Modal.createTrackedDialog('Upload Files confirmation', '', QuestionDialog, { title: _t('Upload Files'), description: (
diff --git a/src/components/views/rooms/MessageComposerInput.js b/src/components/views/rooms/MessageComposerInput.js index e19e5fb1b9..6d6cc0f3f4 100644 --- a/src/components/views/rooms/MessageComposerInput.js +++ b/src/components/views/rooms/MessageComposerInput.js @@ -31,6 +31,7 @@ import KeyCode from '../../../KeyCode'; import Modal from '../../../Modal'; import sdk from '../../../index'; import { _t } from '../../../languageHandler'; +import Analytics from '../../../Analytics'; import dis from '../../../dispatcher'; import UserSettingsStore from '../../../UserSettingsStore'; @@ -50,7 +51,7 @@ const REGEX_MATRIXTO_MARKDOWN_GLOBAL = new RegExp(MATRIXTO_MD_LINK_PATTERN, 'g') import {asciiRegexp, shortnameToUnicode, emojioneList, asciiList, mapUnicodeToShort} from 'emojione'; const EMOJI_SHORTNAMES = Object.keys(emojioneList); const EMOJI_UNICODE_TO_SHORTNAME = mapUnicodeToShort(); -const REGEX_EMOJI_WHITESPACE = new RegExp('(' + asciiRegexp + ')\\s$'); +const REGEX_EMOJI_WHITESPACE = new RegExp('(?:^|\\s)(' + asciiRegexp + ')\\s$'); const TYPING_USER_TIMEOUT = 10000, TYPING_SERVER_TIMEOUT = 30000; @@ -97,20 +98,39 @@ export default class MessageComposerInput extends React.Component { onInputStateChanged: React.PropTypes.func, }; - static getKeyBinding(e: SyntheticKeyboardEvent): string { - // C-m => Toggles between rich text and markdown modes - if (e.keyCode === KeyCode.KEY_M && KeyBindingUtil.isCtrlKeyCommand(e)) { - return 'toggle-mode'; + static getKeyBinding(ev: SyntheticKeyboardEvent): string { + const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0; + let ctrlCmdOnly; + if (isMac) { + ctrlCmdOnly = ev.metaKey && !ev.altKey && !ev.ctrlKey && !ev.shiftKey; + } else { + ctrlCmdOnly = ev.ctrlKey && !ev.altKey && !ev.metaKey && !ev.shiftKey; } - // Allow opening of dev tools. getDefaultKeyBinding would be 'italic' for KEY_I - if (e.keyCode === KeyCode.KEY_I && e.shiftKey && e.ctrlKey) { - // When null is returned, draft-js will NOT preventDefault, allowing dev tools - // to be toggled when the editor is focussed - return null; + // Restrict a subset of key bindings to ONLY having ctrl/meta* pressed and + // importantly NOT having alt, shift, meta/ctrl* pressed. draft-js does not + // handle this in `getDefaultKeyBinding` so we do it ourselves here. + // + // * if macOS, read second option + const ctrlCmdCommand = { + // C-m => Toggles between rich text and markdown modes + [KeyCode.KEY_M]: 'toggle-mode', + [KeyCode.KEY_B]: 'bold', + [KeyCode.KEY_I]: 'italic', + [KeyCode.KEY_U]: 'underline', + [KeyCode.KEY_J]: 'code', + [KeyCode.KEY_O]: 'split-block', + }[ev.keyCode]; + + if (ctrlCmdCommand) { + if (!ctrlCmdOnly) { + return null; + } + return ctrlCmdCommand; } - return getDefaultKeyBinding(e); + // Handle keys such as return, left and right arrows etc. + return getDefaultKeyBinding(ev); } static getBlockStyle(block: ContentBlock): ?string { @@ -141,6 +161,8 @@ export default class MessageComposerInput extends React.Component { const isRichtextEnabled = UserSettingsStore.getSyncedSetting('MessageComposerInput.isRichTextEnabled', false); + Analytics.setRichtextMode(isRichtextEnabled); + this.state = { // whether we're in rich text or markdown mode isRichtextEnabled, @@ -165,17 +187,18 @@ export default class MessageComposerInput extends React.Component { this.client = MatrixClientPeg.get(); } - findLinkEntities(contentBlock, callback) { + findLinkEntities(contentState: ContentState, contentBlock: ContentBlock, callback) { contentBlock.findEntityRanges( (character) => { const entityKey = character.getEntity(); return ( entityKey !== null && - Entity.get(entityKey).getType() === 'LINK' + contentState.getEntity(entityKey).getType() === 'LINK' ); }, callback, ); } + /* * "Does the right thing" to create an EditorState, based on: * - whether we've got rich text mode enabled @@ -184,13 +207,19 @@ export default class MessageComposerInput extends React.Component { createEditorState(richText: boolean, contentState: ?ContentState): EditorState { const decorators = richText ? RichText.getScopedRTDecorators(this.props) : RichText.getScopedMDDecorators(this.props); + const shouldShowPillAvatar = !UserSettingsStore.getSyncedSetting("Pill.shouldHidePillAvatar", false); decorators.push({ strategy: this.findLinkEntities.bind(this), component: (entityProps) => { const Pill = sdk.getComponent('elements.Pill'); - const {url} = Entity.get(entityProps.entityKey).getData(); + const {url} = entityProps.contentState.getEntity(entityProps.entityKey).getData(); if (Pill.isPillUrl(url)) { - return ; + return ; } return ( @@ -243,7 +272,8 @@ export default class MessageComposerInput extends React.Component { // paths for inserting a user pill is not fun const selection = this.state.editorState.getSelection(); const member = this.props.room.getMember(payload.user_id); - const completion = member ? member.name.replace(' (IRC)', '') : payload.user_id; + const completion = member ? + member.rawDisplayName.replace(' (IRC)', '') : payload.user_id; this.setDisplayedCompletion({ completion, selection, @@ -253,10 +283,12 @@ export default class MessageComposerInput extends React.Component { } break; case 'quote': { - let {body, formatted_body} = payload.event.getContent(); - formatted_body = formatted_body || escape(body); - if (formatted_body) { - let content = RichText.htmlToContentState(`
${formatted_body}
`); + /// XXX: Not doing rich-text quoting from formatted-body because draft-js + /// has regressed such that when links are quoted, errors are thrown. See + /// https://github.com/vector-im/riot-web/issues/4756. + let body = escape(payload.text); + if (body) { + let content = RichText.htmlToContentState(`
${body}
`); if (!this.state.isRichtextEnabled) { content = ContentState.createFromText(RichText.stateToMarkdown(content)); } @@ -393,7 +425,7 @@ export default class MessageComposerInput extends React.Component { const newContentState = Modifier.replaceText( editorState.getCurrentContent(), currentSelection.merge({ - anchorOffset: currentStartOffset - emojiMatch[0].length, + anchorOffset: currentStartOffset - emojiMatch[1].length - 1, focusOffset: currentStartOffset, }), unicodeEmoji, @@ -427,6 +459,19 @@ export default class MessageComposerInput extends React.Component { state.editorState = RichText.attachImmutableEntitiesToEmoji( state.editorState); + // Hide the autocomplete if the cursor location changes but the plaintext + // content stays the same. We don't hide if the pt has changed because the + // autocomplete will probably have different completions to show. + if ( + !state.editorState.getSelection().equals( + this.state.editorState.getSelection() + ) + && state.editorState.getCurrentContent().getPlainText() === + this.state.editorState.getCurrentContent().getPlainText() + ) { + this.autocomplete.hide(); + } + if (state.editorState.getCurrentContent().hasText()) { this.onTypingActivity(); } else { @@ -451,14 +496,20 @@ export default class MessageComposerInput extends React.Component { callback(); } + const textContent = this.state.editorState.getCurrentContent().getPlainText(); + const selection = RichText.selectionStateToTextOffsets( + this.state.editorState.getSelection(), + this.state.editorState.getCurrentContent().getBlocksAsArray()); if (this.props.onContentChanged) { - const textContent = this.state.editorState - .getCurrentContent().getPlainText(); - const selection = RichText.selectionStateToTextOffsets( - this.state.editorState.getSelection(), - this.state.editorState.getCurrentContent().getBlocksAsArray()); this.props.onContentChanged(textContent, selection); } + + // Scroll to the bottom of the editor if the cursor is on the last line of the + // composer. For some reason the editor won't scroll automatically if we paste + // blocks of text in or insert newlines. + if (textContent.slice(selection.start).indexOf("\n") === -1) { + this.refs.editor.refs.editor.scrollTop = this.refs.editor.refs.editor.scrollHeight; + } }); } @@ -477,6 +528,8 @@ export default class MessageComposerInput extends React.Component { contentState = ContentState.createFromText(markdown); } + Analytics.setRichtextMode(enabled); + this.setState({ editorState: this.createEditorState(enabled, contentState), isRichtextEnabled: enabled, @@ -496,14 +549,21 @@ export default class MessageComposerInput extends React.Component { // These are block types, not handled by RichUtils by default. const blockCommands = ['code-block', 'blockquote', 'unordered-list-item', 'ordered-list-item']; const currentBlockType = RichUtils.getCurrentBlockType(this.state.editorState); + + const shouldToggleBlockFormat = ( + command === 'backspace' || + command === 'split-block' + ) && currentBlockType !== 'unstyled'; + if (blockCommands.includes(command)) { newState = RichUtils.toggleBlockType(this.state.editorState, command); } else if (command === 'strike') { // this is the only inline style not handled by Draft by default newState = RichUtils.toggleInlineStyle(this.state.editorState, 'STRIKETHROUGH'); - } else if (command === 'backspace' && currentBlockType !== 'unstyled') { + } else if (shouldToggleBlockFormat) { const currentStartOffset = this.state.editorState.getSelection().getStartOffset(); - if (currentStartOffset === 0) { + const currentEndOffset = this.state.editorState.getSelection().getEndOffset(); + if (currentStartOffset === 0 && currentEndOffset === 0) { // Toggle current block type (setting it to 'unstyled') newState = RichUtils.toggleBlockType(this.state.editorState, currentBlockType); } @@ -638,7 +698,17 @@ export default class MessageComposerInput extends React.Component { let contentText = contentState.getPlainText(), contentHTML; - const cmd = SlashCommands.processInput(this.props.room.roomId, contentText); + // Strip MD user (tab-completed) mentions to preserve plaintext mention behaviour. + // We have to do this now as opposed to after calculating the contentText for MD + // mode because entity positions may not be maintained when using + // md.toPlaintext(). + // Unfortunately this means we lose mentions in history when in MD mode. This + // would be fixed if history was stored as contentState. + contentText = this.removeMDLinks(contentState, ['@']); + + // Some commands (/join) require pills to be replaced with their text content + const commandText = this.removeMDLinks(contentState, ['#']); + const cmd = SlashCommands.processInput(this.props.room.roomId, commandText); if (cmd) { if (!cmd.error) { this.setState({ @@ -651,7 +721,7 @@ export default class MessageComposerInput extends React.Component { }, function(err) { console.error("Command failure: %s", err); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - Modal.createDialog(ErrorDialog, { + Modal.createTrackedDialog('Server error', '', ErrorDialog, { title: _t("Server error"), description: ((err && err.message) ? err.message : _t("Server unavailable, overloaded, or something else went wrong.")), }); @@ -659,7 +729,8 @@ export default class MessageComposerInput extends React.Component { } else if (cmd.error) { console.error(cmd.error); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - Modal.createDialog(ErrorDialog, { + // TODO possibly track which command they ran (not its Arguments) here + Modal.createTrackedDialog('Command error', '', ErrorDialog, { title: _t("Command error"), description: cmd.error, }); @@ -691,7 +762,7 @@ export default class MessageComposerInput extends React.Component { const hasLink = blocks.some((block) => { return block.getCharacterList().filter((c) => { const entityKey = c.getEntity(); - return entityKey && Entity.get(entityKey).getType() === 'LINK'; + return entityKey && contentState.getEntity(entityKey).getType() === 'LINK'; }).size > 0; }); shouldSendHTML = hasLink; @@ -702,7 +773,31 @@ export default class MessageComposerInput extends React.Component { ); } } else { - const md = new Markdown(contentText); + // Use the original contentState because `contentText` has had mentions + // stripped and these need to end up in contentHTML. + + // Replace all Entities of type `LINK` with markdown link equivalents. + // TODO: move this into `Markdown` and do the same conversion in the other + // two places (toggling from MD->RT mode and loading MD history into RT mode) + // but this can only be done when history includes Entities. + const pt = contentState.getBlocksAsArray().map((block) => { + let blockText = block.getText(); + let offset = 0; + this.findLinkEntities(contentState, block, (start, end) => { + const entity = contentState.getEntity(block.getEntityAt(start)); + if (entity.getType() !== 'LINK') { + return; + } + const text = blockText.slice(offset + start, offset + end); + const url = entity.getData().url; + const mdLink = `[${text}](${url})`; + blockText = blockText.slice(0, offset + start) + mdLink + blockText.slice(offset + end); + offset += mdLink.length - text.length; + }); + return blockText; + }).join('\n'); + + const md = new Markdown(pt); if (md.isPlainText()) { contentText = md.toPlaintext(); } else { @@ -726,35 +821,6 @@ export default class MessageComposerInput extends React.Component { sendTextFn = this.client.sendEmoteMessage; } - // Strip MD user (tab-completed) mentions to preserve plaintext mention behaviour - contentText = contentText.replace(REGEX_MATRIXTO_MARKDOWN_GLOBAL, - (markdownLink, text, resource, prefix, offset) => { - // Calculate the offset relative to the current block that the offset is in - let sum = 0; - const blocks = contentState.getBlocksAsArray(); - let block; - for (let i = 0; i < blocks.length; i++) { - block = blocks[i]; - sum += block.getLength(); - if (sum > offset) { - sum -= block.getLength(); - break; - } - } - offset -= sum; - - const entityKey = block.getEntityAt(offset); - const entity = entityKey ? Entity.get(entityKey) : null; - if (entity && entity.getData().isCompletion && prefix === '@') { - // This is a completed mention, so do not insert MD link, just text - return text; - } else { - // This is either a MD link that was typed into the composer or another - // type of pill (e.g. room pill) - return markdownLink; - } - }); - let sendMessagePromise; if (contentHTML) { sendMessagePromise = sendHtmlFn.call( @@ -819,6 +885,7 @@ export default class MessageComposerInput extends React.Component { } } else { this.moveAutocompleteSelection(up); + e.preventDefault(); } }; @@ -914,35 +981,27 @@ export default class MessageComposerInput extends React.Component { } const {range = null, completion = '', href = null, suffix = ''} = displayedCompletion; + let contentState = activeEditorState.getCurrentContent(); let entityKey; - let mdCompletion; if (href) { - entityKey = Entity.create('LINK', 'IMMUTABLE', { + contentState = contentState.createEntity('LINK', 'IMMUTABLE', { url: href, isCompletion: true, }); - if (!this.state.isRichtextEnabled) { - mdCompletion = `[${completion}](${href})`; - } + entityKey = contentState.getLastCreatedEntityKey(); } let selection; if (range) { selection = RichText.textOffsetsToSelectionState( - range, activeEditorState.getCurrentContent().getBlocksAsArray(), + range, contentState.getBlocksAsArray(), ); } else { selection = activeEditorState.getSelection(); } - let contentState = Modifier.replaceText( - activeEditorState.getCurrentContent(), - selection, - mdCompletion || completion, - null, - entityKey, - ); + contentState = Modifier.replaceText(contentState, selection, completion, null, entityKey); // Move the selection to the end of the block const afterSelection = contentState.getSelectionAfter(); @@ -1002,6 +1061,44 @@ export default class MessageComposerInput extends React.Component { }; } + getAutocompleteQuery(contentState: ContentState) { + // Don't send markdown links to the autocompleter + return this.removeMDLinks(contentState, ['@', '#']); + } + + removeMDLinks(contentState: ContentState, prefixes: string[]) { + const plaintext = contentState.getPlainText(); + if (!plaintext) return ''; + return plaintext.replace(REGEX_MATRIXTO_MARKDOWN_GLOBAL, + (markdownLink, text, resource, prefix, offset) => { + if (!prefixes.includes(prefix)) return markdownLink; + // Calculate the offset relative to the current block that the offset is in + let sum = 0; + const blocks = contentState.getBlocksAsArray(); + let block; + for (let i = 0; i < blocks.length; i++) { + block = blocks[i]; + sum += block.getLength(); + if (sum > offset) { + sum -= block.getLength(); + break; + } + } + offset -= sum; + + const entityKey = block.getEntityAt(offset); + const entity = entityKey ? contentState.getEntity(entityKey) : null; + if (entity && entity.getData().isCompletion) { + // This is a completed mention, so do not insert MD link, just text + return text; + } else { + // This is either a MD link that was typed into the composer or another + // type of pill (e.g. room pill) + return markdownLink; + } + }); + } + onMarkdownToggleClicked = (e) => { e.preventDefault(); // don't steal focus from the editor! this.handleKeyCommand('toggle-mode'); @@ -1027,7 +1124,6 @@ export default class MessageComposerInput extends React.Component { }); const content = activeEditorState.getCurrentContent(); - const contentText = content.getPlainText(); const selection = RichText.selectionStateToTextOffsets(activeEditorState.getSelection(), activeEditorState.getCurrentContent().getBlocksAsArray()); @@ -1037,7 +1133,7 @@ export default class MessageComposerInput extends React.Component { this.autocomplete = e} onConfirm={this.setDisplayedCompletion} - query={contentText} + query={this.getAutocompleteQuery(content)} selection={selection}/>
diff --git a/src/components/views/rooms/RoomHeader.js b/src/components/views/rooms/RoomHeader.js index 85aedadf64..edd89e4a35 100644 --- a/src/components/views/rooms/RoomHeader.js +++ b/src/components/views/rooms/RoomHeader.js @@ -119,7 +119,7 @@ module.exports = React.createClass({ const errMsg = (typeof err === "string") ? err : (err.error || ""); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); console.error("Failed to set avatar: " + errMsg); - Modal.createDialog(ErrorDialog, { + Modal.createTrackedDialog('Failed to set avatar', '', ErrorDialog, { title: _t("Error"), description: _t("Failed to set avatar."), }); diff --git a/src/components/views/rooms/RoomSettings.js b/src/components/views/rooms/RoomSettings.js index d6a973f648..58473f1fb3 100644 --- a/src/components/views/rooms/RoomSettings.js +++ b/src/components/views/rooms/RoomSettings.js @@ -46,7 +46,7 @@ const BannedUser = React.createClass({ _onUnbanClick: function() { const ConfirmUserActionDialog = sdk.getComponent("dialogs.ConfirmUserActionDialog"); - Modal.createDialog(ConfirmUserActionDialog, { + Modal.createTrackedDialog('Confirm User Action Dialog', 'onUnbanClick', ConfirmUserActionDialog, { member: this.props.member, action: _t('Unban'), danger: false, @@ -58,7 +58,7 @@ const BannedUser = React.createClass({ ).catch((err) => { const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); console.error("Failed to unban: " + err); - Modal.createDialog(ErrorDialog, { + Modal.createTrackedDialog('Failed to unban', '', ErrorDialog, { title: _t('Error'), description: _t('Failed to unban'), }); @@ -423,7 +423,7 @@ module.exports = React.createClass({ ev.preventDefault(); var value = ev.target.value; - Modal.createDialog(QuestionDialog, { + Modal.createTrackedDialog('Privacy warning', '', QuestionDialog, { title: _t('Privacy warning'), description:
@@ -516,7 +516,7 @@ module.exports = React.createClass({ onManageIntegrations(ev) { ev.preventDefault(); var IntegrationsManager = sdk.getComponent("views.settings.IntegrationsManager"); - Modal.createDialog(IntegrationsManager, { + Modal.createTrackedDialog('Integrations Manager', 'onManageIntegrations', IntegrationsManager, { src: (this.scalarClient !== null && this.scalarClient.hasCredentials()) ? this.scalarClient.getScalarInterfaceUrlForRoom(this.props.room.roomId) : null, @@ -549,7 +549,7 @@ module.exports = React.createClass({ }, function(err) { var errCode = err.errcode || _t('unknown error code'); var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - Modal.createDialog(ErrorDialog, { + Modal.createTrackedDialog('Failed to forget room', '', ErrorDialog, { title: _t('Error'), description: _t("Failed to forget room %(errCode)s", { errCode: errCode }), }); @@ -560,7 +560,7 @@ module.exports = React.createClass({ if (!this.refs.encrypt.checked) return; var QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); - Modal.createDialog(QuestionDialog, { + Modal.createTrackedDialog('E2E Enable Warning', '', QuestionDialog, { title: _t('Warning!'), description: (
diff --git a/src/components/views/rooms/SearchableEntityList.js b/src/components/views/rooms/SearchableEntityList.js index 4412fc539f..45ad2c6b9e 100644 --- a/src/components/views/rooms/SearchableEntityList.js +++ b/src/components/views/rooms/SearchableEntityList.js @@ -117,9 +117,7 @@ var SearchableEntityList = React.createClass({ _createOverflowEntity: function(overflowCount, totalCount) { var EntityTile = sdk.getComponent("rooms.EntityTile"); var BaseAvatar = sdk.getComponent("avatars.BaseAvatar"); - var text = (overflowCount > 1) - ? _t("and %(overflowCount)s others...", { overflowCount: overflowCount }) - : _t("and one other..."); + const text = _t("and %(count)s others...", { count: overflowCount }); return ( diff --git a/src/components/views/settings/AddPhoneNumber.js b/src/components/views/settings/AddPhoneNumber.js index 7bc551477e..16e768a23f 100644 --- a/src/components/views/settings/AddPhoneNumber.js +++ b/src/components/views/settings/AddPhoneNumber.js @@ -82,7 +82,7 @@ export default withMatrixClient(React.createClass({ }).catch((err) => { console.error("Unable to add phone number: " + err); let msg = err.message; - Modal.createDialog(ErrorDialog, { + Modal.createTrackedDialog('Add Phone Number Error', '', ErrorDialog, { title: _t("Error"), description: msg, }); @@ -107,7 +107,7 @@ export default withMatrixClient(React.createClass({ } msgElements.push(
{msg}
); } - Modal.createDialog(TextInputDialog, { + Modal.createTrackedDialog('Prompt for MSISDN Verification Code', '', TextInputDialog, { title: _t("Enter Code"), description:
{msgElements}
, button: _t("Submit"), diff --git a/src/components/views/settings/ChangePassword.js b/src/components/views/settings/ChangePassword.js index 14ec9806b4..f3c0d9033c 100644 --- a/src/components/views/settings/ChangePassword.js +++ b/src/components/views/settings/ChangePassword.js @@ -104,7 +104,7 @@ module.exports = React.createClass({ } const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); - Modal.createDialog(QuestionDialog, { + Modal.createTrackedDialog('Change Password', '', QuestionDialog, { title: _t("Warning!"), description:
@@ -164,7 +164,7 @@ module.exports = React.createClass({ const deferred = Promise.defer(); // Ask for an email otherwise the user has no way to reset their password const SetEmailDialog = sdk.getComponent("dialogs.SetEmailDialog"); - Modal.createDialog(SetEmailDialog, { + Modal.createTrackedDialog('Do you want to set an email address?', '', SetEmailDialog, { title: _t('Do you want to set an email address?'), onFinished: (confirmed) => { // ignore confirmed, setting an email is optional @@ -175,15 +175,13 @@ module.exports = React.createClass({ }, _onExportE2eKeysClicked: function() { - Modal.createDialogAsync( - (cb) => { - require.ensure(['../../../async-components/views/dialogs/ExportE2eKeysDialog'], () => { - cb(require('../../../async-components/views/dialogs/ExportE2eKeysDialog')); - }, "e2e-export"); - }, { - matrixClient: MatrixClientPeg.get(), - } - ); + Modal.createTrackedDialogAsync('Export E2E Keys', 'Change Password', (cb) => { + require.ensure(['../../../async-components/views/dialogs/ExportE2eKeysDialog'], () => { + cb(require('../../../async-components/views/dialogs/ExportE2eKeysDialog')); + }, "e2e-export"); + }, { + matrixClient: MatrixClientPeg.get(), + }); }, onClickChange: function() { diff --git a/src/components/views/settings/DevicesPanelEntry.js b/src/components/views/settings/DevicesPanelEntry.js index f295a7c2d5..69534f09b1 100644 --- a/src/components/views/settings/DevicesPanelEntry.js +++ b/src/components/views/settings/DevicesPanelEntry.js @@ -71,8 +71,8 @@ export default class DevicesPanelEntry extends React.Component { // pop up an interactive auth dialog var InteractiveAuthDialog = sdk.getComponent("dialogs.InteractiveAuthDialog"); - Modal.createDialog(InteractiveAuthDialog, { - title: _t("Authentication"), + Modal.createTrackedDialog('Delete Device Dialog', InteractiveAuthDialog, { + title: _t("Authentication"), matrixClient: MatrixClientPeg.get(), authData: error.data, makeRequest: this._makeDeleteRequest, diff --git a/src/createRoom.js b/src/createRoom.js index 74e4b3c2fc..944c6a70a1 100644 --- a/src/createRoom.js +++ b/src/createRoom.js @@ -115,7 +115,7 @@ function createRoom(opts) { action: 'join_room_error', }); console.error("Failed to create room " + roomId + " " + err); - Modal.createDialog(ErrorDialog, { + Modal.createTrackedDialog('Failure to create room', '', ErrorDialog, { title: _t("Failure to create room"), description: _t("Server may be unavailable, overloaded, or you hit a bug."), }); diff --git a/src/i18n/strings/de_DE.json b/src/i18n/strings/de_DE.json index 68cfec2cd9..e855abffc0 100644 --- a/src/i18n/strings/de_DE.json +++ b/src/i18n/strings/de_DE.json @@ -552,8 +552,10 @@ "Failed to forget room %(errCode)s": "Das Entfernen des Raums ist fehlgeschlagen %(errCode)s", "Failed to join the room": "Fehler beim Betreten des Raumes", "A text message has been sent to +%(msisdn)s. Please enter the verification code it contains": "Eine Textnachricht wurde an +%(msisdn)s gesendet. Bitte gebe den Verifikationscode ein, den er beinhaltet", - "and %(overflowCount)s others...": "und %(overflowCount)s weitere...", - "and one other...": "und ein(e) weitere(r)...", + "and %(count)s others...": { + "other": "und %(count)s weitere...", + "one": "und ein(e) weitere(r)..." + }, "Are you sure?": "Bist du sicher?", "Attachment": "Anhang", "Ban": "Dauerhaft aus dem Raum ausschließen", diff --git a/src/i18n/strings/el.json b/src/i18n/strings/el.json index d339508488..bf96278fbd 100644 --- a/src/i18n/strings/el.json +++ b/src/i18n/strings/el.json @@ -175,8 +175,10 @@ "an address": "μία διεύθηνση", "%(items)s and %(remaining)s others": "%(items)s και %(remaining)s ακόμα", "%(items)s and one other": "%(items)s και ένας ακόμα", - "and %(overflowCount)s others...": "και %(overflowCount)s άλλοι...", - "and one other...": "και ένας ακόμα...", + "and %(count)s others...": { + "other": "και %(count)s άλλοι...", + "one": "και ένας ακόμα..." + }, "%(names)s and %(lastPerson)s are typing": "%(names)s και %(lastPerson)s γράφουν", "%(names)s and one other are typing": "%(names)s και ένας ακόμα γράφουν", "%(names)s and %(count)s others are typing": "%(names)s και %(count)s άλλοι γράφουν", diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index c6c563ac5b..83be31de4b 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -134,6 +134,7 @@ "Add phone number": "Add phone number", "Admin": "Admin", "Admin tools": "Admin tools", + "Allow": "Allow", "And %(count)s more...": "And %(count)s more...", "VoIP": "VoIP", "Missing Media Permissions, click here to request.": "Missing Media Permissions, click here to request.", @@ -157,8 +158,10 @@ "%(items)s and %(remaining)s others": "%(items)s and %(remaining)s others", "%(items)s and one other": "%(items)s and one other", "%(items)s and %(lastItem)s": "%(items)s and %(lastItem)s", - "and %(overflowCount)s others...": "and %(overflowCount)s others...", - "and one other...": "and one other...", + "and %(count)s others...": { + "other": "and %(count)s others...", + "one": "and one other..." + }, "%(names)s and %(lastPerson)s are typing": "%(names)s and %(lastPerson)s are typing", "%(names)s and one other are typing": "%(names)s and one other are typing", "%(names)s and %(count)s others are typing": "%(names)s and %(count)s others are typing", @@ -239,6 +242,7 @@ "Decrypt %(text)s": "Decrypt %(text)s", "Decryption error": "Decryption error", "Delete": "Delete", + "Delete widget": "Delete widget", "demote": "demote", "Deops user with given id": "Deops user with given id", "Default": "Default", @@ -265,6 +269,7 @@ "Drop here %(toAction)s": "Drop here %(toAction)s", "Drop here to tag %(section)s": "Drop here to tag %(section)s", "Ed25519 fingerprint": "Ed25519 fingerprint", + "Edit": "Edit", "Email": "Email", "Email address": "Email address", "Email address (optional)": "Email address (optional)", @@ -340,6 +345,8 @@ "had": "had", "Hangup": "Hangup", "Hide Apps": "Hide Apps", + "Hide join/leave messages (invites/kicks/bans unaffected)": "Hide join/leave messages (invites/kicks/bans unaffected)", + "Hide avatar and display name changes": "Hide avatar and display name changes", "Hide read receipts": "Hide read receipts", "Hide Text Formatting Toolbar": "Hide Text Formatting Toolbar", "Historical": "Historical", @@ -457,6 +464,7 @@ "Reason": "Reason", "Reason: %(reasonText)s": "Reason: %(reasonText)s", "Revoke Moderator": "Revoke Moderator", + "Revoke widget access": "Revoke widget access", "Refer a friend to Riot:": "Refer a friend to Riot:", "Register": "Register", "rejected": "rejected", @@ -568,6 +576,7 @@ "To configure the room": "To configure the room", "to demote": "to demote", "to favourite": "to favourite", + "To get started, please pick a username!": "To get started, please pick a username!", "To invite users into the room": "To invite users into the room", "To kick users": "To kick users", "To link to a room it must have an address.": "To link to a room it must have an address.", @@ -959,5 +968,6 @@ "Edit Group": "Edit Group", "Automatically replace plain text Emoji": "Automatically replace plain text Emoji", "Failed to upload image": "Failed to upload image", - "Failed to update group": "Failed to update group" + "Failed to update group": "Failed to update group", + "Hide avatars in user and room mentions": "Hide avatars in user and room mentions" } diff --git a/src/i18n/strings/en_US.json b/src/i18n/strings/en_US.json index 6a2a326870..ae750c6476 100644 --- a/src/i18n/strings/en_US.json +++ b/src/i18n/strings/en_US.json @@ -154,8 +154,10 @@ "%(items)s and %(remaining)s others": "%(items)s and %(remaining)s others", "%(items)s and one other": "%(items)s and one other", "%(items)s and %(lastItem)s": "%(items)s and %(lastItem)s", - "and %(overflowCount)s others...": "and %(overflowCount)s others...", - "and one other...": "and one other...", + "and %(count)s others...": { + "other": "and %(count)s others...", + "one": "and one other..." + }, "%(names)s and %(lastPerson)s are typing": "%(names)s and %(lastPerson)s are typing", "%(names)s and one other are typing": "%(names)s and one other are typing", "%(names)s and %(count)s others are typing": "%(names)s and %(count)s others are typing", @@ -231,6 +233,7 @@ "demote": "demote", "Deops user with given id": "Deops user with given id", "Default": "Default", + "Delete widget": "Delete widget", "Device already verified!": "Device already verified!", "Device ID": "Device ID", "Device ID:": "Device ID:", @@ -250,6 +253,7 @@ "Drop here %(toAction)s": "Drop here %(toAction)s", "Drop here to tag %(section)s": "Drop here to tag %(section)s", "Ed25519 fingerprint": "Ed25519 fingerprint", + "Edit": "Edit", "Email": "Email", "Email address": "Email address", "Email address (optional)": "Email address (optional)", @@ -419,6 +423,7 @@ "Profile": "Profile", "Reason": "Reason", "Revoke Moderator": "Revoke Moderator", + "Revoke widget access": "Revoke widget access", "Refer a friend to Riot:": "Refer a friend to Riot:", "Register": "Register", "rejected": "rejected", diff --git a/src/i18n/strings/es.json b/src/i18n/strings/es.json index f7506b946d..05b1abffd7 100644 --- a/src/i18n/strings/es.json +++ b/src/i18n/strings/es.json @@ -140,8 +140,10 @@ "%(items)s and %(remaining)s others": "%(items)s y %(remaining)s otros", "%(items)s and one other": "%(items)s y otro", "%(items)s and %(lastItem)s": "%(items)s y %(lastItem)s", - "and %(overflowCount)s others...": "y %(overflowCount)s otros...", - "and one other...": "y otro...", + "and %(count)s others...": { + "other": "y %(count)s otros...", + "one": "y otro..." + }, "%(names)s and %(lastPerson)s are typing": "%(names)s y %(lastPerson)s están escribiendo", "%(names)s and one other are typing": "%(names)s y otro están escribiendo", "%(names)s and %(count)s others are typing": "%(names)s y %(count)s otros están escribiendo", diff --git a/src/i18n/strings/fr.json b/src/i18n/strings/fr.json index 9f6fdf3391..9ae015eaff 100644 --- a/src/i18n/strings/fr.json +++ b/src/i18n/strings/fr.json @@ -188,8 +188,10 @@ "%(items)s and %(remaining)s others": "%(items)s et %(remaining)s autres", "%(items)s and one other": "%(items)s et un autre", "%(items)s and %(lastItem)s": "%(items)s et %(lastItem)s", - "and %(overflowCount)s others...": "et %(overflowCount)s autres...", - "and one other...": "et un autre...", + "and %(count)s others...": { + "other": "et %(count)s autres...", + "one": "et un autre..." + }, "%(names)s and %(lastPerson)s are typing": "%(names)s et %(lastPerson)s sont en train de taper", "%(names)s and one other are typing": "%(names)s et un autre sont en train de taper", "%(names)s and %(count)s others are typing": "%(names)s et %(count)s d'autres sont en train de taper", diff --git a/src/i18n/strings/hu.json b/src/i18n/strings/hu.json index 8335976a06..2d1f07c34e 100644 --- a/src/i18n/strings/hu.json +++ b/src/i18n/strings/hu.json @@ -190,8 +190,10 @@ "%(items)s and %(remaining)s others": "%(items)s és még: %(remaining)s", "%(items)s and one other": "%(items)s és még egy", "%(items)s and %(lastItem)s": "%(items)s és %(lastItem)s", - "and %(overflowCount)s others...": "és még: %(overflowCount)s ...", - "and one other...": "és még egy...", + "and %(count)s others...": { + "other": "és még: %(count)s ...", + "one": "és még egy..." + }, "%(names)s and %(lastPerson)s are typing": "%(names)s és %(lastPerson)s írnak", "%(names)s and one other are typing": "%(names)s és még valaki ír", "%(names)s and %(count)s others are typing": "%(names)s és %(count)s ember ír", diff --git a/src/i18n/strings/ko.json b/src/i18n/strings/ko.json index 601f7d1ce0..52970db2ed 100644 --- a/src/i18n/strings/ko.json +++ b/src/i18n/strings/ko.json @@ -225,8 +225,10 @@ "%(items)s and %(remaining)s others": "%(items)s과 %(remaining)s", "%(items)s and one other": "%(items)s과 다른 하나", "%(items)s and %(lastItem)s": "%(items)s과 %(lastItem)s", - "and %(overflowCount)s others...": "그리고 %(overflowCount)s...", - "and one other...": "그리고 다른 하나...", + "and %(count)s others...": { + "other": "그리고 %(count)s...", + "one": "그리고 다른 하나..." + }, "%(names)s and %(lastPerson)s are typing": "%(names)s님과 %(lastPerson)s님이 입력중", "%(names)s and one other are typing": "%(names)s님과 다른 분이 입력중", "%(names)s and %(count)s others are typing": "%(names)s님과 %(count)s 분들이 입력중", diff --git a/src/i18n/strings/nl.json b/src/i18n/strings/nl.json index 95c5e0ee78..ae2cf977ea 100644 --- a/src/i18n/strings/nl.json +++ b/src/i18n/strings/nl.json @@ -141,8 +141,10 @@ "%(items)s and %(remaining)s others": "%(items)s en %(remaining)s andere", "%(items)s and one other": "%(items)s en één andere", "%(items)s and %(lastItem)s": "%(items)s en %(lastItem)s", - "and %(overflowCount)s others...": "en %(overflowCount)s andere...", - "and one other...": "en één andere...", + "and %(count)s others...": { + "other": "en %(count)s andere...", + "one": "en één andere..." + }, "%(names)s and %(lastPerson)s are typing": "%(names)s en %(lastPerson)s zijn aan het typen", "%(names)s and one other are typing": "%(names)s en één andere zijn aan het typen", "%(names)s and %(count)s others are typing": "%(names)s en %(count)s andere zijn aan het typen", diff --git a/src/i18n/strings/pt.json b/src/i18n/strings/pt.json index 0405d000af..16ffc1f107 100644 --- a/src/i18n/strings/pt.json +++ b/src/i18n/strings/pt.json @@ -556,8 +556,10 @@ "%(items)s and %(remaining)s others": "%(items)s e %(remaining)s outros", "%(items)s and one other": "%(items)s e um outro", "%(items)s and %(lastItem)s": "%(items)s e %(lastItem)s", - "and %(overflowCount)s others...": "e %(overflowCount)s outros...", - "and one other...": "e um outro...", + "and %(count)s others...": { + "other": "e %(count)s outros...", + "one": "e um outro..." + }, "Are you sure?": "Você tem certeza?", "Attachment": "Anexo", "Autoplay GIFs and videos": "Reproduzir automaticamente GIFs e videos", diff --git a/src/i18n/strings/pt_BR.json b/src/i18n/strings/pt_BR.json index 4820104aa1..330dc8f143 100644 --- a/src/i18n/strings/pt_BR.json +++ b/src/i18n/strings/pt_BR.json @@ -557,8 +557,10 @@ "%(items)s and %(remaining)s others": "%(items)s e %(remaining)s outros", "%(items)s and one other": "%(items)s e um outro", "%(items)s and %(lastItem)s": "%(items)s e %(lastItem)s", - "and %(overflowCount)s others...": "e %(overflowCount)s outros...", - "and one other...": "e um outro...", + "and %(count)s others...": { + "other": "e %(count)s outros...", + "one": "e um outro..." + }, "Are you sure?": "Você tem certeza?", "Attachment": "Anexo", "Autoplay GIFs and videos": "Reproduzir automaticamente GIFs e videos", diff --git a/src/i18n/strings/ru.json b/src/i18n/strings/ru.json index 76a047d079..cad0617ae2 100644 --- a/src/i18n/strings/ru.json +++ b/src/i18n/strings/ru.json @@ -448,7 +448,10 @@ "sx": "Суту", "zh-hk": "Китайский (Гонконг)", "A text message has been sent to +%(msisdn)s. Please enter the verification code it contains": "На +%(msisdn)s было отправлено текстовое сообщение. Пожалуйста, введите проверочный код из него", - "and %(overflowCount)s others...": "и %(overflowCount)s других...", + "and %(count)s others...": { + "other": "и %(count)s других...", + "one": "и ещё один..." + }, "Are you sure?": "Вы уверены?", "Autoplay GIFs and videos": "Проигрывать GIF и видео автоматически", "Can't connect to homeserver - please check your connectivity and ensure your homeserver's SSL certificate is trusted.": "Невозможно соединиться с домашним сервером - проверьте своё соединение и убедитесь, что SSL-сертификат вашего домашнего сервера включён в доверяемые.", @@ -479,7 +482,6 @@ "%(items)s and %(remaining)s others": "%(items)s и другие %(remaining)s", "%(items)s and one other": "%(items)s и ещё один", "%(items)s and %(lastItem)s": "%(items)s и %(lastItem)s", - "and one other...": "и ещё один...", "An error has occurred.": "Произошла ошибка.", "Attachment": "Вложение", "Ban": "Запретить", diff --git a/src/i18n/strings/sv.json b/src/i18n/strings/sv.json index 21bda3b741..549e06c991 100644 --- a/src/i18n/strings/sv.json +++ b/src/i18n/strings/sv.json @@ -150,8 +150,10 @@ "%(items)s and %(remaining)s others": "%(items)s och %(remaining)s andra", "%(items)s and one other": "%(items)s och en annan", "%(items)s and %(lastItem)s": "%(items)s och %(lastItem)s", - "and %(overflowCount)s others...": "och %(overflowCount)s andra...", - "and one other...": "och en annan...", + "and %(count)s others...": { + "other": "och %(count)s andra...", + "one": "och en annan..." + }, "%(names)s and %(lastPerson)s are typing": "%(names)s och %(lastPerson)s skriver", "%(names)s and one other are typing": "%(names)s och en annan skriver", "%(names)s and %(count)s others are typing": "%(names)s och %(count)s andra skriver", diff --git a/src/i18n/strings/th.json b/src/i18n/strings/th.json index 5dff4816ee..265c3e1b58 100644 --- a/src/i18n/strings/th.json +++ b/src/i18n/strings/th.json @@ -98,8 +98,10 @@ "%(items)s and %(remaining)s others": "%(items)s และอีก %(remaining)s ผู้ใช้", "%(items)s and one other": "%(items)s และอีกหนึ่งผู้ใช้", "%(items)s and %(lastItem)s": "%(items)s และ %(lastItem)s", - "and %(overflowCount)s others...": "และอีก %(overflowCount)s ผู้ใช้...", - "and one other...": "และอีกหนึ่งผู้ใช้...", + "and %(count)s others...": { + "other": "และอีก %(count)s ผู้ใช้...", + "one": "และอีกหนึ่งผู้ใช้..." + }, "%(names)s and %(lastPerson)s are typing": "%(names)s และ %(lastPerson)s กำลังพิมพ์", "%(names)s and one other are typing": "%(names)s และอีกหนึ่งคนกำลังพิมพ์", "%(names)s and %(count)s others are typing": "%(names)s และอีก %(count)s คนกำลังพิมพ์", diff --git a/src/i18n/strings/tr.json b/src/i18n/strings/tr.json index 3d0d774926..effc426464 100644 --- a/src/i18n/strings/tr.json +++ b/src/i18n/strings/tr.json @@ -156,8 +156,10 @@ "%(items)s and %(remaining)s others": "%(items)s ve %(remaining)s diğerleri", "%(items)s and one other": "%(items)s ve bir başkası", "%(items)s and %(lastItem)s": "%(items)s ve %(lastItem)s", - "and %(overflowCount)s others...": "ve %(overflowCount)s diğerleri...", - "and one other...": "ve bir diğeri...", + "and %(count)s others...": { + "other": "ve %(count)s diğerleri...", + "one": "ve bir diğeri..." + }, "%(names)s and %(lastPerson)s are typing": "%(names)s ve %(lastPerson)s yazıyorlar", "%(names)s and one other are typing": "%(names)s ve birisi yazıyor", "%(names)s and %(count)s others are typing": "%(names)s ve %(count)s diğeri yazıyor", diff --git a/src/i18n/strings/zh_Hans.json b/src/i18n/strings/zh_Hans.json index 9fdc7a0b42..ad56ab45b8 100644 --- a/src/i18n/strings/zh_Hans.json +++ b/src/i18n/strings/zh_Hans.json @@ -239,8 +239,10 @@ "%(items)s and %(remaining)s others": "%(items)s 和其它 %(remaining)s 个", "%(items)s and one other": "%(items)s 和其它一个", "%(items)s and %(lastItem)s": "%(items)s 和 %(lastItem)s", - "and %(overflowCount)s others...": "和其它 %(overflowCount)s 个...", - "and one other...": "和其它一个...", + "and %(count)s others...": { + "other": "和其它 %(count)s 个...", + "one": "和其它一个..." + }, "%(names)s and one other are typing": "%(names)s 和另一个人正在打字", "anyone": "任何人", "Anyone": "任何人", diff --git a/src/shouldHideEvent.js b/src/shouldHideEvent.js new file mode 100644 index 0000000000..1501e28875 --- /dev/null +++ b/src/shouldHideEvent.js @@ -0,0 +1,50 @@ +/* + Copyright 2017 Michael Telatynski <7t3chguy@gmail.com> + + 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. + */ + +function memberEventDiff(ev) { + const diff = { + isMemberEvent: ev.getType() === 'm.room.member', + }; + + // If is not a Member Event then the other checks do not apply, so bail early. + if (!diff.isMemberEvent) return diff; + + const content = ev.getContent(); + const prevContent = ev.getPrevContent(); + + diff.isJoin = content.membership === 'join' && prevContent.membership !== 'ban'; + diff.isPart = content.membership === 'leave' && ev.getStateKey() === ev.getSender(); + + const isJoinToJoin = content.membership === prevContent.membership && content.membership === 'join'; + diff.isDisplaynameChange = isJoinToJoin && content.displayname !== prevContent.displayname; + diff.isAvatarChange = isJoinToJoin && content.avatar_url !== prevContent.avatar_url; + return diff; +} + +export default function shouldHideEvent(ev, syncedSettings) { + // Hide redacted events + if (syncedSettings['hideRedactions'] && ev.isRedacted()) return true; + + const eventDiff = memberEventDiff(ev); + + if (eventDiff.isMemberEvent) { + if (syncedSettings['hideJoinLeaves'] && (eventDiff.isJoin || eventDiff.isPart)) return true; + const isMemberAvatarDisplaynameChange = eventDiff.isAvatarChange || eventDiff.isDisplaynameChange; + if (syncedSettings['hideAvatarDisplaynameChanges'] && isMemberAvatarDisplaynameChange) return true; + } + + return false; +} diff --git a/src/stores/RoomViewStore.js b/src/stores/RoomViewStore.js index 865caa8997..bd9d3ea0fa 100644 --- a/src/stores/RoomViewStore.js +++ b/src/stores/RoomViewStore.js @@ -221,7 +221,7 @@ class RoomViewStore extends Store { }); const msg = err.message ? err.message : JSON.stringify(err); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - Modal.createDialog(ErrorDialog, { + Modal.createTrackedDialog('Failed to join room', '', ErrorDialog, { title: _t("Failed to join room"), description: msg, }); diff --git a/test/components/structures/MessagePanel-test.js b/test/components/structures/MessagePanel-test.js index a96d0ed5d1..ad7d9c15c7 100644 --- a/test/components/structures/MessagePanel-test.js +++ b/test/components/structures/MessagePanel-test.js @@ -18,10 +18,12 @@ var React = require('react'); var ReactDOM = require("react-dom"); var TestUtils = require('react-addons-test-utils'); var expect = require('expect'); +import sinon from 'sinon'; var sdk = require('matrix-react-sdk'); var MessagePanel = sdk.getComponent('structures.MessagePanel'); +import UserSettingsStore from '../../../src/UserSettingsStore'; var test_utils = require('test-utils'); var mockclock = require('mock-clock'); @@ -54,9 +56,10 @@ describe('MessagePanel', function () { test_utils.beforeEach(this); client = test_utils.createTestClient(); client.credentials = {userId: '@me:here'}; + UserSettingsStore.getSyncedSettings = sinon.stub().returns({}); }); - afterEach(function () { + afterEach(function() { clock.uninstall(); });