diff --git a/.eslintignore.errorfiles b/.eslintignore.errorfiles index 498dfb8818..50350f983a 100644 --- a/.eslintignore.errorfiles +++ b/.eslintignore.errorfiles @@ -6,7 +6,6 @@ src/autocomplete/Autocompleter.js src/autocomplete/Components.js src/autocomplete/DuckDuckGoProvider.js src/autocomplete/EmojiProvider.js -src/autocomplete/RoomProvider.js src/autocomplete/UserProvider.js src/CallHandler.js src/component-index.js @@ -35,7 +34,6 @@ src/components/views/create_room/RoomAlias.js src/components/views/dialogs/ChatCreateOrReuseDialog.js src/components/views/dialogs/DeactivateAccountDialog.js src/components/views/dialogs/InteractiveAuthDialog.js -src/components/views/dialogs/SetMxIdDialog.js src/components/views/dialogs/UnknownDeviceDialog.js src/components/views/elements/AccessibleButton.js src/components/views/elements/ActionButton.js @@ -56,7 +54,6 @@ src/components/views/elements/RoomDirectoryButton.js src/components/views/elements/SettingsButton.js src/components/views/elements/StartChatButton.js src/components/views/elements/TintableSvg.js -src/components/views/elements/TruncatedList.js src/components/views/elements/UserSelector.js src/components/views/login/CaptchaForm.js src/components/views/login/CasLogin.js @@ -89,7 +86,6 @@ src/components/views/rooms/MemberList.js src/components/views/rooms/MemberTile.js src/components/views/rooms/MessageComposer.js src/components/views/rooms/MessageComposerInput.js -src/components/views/rooms/MessageComposerInputOld.js src/components/views/rooms/PresenceLabel.js src/components/views/rooms/ReadReceiptMarker.js src/components/views/rooms/RoomList.js @@ -100,7 +96,6 @@ src/components/views/rooms/RoomTile.js src/components/views/rooms/RoomTopicEditor.js src/components/views/rooms/SearchableEntityList.js src/components/views/rooms/SearchResultTile.js -src/components/views/rooms/TabCompleteBar.js src/components/views/rooms/TopUnreadMessagesBar.js src/components/views/rooms/UserTile.js src/components/views/settings/AddPhoneNumber.js @@ -128,9 +123,6 @@ src/Roles.js src/Rooms.js src/ScalarAuthClient.js src/ScalarMessaging.js -src/TabComplete.js -src/TabCompleteEntries.js -src/TextForEvent.js src/Tinter.js src/UiEffects.js src/Unread.js @@ -142,7 +134,7 @@ src/utils/Receipt.js src/Velociraptor.js src/VelocityBounce.js src/WhoIsTyping.js -src/wrappers/WithMatrixClient.js +src/wrappers/withMatrixClient.js test/all-tests.js test/components/structures/login/Registration-test.js test/components/structures/MessagePanel-test.js diff --git a/.eslintrc.js b/.eslintrc.js index 74790a2964..429aa24993 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -40,6 +40,19 @@ module.exports = { }], "react/jsx-key": ["error"], + // Assert no spacing in JSX curly brackets + // + // + // https://github.com/yannickcr/eslint-plugin-react/blob/HEAD/docs/rules/jsx-curly-spacing.md + "react/jsx-curly-spacing": ["error", {"when": "never", "children": {"when": "always"}}], + + // Assert spacing before self-closing JSX tags, and no spacing before or + // after the closing slash, and no spacing after the opening bracket of + // the opening tag or closing tag. + // + // https://github.com/yannickcr/eslint-plugin-react/blob/HEAD/docs/rules/jsx-tag-spacing.md + "react/jsx-tag-spacing": ["error"], + /** flowtype **/ "flowtype/require-parameter-type": ["warn", { "excludeArrowFunctions": true, diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bc4bbcfce..97523e9189 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,229 @@ +Changes in [0.10.6](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.10.6) (2017-09-21) +===================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.10.5...v0.10.6) + + * New version of js-sdk with fixed build + +Changes in [0.10.5](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.10.5) (2017-09-21) +===================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.10.4...v0.10.5) + + * Fix build error (https://github.com/vector-im/riot-web/issues/5091) + +Changes in [0.10.4](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.10.4) (2017-09-20) +===================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.10.4-rc.1...v0.10.4) + + * No changes + +Changes in [0.10.4-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.10.4-rc.1) (2017-09-19) +=============================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.10.3...v0.10.4-rc.1) + + * Fix RoomView stuck in 'accept invite' state + [\#1396](https://github.com/matrix-org/matrix-react-sdk/pull/1396) + * Only show the integ management button if user is joined + [\#1398](https://github.com/matrix-org/matrix-react-sdk/pull/1398) + * suppressOnHover for member entity tiles which have no onClick + [\#1273](https://github.com/matrix-org/matrix-react-sdk/pull/1273) + * add /devtools command + [\#1268](https://github.com/matrix-org/matrix-react-sdk/pull/1268) + * Fix broken Link + [\#1359](https://github.com/matrix-org/matrix-react-sdk/pull/1359) + * Show who redacted an event on hover + [\#1387](https://github.com/matrix-org/matrix-react-sdk/pull/1387) + * start MELS expanded if it contains a highlighted/permalinked event. + [\#1388](https://github.com/matrix-org/matrix-react-sdk/pull/1388) + * Add ignore user API support + [\#1389](https://github.com/matrix-org/matrix-react-sdk/pull/1389) + * Add option to disable Emoji suggestions + [\#1392](https://github.com/matrix-org/matrix-react-sdk/pull/1392) + * sanitize the i18n for fn:textForHistoryVisibilityEvent + [\#1397](https://github.com/matrix-org/matrix-react-sdk/pull/1397) + * Don't check for only-emoji if there were none + [\#1394](https://github.com/matrix-org/matrix-react-sdk/pull/1394) + * Fix emojification of symbol characters + [\#1393](https://github.com/matrix-org/matrix-react-sdk/pull/1393) + * Update from Weblate. + [\#1395](https://github.com/matrix-org/matrix-react-sdk/pull/1395) + * Make /join join again + [\#1391](https://github.com/matrix-org/matrix-react-sdk/pull/1391) + * Display spinner not room preview after room create + [\#1390](https://github.com/matrix-org/matrix-react-sdk/pull/1390) + * Fix the avatar / room name in room preview + [\#1384](https://github.com/matrix-org/matrix-react-sdk/pull/1384) + * Remove spurious cancel button + [\#1381](https://github.com/matrix-org/matrix-react-sdk/pull/1381) + * Fix starting a chat by email address + [\#1386](https://github.com/matrix-org/matrix-react-sdk/pull/1386) + * respond on copy code block + [\#1363](https://github.com/matrix-org/matrix-react-sdk/pull/1363) + * fix DateUtils inconsistency with 12/24h + [\#1383](https://github.com/matrix-org/matrix-react-sdk/pull/1383) + * allow sending sub,sup and whitelist them on receive + [\#1382](https://github.com/matrix-org/matrix-react-sdk/pull/1382) + * Update roomlist when an event is decrypted + [\#1380](https://github.com/matrix-org/matrix-react-sdk/pull/1380) + * Update from Weblate. + [\#1379](https://github.com/matrix-org/matrix-react-sdk/pull/1379) + * fix radio for theme selection + [\#1368](https://github.com/matrix-org/matrix-react-sdk/pull/1368) + * fix some more zh_Hans - remove entirely broken lines + [\#1378](https://github.com/matrix-org/matrix-react-sdk/pull/1378) + * fix placeholder causing app to break when using zh + [\#1377](https://github.com/matrix-org/matrix-react-sdk/pull/1377) + * Avoid re-rendering RoomList on room switch + [\#1375](https://github.com/matrix-org/matrix-react-sdk/pull/1375) + * Fix 'Failed to load timeline position' regression + [\#1376](https://github.com/matrix-org/matrix-react-sdk/pull/1376) + * Fast path for emojifying strings + [\#1372](https://github.com/matrix-org/matrix-react-sdk/pull/1372) + * Consolidate the code copy button + [\#1374](https://github.com/matrix-org/matrix-react-sdk/pull/1374) + * Only add the code copy button for HTML messages + [\#1373](https://github.com/matrix-org/matrix-react-sdk/pull/1373) + * Don't re-render matrixchat unnecessarily + [\#1371](https://github.com/matrix-org/matrix-react-sdk/pull/1371) + * Don't wait for setState to run onHaveRoom + [\#1370](https://github.com/matrix-org/matrix-react-sdk/pull/1370) + * Introduce a RoomScrollStateStore + [\#1367](https://github.com/matrix-org/matrix-react-sdk/pull/1367) + * Don't always paginate when mounting a ScrollPanel + [\#1369](https://github.com/matrix-org/matrix-react-sdk/pull/1369) + * Remove unused scrollStateMap from LoggedinView + [\#1366](https://github.com/matrix-org/matrix-react-sdk/pull/1366) + * Revert "Implement sticky date separators" + [\#1365](https://github.com/matrix-org/matrix-react-sdk/pull/1365) + * Remove unused string "changing room on a RoomView is not supported" + [\#1361](https://github.com/matrix-org/matrix-react-sdk/pull/1361) + * Remove unused translation code translations + [\#1360](https://github.com/matrix-org/matrix-react-sdk/pull/1360) + * Implement sticky date separators + [\#1353](https://github.com/matrix-org/matrix-react-sdk/pull/1353) + +Changes in [0.10.3](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.10.3) (2017-09-06) +===================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.10.3-rc.2...v0.10.3) + + * No changes + +Changes in [0.10.3-rc.2](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.10.3-rc.2) (2017-09-05) +=============================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.10.3-rc.1...v0.10.3-rc.2) + + * Fix plurals in translations + [\#1358](https://github.com/matrix-org/matrix-react-sdk/pull/1358) + * Fix typo + [\#1357](https://github.com/matrix-org/matrix-react-sdk/pull/1357) + * Update from Weblate. + [\#1356](https://github.com/matrix-org/matrix-react-sdk/pull/1356) + +Changes in [0.10.3-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.10.3-rc.1) (2017-09-01) +=============================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.10.2...v0.10.3-rc.1) + + * Fix room change sometimes being very slow + [\#1354](https://github.com/matrix-org/matrix-react-sdk/pull/1354) + * apply shouldHideEvent fn to onRoomTimeline for RoomStatusBar + [\#1346](https://github.com/matrix-org/matrix-react-sdk/pull/1346) + * text4event widget modified, used to show widget added each time. + [\#1345](https://github.com/matrix-org/matrix-react-sdk/pull/1345) + * separate concepts of showing and managing RRs to fix regression + [\#1352](https://github.com/matrix-org/matrix-react-sdk/pull/1352) + * Make staging widgets work with live and vice versa. + [\#1350](https://github.com/matrix-org/matrix-react-sdk/pull/1350) + * Avoid breaking /sync with uncaught exceptions + [\#1349](https://github.com/matrix-org/matrix-react-sdk/pull/1349) + * we need to pass whether it is an invite RoomSubList explicitly (i18n) + [\#1343](https://github.com/matrix-org/matrix-react-sdk/pull/1343) + * Percent encoding isn't a valid thing within _t + [\#1348](https://github.com/matrix-org/matrix-react-sdk/pull/1348) + * Fix spurious notifications + [\#1339](https://github.com/matrix-org/matrix-react-sdk/pull/1339) + * Unbreak password reset with a non-default HS + [\#1347](https://github.com/matrix-org/matrix-react-sdk/pull/1347) + * Remove unnecessary 'load' on notif audio element + [\#1341](https://github.com/matrix-org/matrix-react-sdk/pull/1341) + * _tJsx returns a React Object, the sub fn must return a React Object + [\#1340](https://github.com/matrix-org/matrix-react-sdk/pull/1340) + * Fix deprecation warning about promise.defer() + [\#1292](https://github.com/matrix-org/matrix-react-sdk/pull/1292) + * Fix click to insert completion + [\#1331](https://github.com/matrix-org/matrix-react-sdk/pull/1331) + +Changes in [0.10.2](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.10.2) (2017-08-24) +===================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.10.1...v0.10.2) + + * Force update on timelinepanel when event decrypted + [\#1334](https://github.com/matrix-org/matrix-react-sdk/pull/1334) + * Dispatch incoming_call synchronously + [\#1337](https://github.com/matrix-org/matrix-react-sdk/pull/1337) + * Fix React crying on machines without internet due to return undefined + [\#1335](https://github.com/matrix-org/matrix-react-sdk/pull/1335) + * Catch the promise rejection if scalar fails + [\#1333](https://github.com/matrix-org/matrix-react-sdk/pull/1333) + * Update from Weblate. + [\#1329](https://github.com/matrix-org/matrix-react-sdk/pull/1329) + +Changes in [0.10.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.10.1) (2017-08-23) +===================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.10.1-rc.1...v0.10.1) + + * [No changes] + +Changes in [0.10.1-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.10.1-rc.1) (2017-08-22) +=============================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.10.0-rc.2...v0.10.1-rc.1) + + * Matthew/multiple widgets + [\#1327](https://github.com/matrix-org/matrix-react-sdk/pull/1327) + * Fix proptypes on UserPickerDialog + [\#1326](https://github.com/matrix-org/matrix-react-sdk/pull/1326) + * AppsDrawer: Remove unnecessary bind + [\#1325](https://github.com/matrix-org/matrix-react-sdk/pull/1325) + * Position add app widget link + [\#1322](https://github.com/matrix-org/matrix-react-sdk/pull/1322) + * Remove app tile beta tag. + [\#1323](https://github.com/matrix-org/matrix-react-sdk/pull/1323) + * Add missing translation. + [\#1324](https://github.com/matrix-org/matrix-react-sdk/pull/1324) + * Note that apps are not E2EE + [\#1319](https://github.com/matrix-org/matrix-react-sdk/pull/1319) + * Only render appTile body (including warnings) if drawer shown. + [\#1321](https://github.com/matrix-org/matrix-react-sdk/pull/1321) + * Timeline improvements + [\#1320](https://github.com/matrix-org/matrix-react-sdk/pull/1320) + * Add a space between widget name and "widget" in widget event tiles + [\#1318](https://github.com/matrix-org/matrix-react-sdk/pull/1318) + * Move manage integrations button from settings page to room header as a + stand-alone component + [\#1286](https://github.com/matrix-org/matrix-react-sdk/pull/1286) + * Don't apply case logic to app names + [\#1316](https://github.com/matrix-org/matrix-react-sdk/pull/1316) + * Stop integ manager opening on every room switch + [\#1315](https://github.com/matrix-org/matrix-react-sdk/pull/1315) + * Add behaviour to toggle app draw on app tile header click + [\#1313](https://github.com/matrix-org/matrix-react-sdk/pull/1313) + * Change OOO so that MELS generation will continue over hidden events + [\#1308](https://github.com/matrix-org/matrix-react-sdk/pull/1308) + * Implement TextualEvent tiles for im.vector.modular.widgets + [\#1312](https://github.com/matrix-org/matrix-react-sdk/pull/1312) + * Don't show widget security warning to the person that added it to the room + [\#1314](https://github.com/matrix-org/matrix-react-sdk/pull/1314) + * remove unused strings introduced by string change + [\#1311](https://github.com/matrix-org/matrix-react-sdk/pull/1311) + * hotfix bad fn signature regression + [\#1310](https://github.com/matrix-org/matrix-react-sdk/pull/1310) + * Show a dialog if the maximum number of widgets allowed has been reached. + [\#1291](https://github.com/matrix-org/matrix-react-sdk/pull/1291) + * Fix Robot translation + [\#1309](https://github.com/matrix-org/matrix-react-sdk/pull/1309) + * Refactor ChatInviteDialog to be UserPickerDialog + [\#1300](https://github.com/matrix-org/matrix-react-sdk/pull/1300) + * Update Link to Translation status + [\#1302](https://github.com/matrix-org/matrix-react-sdk/pull/1302) + Changes in [0.9.7](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.9.7) (2017-06-22) =================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.9.6...v0.9.7) diff --git a/README.md b/README.md index 144e89c938..c3106ccec7 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ Please follow the standard Matrix contributor's guide: https://github.com/matrix-org/synapse/tree/master/CONTRIBUTING.rst Please follow the Matrix JS/React code style as per: -https://github.com/matrix-org/matrix-react-sdk/tree/master/code_style.rst +https://github.com/matrix-org/matrix-react-sdk/blob/master/code_style.md Whilst the layering separation between matrix-react-sdk and Riot is broken (as of July 2016), code should be committed as follows: diff --git a/jenkins.sh b/jenkins.sh index 0979edfa13..3a2d66739e 100755 --- a/jenkins.sh +++ b/jenkins.sh @@ -21,9 +21,7 @@ npm run test -- --no-colors npm run lintall -- -f checkstyle -o eslint.xml || true # re-run the linter, excluding any files known to have errors or warnings. -./node_modules/.bin/eslint --max-warnings 0 \ - --ignore-path .eslintignore.errorfiles \ - src test +npm run lintwithexclusions # delete the old tarball, if it exists rm -f matrix-react-sdk-*.tgz diff --git a/package.json b/package.json index 661db4b6bc..e185a9027d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "0.9.7", + "version": "0.10.6", "description": "SDK for matrix.org using React", "author": "matrix.org", "repository": { @@ -39,8 +39,9 @@ "start": "parallelshell \"npm run build:watch\" \"npm run reskindex:watch\"", "lint": "eslint src/", "lintall": "eslint src/ test/", + "lintwithexclusions": "eslint --max-warnings 0 --ignore-path .eslintignore.errorfiles src test", "clean": "rimraf lib", - "prepublish": "npm run build && git rev-parse HEAD > git-revision.txt", + "prepublish": "npm run clean && npm run build && git rev-parse HEAD > git-revision.txt", "test": "karma start --single-run=true --browsers ChromeHeadless", "test-multi": "karma start" }, @@ -66,7 +67,7 @@ "isomorphic-fetch": "^2.2.1", "linkifyjs": "^2.1.3", "lodash": "^4.13.1", - "matrix-js-sdk": "matrix-org/matrix-js-sdk#develop", + "matrix-js-sdk": "0.8.4", "optimist": "^0.6.1", "prop-types": "^15.5.8", "react": "^15.4.0", @@ -99,7 +100,7 @@ "eslint-config-google": "^0.7.1", "eslint-plugin-babel": "^4.0.1", "eslint-plugin-flowtype": "^2.30.0", - "eslint-plugin-react": "^6.9.0", + "eslint-plugin-react": "^7.4.0", "expect": "^1.16.0", "json-loader": "^0.5.3", "karma": "^1.7.0", diff --git a/scripts/travis.sh b/scripts/travis.sh index f349b06ad5..c4a06c1bd1 100755 --- a/scripts/travis.sh +++ b/scripts/travis.sh @@ -6,6 +6,4 @@ npm run test ./.travis-test-riot.sh # run the linter, but exclude any files known to have errors or warnings. -./node_modules/.bin/eslint --max-warnings 0 \ - --ignore-path .eslintignore.errorfiles \ - src test +npm run lintwithexclusions diff --git a/src/ActiveRoomObserver.js b/src/ActiveRoomObserver.js new file mode 100644 index 0000000000..d6fbb460b5 --- /dev/null +++ b/src/ActiveRoomObserver.js @@ -0,0 +1,77 @@ +/* +Copyright 2017 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import RoomViewStore from './stores/RoomViewStore'; + +/** + * Consumes changes from the RoomViewStore and notifies specific things + * about when the active room changes. Unlike listening for RoomViewStore + * changes, you can subscribe to only changes relevant to a particular + * room. + * + * TODO: If we introduce an observer for something else, factor out + * the adding / removing of listeners & emitting into a common class. + */ +class ActiveRoomObserver { + constructor() { + this._listeners = {}; + + this._activeRoomId = RoomViewStore.getRoomId(); + // TODO: We could self-destruct when the last listener goes away, or at least + // stop listening. + this._roomStoreToken = RoomViewStore.addListener(this._onRoomViewStoreUpdate.bind(this)); + } + + addListener(roomId, listener) { + if (!this._listeners[roomId]) this._listeners[roomId] = []; + this._listeners[roomId].push(listener); + } + + removeListener(roomId, listener) { + if (this._listeners[roomId]) { + const i = this._listeners[roomId].indexOf(listener); + if (i > -1) { + this._listeners[roomId].splice(i, 1); + } + } else { + console.warn("Unregistering unrecognised listener (roomId=" + roomId + ")"); + } + } + + _emit(roomId) { + if (!this._listeners[roomId]) return; + + for (const l of this._listeners[roomId]) { + l.call(); + } + } + + _onRoomViewStoreUpdate() { + // emit for the old room ID + if (this._activeRoomId) this._emit(this._activeRoomId); + + // update our cache + this._activeRoomId = RoomViewStore.getRoomId(); + + // and emit for the new one + if (this._activeRoomId) this._emit(this._activeRoomId); + } +} + +if (global.mx_ActiveRoomObserver === undefined) { + global.mx_ActiveRoomObserver = new ActiveRoomObserver(); +} +export default global.mx_ActiveRoomObserver; diff --git a/src/BasePlatform.js b/src/BasePlatform.js index 5f8772c7aa..abc9aa0bed 100644 --- a/src/BasePlatform.js +++ b/src/BasePlatform.js @@ -107,6 +107,9 @@ export default class BasePlatform { isElectron(): boolean { return false; } + setupScreenSharingForIframe() { + } + /** * Restarts the application, without neccessarily reloading * any application code diff --git a/src/DateUtils.js b/src/DateUtils.js index 78eef57eae..77f3644f6f 100644 --- a/src/DateUtils.js +++ b/src/DateUtils.js @@ -65,7 +65,7 @@ module.exports = { const days = getDaysArray(); const months = getMonthsArray(); if (date.toDateString() === now.toDateString()) { - return this.formatTime(date); + return this.formatTime(date, showTwelveHour); } else if (now.getTime() - date.getTime() < 6 * 24 * 60 * 60 * 1000) { // TODO: use standard date localize function provided in counterpart return _t('%(weekDayName)s %(time)s', { @@ -78,7 +78,7 @@ module.exports = { weekDayName: days[date.getDay()], monthName: months[date.getMonth()], day: date.getDate(), - time: this.formatTime(date), + time: this.formatTime(date, showTwelveHour), }); } return this.formatFullDate(date, showTwelveHour); @@ -92,13 +92,13 @@ module.exports = { monthName: months[date.getMonth()], day: date.getDate(), fullYear: date.getFullYear(), - time: showTwelveHour ? twelveHourTime(date) : this.formatTime(date), + time: this.formatTime(date, showTwelveHour), }); }, formatTime: function(date, showTwelveHour=false) { if (showTwelveHour) { - return twelveHourTime(date); + return twelveHourTime(date); } return pad(date.getHours()) + ':' + pad(date.getMinutes()); }, diff --git a/src/GroupAddressPicker.js b/src/GroupAddressPicker.js new file mode 100644 index 0000000000..cfd2590780 --- /dev/null +++ b/src/GroupAddressPicker.js @@ -0,0 +1,113 @@ +/* +Copyright 2017 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import Modal from './Modal'; +import sdk from './'; +import MultiInviter from './utils/MultiInviter'; +import { _t } from './languageHandler'; +import MatrixClientPeg from './MatrixClientPeg'; +import GroupStoreCache from './stores/GroupStoreCache'; + +export function showGroupInviteDialog(groupId) { + const AddressPickerDialog = sdk.getComponent("dialogs.AddressPickerDialog"); + Modal.createTrackedDialog('Group Invite', '', AddressPickerDialog, { + title: _t("Invite new group members"), + description: _t("Who would you like to add to this group?"), + placeholder: _t("Name or matrix ID"), + button: _t("Invite to Group"), + validAddressTypes: ['mx-user-id'], + onFinished: (success, addrs) => { + if (!success) return; + + _onGroupInviteFinished(groupId, addrs); + }, + }); +} + +export function showGroupAddRoomDialog(groupId) { + return new Promise((resolve, reject) => { + const AddressPickerDialog = sdk.getComponent("dialogs.AddressPickerDialog"); + Modal.createTrackedDialog('Add Rooms to Group', '', AddressPickerDialog, { + title: _t("Add rooms to the group"), + description: _t("Which rooms would you like to add to this group?"), + placeholder: _t("Room name or alias"), + button: _t("Add to group"), + pickerType: 'room', + validAddressTypes: ['mx-room-id'], + onFinished: (success, addrs) => { + if (!success) return; + + _onGroupAddRoomFinished(groupId, addrs).then(resolve, reject); + }, + }); + }); +} + +function _onGroupInviteFinished(groupId, addrs) { + const multiInviter = new MultiInviter(groupId); + + const addrTexts = addrs.map((addr) => addr.address); + + multiInviter.invite(addrTexts).then((completionStates) => { + // Show user any errors + const errorList = []; + for (const addr of Object.keys(completionStates)) { + if (addrs[addr] === "error") { + errorList.push(addr); + } + } + + if (errorList.length > 0) { + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + Modal.createTrackedDialog('Failed to invite the following users to the group', '', ErrorDialog, { + title: _t("Failed to invite the following users to %(groupId)s:", {groupId: groupId}), + description: errorList.join(", "), + }); + } + }).catch((err) => { + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + Modal.createTrackedDialog('Failed to invite users to group', '', ErrorDialog, { + title: _t("Failed to invite users group"), + description: _t("Failed to invite users to %(groupId)s", {groupId: groupId}), + }); + }); +} + +function _onGroupAddRoomFinished(groupId, addrs) { + const groupStore = GroupStoreCache.getGroupStore(MatrixClientPeg.get(), groupId); + const errorList = []; + return Promise.all(addrs.map((addr) => { + return groupStore + .addRoomToGroup(addr.address) + .catch(() => { errorList.push(addr.address); }) + .reflect(); + })).then(() => { + if (errorList.length === 0) { + return; + } + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + Modal.createTrackedDialog( + 'Failed to add the following room to the group', + '', ErrorDialog, + { + title: _t( + "Failed to add the following rooms to %(groupId)s:", + {groupId}, + ), + description: errorList.join(", "), + }); + }); +} diff --git a/src/HtmlUtils.js b/src/HtmlUtils.js index 87e714083b..ee2bcd2b0f 100644 --- a/src/HtmlUtils.js +++ b/src/HtmlUtils.js @@ -1,5 +1,6 @@ /* Copyright 2015, 2016 OpenMarket Ltd +Copyright 2017 New Vector Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -31,13 +32,33 @@ emojione.imagePathPNG = 'emojione/png/'; // Use SVGs for emojis emojione.imageType = 'svg'; +// Anything outside the basic multilingual plane will be a surrogate pair +const SURROGATE_PAIR_PATTERN = /([\ud800-\udbff])([\udc00-\udfff])/; +// And there a bunch more symbol characters that emojione has within the +// BMP, so this includes the ranges from 'letterlike symbols' to +// 'miscellaneous symbols and arrows' which should catch all of them +// (with plenty of false positives, but that's OK) +const SYMBOL_PATTERN = /([\u2100-\u2bff])/; + +// And this is emojione's complete regex const EMOJI_REGEX = new RegExp(emojione.unicodeRegexp+"+", "gi"); const COLOR_REGEX = /^#[0-9a-fA-F]{6}$/; +/* + * Return true if the given string contains emoji + * Uses a much, much simpler regex than emojione's so will give false + * positives, but useful for fast-path testing strings to see if they + * need emojification. + * unicodeToImage uses this function. + */ +export function containsEmoji(str) { + return SURROGATE_PAIR_PATTERN.test(str) || SYMBOL_PATTERN.test(str); +} + /* modified from https://github.com/Ranks/emojione/blob/master/lib/js/emojione.js * because we want to include emoji shortnames in title text */ -export function unicodeToImage(str) { +function unicodeToImage(str) { let replaceWith, unicode, alt, short, fname; const mappedUnicode = emojione.mapUnicodeToShort(); @@ -127,7 +148,7 @@ export function processHtmlForSending(html: string): string { * of that HTML. */ export function sanitizedHtmlNode(insaneHtml) { - const saneHtml = sanitizeHtml(insaneHtml, sanitizeHtmlParams); + const saneHtml = sanitizeHtml(insaneHtml, sanitizeHtmlParams); return
; } @@ -136,7 +157,7 @@ const sanitizeHtmlParams = { allowedTags: [ 'font', // custom to matrix for IRC-style font coloring 'del', // for markdown - 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'p', 'a', 'ul', 'ol', + 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'p', 'a', 'ul', 'ol', 'sup', 'sub', 'nl', 'li', 'b', 'i', 'u', 'strong', 'em', 'strike', 'code', 'hr', 'br', 'div', 'table', 'thead', 'caption', 'tbody', 'tr', 'th', 'td', 'pre', 'span', 'img', ], @@ -375,6 +396,8 @@ export function bodyToHtml(content, highlights, opts) { var isHtml = (content.format === "org.matrix.custom.html"); let body = isHtml ? content.formatted_body : escape(content.body); + let bodyHasEmoji = false; + var safeBody; // XXX: We sanitize the HTML whilst also highlighting its text nodes, to avoid accidentally trying // to highlight HTML tags themselves. However, this does mean that we don't highlight textnodes which @@ -392,17 +415,20 @@ export function bodyToHtml(content, highlights, opts) { }; } safeBody = sanitizeHtml(body, sanitizeHtmlParams); - safeBody = unicodeToImage(safeBody); - safeBody = addCodeCopyButton(safeBody); + bodyHasEmoji = containsEmoji(body); + if (bodyHasEmoji) safeBody = unicodeToImage(safeBody); } finally { delete sanitizeHtmlParams.textFilter; } - EMOJI_REGEX.lastIndex = 0; - let contentBodyTrimmed = content.body !== undefined ? content.body.trim() : ''; - let match = EMOJI_REGEX.exec(contentBodyTrimmed); - let emojiBody = match && match[0] && match[0].length === contentBodyTrimmed.length; + let emojiBody = false; + if (bodyHasEmoji) { + EMOJI_REGEX.lastIndex = 0; + let contentBodyTrimmed = content.body !== undefined ? content.body.trim() : ''; + let match = EMOJI_REGEX.exec(contentBodyTrimmed); + emojiBody = match && match[0] && match[0].length === contentBodyTrimmed.length; + } const className = classNames({ 'mx_EventTile_body': true, @@ -412,23 +438,6 @@ export function bodyToHtml(content, highlights, opts) { return ; } -function addCodeCopyButton(safeBody) { - // Adds 'copy' buttons to pre blocks - // Note that this only manipulates the markup to add the buttons: - // we need to add the event handlers once the nodes are in the DOM - // since we can't save functions in the markup. - // This is done in TextualBody - const el = document.createElement("div"); - el.innerHTML = safeBody; - const codeBlocks = Array.from(el.getElementsByTagName("pre")); - codeBlocks.forEach(p => { - const button = document.createElement("span"); - button.className = "mx_EventTile_copyButton"; - p.appendChild(button); - }); - return el.innerHTML; -} - export function emojifyText(text) { return { __html: unicodeToImage(escape(text)), diff --git a/src/Markdown.js b/src/Markdown.js index 6e735c6f0e..455d5e95bd 100644 --- a/src/Markdown.js +++ b/src/Markdown.js @@ -17,7 +17,7 @@ limitations under the License. import commonmark from 'commonmark'; import escape from 'lodash/escape'; -const ALLOWED_HTML_TAGS = ['del', 'u']; +const ALLOWED_HTML_TAGS = ['sub', 'sup', 'del', 'u']; // These types of node are definitely text const TEXT_NODES = ['text', 'softbreak', 'linebreak', 'paragraph', 'document']; diff --git a/src/Notifier.js b/src/Notifier.js index 1bb435307d..155564dcdf 100644 --- a/src/Notifier.js +++ b/src/Notifier.js @@ -1,6 +1,7 @@ /* Copyright 2015, 2016 OpenMarket Ltd Copyright 2017 Vector Creations Ltd +Copyright 2017 New Vector Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -33,9 +34,16 @@ import Modal from './Modal'; * } */ +const MAX_PENDING_ENCRYPTED = 20; + const Notifier = { notifsByRoom: {}, + // A list of event IDs that we've received but need to wait until + // they're decrypted until we decide whether to notify for them + // or not + pendingEncryptedEventIds: [], + notificationMessageForEvent: function(ev) { return TextForEvent.textForEvent(ev); }, @@ -89,17 +97,18 @@ const Notifier = { _playAudioNotification: function(ev, room) { const e = document.getElementById("messageAudio"); if (e) { - e.load(); e.play(); } }, start: function() { - this.boundOnRoomTimeline = this.onRoomTimeline.bind(this); + this.boundOnEvent = this.onEvent.bind(this); this.boundOnSyncStateChange = this.onSyncStateChange.bind(this); this.boundOnRoomReceipt = this.onRoomReceipt.bind(this); - MatrixClientPeg.get().on('Room.timeline', this.boundOnRoomTimeline); + this.boundOnEventDecrypted = this.onEventDecrypted.bind(this); + MatrixClientPeg.get().on('event', this.boundOnEvent); MatrixClientPeg.get().on('Room.receipt', this.boundOnRoomReceipt); + MatrixClientPeg.get().on('Event.decrypted', this.boundOnEventDecrypted); MatrixClientPeg.get().on("sync", this.boundOnSyncStateChange); this.toolbarHidden = false; this.isSyncing = false; @@ -107,8 +116,9 @@ const Notifier = { stop: function() { if (MatrixClientPeg.get() && this.boundOnRoomTimeline) { - MatrixClientPeg.get().removeListener('Room.timeline', this.boundOnRoomTimeline); + MatrixClientPeg.get().removeListener('Event', this.boundOnEvent); MatrixClientPeg.get().removeListener('Room.receipt', this.boundOnRoomReceipt); + MatrixClientPeg.get().removeListener('Event.decrypted', this.boundOnEventDecrypted); MatrixClientPeg.get().removeListener('sync', this.boundOnSyncStateChange); } this.isSyncing = false; @@ -237,23 +247,30 @@ const Notifier = { } }, - onRoomTimeline: function(ev, room, toStartOfTimeline, removed, data) { - if (toStartOfTimeline) return; - if (!room) return; + onEvent: function(ev) { if (!this.isSyncing) return; // don't alert for any messages initially if (ev.sender && ev.sender.userId === MatrixClientPeg.get().credentials.userId) return; - if (data.timeline.getTimelineSet() !== room.getUnfilteredTimelineSet()) return; - const actions = MatrixClientPeg.get().getPushActionsForEvent(ev); - if (actions && actions.notify) { - if (this.isEnabled()) { - this._displayPopupNotification(ev, room); - } - if (actions.tweaks.sound && this.isAudioEnabled()) { - PlatformPeg.get().loudNotification(ev, room); - this._playAudioNotification(ev, room); + // If it's an encrypted event and the type is still 'm.room.encrypted', + // it hasn't yet been decrypted, so wait until it is. + if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) { + this.pendingEncryptedEventIds.push(ev.getId()); + // don't let the list fill up indefinitely + while (this.pendingEncryptedEventIds.length > MAX_PENDING_ENCRYPTED) { + this.pendingEncryptedEventIds.shift(); } + return; } + + this._evaluateEvent(ev); + }, + + onEventDecrypted: function(ev) { + const idx = this.pendingEncryptedEventIds.indexOf(ev.getId()); + if (idx === -1) return; + + this.pendingEncryptedEventIds.splice(idx, 1); + this._evaluateEvent(ev); }, onRoomReceipt: function(ev, room) { @@ -273,6 +290,20 @@ const Notifier = { delete this.notifsByRoom[room.roomId]; } }, + + _evaluateEvent: function(ev) { + const room = MatrixClientPeg.get().getRoom(ev.getRoomId()); + const actions = MatrixClientPeg.get().getPushActionsForEvent(ev); + if (actions && actions.notify) { + if (this.isEnabled()) { + this._displayPopupNotification(ev, room); + } + if (actions.tweaks.sound && this.isAudioEnabled()) { + PlatformPeg.get().loudNotification(ev, room); + this._playAudioNotification(ev, room); + } + } + } }; if (!global.mxNotifier) { diff --git a/src/Invite.js b/src/RoomInvite.js similarity index 92% rename from src/Invite.js rename to src/RoomInvite.js index b8e33d318a..ceb3dd0fda 100644 --- a/src/Invite.js +++ b/src/RoomInvite.js @@ -28,7 +28,7 @@ export function inviteToRoom(roomId, addr) { if (addrType == 'email') { return MatrixClientPeg.get().inviteByEmail(roomId, addr); - } else if (addrType == 'mx') { + } else if (addrType == 'mx-user-id') { return MatrixClientPeg.get().invite(roomId, addr); } else { throw new Error('Unsupported address'); @@ -50,8 +50,8 @@ export function inviteMultipleToRoom(roomId, addrs) { } export function showStartChatInviteDialog() { - const UserPickerDialog = sdk.getComponent("dialogs.UserPickerDialog"); - Modal.createTrackedDialog('Start a chat', '', UserPickerDialog, { + const AddressPickerDialog = sdk.getComponent("dialogs.AddressPickerDialog"); + Modal.createTrackedDialog('Start a chat', '', AddressPickerDialog, { title: _t('Start a chat'), description: _t("Who would you like to communicate with?"), placeholder: _t("Email, name or matrix ID"), @@ -61,8 +61,8 @@ export function showStartChatInviteDialog() { } export function showRoomInviteDialog(roomId) { - const UserPickerDialog = sdk.getComponent("dialogs.UserPickerDialog"); - Modal.createTrackedDialog('Chat Invite', '', UserPickerDialog, { + const AddressPickerDialog = sdk.getComponent("dialogs.AddressPickerDialog"); + Modal.createTrackedDialog('Chat Invite', '', AddressPickerDialog, { title: _t('Invite new room members'), description: _t('Who would you like to add to this room?'), button: _t('Send Invites'), @@ -127,7 +127,7 @@ function _onRoomInviteFinished(roomId, shouldInvite, addrs) { } function _isDmChat(addrTexts) { - if (addrTexts.length === 1 && getAddressType(addrTexts[0])) { + if (addrTexts.length === 1 && getAddressType(addrTexts[0]) === 'mx') { return true; } else { return false; diff --git a/src/ScalarAuthClient.js b/src/ScalarAuthClient.js index b1d17b93a9..0b753cf3ab 100644 --- a/src/ScalarAuthClient.js +++ b/src/ScalarAuthClient.js @@ -76,10 +76,13 @@ class ScalarAuthClient { return defer.promise; } - getScalarInterfaceUrlForRoom(roomId, screen) { + getScalarInterfaceUrlForRoom(roomId, screen, id) { var url = SdkConfig.get().integrations_ui_url; url += "?scalar_token=" + encodeURIComponent(this.scalarToken); url += "&room_id=" + encodeURIComponent(roomId); + if (id) { + url += '&integ_id=' + encodeURIComponent(id); + } if (screen) { url += '&screen=' + encodeURIComponent(screen); } diff --git a/src/Skinner.js b/src/Skinner.js index f47572ba01..1fe12f85ab 100644 --- a/src/Skinner.js +++ b/src/Skinner.js @@ -84,6 +84,9 @@ class Skinner { // behaviour with multiple copies of files etc. is erratic at best. // XXX: We can still end up with the same file twice in the resulting // JS bundle which is nonideal. +// See https://derickbailey.com/2016/03/09/creating-a-true-singleton-in-node-js-with-es6-symbols/ +// or https://nodejs.org/api/modules.html#modules_module_caching_caveats +// ("Modules are cached based on their resolved filename") if (global.mxSkinner === undefined) { global.mxSkinner = new Skinner(); } diff --git a/src/SlashCommands.js b/src/SlashCommands.js index e5378d4347..82665cc2f3 100644 --- a/src/SlashCommands.js +++ b/src/SlashCommands.js @@ -240,6 +240,59 @@ const commands = { return reject(this.getUsage()); }), + ignore: new Command("ignore", "", function(roomId, args) { + if (args) { + const matches = args.match(/^(\S+)$/); + if (matches) { + const userId = matches[1]; + const ignoredUsers = MatrixClientPeg.get().getIgnoredUsers(); + ignoredUsers.push(userId); // de-duped internally in the js-sdk + return success( + MatrixClientPeg.get().setIgnoredUsers(ignoredUsers).then(() => { + const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); + Modal.createTrackedDialog('Slash Commands', 'User ignored', QuestionDialog, { + title: _t("Ignored user"), + description: ( +
+

{ _t("You are now ignoring %(userId)s", {userId: userId}) }

+
+ ), + hasCancelButton: false, + }); + }), + ); + } + } + return reject(this.getUsage()); + }), + + unignore: new Command("unignore", "", function(roomId, args) { + if (args) { + const matches = args.match(/^(\S+)$/); + if (matches) { + const userId = matches[1]; + const ignoredUsers = MatrixClientPeg.get().getIgnoredUsers(); + const index = ignoredUsers.indexOf(userId); + if (index !== -1) ignoredUsers.splice(index, 1); + return success( + MatrixClientPeg.get().setIgnoredUsers(ignoredUsers).then(() => { + const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); + Modal.createTrackedDialog('Slash Commands', 'User unignored', QuestionDialog, { + title: _t("Unignored user"), + description: ( +
+

{ _t("You are no longer ignoring %(userId)s", {userId: userId}) }

+
+ ), + hasCancelButton: false, + }); + }), + ); + } + } + return reject(this.getUsage()); + }), + // Define the power level of a user op: new Command("op", " []", function(roomId, args) { if (args) { @@ -292,6 +345,13 @@ const commands = { return reject(this.getUsage()); }), + // Open developer tools + devtools: new Command("devtools", "", function(roomId) { + const DevtoolsDialog = sdk.getComponent("dialogs.DevtoolsDialog"); + Modal.createDialog(DevtoolsDialog, { roomId }); + return success(); + }), + // Verify a user, device, and pubkey tuple verify: new Command("verify", " ", function(roomId, args) { if (args) { diff --git a/src/TextForEvent.js b/src/TextForEvent.js index de12cec502..a21eb5c251 100644 --- a/src/TextForEvent.js +++ b/src/TextForEvent.js @@ -13,56 +13,67 @@ 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"; -import CallHandler from "./CallHandler"; +import MatrixClientPeg from './MatrixClientPeg'; +import CallHandler from './CallHandler'; import { _t } from './languageHandler'; import * as Roles from './Roles'; function textForMemberEvent(ev) { // XXX: SYJS-16 "sender is sometimes null for join messages" - var senderName = ev.sender ? ev.sender.name : ev.getSender(); - var targetName = ev.target ? ev.target.name : ev.getStateKey(); - var ConferenceHandler = CallHandler.getConferenceHandler(); - var reason = ev.getContent().reason ? ( - _t('Reason') + ': ' + ev.getContent().reason - ) : ""; - switch (ev.getContent().membership) { - case 'invite': - var threePidContent = ev.getContent().third_party_invite; + const senderName = ev.sender ? ev.sender.name : ev.getSender(); + const targetName = ev.target ? ev.target.name : ev.getStateKey(); + const prevContent = ev.getPrevContent(); + const content = ev.getContent(); + + const ConferenceHandler = CallHandler.getConferenceHandler(); + const reason = content.reason ? (_t('Reason') + ': ' + content.reason) : ''; + switch (content.membership) { + case 'invite': { + const threePidContent = content.third_party_invite; if (threePidContent) { if (threePidContent.display_name) { - return _t('%(targetName)s accepted the invitation for %(displayName)s.', {targetName: targetName, displayName: threePidContent.display_name}); + return _t('%(targetName)s accepted the invitation for %(displayName)s.', { + targetName, + displayName: threePidContent.display_name, + }); } else { - return _t('%(targetName)s accepted an invitation.', {targetName: targetName}); + return _t('%(targetName)s accepted an invitation.', {targetName}); } - } - else { + } else { if (ConferenceHandler && ConferenceHandler.isConferenceUser(ev.getStateKey())) { - return _t('%(senderName)s requested a VoIP conference.', {senderName: senderName}); - } - else { - return _t('%(senderName)s invited %(targetName)s.', {senderName: senderName, targetName: targetName}); + return _t('%(senderName)s requested a VoIP conference.', {senderName}); + } else { + return _t('%(senderName)s invited %(targetName)s.', {senderName, targetName}); } } + } case 'ban': - return _t( - '%(senderName)s banned %(targetName)s.', - {senderName: senderName, targetName: targetName} - ) + ' ' + reason; + return _t('%(senderName)s banned %(targetName)s.', {senderName, targetName}) + ' ' + reason; case 'join': - if (ev.getPrevContent() && ev.getPrevContent().membership == 'join') { - if (ev.getPrevContent().displayname && ev.getContent().displayname && ev.getPrevContent().displayname != ev.getContent().displayname) { - return _t('%(senderName)s changed their display name from %(oldDisplayName)s to %(displayName)s.', {senderName: ev.getSender(), oldDisplayName: ev.getPrevContent().displayname, displayName: ev.getContent().displayname}); - } else if (!ev.getPrevContent().displayname && ev.getContent().displayname) { - return _t('%(senderName)s set their display name to %(displayName)s.', {senderName: ev.getSender(), displayName: ev.getContent().displayname}); - } else if (ev.getPrevContent().displayname && !ev.getContent().displayname) { - return _t('%(senderName)s removed their display name (%(oldDisplayName)s).', {senderName: ev.getSender(), oldDisplayName: ev.getPrevContent().displayname}); - } else if (ev.getPrevContent().avatar_url && !ev.getContent().avatar_url) { - return _t('%(senderName)s removed their profile picture.', {senderName: senderName}); - } else if (ev.getPrevContent().avatar_url && ev.getContent().avatar_url && ev.getPrevContent().avatar_url != ev.getContent().avatar_url) { - return _t('%(senderName)s changed their profile picture.', {senderName: senderName}); - } else if (!ev.getPrevContent().avatar_url && ev.getContent().avatar_url) { - return _t('%(senderName)s set a profile picture.', {senderName: senderName}); + if (prevContent && prevContent.membership === 'join') { + if (prevContent.displayname && content.displayname && prevContent.displayname !== content.displayname) { + return _t('%(senderName)s changed their display name from %(oldDisplayName)s to %(displayName)s.', { + senderName, + oldDisplayName: prevContent.displayname, + displayName: content.displayname, + }); + } else if (!prevContent.displayname && content.displayname) { + return _t('%(senderName)s set their display name to %(displayName)s.', { + senderName, + displayName: content.displayname, + }); + } else if (prevContent.displayname && !content.displayname) { + return _t('%(senderName)s removed their display name (%(oldDisplayName)s).', { + senderName, + oldDisplayName: prevContent.displayname, + }); + } else if (prevContent.avatar_url && !content.avatar_url) { + return _t('%(senderName)s removed their profile picture.', {senderName}); + } else if (prevContent.avatar_url && content.avatar_url && + prevContent.avatar_url !== content.avatar_url) { + return _t('%(senderName)s changed their profile picture.', {senderName}); + } else if (!prevContent.avatar_url && content.avatar_url) { + return _t('%(senderName)s set a profile picture.', {senderName}); } else { // suppress null rejoins return ''; @@ -71,73 +82,69 @@ function textForMemberEvent(ev) { if (!ev.target) console.warn("Join message has no target! -- " + ev.getContent().state_key); if (ConferenceHandler && ConferenceHandler.isConferenceUser(ev.getStateKey())) { return _t('VoIP conference started.'); - } - else { - return _t('%(targetName)s joined the room.', {targetName: targetName}); + } else { + return _t('%(targetName)s joined the room.', {targetName}); } } case 'leave': if (ev.getSender() === ev.getStateKey()) { if (ConferenceHandler && ConferenceHandler.isConferenceUser(ev.getStateKey())) { return _t('VoIP conference finished.'); + } else if (prevContent.membership === "invite") { + return _t('%(targetName)s rejected the invitation.', {targetName}); + } else { + return _t('%(targetName)s left the room.', {targetName}); } - else if (ev.getPrevContent().membership === "invite") { - return _t('%(targetName)s rejected the invitation.', {targetName: targetName}); - } - else { - return _t('%(targetName)s left the room.', {targetName: targetName}); - } - } - else if (ev.getPrevContent().membership === "ban") { - return _t('%(senderName)s unbanned %(targetName)s.', {senderName: senderName, targetName: targetName}); - } - else if (ev.getPrevContent().membership === "join") { - return _t( - '%(senderName)s kicked %(targetName)s.', - {senderName: senderName, targetName: targetName} - ) + ' ' + reason; - } - else if (ev.getPrevContent().membership === "invite") { - return _t( - '%(senderName)s withdrew %(targetName)s\'s invitation.', - {senderName: senderName, targetName: targetName} - ) + ' ' + reason; - } - else { - return _t('%(targetName)s left the room.', {targetName: targetName}); + } else if (prevContent.membership === "ban") { + return _t('%(senderName)s unbanned %(targetName)s.', {senderName, targetName}); + } else if (prevContent.membership === "join") { + return _t('%(senderName)s kicked %(targetName)s.', {senderName, targetName}) + ' ' + reason; + } else if (prevContent.membership === "invite") { + return _t('%(senderName)s withdrew %(targetName)s\'s invitation.', { + senderName, + targetName, + }) + ' ' + reason; + } else { + return _t('%(targetName)s left the room.', {targetName}); } } } function textForTopicEvent(ev) { - var senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); - return _t('%(senderDisplayName)s changed the topic to "%(topic)s".', {senderDisplayName: senderDisplayName, topic: ev.getContent().topic}); + const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); + return _t('%(senderDisplayName)s changed the topic to "%(topic)s".', { + senderDisplayName, + topic: ev.getContent().topic, + }); } function textForRoomNameEvent(ev) { - var senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); + const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); if (!ev.getContent().name || ev.getContent().name.trim().length === 0) { - return _t('%(senderDisplayName)s removed the room name.', {senderDisplayName: senderDisplayName}); + return _t('%(senderDisplayName)s removed the room name.', {senderDisplayName}); } - return _t('%(senderDisplayName)s changed the room name to %(roomName)s.', {senderDisplayName: senderDisplayName, roomName: ev.getContent().name}); + return _t('%(senderDisplayName)s changed the room name to %(roomName)s.', { + senderDisplayName, + roomName: ev.getContent().name, + }); } function textForMessageEvent(ev) { - var senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); - var message = senderDisplayName + ': ' + ev.getContent().body; + const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); + let message = senderDisplayName + ': ' + ev.getContent().body; if (ev.getContent().msgtype === "m.emote") { message = "* " + senderDisplayName + " " + message; } else if (ev.getContent().msgtype === "m.image") { - message = _t('%(senderDisplayName)s sent an image.', {senderDisplayName: senderDisplayName}); + message = _t('%(senderDisplayName)s sent an image.', {senderDisplayName}); } return message; } function textForCallAnswerEvent(event) { - var senderName = event.sender ? event.sender.name : _t('Someone'); - var supported = MatrixClientPeg.get().supportsVoip() ? "" : _t('(not supported by this browser)'); - return _t('%(senderName)s answered the call.', {senderName: senderName}) + ' ' + supported; + const senderName = event.sender ? event.sender.name : _t('Someone'); + const supported = MatrixClientPeg.get().supportsVoip() ? '' : _t('(not supported by this browser)'); + return _t('%(senderName)s answered the call.', {senderName}) + ' ' + supported; } function textForCallHangupEvent(event) { @@ -159,48 +166,52 @@ function textForCallHangupEvent(event) { } function textForCallInviteEvent(event) { - var senderName = event.sender ? event.sender.name : _t('Someone'); + const senderName = event.sender ? event.sender.name : _t('Someone'); // FIXME: Find a better way to determine this from the event? - var type = "voice"; + let callType = "voice"; if (event.getContent().offer && event.getContent().offer.sdp && event.getContent().offer.sdp.indexOf('m=video') !== -1) { - type = "video"; + callType = "video"; } - var supported = MatrixClientPeg.get().supportsVoip() ? "" : _t('(not supported by this browser)'); - return _t('%(senderName)s placed a %(callType)s call.', {senderName: senderName, callType: type}) + ' ' + supported; + const supported = MatrixClientPeg.get().supportsVoip() ? "" : _t('(not supported by this browser)'); + return _t('%(senderName)s placed a %(callType)s call.', {senderName, callType}) + ' ' + supported; } function textForThreePidInviteEvent(event) { - var senderName = event.sender ? event.sender.name : event.getSender(); - return _t('%(senderName)s sent an invitation to %(targetDisplayName)s to join the room.', {senderName: senderName, targetDisplayName: event.getContent().display_name}); + const senderName = event.sender ? event.sender.name : event.getSender(); + return _t('%(senderName)s sent an invitation to %(targetDisplayName)s to join the room.', { + senderName, + targetDisplayName: event.getContent().display_name, + }); } function textForHistoryVisibilityEvent(event) { - var senderName = event.sender ? event.sender.name : event.getSender(); - var vis = event.getContent().history_visibility; - // XXX: This i18n just isn't going to work for languages with different sentence structure. - var text = _t('%(senderName)s made future room history visible to', {senderName: senderName}) + ' '; - if (vis === "invited") { - text += _t('all room members, from the point they are invited') + '.'; + const senderName = event.sender ? event.sender.name : event.getSender(); + switch (event.getContent().history_visibility) { + case 'invited': + return _t('%(senderName)s made future room history visible to all room members, ' + + 'from the point they are invited.', {senderName}); + case 'joined': + return _t('%(senderName)s made future room history visible to all room members, ' + + 'from the point they joined.', {senderName}); + case 'shared': + return _t('%(senderName)s made future room history visible to all room members.', {senderName}); + case 'world_readable': + return _t('%(senderName)s made future room history visible to anyone.', {senderName}); + default: + return _t('%(senderName)s made future room history visible to unknown (%(visibility)s).', { + senderName, + visibility: event.getContent().history_visibility, + }); } - else if (vis === "joined") { - text += _t('all room members, from the point they joined') + '.'; - } - else if (vis === "shared") { - text += _t('all room members') + '.'; - } - else if (vis === "world_readable") { - text += _t('anyone') + '.'; - } - else { - text += ' ' + _t('unknown') + ' (' + vis + ').'; - } - return text; } function textForEncryptionEvent(event) { - var senderName = event.sender ? event.sender.name : event.getSender(); - return _t('%(senderName)s turned on end-to-end encryption (algorithm %(algorithm)s).', {senderName: senderName, algorithm: event.getContent().algorithm}); + const senderName = event.sender ? event.sender.name : event.getSender(); + return _t('%(senderName)s turned on end-to-end encryption (algorithm %(algorithm)s).', { + senderName, + algorithm: event.getContent().algorithm, + }); } // Currently will only display a change if a user's power level is changed @@ -211,18 +222,18 @@ function textForPowerEvent(event) { } const userDefault = event.getContent().users_default || 0; // Construct set of userIds - let users = []; + const users = []; Object.keys(event.getContent().users).forEach( (userId) => { if (users.indexOf(userId) === -1) users.push(userId); - } + }, ); Object.keys(event.getPrevContent().users).forEach( (userId) => { if (users.indexOf(userId) === -1) users.push(userId); - } + }, ); - let diff = []; + const diff = []; // XXX: This is also surely broken for i18n users.forEach((userId) => { // Previous power level @@ -231,11 +242,11 @@ function textForPowerEvent(event) { const to = event.getContent().users[userId]; if (to !== from) { diff.push( - _t('%(userId)s from %(fromPowerLevel)s to %(toPowerLevel)s', { + _t('%(userId)s from %(fromPowerLevel)s to %(toPowerLevel)s', { userId: userId, fromPowerLevel: Roles.textualPowerLevel(from, userDefault), - toPowerLevel: Roles.textualPowerLevel(to, userDefault) - }) + toPowerLevel: Roles.textualPowerLevel(to, userDefault), + }), ); } }); @@ -244,28 +255,60 @@ function textForPowerEvent(event) { } return _t('%(senderName)s changed the power level of %(powerLevelDiffText)s.', { senderName: senderName, - powerLevelDiffText: diff.join(", ") + powerLevelDiffText: diff.join(", "), }); } -var handlers = { +function textForWidgetEvent(event) { + const senderName = event.getSender(); + const {name: prevName, type: prevType, url: prevUrl} = event.getPrevContent(); + const {name, type, url} = event.getContent() || {}; + + let widgetName = name || prevName || type || prevType || ''; + // Apply sentence case to widget name + if (widgetName && widgetName.length > 0) { + widgetName = widgetName[0].toUpperCase() + widgetName.slice(1) + ' '; + } + + // If the widget was removed, its content should be {}, but this is sufficiently + // equivalent to that condition. + if (url) { + if (prevUrl) { + return _t('%(widgetName)s widget modified by %(senderName)s', { + widgetName, senderName, + }); + } else { + return _t('%(widgetName)s widget added by %(senderName)s', { + widgetName, senderName, + }); + } + } else { + return _t('%(widgetName)s widget removed by %(senderName)s', { + widgetName, senderName, + }); + } +} + +const handlers = { 'm.room.message': textForMessageEvent, - 'm.room.name': textForRoomNameEvent, - 'm.room.topic': textForTopicEvent, - 'm.room.member': textForMemberEvent, - 'm.call.invite': textForCallInviteEvent, - 'm.call.answer': textForCallAnswerEvent, - 'm.call.hangup': textForCallHangupEvent, + 'm.room.name': textForRoomNameEvent, + 'm.room.topic': textForTopicEvent, + 'm.room.member': textForMemberEvent, + 'm.call.invite': textForCallInviteEvent, + 'm.call.answer': textForCallAnswerEvent, + 'm.call.hangup': textForCallHangupEvent, 'm.room.third_party_invite': textForThreePidInviteEvent, 'm.room.history_visibility': textForHistoryVisibilityEvent, 'm.room.encryption': textForEncryptionEvent, 'm.room.power_levels': textForPowerEvent, + + 'im.vector.modular.widgets': textForWidgetEvent, }; module.exports = { textForEvent: function(ev) { - var hdlr = handlers[ev.getType()]; - if (!hdlr) return ""; + const hdlr = handlers[ev.getType()]; + if (!hdlr) return ''; return hdlr(ev); - } + }, }; diff --git a/src/UserAddress.js b/src/UserAddress.js index 9eee48629d..e7501a0d91 100644 --- a/src/UserAddress.js +++ b/src/UserAddress.js @@ -16,11 +16,12 @@ limitations under the License. const emailRegex = /^\S+@\S+\.\S+$/; -const mxidRegex = /^@\S+:\S+$/; +const mxUserIdRegex = /^@\S+:\S+$/; +const mxRoomIdRegex = /^!\S+:\S+$/; import PropTypes from 'prop-types'; export const addressTypes = [ - 'mx', 'email', + 'mx-user-id', 'mx-room-id', 'email', ]; // PropType definition for an object describing @@ -41,13 +42,16 @@ export const UserAddressType = PropTypes.shape({ export function getAddressType(inputText) { const isEmailAddress = emailRegex.test(inputText); - const isMatrixId = mxidRegex.test(inputText); + const isUserId = mxUserIdRegex.test(inputText); + const isRoomId = mxRoomIdRegex.test(inputText); // sanity check the input for user IDs if (isEmailAddress) { return 'email'; - } else if (isMatrixId) { - return 'mx'; + } else if (isUserId) { + return 'mx-user-id'; + } else if (isRoomId) { + return 'mx-room-id'; } else { return null; } diff --git a/src/UserSettingsStore.js b/src/UserSettingsStore.js index 68a1ba229f..9b7554bda2 100644 --- a/src/UserSettingsStore.js +++ b/src/UserSettingsStore.js @@ -33,11 +33,17 @@ export default { // XXX: Always use default, ignore localStorage and remove from labs override: true, }, + { + name: "-", + id: 'feature_groups', + default: false, + }, ], // horrible but it works. The locality makes this somewhat more palatable. doTranslations: function() { this.LABS_FEATURES[0].name = _t("Matrix Apps"); + this.LABS_FEATURES[1].name = _t("Groups"); }, loadProfileInfo: function() { diff --git a/src/WhoIsTyping.js b/src/WhoIsTyping.js index f3d89f0ff2..2a12703a27 100644 --- a/src/WhoIsTyping.js +++ b/src/WhoIsTyping.js @@ -18,6 +18,12 @@ var MatrixClientPeg = require("./MatrixClientPeg"); import { _t } from './languageHandler'; module.exports = { + usersTypingApartFromMeAndIgnored: function(room) { + return this.usersTyping( + room, [MatrixClientPeg.get().credentials.userId].concat(MatrixClientPeg.get().getIgnoredUsers()) + ); + }, + usersTypingApartFromMe: function(room) { return this.usersTyping( room, [MatrixClientPeg.get().credentials.userId] diff --git a/src/async-components/views/dialogs/ExportE2eKeysDialog.js b/src/async-components/views/dialogs/ExportE2eKeysDialog.js index 8f113353d9..04274442c2 100644 --- a/src/async-components/views/dialogs/ExportE2eKeysDialog.js +++ b/src/async-components/views/dialogs/ExportE2eKeysDialog.js @@ -136,13 +136,13 @@ export default React.createClass({ ) }

