diff --git a/.eslintignore.errorfiles b/.eslintignore.errorfiles index 28e56e6e32..f9c5e58099 100644 --- a/.eslintignore.errorfiles +++ b/.eslintignore.errorfiles @@ -54,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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 090f5a49da..97523e9189 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,106 @@ +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) 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/package.json b/package.json index 38a647ff67..e9b4aa9a53 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "0.10.3", + "version": "0.10.6", "description": "SDK for matrix.org using React", "author": "matrix.org", "repository": { @@ -40,7 +40,7 @@ "lint": "eslint src/", "lintall": "eslint 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 +66,7 @@ "isomorphic-fetch": "^2.2.1", "linkifyjs": "^2.1.3", "lodash": "^4.13.1", - "matrix-js-sdk": "0.8.2", + "matrix-js-sdk": "0.8.4", "optimist": "^0.6.1", "prop-types": "^15.5.8", "react": "^15.4.0", 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/GroupInvite.js b/src/GroupInvite.js new file mode 100644 index 0000000000..e04e90d751 --- /dev/null +++ b/src/GroupInvite.js @@ -0,0 +1,67 @@ +/* +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'; + +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'], + onFinished: (success, addrs) => { + if (!success) return; + + _onGroupInviteFinished(groupId, addrs); + }, + }); +} + +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}), + }); + }); +} + diff --git a/src/HtmlUtils.js b/src/HtmlUtils.js index 63ee5fa480..ee2bcd2b0f 100644 --- a/src/HtmlUtils.js +++ b/src/HtmlUtils.js @@ -32,7 +32,15 @@ emojione.imagePathPNG = 'emojione/png/'; // Use SVGs for emojis emojione.imageType = 'svg'; -const SIMPLE_EMOJI_PATTERN = /([\ud800-\udbff])([\udc00-\udfff])/; +// Anything outside the basic multilingual plane will be a surrogate pair +const SURROGATE_PAIR_PATTERN = /([\ud800-\udbff])([\udc00-\udfff])/; +// And there a bunch more symbol characters that emojione has within the +// BMP, so this includes the ranges from 'letterlike symbols' to +// 'miscellaneous symbols and arrows' which should catch all of them +// (with plenty of false positives, but that's OK) +const SYMBOL_PATTERN = /([\u2100-\u2bff])/; + +// And this is emojione's complete regex const EMOJI_REGEX = new RegExp(emojione.unicodeRegexp+"+", "gi"); const COLOR_REGEX = /^#[0-9a-fA-F]{6}$/; @@ -44,16 +52,13 @@ const COLOR_REGEX = /^#[0-9a-fA-F]{6}$/; * unicodeToImage uses this function. */ export function containsEmoji(str) { - return SIMPLE_EMOJI_PATTERN.test(str); + return SURROGATE_PAIR_PATTERN.test(str) || SYMBOL_PATTERN.test(str); } /* modified from https://github.com/Ranks/emojione/blob/master/lib/js/emojione.js * because we want to include emoji shortnames in title text */ -export function unicodeToImage(str) { - // fast path - if (!containsEmoji(str)) return str; - +function unicodeToImage(str) { let replaceWith, unicode, alt, short, fname; const mappedUnicode = emojione.mapUnicodeToShort(); @@ -143,7 +148,7 @@ export function processHtmlForSending(html: string): string { * of that HTML. */ export function sanitizedHtmlNode(insaneHtml) { - const saneHtml = sanitizeHtml(insaneHtml, sanitizeHtmlParams); + const saneHtml = sanitizeHtml(insaneHtml, sanitizeHtmlParams); return
; } @@ -152,7 +157,7 @@ const sanitizeHtmlParams = { allowedTags: [ 'font', // custom to matrix for IRC-style font coloring 'del', // for markdown - 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'p', 'a', 'ul', 'ol', + 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'p', 'a', 'ul', 'ol', 'sup', 'sub', 'nl', 'li', 'b', 'i', 'u', 'strong', 'em', 'strike', 'code', 'hr', 'br', 'div', 'table', 'thead', 'caption', 'tbody', 'tr', 'th', 'td', 'pre', 'span', 'img', ], @@ -391,6 +396,8 @@ export function bodyToHtml(content, highlights, opts) { var isHtml = (content.format === "org.matrix.custom.html"); let body = isHtml ? content.formatted_body : escape(content.body); + let bodyHasEmoji = false; + var safeBody; // XXX: We sanitize the HTML whilst also highlighting its text nodes, to avoid accidentally trying // to highlight HTML tags themselves. However, this does mean that we don't highlight textnodes which @@ -408,16 +415,20 @@ export function bodyToHtml(content, highlights, opts) { }; } safeBody = sanitizeHtml(body, sanitizeHtmlParams); - safeBody = unicodeToImage(safeBody); + bodyHasEmoji = containsEmoji(body); + if (bodyHasEmoji) safeBody = unicodeToImage(safeBody); } finally { delete sanitizeHtmlParams.textFilter; } - EMOJI_REGEX.lastIndex = 0; - let contentBodyTrimmed = content.body !== undefined ? content.body.trim() : ''; - let match = EMOJI_REGEX.exec(contentBodyTrimmed); - let emojiBody = match && match[0] && match[0].length === contentBodyTrimmed.length; + let emojiBody = false; + if (bodyHasEmoji) { + EMOJI_REGEX.lastIndex = 0; + let contentBodyTrimmed = content.body !== undefined ? content.body.trim() : ''; + let match = EMOJI_REGEX.exec(contentBodyTrimmed); + emojiBody = match && match[0] && match[0].length === contentBodyTrimmed.length; + } const className = classNames({ 'mx_EventTile_body': true, diff --git a/src/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/Invite.js b/src/RoomInvite.js similarity index 93% rename from src/Invite.js rename to src/RoomInvite.js index b8e33d318a..af0ba3d1e7 100644 --- a/src/Invite.js +++ b/src/RoomInvite.js @@ -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/SlashCommands.js b/src/SlashCommands.js index e5378d4347..1302aaa423 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 36b8b538a7..902c10307e 100644 --- a/src/TextForEvent.js +++ b/src/TextForEvent.js @@ -176,26 +176,24 @@ function textForThreePidInviteEvent(event) { } 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) { diff --git a/src/UserSettingsStore.js b/src/UserSettingsStore.js index 68a1ba229f..1d1924cd23 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_flair', + 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("Flair"); }, 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/autocomplete/CommandProvider.js b/src/autocomplete/CommandProvider.js index 6f2f68b121..011ad0a7dc 100644 --- a/src/autocomplete/CommandProvider.js +++ b/src/autocomplete/CommandProvider.js @@ -94,6 +94,16 @@ const COMMANDS = [ args: ' ', description: 'Verifies a user, device, and pubkey tuple', }, + { + command: '/ignore', + args: '', + description: 'Ignores a user, hiding their messages from you', + }, + { + command: '/unignore', + args: '', + description: 'Stops ignoring a user, showing their messages going forward', + }, // Omitting `/markdown` as it only seems to apply to OldComposer ]; 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/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..6a30c1ce41 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,8 @@ import AccessibleButton from '../views/elements/AccessibleButton'; import Modal from '../../Modal'; import classnames from 'classnames'; +import GroupSummaryStore from '../../stores/GroupSummaryStore'; + const RoomSummaryType = PropTypes.shape({ room_id: PropTypes.string.isRequired, profile: PropTypes.shape({ @@ -37,6 +41,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 +57,77 @@ 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'], + groupId: this.props.groupId, + onFinished: (success, addrs) => { + if (!success) return; + const errorList = []; + Promise.all(addrs.map((addr) => { + return this.context.groupSummaryStore + .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}
; } - return
+ return
{catHeader} {roomNodes} + {addButton}
; }, }); @@ -72,6 +137,8 @@ const FeaturedRoom = React.createClass({ props: { summaryInfo: RoomSummaryType.isRequired, + editing: PropTypes.bool.isRequired, + groupId: PropTypes.string.isRequired, }, onClick: function(e) { @@ -85,6 +152,30 @@ const FeaturedRoom = React.createClass({ }); }, + onDeleteClicked: function(e) { + e.preventDefault(); + e.stopPropagation(); + this.context.groupSummaryStore.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"); @@ -104,9 +195,20 @@ const FeaturedRoom = React.createClass({ roomNameNode = {this.props.summaryInfo.profile.name}; } + const deleteButton = this.props.editing ? + Delete + :
; + return
{roomNameNode}
+ {deleteButton}
; }, }); @@ -121,19 +223,74 @@ 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'], + groupId: this.props.groupId, + onFinished: (success, addrs) => { + if (!success) return; + const errorList = []; + Promise.all(addrs.map((addr) => { + return this.context.groupSummaryStore + .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}
; } - return
+ return
{roleHeader} {userNodes} + {addButton}
; }, }); @@ -143,6 +300,8 @@ const FeaturedUser = React.createClass({ props: { summaryInfo: UserSummaryType.isRequired, + editing: PropTypes.bool.isRequired, + groupId: PropTypes.string.isRequired, }, onClick: function(e) { @@ -156,19 +315,64 @@ const FeaturedUser = React.createClass({ }); }, + onDeleteClicked: function(e) { + e.preventDefault(); + e.stopPropagation(); + this.context.groupSummaryStore.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}
+ {deleteButton}
; }, }); +const GroupSummaryContext = { + groupSummaryStore: React.PropTypes.instanceOf(GroupSummaryStore).isRequired, +}; + +CategoryRoomList.contextTypes = GroupSummaryContext; +FeaturedRoom.contextTypes = GroupSummaryContext; +RoleUserList.contextTypes = GroupSummaryContext; +FeaturedUser.contextTypes = GroupSummaryContext; + export default React.createClass({ displayName: 'GroupView', @@ -176,6 +380,16 @@ export default React.createClass({ groupId: PropTypes.string.isRequired, }, + childContextTypes: { + groupSummaryStore: React.PropTypes.instanceOf(GroupSummaryStore), + }, + + getChildContext: function() { + return { + groupSummaryStore: this._groupSummaryStore, + }; + }, + getInitialState: function() { return { summary: null, @@ -183,12 +397,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._initGroupSummaryStore(this.props.groupId); + + MatrixClientPeg.get().on("Group.myMembership", this._onGroupMyMembership); + }, + + componentWillUnmount: function() { + MatrixClientPeg.get().removeListener("Group.myMembership", this._onGroupMyMembership); + this._groupSummaryStore.removeAllListeners(); }, componentWillReceiveProps: function(newProps) { @@ -197,18 +420,28 @@ export default React.createClass({ summary: null, error: null, }, () => { - this._loadGroupFromServer(newProps.groupId); + this._initGroupSummaryStore(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}); + }, + + _initGroupSummaryStore: function(groupId) { + this._groupSummaryStore = new GroupSummaryStore( + MatrixClientPeg.get(), this.props.groupId, + ); + this._groupSummaryStore.on('update', () => { this.setState({ - summary: res, + summary: this._groupSummaryStore.getSummary(), error: null, }); - }, (err) => { + }); + this._groupSummaryStore.on('error', (err) => { this.setState({ summary: null, error: err, @@ -216,6 +449,10 @@ export default React.createClass({ }); }, + _onShowRhsClick: function(ev) { + dis.dispatch({ action: 'show_right_panel' }); + }, + _onEditClick: function() { this.setState({ editing: true, @@ -281,7 +518,7 @@ export default React.createClass({ editing: false, summary: null, }); - this._loadGroupFromServer(this.props.groupId); + this._initGroupSummaryStore(this.props.groupId); }).catch((e) => { this.setState({ saving: false, @@ -295,10 +532,82 @@ 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, + }); + MatrixClientPeg.get().setGroupPublicity(this.props.groupId, publicity).then(() => { + this._loadGroupFromServer(this.props.groupId); + }).then(() => { + this.setState({ + publicityBusy: false, + }); + }); + }, + + _getFeaturedRoomsNode: function() { + const summary = this.state.summary; const defaultCategoryRooms = []; const categoryRooms = {}; @@ -315,13 +624,18 @@ 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
@@ -333,11 +647,9 @@ export default React.createClass({
; }, - _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,13 +665,18 @@ 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
@@ -371,6 +688,98 @@ export default React.createClass({
; }, + _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_public) { + if (!this.state.publicityBusy) { + publicisedButton = + {_t("Make private")} + ; + } + publicisedSection =
+ {_t("Your membership of this group is public")} +
+ {publicisedButton} +
+
; + } else { + if (!this.state.publicityBusy) { + publicisedButton = + {_t("Make public")} + ; + } + publicisedSection =
+ {_t("Your membership of this group is private")} +
+ {publicisedButton} +
+
; + } + + return
+
+
+ {youAreAMemberText} +
+
+ + {_t("Leave")} + +
+
+ {publicisedSection} +
; + } + + return null; + }, + render: function() { const GroupAvatar = sdk.getComponent("avatars.GroupAvatar"); const Loader = sdk.getComponent("elements.Spinner"); @@ -384,8 +793,8 @@ export default React.createClass({ let avatarNode; let nameNode; let shortDescNode; - let rightButtons; let roomBody; + const rightButtons = []; const headerClasses = { mx_GroupView_header: true, }; @@ -428,20 +837,26 @@ export default React.createClass({ placeholder={_t('Description')} tabIndex="2" />; - rightButtons = - + rightButtons.push( + {_t('Save')} - - + , + ); + rightButtons.push( + {_t("Cancel")}/ - - ; + , + ); roomBody =