- {this.state.errStr} + { this.state.errStr }
@@ -155,7 +155,7 @@ export default React.createClass({
@@ -172,7 +172,7 @@ export default React.createClass({ disabled={disableForm} />
diff --git a/src/async-components/views/dialogs/ImportE2eKeysDialog.js b/src/async-components/views/dialogs/ImportE2eKeysDialog.js index 9eac7f78b2..a01b6580f1 100644 --- a/src/async-components/views/dialogs/ImportE2eKeysDialog.js +++ b/src/async-components/views/dialogs/ImportE2eKeysDialog.js @@ -134,13 +134,13 @@ export default React.createClass({ ) }

- {this.state.errStr} + { this.state.errStr }
@@ -153,14 +153,14 @@ export default React.createClass({
+ disabled={disableForm} />
@@ -170,7 +170,7 @@ export default React.createClass({ disabled={!this.state.enableSubmit || disableForm} />
diff --git a/src/autocomplete/CommandProvider.js b/src/autocomplete/CommandProvider.js index 6f2f68b121..e85457e6aa 100644 --- a/src/autocomplete/CommandProvider.js +++ b/src/autocomplete/CommandProvider.js @@ -16,7 +16,7 @@ limitations under the License. */ import React from 'react'; -import { _t } from '../languageHandler'; +import { _t, _td } from '../languageHandler'; import AutocompleteProvider from './AutocompleteProvider'; import FuzzyMatcher from './FuzzyMatcher'; import {TextualCompletion} from './Components'; @@ -27,72 +27,82 @@ const COMMANDS = [ { command: '/me', args: '', - description: 'Displays action', + description: _td('Displays action'), }, { command: '/ban', args: ' [reason]', - description: 'Bans user with given id', + description: _td('Bans user with given id'), }, { command: '/unban', args: '', - description: 'Unbans user with given id', + description: _td('Unbans user with given id'), }, { command: '/op', args: ' []', - description: 'Define the power level of a user', + description: _td('Define the power level of a user'), }, { command: '/deop', args: '', - description: 'Deops user with given id', + description: _td('Deops user with given id'), }, { command: '/invite', args: '', - description: 'Invites user with given id to current room', + description: _td('Invites user with given id to current room'), }, { command: '/join', args: '', - description: 'Joins room with given alias', + description: _td('Joins room with given alias'), }, { command: '/part', args: '[]', - description: 'Leave room', + description: _td('Leave room'), }, { command: '/topic', args: '', - description: 'Sets the room topic', + description: _td('Sets the room topic'), }, { command: '/kick', args: ' [reason]', - description: 'Kicks user with given id', + description: _td('Kicks user with given id'), }, { command: '/nick', args: '', - description: 'Changes your display nickname', + description: _td('Changes your display nickname'), }, { command: '/ddg', args: '', - description: 'Searches DuckDuckGo for results', + description: _td('Searches DuckDuckGo for results'), }, { command: '/tint', args: ' []', - description: 'Changes colour scheme of current room', + description: _td('Changes colour scheme of current room'), }, { command: '/verify', args: ' ', - description: 'Verifies a user, device, and pubkey tuple', + description: _td('Verifies a user, device, and pubkey tuple'), + }, + { + command: '/ignore', + args: '', + description: _td('Ignores a user, hiding their messages from you'), + }, + { + command: '/unignore', + args: '', + description: _td('Stops ignoring a user, showing their messages going forward'), }, // Omitting `/markdown` as it only seems to apply to OldComposer ]; @@ -119,7 +129,7 @@ export default class CommandProvider extends AutocompleteProvider { component: (), range, }; @@ -140,7 +150,7 @@ export default class CommandProvider extends AutocompleteProvider { renderCompletions(completions: [React.Component]): ?React.Component { return
- {completions} + { completions }
; } } diff --git a/src/autocomplete/EmojiProvider.js b/src/autocomplete/EmojiProvider.js index 16e0347a5b..35a2ee6b53 100644 --- a/src/autocomplete/EmojiProvider.js +++ b/src/autocomplete/EmojiProvider.js @@ -25,6 +25,7 @@ import {PillCompletion} from './Components'; import type {SelectionRange, Completion} from './Autocompleter'; import _uniq from 'lodash/uniq'; import _sortBy from 'lodash/sortBy'; +import UserSettingsStore from '../UserSettingsStore'; import EmojiData from '../stripped-emoji.json'; @@ -96,6 +97,10 @@ export default class EmojiProvider extends AutocompleteProvider { } async getCompletions(query: string, selection: SelectionRange) { + if (UserSettingsStore.getSyncedSetting("MessageComposerInput.dontSuggestEmoji")) { + return []; // don't give any suggestions if the user doesn't want them + } + const EmojiText = sdk.getComponent('views.elements.EmojiText'); let completions = []; diff --git a/src/autocomplete/RoomProvider.js b/src/autocomplete/RoomProvider.js index 1770089eb2..cc04f54dda 100644 --- a/src/autocomplete/RoomProvider.js +++ b/src/autocomplete/RoomProvider.js @@ -106,7 +106,7 @@ export default class RoomProvider extends AutocompleteProvider { renderCompletions(completions: [React.Component]): ?React.Component { return
- {completions} + { completions }
; } } diff --git a/src/autocomplete/UserProvider.js b/src/autocomplete/UserProvider.js index 017491a07e..26b30a3d27 100644 --- a/src/autocomplete/UserProvider.js +++ b/src/autocomplete/UserProvider.js @@ -33,7 +33,8 @@ const USER_REGEX = /@\S*/g; let instance = null; export default class UserProvider extends AutocompleteProvider { - users: Array = []; + users: Array = null; + room: Room = null; constructor() { super(USER_REGEX, { @@ -54,6 +55,9 @@ export default class UserProvider extends AutocompleteProvider { return []; } + // lazy-load user list into matcher + if (this.users === null) this._makeUsers(); + let completions = []; let {command, range} = this.getCurrentCommand(query, selection, force); if (command) { @@ -83,7 +87,12 @@ export default class UserProvider extends AutocompleteProvider { } setUserListFromRoom(room: Room) { - const events = room.getLiveTimeline().getEvents(); + this.room = room; + this.users = null; + } + + _makeUsers() { + const events = this.room.getLiveTimeline().getEvents(); const lastSpoken = {}; for(const event of events) { @@ -91,7 +100,7 @@ export default class UserProvider extends AutocompleteProvider { } const currentUserId = MatrixClientPeg.get().credentials.userId; - this.users = room.getJoinedMembers().filter((member) => { + this.users = this.room.getJoinedMembers().filter((member) => { if (member.userId !== currentUserId) return true; }); @@ -103,7 +112,8 @@ export default class UserProvider extends AutocompleteProvider { } onUserSpoke(user: RoomMember) { - if(user.userId === MatrixClientPeg.get().credentials.userId) return; + if (this.users === null) return; + if (user.userId === MatrixClientPeg.get().credentials.userId) return; // Move the user that spoke to the front of the array this.users.splice( diff --git a/src/components/structures/GroupView.js b/src/components/structures/GroupView.js index 20fc4841ba..337ac6ab75 100644 --- a/src/components/structures/GroupView.js +++ b/src/components/structures/GroupView.js @@ -1,5 +1,6 @@ /* Copyright 2017 Vector Creations Ltd. +Copyright 2017 New Vector Ltd. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -16,6 +17,7 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; +import Promise from 'bluebird'; import MatrixClientPeg from '../../MatrixClientPeg'; import sdk from '../../index'; import dis from '../../dispatcher'; @@ -25,6 +27,9 @@ import AccessibleButton from '../views/elements/AccessibleButton'; import Modal from '../../Modal'; import classnames from 'classnames'; +import GroupStoreCache from '../../stores/GroupStoreCache'; +import GroupStore from '../../stores/GroupStore'; + const RoomSummaryType = PropTypes.shape({ room_id: PropTypes.string.isRequired, profile: PropTypes.shape({ @@ -37,6 +42,9 @@ const RoomSummaryType = PropTypes.shape({ const UserSummaryType = PropTypes.shape({ summaryInfo: PropTypes.shape({ user_id: PropTypes.string.isRequired, + role_id: PropTypes.string, + avatar_url: PropTypes.string, + displayname: PropTypes.string, }).isRequired, }); @@ -50,19 +58,79 @@ const CategoryRoomList = React.createClass({ name: PropTypes.string, }).isRequired, }), + groupId: PropTypes.string.isRequired, + + // Whether the list should be editable + editing: PropTypes.bool.isRequired, + }, + + onAddRoomsClicked: function(ev) { + ev.preventDefault(); + const AddressPickerDialog = sdk.getComponent("dialogs.AddressPickerDialog"); + Modal.createTrackedDialog('Add Rooms to Group Summary', '', AddressPickerDialog, { + title: _t('Add rooms to the group summary'), + description: _t("Which rooms would you like to add to this summary?"), + placeholder: _t("Room name or alias"), + button: _t("Add to summary"), + pickerType: 'room', + validAddressTypes: ['mx-room-id'], + groupId: this.props.groupId, + onFinished: (success, addrs) => { + if (!success) return; + const errorList = []; + Promise.all(addrs.map((addr) => { + return this.context.groupStore + .addRoomToGroupSummary(addr.address) + .catch(() => { errorList.push(addr.address); }) + .reflect(); + })).then(() => { + if (errorList.length === 0) { + return; + } + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + Modal.createTrackedDialog( + 'Failed to add the following room to the group summary', + '', ErrorDialog, + { + title: _t( + "Failed to add the following rooms to the summary of %(groupId)s:", + {groupId: this.props.groupId}, + ), + description: errorList.join(", "), + }); + }); + }, + }); }, render: function() { + const TintableSvg = sdk.getComponent("elements.TintableSvg"); + const addButton = this.props.editing ? + ( + +
+ { _t('Add a Room') } +
+
) :
; + const roomNodes = this.props.rooms.map((r) => { - return ; + return ; }); - let catHeader = null; + + let catHeader =
; if (this.props.category && this.props.category.profile) { - catHeader =
{this.props.category.profile.name}
; + catHeader =
+ { this.props.category.profile.name } +
; } - return
- {catHeader} - {roomNodes} + return
+ { catHeader } + { roomNodes } + { addButton }
; }, }); @@ -72,6 +140,8 @@ const FeaturedRoom = React.createClass({ props: { summaryInfo: RoomSummaryType.isRequired, + editing: PropTypes.bool.isRequired, + groupId: PropTypes.string.isRequired, }, onClick: function(e) { @@ -85,28 +155,69 @@ const FeaturedRoom = React.createClass({ }); }, + onDeleteClicked: function(e) { + e.preventDefault(); + e.stopPropagation(); + this.context.groupStore.removeRoomFromGroupSummary( + this.props.summaryInfo.room_id, + ).catch((err) => { + console.error('Error whilst removing room from group summary', err); + const roomName = this.props.summaryInfo.name || + this.props.summaryInfo.canonical_alias || + this.props.summaryInfo.room_id; + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + Modal.createTrackedDialog( + 'Failed to remove room from group summary', + '', ErrorDialog, + { + title: _t( + "Failed to remove the room from the summary of %(groupId)s", + {groupId: this.props.groupId}, + ), + description: _t("The room '%(roomName)s' could not be removed from the summary.", {roomName}), + }); + }); + }, + render: function() { const RoomAvatar = sdk.getComponent("avatars.RoomAvatar"); + const roomName = this.props.summaryInfo.profile.name || + this.props.summaryInfo.profile.canonical_alias || + _t("Unnamed Room"); + const oobData = { roomId: this.props.summaryInfo.room_id, avatarUrl: this.props.summaryInfo.profile.avatar_url, - name: this.props.summaryInfo.profile.name, + name: roomName, }; + let permalink = null; if (this.props.summaryInfo.profile && this.props.summaryInfo.profile.canonical_alias) { permalink = 'https://matrix.to/#/' + this.props.summaryInfo.profile.canonical_alias; } + let roomNameNode = null; if (permalink) { - roomNameNode = {this.props.summaryInfo.profile.name}; + roomNameNode = { roomName }; } else { - roomNameNode = {this.props.summaryInfo.profile.name}; + roomNameNode = { roomName }; } + const deleteButton = this.props.editing ? + Delete + :
; + return -
{roomNameNode}
+
{ roomNameNode }
+ { deleteButton }
; }, }); @@ -121,19 +232,75 @@ const RoleUserList = React.createClass({ name: PropTypes.string, }).isRequired, }), + groupId: PropTypes.string.isRequired, + + // Whether the list should be editable + editing: PropTypes.bool.isRequired, + }, + + onAddUsersClicked: function(ev) { + ev.preventDefault(); + const AddressPickerDialog = sdk.getComponent("dialogs.AddressPickerDialog"); + Modal.createTrackedDialog('Add Users to Group Summary', '', AddressPickerDialog, { + title: _t('Add users to the group summary'), + description: _t("Who would you like to add to this summary?"), + placeholder: _t("Name or matrix ID"), + button: _t("Add to summary"), + validAddressTypes: ['mx-user-id'], + groupId: this.props.groupId, + shouldOmitSelf: false, + onFinished: (success, addrs) => { + if (!success) return; + const errorList = []; + Promise.all(addrs.map((addr) => { + return this.context.groupStore + .addUserToGroupSummary(addr.address) + .catch(() => { errorList.push(addr.address); }) + .reflect(); + })).then(() => { + if (errorList.length === 0) { + return; + } + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + Modal.createTrackedDialog( + 'Failed to add the following users to the group summary', + '', ErrorDialog, + { + title: _t( + "Failed to add the following users to the summary of %(groupId)s:", + {groupId: this.props.groupId}, + ), + description: errorList.join(", "), + }); + }); + }, + }); }, render: function() { + const TintableSvg = sdk.getComponent("elements.TintableSvg"); + const addButton = this.props.editing ? + ( + +
+ { _t('Add a User') } +
+
) :
; const userNodes = this.props.users.map((u) => { - return ; + return ; }); - let roleHeader = null; + let roleHeader =
; if (this.props.role && this.props.role.profile) { - roleHeader =
{this.props.role.profile.name}
; + roleHeader =
{ this.props.role.profile.name }
; } - return
- {roleHeader} - {userNodes} + return
+ { roleHeader } + { userNodes } + { addButton }
; }, }); @@ -143,6 +310,8 @@ const FeaturedUser = React.createClass({ props: { summaryInfo: UserSummaryType.isRequired, + editing: PropTypes.bool.isRequired, + groupId: PropTypes.string.isRequired, }, onClick: function(e) { @@ -156,19 +325,64 @@ const FeaturedUser = React.createClass({ }); }, + onDeleteClicked: function(e) { + e.preventDefault(); + e.stopPropagation(); + this.context.groupStore.removeUserFromGroupSummary( + this.props.summaryInfo.user_id, + ).catch((err) => { + console.error('Error whilst removing user from group summary', err); + const displayName = this.props.summaryInfo.displayname || this.props.summaryInfo.user_id; + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + Modal.createTrackedDialog( + 'Failed to remove user from group summary', + '', ErrorDialog, + { + title: _t( + "Failed to remove a user from the summary of %(groupId)s", + {groupId: this.props.groupId}, + ), + description: _t("The user '%(displayName)s' could not be removed from the summary.", {displayName}), + }); + }); + }, + render: function() { - // Add avatar once we get profile info inline in the summary response - //const BaseAvatar = sdk.getComponent("avatars.BaseAvatar"); + const BaseAvatar = sdk.getComponent("avatars.BaseAvatar"); + const name = this.props.summaryInfo.displayname || this.props.summaryInfo.user_id; const permalink = 'https://matrix.to/#/' + this.props.summaryInfo.user_id; - const userNameNode = {this.props.summaryInfo.user_id}; + const userNameNode = { name }; + const httpUrl = MatrixClientPeg.get() + .mxcUrlToHttp(this.props.summaryInfo.avatar_url, 64, 64); + + const deleteButton = this.props.editing ? + Delete + :
; return -
{userNameNode}
+ +
{ userNameNode }
+ { deleteButton }
; }, }); +const GroupContext = { + groupStore: React.PropTypes.instanceOf(GroupStore).isRequired, +}; + +CategoryRoomList.contextTypes = GroupContext; +FeaturedRoom.contextTypes = GroupContext; +RoleUserList.contextTypes = GroupContext; +FeaturedUser.contextTypes = GroupContext; + export default React.createClass({ displayName: 'GroupView', @@ -176,6 +390,16 @@ export default React.createClass({ groupId: PropTypes.string.isRequired, }, + childContextTypes: { + groupStore: React.PropTypes.instanceOf(GroupStore), + }, + + getChildContext: function() { + return { + groupStore: this._groupStore, + }; + }, + getInitialState: function() { return { summary: null, @@ -183,12 +407,21 @@ export default React.createClass({ editing: false, saving: false, uploadingAvatar: false, + membershipBusy: false, + publicityBusy: false, }; }, componentWillMount: function() { this._changeAvatarComponent = null; - this._loadGroupFromServer(this.props.groupId); + this._initGroupStore(this.props.groupId); + + MatrixClientPeg.get().on("Group.myMembership", this._onGroupMyMembership); + }, + + componentWillUnmount: function() { + MatrixClientPeg.get().removeListener("Group.myMembership", this._onGroupMyMembership); + this._groupStore.removeAllListeners(); }, componentWillReceiveProps: function(newProps) { @@ -197,18 +430,26 @@ export default React.createClass({ summary: null, error: null, }, () => { - this._loadGroupFromServer(newProps.groupId); + this._initGroupStore(newProps.groupId); }); } }, - _loadGroupFromServer: function(groupId) { - MatrixClientPeg.get().getGroupSummary(groupId).done((res) => { + _onGroupMyMembership: function(group) { + if (group.groupId !== this.props.groupId) return; + + this.setState({membershipBusy: false}); + }, + + _initGroupStore: function(groupId) { + this._groupStore = GroupStoreCache.getGroupStore(MatrixClientPeg.get(), groupId); + this._groupStore.on('update', () => { this.setState({ - summary: res, + summary: this._groupStore.getSummary(), error: null, }); - }, (err) => { + }); + this._groupStore.on('error', (err) => { this.setState({ summary: null, error: err, @@ -216,6 +457,10 @@ export default React.createClass({ }); }, + _onShowRhsClick: function(ev) { + dis.dispatch({ action: 'show_right_panel' }); + }, + _onEditClick: function() { this.setState({ editing: true, @@ -281,7 +526,7 @@ export default React.createClass({ editing: false, summary: null, }); - this._loadGroupFromServer(this.props.groupId); + this._initGroupStore(this.props.groupId); }).catch((e) => { this.setState({ saving: false, @@ -295,10 +540,80 @@ export default React.createClass({ }).done(); }, - _getFeaturedRoomsNode() { - const summary = this.state.summary; + _onAcceptInviteClick: function() { + this.setState({membershipBusy: true}); + MatrixClientPeg.get().acceptGroupInvite(this.props.groupId).then(() => { + // don't reset membershipBusy here: wait for the membership change to come down the sync + }).catch((e) => { + this.setState({membershipBusy: false}); + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + Modal.createTrackedDialog('Error accepting invite', '', ErrorDialog, { + title: _t("Error"), + description: _t("Unable to accept invite"), + }); + }); + }, - if (summary.rooms_section.rooms.length == 0) return null; + _onRejectInviteClick: function() { + this.setState({membershipBusy: true}); + MatrixClientPeg.get().leaveGroup(this.props.groupId).then(() => { + // don't reset membershipBusy here: wait for the membership change to come down the sync + }).catch((e) => { + this.setState({membershipBusy: false}); + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + Modal.createTrackedDialog('Error rejecting invite', '', ErrorDialog, { + title: _t("Error"), + description: _t("Unable to reject invite"), + }); + }); + }, + + _onLeaveClick: function() { + const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); + Modal.createTrackedDialog('Leave Group', '', QuestionDialog, { + title: _t("Leave Group"), + description: _t("Leave %(groupName)s?", {groupName: this.props.groupId}), + button: _t("Leave"), + danger: true, + onFinished: (confirmed) => { + if (!confirmed) return; + + this.setState({membershipBusy: true}); + MatrixClientPeg.get().leaveGroup(this.props.groupId).then(() => { + // don't reset membershipBusy here: wait for the membership change to come down the sync + }).catch((e) => { + this.setState({membershipBusy: false}); + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + Modal.createTrackedDialog('Error leaving room', '', ErrorDialog, { + title: _t("Error"), + description: _t("Unable to leave room"), + }); + }); + }, + }); + }, + + _onPubliciseOffClick: function() { + this._setPublicity(false); + }, + + _onPubliciseOnClick: function() { + this._setPublicity(true); + }, + + _setPublicity: function(publicity) { + this.setState({ + publicityBusy: true, + }); + this._groupStore.setGroupPublicity(publicity).then(() => { + this.setState({ + publicityBusy: false, + }); + }); + }, + + _getFeaturedRoomsNode: function() { + const summary = this.state.summary; const defaultCategoryRooms = []; const categoryRooms = {}; @@ -315,29 +630,32 @@ export default React.createClass({ } }); - let defaultCategoryNode = null; - if (defaultCategoryRooms.length > 0) { - defaultCategoryNode = ; - } + const defaultCategoryNode = ; const categoryRoomNodes = Object.keys(categoryRooms).map((catId) => { const cat = summary.rooms_section.categories[catId]; - return ; + return ; }); return
- {_t('Featured Rooms:')} + { _t('Featured Rooms:') }
- {defaultCategoryNode} - {categoryRoomNodes} + { defaultCategoryNode } + { categoryRoomNodes }
; }, - _getFeaturedUsersNode() { + _getFeaturedUsersNode: function() { const summary = this.state.summary; - if (summary.users_section.users.length == 0) return null; - const noRoleUsers = []; const roleUsers = {}; summary.users_section.users.forEach((u) => { @@ -353,24 +671,121 @@ export default React.createClass({ } }); - let noRoleNode = null; - if (noRoleUsers.length > 0) { - noRoleNode = ; - } + const noRoleNode = ; const roleUserNodes = Object.keys(roleUsers).map((roleId) => { const role = summary.users_section.roles[roleId]; - return ; + return ; }); return
- {_t('Featured Users:')} + { _t('Featured Users:') }
- {noRoleNode} - {roleUserNodes} + { noRoleNode } + { roleUserNodes }
; }, + _getMembershipSection: function() { + const Spinner = sdk.getComponent("elements.Spinner"); + + const group = MatrixClientPeg.get().getGroup(this.props.groupId); + if (!group) return null; + + if (group.myMembership === 'invite') { + if (this.state.membershipBusy) { + return
+ +
; + } + + return
+
+ { _t("%(inviter)s has invited you to join this group", {inviter: group.inviter.userId}) } +
+
+ + { _t("Accept") } + + + { _t("Decline") } + +
+
; + } else if (group.myMembership === 'join') { + let youAreAMemberText = _t("You are a member of this group"); + if (this.state.summary.user && this.state.summary.user.is_privileged) { + youAreAMemberText = _t("You are an administrator of this group"); + } + + let publicisedButton; + if (this.state.publicityBusy) { + publicisedButton = ; + } + + let publicisedSection; + if (this.state.summary.user && this.state.summary.user.is_publicised) { + if (!this.state.publicityBusy) { + publicisedButton = + { _t("Unpublish") } + ; + } + publicisedSection =
+ { _t("This group is published on your profile") } +
+ { publicisedButton } +
+
; + } else { + if (!this.state.publicityBusy) { + publicisedButton = + { _t("Publish") } + ; + } + publicisedSection =
+ { _t("This group is not published on your profile") } +
+ { publicisedButton } +
+
; + } + + return
+
+
+ { youAreAMemberText } +
+
+ + { _t("Leave") } + +
+
+ { publicisedSection } +
; + } + + return null; + }, + render: function() { const GroupAvatar = sdk.getComponent("avatars.GroupAvatar"); const Loader = sdk.getComponent("elements.Spinner"); @@ -384,8 +799,8 @@ export default React.createClass({ let avatarNode; let nameNode; let shortDescNode; - let rightButtons; let roomBody; + const rightButtons = []; const headerClasses = { mx_GroupView_header: true, }; @@ -404,15 +819,15 @@ export default React.createClass({ avatarNode = (
- +
); @@ -428,20 +843,26 @@ export default React.createClass({ placeholder={_t('Description')} tabIndex="2" />; - rightButtons = - - {_t('Save')} - - - {_t("Cancel")}/ - - ; + rightButtons.push( + + { _t('Save') } + , + ); + rightButtons.push( + + {_t("Cancel")} + , + ); roomBody =