From 1f44233e05d2a9ef1bf00c51004f0d395b9f9fb3 Mon Sep 17 00:00:00 2001 From: Stefan Parviainen Date: Thu, 12 Oct 2017 21:24:45 +0200 Subject: [PATCH 01/37] Better translations in RoomList.js Signed-off-by: Stefan Parviainen --- src/components/views/rooms/RoomList.js | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/src/components/views/rooms/RoomList.js b/src/components/views/rooms/RoomList.js index da77174dff..56589353f9 100644 --- a/src/components/views/rooms/RoomList.js +++ b/src/components/views/rooms/RoomList.js @@ -34,27 +34,18 @@ const Receipt = require('../../../utils/Receipt'); const HIDE_CONFERENCE_CHANS = true; function phraseForSection(section) { - // These would probably be better as individual strings, - // but for some reason we have translations for these strings - // as-is, so keeping it like this for now. - let verb; switch (section) { case 'm.favourite': - verb = _t('to favourite'); - break; + return _t('Drop here to favourite'); case 'im.vector.fake.direct': - verb = _t('to tag direct chat'); - break; + return _t('Drop here to tag direct chat'); case 'im.vector.fake.recent': - verb = _t('to restore'); - break; + return _t('Drop here to restore'); case 'm.lowpriority': - verb = _t('to demote'); - break; + return _t('Drop here to demote'); default: return _t('Drop here to tag %(section)s', {section: section}); } - return _t('Drop here %(toAction)s', {toAction: verb}); } module.exports = React.createClass({ From 9495ccdbb53f8bf7e30388cdb39b61fe6b2dadb2 Mon Sep 17 00:00:00 2001 From: Stefan Parviainen Date: Thu, 12 Oct 2017 21:37:12 +0200 Subject: [PATCH 02/37] Don't hardcode ConfirmUserActionDialog title Signed-off-by: Stefan Parviainen --- src/components/views/dialogs/ConfirmUserActionDialog.js | 4 ++-- src/components/views/groups/GroupMemberInfo.js | 1 + src/components/views/rooms/MemberInfo.js | 5 +++-- src/components/views/rooms/RoomSettings.js | 1 + 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/components/views/dialogs/ConfirmUserActionDialog.js b/src/components/views/dialogs/ConfirmUserActionDialog.js index 9091d8975e..64e25df5f1 100644 --- a/src/components/views/dialogs/ConfirmUserActionDialog.js +++ b/src/components/views/dialogs/ConfirmUserActionDialog.js @@ -36,6 +36,7 @@ export default React.createClass({ // group member object. Supply either this or 'member' groupMember: GroupMemberType, action: React.PropTypes.string.isRequired, // eg. 'Ban' + title: React.PropTypes.string.isRequired, // eg. 'Ban this user?' // Whether to display a text field for a reason // If true, the second argument to onFinished will @@ -75,7 +76,6 @@ export default React.createClass({ const MemberAvatar = sdk.getComponent("views.avatars.MemberAvatar"); const BaseAvatar = sdk.getComponent("views.avatars.BaseAvatar"); - const title = _t("%(actionVerb)s this person?", { actionVerb: this.props.action}); const confirmButtonClass = classnames({ 'mx_Dialog_primary': true, 'danger': this.props.danger, @@ -113,7 +113,7 @@ export default React.createClass({ return (
diff --git a/src/components/views/groups/GroupMemberInfo.js b/src/components/views/groups/GroupMemberInfo.js index 6f1a370f26..aca2b1b222 100644 --- a/src/components/views/groups/GroupMemberInfo.js +++ b/src/components/views/groups/GroupMemberInfo.js @@ -69,6 +69,7 @@ module.exports = withMatrixClient(React.createClass({ Modal.createDialog(ConfirmUserActionDialog, { groupMember: this.props.groupMember, action: _t('Remove from group'), + title: _t('Remove this user from group?'), danger: true, onFinished: (proceed) => { if (!proceed) return; diff --git a/src/components/views/rooms/MemberInfo.js b/src/components/views/rooms/MemberInfo.js index 856d3ebad4..180db1d5dd 100644 --- a/src/components/views/rooms/MemberInfo.js +++ b/src/components/views/rooms/MemberInfo.js @@ -247,11 +247,11 @@ module.exports = withMatrixClient(React.createClass({ onKick: function() { const membership = this.props.member.membership; - const kickLabel = membership === "invite" ? _t("Disinvite") : _t("Kick"); const ConfirmUserActionDialog = sdk.getComponent("dialogs.ConfirmUserActionDialog"); Modal.createTrackedDialog('Confirm User Action Dialog', 'onKick', ConfirmUserActionDialog, { member: this.props.member, - action: kickLabel, + action: membership === "invite" ? _t("Disinvite") : _t("Kick"), + title: membership === "invite" ? _t("Disinvite this user?") : _t("Kick this user?"), askReason: membership == "join", danger: true, onFinished: (proceed, reason) => { @@ -285,6 +285,7 @@ module.exports = withMatrixClient(React.createClass({ Modal.createTrackedDialog('Confirm User Action Dialog', 'onBanOrUnban', ConfirmUserActionDialog, { member: this.props.member, action: this.props.member.membership == 'ban' ? _t("Unban") : _t("Ban"), + title: this.props.member.membership == 'ban' ? _t("Unban this user?") : _t("Ban this user?"), askReason: this.props.member.membership != 'ban', danger: this.props.member.membership != 'ban', onFinished: (proceed, reason) => { diff --git a/src/components/views/rooms/RoomSettings.js b/src/components/views/rooms/RoomSettings.js index 9934456597..b1a2f41cec 100644 --- a/src/components/views/rooms/RoomSettings.js +++ b/src/components/views/rooms/RoomSettings.js @@ -72,6 +72,7 @@ const BannedUser = React.createClass({ Modal.createTrackedDialog('Confirm User Action Dialog', 'onUnbanClick', ConfirmUserActionDialog, { member: this.props.member, action: _t('Unban'), + title: _t('Unban this user?'), danger: false, onFinished: (proceed) => { if (!proceed) return; From 3b91ada4c8806fedada2d3d0f1fd809070044ecc Mon Sep 17 00:00:00 2001 From: Stefan Parviainen Date: Fri, 13 Oct 2017 20:44:01 +0200 Subject: [PATCH 03/37] Departify sending emails and text messages Signed-off-by: Stefan Parviainen --- src/components/structures/login/ForgotPassword.js | 2 +- src/components/views/login/InteractiveAuthEntryComponents.js | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/structures/login/ForgotPassword.js b/src/components/structures/login/ForgotPassword.js index 3e76291d20..4500e385e5 100644 --- a/src/components/structures/login/ForgotPassword.js +++ b/src/components/structures/login/ForgotPassword.js @@ -166,7 +166,7 @@ module.exports = React.createClass({ } else if (this.state.progress === "sent_email") { resetPasswordJsx = (
- { _t('An email has been sent to') } { this.state.email }. { _t("Once you've followed the link it contains, click below") }. + { _t("An email has been sent to %(emailAddress)s. Once you've followed the link it contains, click below.", { emailAddress: this.state.email }) }
diff --git a/src/components/views/login/InteractiveAuthEntryComponents.js b/src/components/views/login/InteractiveAuthEntryComponents.js index 4c53c23f76..d0cd3931e4 100644 --- a/src/components/views/login/InteractiveAuthEntryComponents.js +++ b/src/components/views/login/InteractiveAuthEntryComponents.js @@ -256,7 +256,7 @@ export const EmailIdentityAuthEntry = React.createClass({ } else { return (
-

{ _t("An email has been sent to") } { this.props.inputs.emailAddress }

+

{ _t("An email has been sent to %(emailAddress)s", { emailAddress: '' + this.props.inputs.emailAddress + '' }) }

{ _t("Please check your email to continue registration.") }

); @@ -370,7 +370,7 @@ export const MsisdnAuthEntry = React.createClass({ }); return (
-

{ _t("A text message has been sent to") } +{ this._msisdn }

+

{ _t("A text message has been sent to %(msisdn)s", { msisdn: '' + this._msisdn + '' }) }

{ _t("Please enter the code it contains:") }

From a84b42bf24883dc57160f315027cf0802ec7bf49 Mon Sep 17 00:00:00 2001 From: Stefan Parviainen Date: Fri, 13 Oct 2017 21:10:50 +0200 Subject: [PATCH 04/37] Departify RoomSettings Signed-off-by: Stefan Parviainen --- src/components/views/rooms/RoomSettings.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/views/rooms/RoomSettings.js b/src/components/views/rooms/RoomSettings.js index b1a2f41cec..0cb12002e7 100644 --- a/src/components/views/rooms/RoomSettings.js +++ b/src/components/views/rooms/RoomSettings.js @@ -868,21 +868,21 @@ module.exports = React.createClass({ disabled={!roomState.mayClientSendStateEvent("m.room.history_visibility", cli)} checked={historyVisibility === "shared"} onChange={this._onHistoryRadioToggle} /> - { _t('Members only') } ({ _t('since the point in time of selecting this option') }) + { _t('Members only since the point in time of selecting this option') })
From 15d1dc1f3b0c441b6feef2b23ccb92325dd538d7 Mon Sep 17 00:00:00 2001 From: Stefan Parviainen Date: Sun, 15 Oct 2017 16:57:13 +0200 Subject: [PATCH 05/37] Fix indentation Signed-off-by: Stefan Parviainen --- src/components/views/rooms/RoomList.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/views/rooms/RoomList.js b/src/components/views/rooms/RoomList.js index 56589353f9..e689579650 100644 --- a/src/components/views/rooms/RoomList.js +++ b/src/components/views/rooms/RoomList.js @@ -36,13 +36,13 @@ const HIDE_CONFERENCE_CHANS = true; function phraseForSection(section) { switch (section) { case 'm.favourite': - return _t('Drop here to favourite'); + return _t('Drop here to favourite'); case 'im.vector.fake.direct': - return _t('Drop here to tag direct chat'); + return _t('Drop here to tag direct chat'); case 'im.vector.fake.recent': - return _t('Drop here to restore'); + return _t('Drop here to restore'); case 'm.lowpriority': - return _t('Drop here to demote'); + return _t('Drop here to demote'); default: return _t('Drop here to tag %(section)s', {section: section}); } From ad2f54f8ab228383eb602c9cead1083ad16dec61 Mon Sep 17 00:00:00 2001 From: Stefan Parviainen Date: Sun, 15 Oct 2017 18:01:57 +0200 Subject: [PATCH 06/37] Fix italics and parens Signed-off-by: Stefan Parviainen --- .../views/login/InteractiveAuthEntryComponents.js | 6 +++--- src/components/views/rooms/RoomSettings.js | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/components/views/login/InteractiveAuthEntryComponents.js b/src/components/views/login/InteractiveAuthEntryComponents.js index d0cd3931e4..5f5a74ccd1 100644 --- a/src/components/views/login/InteractiveAuthEntryComponents.js +++ b/src/components/views/login/InteractiveAuthEntryComponents.js @@ -20,7 +20,7 @@ import url from 'url'; import classnames from 'classnames'; import sdk from '../../../index'; -import { _t } from '../../../languageHandler'; +import { _t, _tJsx } from '../../../languageHandler'; /* This file contains a collection of components which are used by the * InteractiveAuth to prompt the user to enter the information needed @@ -256,7 +256,7 @@ export const EmailIdentityAuthEntry = React.createClass({ } else { return (
-

{ _t("An email has been sent to %(emailAddress)s", { emailAddress: '' + this.props.inputs.emailAddress + '' }) }

+

{ _tJsx("An email has been sent to %(emailAddress)s", /%\(emailAddress\)s/, (sub) => {this.props.inputs.emailAddress}) }

{ _t("Please check your email to continue registration.") }

); @@ -370,7 +370,7 @@ export const MsisdnAuthEntry = React.createClass({ }); return (
-

{ _t("A text message has been sent to %(msisdn)s", { msisdn: '' + this._msisdn + '' }) }

+

{ _tJsx("A text message has been sent to %(msisdn)s", /%\(msisdn\)s/, (sub) => {this._msisdn}) }

{ _t("Please enter the code it contains:") }

diff --git a/src/components/views/rooms/RoomSettings.js b/src/components/views/rooms/RoomSettings.js index 0cb12002e7..50478eaf43 100644 --- a/src/components/views/rooms/RoomSettings.js +++ b/src/components/views/rooms/RoomSettings.js @@ -868,21 +868,21 @@ module.exports = React.createClass({ disabled={!roomState.mayClientSendStateEvent("m.room.history_visibility", cli)} checked={historyVisibility === "shared"} onChange={this._onHistoryRadioToggle} /> - { _t('Members only since the point in time of selecting this option') }) + { _t('Members only (since the point in time of selecting this option)') }
From 8083dccfa5239340dc8994d743e629fa8b055bd2 Mon Sep 17 00:00:00 2001 From: Stefan Parviainen Date: Sun, 15 Oct 2017 21:08:41 +0200 Subject: [PATCH 07/37] De-partify SenderProfile Signed-off-by: Stefan Parviainen Also, text does not need to be EmojiText --- .../views/messages/SenderProfile.js | 38 ++++++++++++++----- src/components/views/rooms/EventTile.js | 12 +++--- 2 files changed, 34 insertions(+), 16 deletions(-) diff --git a/src/components/views/messages/SenderProfile.js b/src/components/views/messages/SenderProfile.js index 63e3144115..f6940cd4b3 100644 --- a/src/components/views/messages/SenderProfile.js +++ b/src/components/views/messages/SenderProfile.js @@ -19,6 +19,7 @@ import React from 'react'; import sdk from '../../../index'; import Flair from '../elements/Flair.js'; +import { _tJsx } from '../../../languageHandler'; export default function SenderProfile(props) { const EmojiText = sdk.getComponent('elements.EmojiText'); @@ -26,27 +27,44 @@ export default function SenderProfile(props) { const name = mxEvent.sender ? mxEvent.sender.name : mxEvent.getSender(); const {msgtype} = mxEvent.getContent(); + // Display sender name by default if nothing else is given + const text = props.text ? props.text : '%(senderName)s'; + if (msgtype === 'm.emote') { return ; // emote message must include the name so don't duplicate it } + // Name + flair + const nameElem = [ + { name || '' }, + props.enableFlair ? + + : null, + ] + + if(props.text) { + // Replace senderName, and wrap surrounding text in spans with the right class + content = _tJsx(props.text, /^(.*)\%\(senderName\)s(.*)$/m, (p1, p2) => [ + p1 ? {p1} : null, + nameElem, + p2 ? {p2} : null, + ]); + } else { + content = nameElem; + } + return (
- { name || '' } - { props.enableFlair ? - - : null - } - { props.aux ? { props.aux } : null } + { content }
); } SenderProfile.propTypes = { mxEvent: React.PropTypes.object.isRequired, // event whose sender we're showing - aux: React.PropTypes.string, // stuff to go after the sender name, if anything + text: React.PropTypes.string, // Text to show. Defaults to sender name onClick: React.PropTypes.func, }; diff --git a/src/components/views/rooms/EventTile.js b/src/components/views/rooms/EventTile.js index 499d0ec09a..812d72a26a 100644 --- a/src/components/views/rooms/EventTile.js +++ b/src/components/views/rooms/EventTile.js @@ -19,7 +19,7 @@ limitations under the License. const React = require('react'); const classNames = require("classnames"); -import { _t } from '../../../languageHandler'; +import { _t, _td } from '../../../languageHandler'; const Modal = require('../../../Modal'); const sdk = require('../../../index'); @@ -502,12 +502,12 @@ module.exports = withMatrixClient(React.createClass({ } if (needsSenderProfile) { - let aux = null; + let text = null; if (!this.props.tileShape) { - if (msgtype === 'm.image') aux = _t('sent an image'); - else if (msgtype === 'm.video') aux = _t('sent a video'); - else if (msgtype === 'm.file') aux = _t('uploaded a file'); - sender = ; + if (msgtype === 'm.image') text = _td('%(senderName)s sent an image'); + else if (msgtype === 'm.video') text = _td('%(senderName)s sent a video'); + else if (msgtype === 'm.file') text = _td('%(senderName)s uploaded a file'); + sender = ; } else { sender = ; } From 468a05c6f1f2e14218cbf7fa083dd9ab9fb73612 Mon Sep 17 00:00:00 2001 From: Stefan Parviainen Date: Tue, 17 Oct 2017 21:32:35 +0200 Subject: [PATCH 08/37] Fix SenderProfile Signed-off-by: Stefan Parviainen --- src/components/views/messages/SenderProfile.js | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/components/views/messages/SenderProfile.js b/src/components/views/messages/SenderProfile.js index f6940cd4b3..afdb97272f 100644 --- a/src/components/views/messages/SenderProfile.js +++ b/src/components/views/messages/SenderProfile.js @@ -27,30 +27,29 @@ export default function SenderProfile(props) { const name = mxEvent.sender ? mxEvent.sender.name : mxEvent.getSender(); const {msgtype} = mxEvent.getContent(); - // Display sender name by default if nothing else is given - const text = props.text ? props.text : '%(senderName)s'; - if (msgtype === 'm.emote') { return ; // emote message must include the name so don't duplicate it } // Name + flair const nameElem = [ - { name || '' }, + { name || '' }, props.enableFlair ? - : null, - ] + ]; + + let content = ''; if(props.text) { // Replace senderName, and wrap surrounding text in spans with the right class content = _tJsx(props.text, /^(.*)\%\(senderName\)s(.*)$/m, (p1, p2) => [ - p1 ? {p1} : null, + p1 ? { p1 } : null, nameElem, - p2 ? {p2} : null, + p2 ? { p2 } : null, ]); } else { content = nameElem; From fc860c66bc0f1d7cb5b4785a1372bbb422fd47e9 Mon Sep 17 00:00:00 2001 From: Stefan Parviainen Date: Tue, 17 Oct 2017 22:03:49 +0200 Subject: [PATCH 09/37] De-partify RoomPreviewBar Signed-off-by: Stefan Parviainen --- src/components/views/rooms/RoomPreviewBar.js | 22 +++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/src/components/views/rooms/RoomPreviewBar.js b/src/components/views/rooms/RoomPreviewBar.js index 368d81e606..0c0601a504 100644 --- a/src/components/views/rooms/RoomPreviewBar.js +++ b/src/components/views/rooms/RoomPreviewBar.js @@ -83,10 +83,8 @@ module.exports = React.createClass({ } }, - _roomNameElement: function(fallback) { - fallback = fallback || _t('a room'); - const name = this.props.room ? this.props.room.name : (this.props.room_alias || ""); - return name ? name : fallback; + _roomNameElement: function() { + return this.props.room ? this.props.room.name : (this.props.room_alias || ""); }, render: function() { @@ -150,7 +148,7 @@ module.exports = React.createClass({
); } else if (kicked || banned) { - const roomName = this._roomNameElement(_t('This room')); + const roomName = this._roomNameElement(); const kickerMember = this.props.room.currentState.getMember( myMember.events.member.getSender(), ); @@ -167,9 +165,17 @@ module.exports = React.createClass({ let actionText; if (kicked) { - actionText = _t("You have been kicked from %(roomName)s by %(userName)s.", {roomName: roomName, userName: kickerName}); + if(roomName) { + actionText = _t("You have been kicked from %(roomName)s by %(userName)s.", {roomName: roomName, userName: kickerName}); + } else { + actionText = _t("You have been kicked from this room by %(userName)s.", {userName: kickerName}); + } } else if (banned) { - actionText = _t("You have been banned from %(roomName)s by %(userName)s.", {roomName: roomName, userName: kickerName}); + if(roomName) { + actionText = _t("You have been banned from %(roomName)s by %(userName)s.", {roomName: roomName, userName: kickerName}); + } else { + actionText = _t("You have been banned from this room by %(userName)s.", {userName: kickerName}); + } } // no other options possible due to the kicked || banned check above. joinBlock = ( @@ -203,7 +209,7 @@ module.exports = React.createClass({ joinBlock = (
- { _t('You are trying to access %(roomName)s.', {roomName: name}) } + { name ? _t('You are trying to access %(roomName)s.', {roomName: name}) : _t('You are trying to access a room.') }
{ _tJsx("Click here to join the discussion!", /(.*?)<\/a>/, From 7eeed3e0932841fea286d5a7bdad86cfc412c98e Mon Sep 17 00:00:00 2001 From: Stefan Parviainen Date: Tue, 17 Oct 2017 23:46:23 +0200 Subject: [PATCH 10/37] Simplify MemberEventListSummary by using pluralization provided by the i18n library Signed-off-by: Stefan Parviainen --- .../views/elements/MemberEventListSummary.js | 154 +++++------------- 1 file changed, 40 insertions(+), 114 deletions(-) diff --git a/src/components/views/elements/MemberEventListSummary.js b/src/components/views/elements/MemberEventListSummary.js index 596838febe..9b303b4bd9 100644 --- a/src/components/views/elements/MemberEventListSummary.js +++ b/src/components/views/elements/MemberEventListSummary.js @@ -86,7 +86,6 @@ module.exports = React.createClass({ const summaries = orderedTransitionSequences.map((transitions) => { const userNames = eventAggregates[transitions]; const nameList = this._renderNameList(userNames); - const plural = userNames.length > 1; const splitTransitions = transitions.split(','); @@ -101,7 +100,7 @@ module.exports = React.createClass({ const descs = coalescedTransitions.map((t) => { return this._getDescriptionForTransition( - t.transitionType, plural, t.repeats, + t.transitionType, userNames.length, t.repeats, ); }); @@ -208,148 +207,75 @@ module.exports = React.createClass({ * For a certain transition, t, describe what happened to the users that * underwent the transition. * @param {string} t the transition type. - * @param {boolean} plural whether there were multiple users undergoing the same - * transition. + * @param {integer} userCount number of usernames * @param {number} repeats the number of times the transition was repeated in a row. * @returns {string} the written Human Readable equivalent of the transition. */ - _getDescriptionForTransition(t, plural, repeats) { + _getDescriptionForTransition(t, userCount, repeats) { // The empty interpolations 'severalUsers' and 'oneUser' // are there only to show translators to non-English languages // that the verb is conjugated to plural or singular Subject. let res = null; switch(t) { case "joined": - if (repeats > 1) { - res = (plural) - ? _t("%(severalUsers)sjoined %(repeats)s times", { severalUsers: "", repeats: repeats }) - : _t("%(oneUser)sjoined %(repeats)s times", { oneUser: "", repeats: repeats }); - } else { - res = (plural) - ? _t("%(severalUsers)sjoined", { severalUsers: "" }) - : _t("%(oneUser)sjoined", { oneUser: "" }); - } + res = (userCount > 1) + ? _t("%(severalUsers)sjoined %(count)s times", { severalUsers: "", count: repeats }) + : _t("%(oneUser)sjoined %(count)s times", { oneUser: "", count: repeats }); break; case "left": - if (repeats > 1) { - res = (plural) - ? _t("%(severalUsers)sleft %(repeats)s times", { severalUsers: "", repeats: repeats }) - : _t("%(oneUser)sleft %(repeats)s times", { oneUser: "", repeats: repeats }); - } else { - res = (plural) - ? _t("%(severalUsers)sleft", { severalUsers: "" }) - : _t("%(oneUser)sleft", { oneUser: "" }); - } - break; + res = (userCount > 1) + ? _t("%(severalUsers)sleft %(count)s times", { severalUsers: "", count: repeats }) + : _t("%(oneUser)sleft %(count)s times", { oneUser: "", count: repeats }); + break; case "joined_and_left": - if (repeats > 1) { - res = (plural) - ? _t("%(severalUsers)sjoined and left %(repeats)s times", { severalUsers: "", repeats: repeats }) - : _t("%(oneUser)sjoined and left %(repeats)s times", { oneUser: "", repeats: repeats }); - } else { - res = (plural) - ? _t("%(severalUsers)sjoined and left", { severalUsers: "" }) - : _t("%(oneUser)sjoined and left", { oneUser: "" }); - } + res = (userCount > 1) + ? _t("%(severalUsers)sjoined and left %(count)s times", { severalUsers: "", count: repeats }) + : _t("%(oneUser)sjoined and left %(count)s times", { oneUser: "", count: repeats }); break; case "left_and_joined": - if (repeats > 1) { - res = (plural) - ? _t("%(severalUsers)sleft and rejoined %(repeats)s times", { severalUsers: "", repeats: repeats }) - : _t("%(oneUser)sleft and rejoined %(repeats)s times", { oneUser: "", repeats: repeats }); - } else { - res = (plural) - ? _t("%(severalUsers)sleft and rejoined", { severalUsers: "" }) - : _t("%(oneUser)sleft and rejoined", { oneUser: "" }); - } + res = (userCount > 1) + ? _t("%(severalUsers)sleft and rejoined %(count)s times", { severalUsers: "", count: repeats }) + : _t("%(oneUser)sleft and rejoined %(count)s times", { oneUser: "", count: repeats }); break; case "invite_reject": - if (repeats > 1) { - res = (plural) - ? _t("%(severalUsers)srejected their invitations %(repeats)s times", { severalUsers: "", repeats: repeats }) - : _t("%(oneUser)srejected their invitation %(repeats)s times", { oneUser: "", repeats: repeats }); - } else { - res = (plural) - ? _t("%(severalUsers)srejected their invitations", { severalUsers: "" }) - : _t("%(oneUser)srejected their invitation", { oneUser: "" }); - } + res = (userCount > 1) + ? _t("%(severalUsers)srejected their invitations %(count)s times", { severalUsers: "", count: repeats }) + : _t("%(oneUser)srejected their invitation %(count)s times", { oneUser: "", count: repeats }); break; case "invite_withdrawal": - if (repeats > 1) { - res = (plural) - ? _t("%(severalUsers)shad their invitations withdrawn %(repeats)s times", { severalUsers: "", repeats: repeats }) - : _t("%(oneUser)shad their invitation withdrawn %(repeats)s times", { oneUser: "", repeats: repeats }); - } else { - res = (plural) - ? _t("%(severalUsers)shad their invitations withdrawn", { severalUsers: "" }) - : _t("%(oneUser)shad their invitation withdrawn", { oneUser: "" }); - } + res = (userCount > 1) + ? _t("%(severalUsers)shad their invitations withdrawn %(count)s times", { severalUsers: "", count: repeats }) + : _t("%(oneUser)shad their invitation withdrawn %(count)s times", { oneUser: "", count: repeats }); break; case "invited": - if (repeats > 1) { - res = (plural) - ? _t("were invited %(repeats)s times", { repeats: repeats }) - : _t("was invited %(repeats)s times", { repeats: repeats }); - } else { - res = (plural) - ? _t("were invited") - : _t("was invited"); - } + res = (userCount > 1) + ? _t("were invited %(count)s times", { count: repeats }) + : _t("was invited %(count)s times", { count: repeats }); break; case "banned": - if (repeats > 1) { - res = (plural) - ? _t("were banned %(repeats)s times", { repeats: repeats }) - : _t("was banned %(repeats)s times", { repeats: repeats }); - } else { - res = (plural) - ? _t("were banned") - : _t("was banned"); - } + res = (userCount > 1) + ? _t("were banned %(count)s times", { count: repeats }) + : _t("was banned %(count)s times", { count: repeats }); break; case "unbanned": - if (repeats > 1) { - res = (plural) - ? _t("were unbanned %(repeats)s times", { repeats: repeats }) - : _t("was unbanned %(repeats)s times", { repeats: repeats }); - } else { - res = (plural) - ? _t("were unbanned") - : _t("was unbanned"); - } + res = (userCount > 1) + ? _t("were unbanned %(count)s times", { count: repeats }) + : _t("was unbanned %(count)s times", { count: repeats }); break; case "kicked": - if (repeats > 1) { - res = (plural) - ? _t("were kicked %(repeats)s times", { repeats: repeats }) - : _t("was kicked %(repeats)s times", { repeats: repeats }); - } else { - res = (plural) - ? _t("were kicked") - : _t("was kicked"); - } + res = (userCount > 1) + ? _t("were kicked %(count)s times", { count: repeats }) + : _t("was kicked %(count)s times", { count: repeats }); break; case "changed_name": - if (repeats > 1) { - res = (plural) - ? _t("%(severalUsers)schanged their name %(repeats)s times", { severalUsers: "", repeats: repeats }) - : _t("%(oneUser)schanged their name %(repeats)s times", { oneUser: "", repeats: repeats }); - } else { - res = (plural) - ? _t("%(severalUsers)schanged their name", { severalUsers: "" }) - : _t("%(oneUser)schanged their name", { oneUser: "" }); - } + res = (userCount > 1) + ? _t("%(severalUsers)schanged their name %(count)s times", { severalUsers: "", count: repeats }) + : _t("%(oneUser)schanged their name %(count)s times", { oneUser: "", count: repeats }); break; case "changed_avatar": - if (repeats > 1) { - res = (plural) - ? _t("%(severalUsers)schanged their avatar %(repeats)s times", { severalUsers: "", repeats: repeats }) - : _t("%(oneUser)schanged their avatar %(repeats)s times", { oneUser: "", repeats: repeats }); - } else { - res = (plural) - ? _t("%(severalUsers)schanged their avatar", { severalUsers: "" }) - : _t("%(oneUser)schanged their avatar", { oneUser: "" }); - } + res = (userCount > 1) + ? _t("%(severalUsers)schanged their avatar %(count)s times", { severalUsers: "", count: repeats }) + : _t("%(oneUser)schanged their avatar %(count)s times", { oneUser: "", count: repeats }); break; } From ef30ba889b617a22a46c2173d0d388bc480cd305 Mon Sep 17 00:00:00 2001 From: Stefan Parviainen Date: Mon, 23 Oct 2017 19:55:40 +0200 Subject: [PATCH 11/37] Make MemberEventListSummary more translatable Signed-off-by: Stefan Parviainen --- src/components/views/elements/MemberEventListSummary.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/views/elements/MemberEventListSummary.js b/src/components/views/elements/MemberEventListSummary.js index 9b303b4bd9..6e75c32e0d 100644 --- a/src/components/views/elements/MemberEventListSummary.js +++ b/src/components/views/elements/MemberEventListSummary.js @@ -106,7 +106,7 @@ module.exports = React.createClass({ const desc = this._renderCommaSeparatedList(descs); - return nameList + " " + desc; + return _t('%(nameList)s %(transitionList)s', { nameList: nameList, transitionList: desc }); }); if (!summaries) { From 6406fc3865ff3fe434b5e7c341bcf7a5792e63e0 Mon Sep 17 00:00:00 2001 From: Stefan Parviainen Date: Tue, 24 Oct 2017 18:32:50 +0200 Subject: [PATCH 12/37] Use plurals in WhoIsTyping --- src/WhoIsTyping.js | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/WhoIsTyping.js b/src/WhoIsTyping.js index 6bea2cbb92..0edad8d4a5 100644 --- a/src/WhoIsTyping.js +++ b/src/WhoIsTyping.js @@ -68,10 +68,8 @@ module.exports = { const names = whoIsTyping.map(function(m) { return m.name; }); - if (othersCount==1) { - return _t('%(names)s and one other are typing', {names: names.slice(0, limit - 1).join(', ')}); - } else if (othersCount>1) { - return _t('%(names)s and %(count)s others are typing', {names: names.slice(0, limit - 1).join(', '), count: othersCount}); + if (othersCount>=1) { + return _t('%(names)s and %(count)s others are typing', {names: names.slice(0, limit - 1).join(', '), count: othersCount}); } else { const lastPerson = names.pop(); return _t('%(names)s and %(lastPerson)s are typing', {names: names.join(', '), lastPerson: lastPerson}); From b5024cca750d546eb917ff48f41abb1e613d943a Mon Sep 17 00:00:00 2001 From: Stefan Parviainen Date: Tue, 24 Oct 2017 19:34:08 +0200 Subject: [PATCH 13/37] Further simplify MemberEventListSummary a bit Signed-off-by: Stefan Parviainen --- src/components/views/elements/MemberEventListSummary.js | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/components/views/elements/MemberEventListSummary.js b/src/components/views/elements/MemberEventListSummary.js index 6e75c32e0d..6a1566c961 100644 --- a/src/components/views/elements/MemberEventListSummary.js +++ b/src/components/views/elements/MemberEventListSummary.js @@ -302,11 +302,9 @@ module.exports = React.createClass({ return ""; } else if (items.length === 1) { return items[0]; - } else if (remaining) { + } else if (remaining >= 0) { items = items.slice(0, itemLimit); - return (remaining > 1) - ? _t("%(items)s and %(remaining)s others", { items: items.join(', '), remaining: remaining } ) - : _t("%(items)s and one other", { items: items.join(', ') }); + return _t("%(items)s and %(count)s others", { items: items.join(', '), count: remaining } ) } else { const lastItem = items.pop(); return _t("%(items)s and %(lastItem)s", { items: items.join(', '), lastItem: lastItem }); From bc034f3083796384f42999bc355534052f149d63 Mon Sep 17 00:00:00 2001 From: Stefan Parviainen Date: Tue, 24 Oct 2017 19:34:32 +0200 Subject: [PATCH 14/37] Update strings Signed-off-by: Stefan Parviainen --- src/i18n/strings/en_EN.json | 145 +++++++++++++++++++----------------- 1 file changed, 75 insertions(+), 70 deletions(-) diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 492113989b..6955ed053a 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -153,8 +153,8 @@ "Communities": "Communities", "Message Pinning": "Message Pinning", "%(displayName)s is typing": "%(displayName)s is typing", - "%(names)s and one other are typing": "%(names)s and one other are typing", "%(names)s and %(count)s others are typing|other": "%(names)s and %(count)s others are typing", + "%(names)s and %(count)s others are typing|one": "%(names)s and one other is typing", "%(names)s and %(lastPerson)s are typing": "%(names)s and %(lastPerson)s are typing", "Failure to create room": "Failure to create room", "Server may be unavailable, overloaded, or you hit a bug.": "Server may be unavailable, overloaded, or you hit a bug.", @@ -210,9 +210,9 @@ " (unsupported)": " (unsupported)", "Join as voice or video.": "Join as voice or video.", "Ongoing conference call%(supportedText)s.": "Ongoing conference call%(supportedText)s.", - "sent an image": "sent an image", - "sent a video": "sent a video", - "uploaded a file": "uploaded a file", + "%(senderName)s sent an image": "%(senderName)s sent an image", + "%(senderName)s sent a video": "%(senderName)s sent a video", + "%(senderName)s uploaded a file": "%(senderName)s uploaded a file", "Options": "Options", "Undecryptable": "Undecryptable", "Encrypted by a verified device": "Encrypted by a verified device", @@ -225,9 +225,13 @@ "device id: ": "device id: ", "Disinvite": "Disinvite", "Kick": "Kick", + "Disinvite this user?": "Disinvite this user?", + "Kick this user?": "Kick this user?", "Failed to kick": "Failed to kick", "Unban": "Unban", "Ban": "Ban", + "Unban this user?": "Unban this user?", + "Ban this user?": "Ban this user?", "Failed to ban user": "Failed to ban user", "Failed to mute user": "Failed to mute user", "Failed to toggle moderator status": "Failed to toggle moderator status", @@ -312,12 +316,11 @@ "Forget room": "Forget room", "Search": "Search", "Show panel": "Show panel", - "to favourite": "to favourite", - "to tag direct chat": "to tag direct chat", - "to restore": "to restore", - "to demote": "to demote", + "Drop here to favourite": "Drop here to favourite", + "Drop here to tag direct chat": "Drop here to tag direct chat", + "Drop here to restore": "Drop here to restore", + "Drop here to demote": "Drop here to demote", "Drop here to tag %(section)s": "Drop here to tag %(section)s", - "Drop here %(toAction)s": "Drop here %(toAction)s", "Press to start a chat with someone": "Press to start a chat with someone", "You're not in any rooms yet! Press to make a room or to browse the directory": "You're not in any rooms yet! Press to make a room or to browse the directory", "Invites": "Invites", @@ -327,20 +330,22 @@ "Low priority": "Low priority", "Historical": "Historical", "Unnamed Room": "Unnamed Room", - "a room": "a room", "Unable to ascertain that the address this invite was sent to matches one associated with your account.": "Unable to ascertain that the address this invite was sent to matches one associated with your account.", "This invitation was sent to an email address which is not associated with this account:": "This invitation was sent to an email address which is not associated with this account:", "You may wish to login with a different account, or add this email to this account.": "You may wish to login with a different account, or add this email to this account.", "You have been invited to join this room by %(inviterName)s": "You have been invited to join this room by %(inviterName)s", "Would you like to accept or decline this invitation?": "Would you like to accept or decline this invitation?", - "This room": "This room", "Reason: %(reasonText)s": "Reason: %(reasonText)s", "Rejoin": "Rejoin", "You have been kicked from %(roomName)s by %(userName)s.": "You have been kicked from %(roomName)s by %(userName)s.", + "You have been kicked from this room by %(userName)s.": "You have been kicked from this room by %(userName)s.", "You have been banned from %(roomName)s by %(userName)s.": "You have been banned from %(roomName)s by %(userName)s.", + "You have been banned from this room by %(userName)s.": "You have been banned from this room by %(userName)s.", + "This room": "This room", "%(roomName)s does not exist.": "%(roomName)s does not exist.", "%(roomName)s is not accessible at this time.": "%(roomName)s is not accessible at this time.", "You are trying to access %(roomName)s.": "You are trying to access %(roomName)s.", + "You are trying to access a room.": "You are trying to access a room.", "Click here to join the discussion!": "Click here to join the discussion!", "This is a preview of this room. Room interactions have been disabled": "This is a preview of this room. Room interactions have been disabled", "To change the room's avatar, you must be a": "To change the room's avatar, you must be a", @@ -385,10 +390,9 @@ "Publish this room to the public in %(domain)s's room directory?": "Publish this room to the public in %(domain)s's room directory?", "Who can read history?": "Who can read history?", "Anyone": "Anyone", - "Members only": "Members only", - "since the point in time of selecting this option": "since the point in time of selecting this option", - "since they were invited": "since they were invited", - "since they joined": "since they joined", + "Members only (since the point in time of selecting this option)": "Members only (since the point in time of selecting this option)", + "Members only (since they were invited)": "Members only (since they were invited)", + "Members only (since they joined)": "Members only (since they joined)", "Room Colour": "Room Colour", "Permissions": "Permissions", "The default role for new room members is": "The default role for new room members is", @@ -460,10 +464,10 @@ "Dismiss": "Dismiss", "To continue, please enter your password.": "To continue, please enter your password.", "Password:": "Password:", - "An email has been sent to": "An email has been sent to", + "An email has been sent to %(emailAddress)s": "An email has been sent to %(emailAddress)s", "Please check your email to continue registration.": "Please check your email to continue registration.", "Token incorrect": "Token incorrect", - "A text message has been sent to": "A text message has been sent to", + "A text message has been sent to %(msisdn)s": "A text message has been sent to %(msisdn)s", "Please enter the code it contains:": "Please enter the code it contains:", "Start authentication": "Start authentication", "powered by Matrix": "powered by Matrix", @@ -485,6 +489,7 @@ "Identity server URL": "Identity server URL", "What does this mean?": "What does this mean?", "Remove from community": "Remove from community", + "Remove this user from community?": "Remove this user from community?", "Failed to remove user from community": "Failed to remove user from community", "Filter community members": "Filter community members", "Filter community rooms": "Filter community rooms", @@ -510,56 +515,57 @@ "Integrations Error": "Integrations Error", "Could not connect to the integration server": "Could not connect to the integration server", "Manage Integrations": "Manage Integrations", - "%(severalUsers)sjoined %(repeats)s times": "%(severalUsers)sjoined %(repeats)s times", - "%(oneUser)sjoined %(repeats)s times": "%(oneUser)sjoined %(repeats)s times", - "%(severalUsers)sjoined": "%(severalUsers)sjoined", - "%(oneUser)sjoined": "%(oneUser)sjoined", - "%(severalUsers)sleft %(repeats)s times": "%(severalUsers)sleft %(repeats)s times", - "%(oneUser)sleft %(repeats)s times": "%(oneUser)sleft %(repeats)s times", - "%(severalUsers)sleft": "%(severalUsers)sleft", - "%(oneUser)sleft": "%(oneUser)sleft", - "%(severalUsers)sjoined and left %(repeats)s times": "%(severalUsers)sjoined and left %(repeats)s times", - "%(oneUser)sjoined and left %(repeats)s times": "%(oneUser)sjoined and left %(repeats)s times", - "%(severalUsers)sjoined and left": "%(severalUsers)sjoined and left", - "%(oneUser)sjoined and left": "%(oneUser)sjoined and left", - "%(severalUsers)sleft and rejoined %(repeats)s times": "%(severalUsers)sleft and rejoined %(repeats)s times", - "%(oneUser)sleft and rejoined %(repeats)s times": "%(oneUser)sleft and rejoined %(repeats)s times", - "%(severalUsers)sleft and rejoined": "%(severalUsers)sleft and rejoined", - "%(oneUser)sleft and rejoined": "%(oneUser)sleft and rejoined", - "%(severalUsers)srejected their invitations %(repeats)s times": "%(severalUsers)srejected their invitations %(repeats)s times", - "%(oneUser)srejected their invitation %(repeats)s times": "%(oneUser)srejected their invitation %(repeats)s times", - "%(severalUsers)srejected their invitations": "%(severalUsers)srejected their invitations", - "%(oneUser)srejected their invitation": "%(oneUser)srejected their invitation", - "%(severalUsers)shad their invitations withdrawn %(repeats)s times": "%(severalUsers)shad their invitations withdrawn %(repeats)s times", - "%(oneUser)shad their invitation withdrawn %(repeats)s times": "%(oneUser)shad their invitation withdrawn %(repeats)s times", - "%(severalUsers)shad their invitations withdrawn": "%(severalUsers)shad their invitations withdrawn", - "%(oneUser)shad their invitation withdrawn": "%(oneUser)shad their invitation withdrawn", - "were invited %(repeats)s times": "were invited %(repeats)s times", - "was invited %(repeats)s times": "was invited %(repeats)s times", - "were invited": "were invited", - "was invited": "was invited", - "were banned %(repeats)s times": "were banned %(repeats)s times", - "was banned %(repeats)s times": "was banned %(repeats)s times", - "were banned": "were banned", - "was banned": "was banned", - "were unbanned %(repeats)s times": "were unbanned %(repeats)s times", - "was unbanned %(repeats)s times": "was unbanned %(repeats)s times", - "were unbanned": "were unbanned", - "was unbanned": "was unbanned", - "were kicked %(repeats)s times": "were kicked %(repeats)s times", - "was kicked %(repeats)s times": "was kicked %(repeats)s times", - "were kicked": "were kicked", - "was kicked": "was kicked", - "%(severalUsers)schanged their name %(repeats)s times": "%(severalUsers)schanged their name %(repeats)s times", - "%(oneUser)schanged their name %(repeats)s times": "%(oneUser)schanged their name %(repeats)s times", - "%(severalUsers)schanged their name": "%(severalUsers)schanged their name", - "%(oneUser)schanged their name": "%(oneUser)schanged their name", - "%(severalUsers)schanged their avatar %(repeats)s times": "%(severalUsers)schanged their avatar %(repeats)s times", - "%(oneUser)schanged their avatar %(repeats)s times": "%(oneUser)schanged their avatar %(repeats)s times", - "%(severalUsers)schanged their avatar": "%(severalUsers)schanged their avatar", - "%(oneUser)schanged their avatar": "%(oneUser)schanged their avatar", - "%(items)s and %(remaining)s others": "%(items)s and %(remaining)s others", - "%(items)s and one other": "%(items)s and one other", + "%(nameList)s %(transitionList)s": "%(nameList)s %(transitionList)s", + "%(severalUsers)sjoined %(count)s times|other": "%(severalUsers)sjoined %(count)s times", + "%(severalUsers)sjoined %(count)s times|one": "%(severalUsers)sjoined", + "%(oneUser)sjoined %(count)s times|other": "%(oneUser)sjoined %(count)s times", + "%(oneUser)sjoined %(count)s times|one": "%(oneUser)sjoined", + "%(severalUsers)sleft %(count)s times|other": "%(severalUsers)sleft %(count)s times", + "%(severalUsers)sleft %(count)s times|one": "%(severalUsers)sleft", + "%(oneUser)sleft %(count)s times|other": "%(oneUser)sleft %(count)s times", + "%(oneUser)sleft %(count)s times|one": "%(oneUser)sleft", + "%(severalUsers)sjoined and left %(count)s times|other": "%(severalUsers)sjoined and left %(count)s times", + "%(severalUsers)sjoined and left %(count)s times|one": "%(severalUsers)sjoined and left", + "%(oneUser)sjoined and left %(count)s times|other": "%(oneUser)sjoined and left %(count)s times", + "%(oneUser)sjoined and left %(count)s times|one": "%(oneUser)sjoined and left", + "%(severalUsers)sleft and rejoined %(count)s times|other": "%(severalUsers)sleft and rejoined %(count)s times", + "%(severalUsers)sleft and rejoined %(count)s times|one": "%(severalUsers)sleft and rejoined", + "%(oneUser)sleft and rejoined %(count)s times|other": "%(oneUser)sleft and rejoined %(count)s times", + "%(oneUser)sleft and rejoined %(count)s times|one": "%(oneUser)sleft and rejoined", + "%(severalUsers)srejected their invitations %(count)s times|other": "%(severalUsers)srejected their invitations %(count)s times", + "%(severalUsers)srejected their invitations %(count)s times|one": "%(severalUsers)srejected their invitations", + "%(oneUser)srejected their invitation %(count)s times|other": "%(oneUser)srejected their invitation %(count)s times", + "%(oneUser)srejected their invitation %(count)s times|one": "%(oneUser)srejected their invitation", + "%(severalUsers)shad their invitations withdrawn %(count)s times|other": "%(severalUsers)shad their invitations withdrawn %(count)s times", + "%(severalUsers)shad their invitations withdrawn %(count)s times|one": "%(severalUsers)shad their invitations withdrawn", + "%(oneUser)shad their invitation withdrawn %(count)s times|other": "%(oneUser)shad their invitation withdrawn %(count)s times", + "%(oneUser)shad their invitation withdrawn %(count)s times|one": "%(oneUser)shad their invitation withdrawn", + "were invited %(count)s times|other": "were invited %(count)s times", + "were invited %(count)s times|one": "were invited", + "was invited %(count)s times|other": "was invited %(count)s times", + "was invited %(count)s times|one": "was invited", + "were banned %(count)s times|other": "were banned %(count)s times", + "were banned %(count)s times|one": "were banned", + "was banned %(count)s times|other": "was banned %(count)s times", + "was banned %(count)s times|one": "was banned", + "were unbanned %(count)s times|other": "were unbanned %(count)s times", + "were unbanned %(count)s times|one": "were unbanned", + "was unbanned %(count)s times|other": "was unbanned %(count)s times", + "was unbanned %(count)s times|one": "was unbanned", + "were kicked %(count)s times|other": "were kicked %(count)s times", + "were kicked %(count)s times|one": "were kicked", + "was kicked %(count)s times|other": "was kicked %(count)s times", + "was kicked %(count)s times|one": "was kicked", + "%(severalUsers)schanged their name %(count)s times|other": "%(severalUsers)schanged their name %(count)s times", + "%(severalUsers)schanged their name %(count)s times|one": "%(severalUsers)schanged their name", + "%(oneUser)schanged their name %(count)s times|other": "%(oneUser)schanged their name %(count)s times", + "%(oneUser)schanged their name %(count)s times|one": "%(oneUser)schanged their name", + "%(severalUsers)schanged their avatar %(count)s times|other": "%(severalUsers)schanged their avatar %(count)s times", + "%(severalUsers)schanged their avatar %(count)s times|one": "%(severalUsers)schanged their avatar", + "%(oneUser)schanged their avatar %(count)s times|other": "%(oneUser)schanged their avatar %(count)s times", + "%(oneUser)schanged their avatar %(count)s times|one": "%(oneUser)schanged their avatar", + "%(items)s and %(count)s others|other": "%(items)s and %(count)s others", + "%(items)s and %(count)s others|one": "%(items)s and one other", "%(items)s and %(lastItem)s": "%(items)s and %(lastItem)s", "Custom level": "Custom level", "Room directory": "Room directory", @@ -581,7 +587,6 @@ "Start Chatting": "Start Chatting", "Confirm Removal": "Confirm Removal", "Are you sure you wish to remove (delete) this event? Note that if you delete a room name or topic change, it could undo the change.": "Are you sure you wish to remove (delete) this event? Note that if you delete a room name or topic change, it could undo the change.", - "%(actionVerb)s this person?": "%(actionVerb)s this person?", "Community IDs may only contain alphanumeric characters": "Community IDs may only contain alphanumeric characters", "Something went wrong whilst creating your community": "Something went wrong whilst creating your community", "Create Community": "Create Community", @@ -676,6 +681,7 @@ "Leave %(groupName)s?": "Leave %(groupName)s?", "Leave": "Leave", "Unable to leave room": "Unable to leave room", + "Community Settings": "Community Settings", "Add rooms to this community": "Add rooms to this community", "Featured Rooms:": "Featured Rooms:", "Featured Users:": "Featured Users:", @@ -686,7 +692,6 @@ "Publish this community on your profile": "Publish this community on your profile", "Long Description (HTML)": "Long Description (HTML)", "Description": "Description", - "Community Settings": "Community Settings", "Community %(groupId)s not found": "Community %(groupId)s not found", "This Home server does not support communities": "This Home server does not support communities", "Failed to load %(groupId)s": "Failed to load %(groupId)s", @@ -824,7 +829,7 @@ "A new password must be entered.": "A new password must be entered.", "New passwords must match each other.": "New passwords must match each other.", "Resetting password will currently reset any end-to-end encryption keys on all devices, making encrypted chat history unreadable, unless you first export your room keys and re-import them afterwards. In future this will be improved.": "Resetting password will currently reset any end-to-end encryption keys on all devices, making encrypted chat history unreadable, unless you first export your room keys and re-import them afterwards. In future this will be improved.", - "Once you've followed the link it contains, click below": "Once you've followed the link it contains, click below", + "An email has been sent to %(emailAddress)s. Once you've followed the link it contains, click below.": "An email has been sent to %(emailAddress)s. Once you've followed the link it contains, click below.", "I have verified my email address": "I have verified my email address", "Your password has been reset": "Your password has been reset", "You have been logged out of all devices and will no longer receive push notifications. To re-enable notifications, sign in again on each device": "You have been logged out of all devices and will no longer receive push notifications. To re-enable notifications, sign in again on each device", From 88fd60066fd011d8cb475961e9ae398413974820 Mon Sep 17 00:00:00 2001 From: Stefan Parviainen Date: Tue, 24 Oct 2017 20:07:57 +0200 Subject: [PATCH 15/37] Fix typo Signed-off-by: Stefan Parviainen --- src/components/views/elements/MemberEventListSummary.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/views/elements/MemberEventListSummary.js b/src/components/views/elements/MemberEventListSummary.js index 6a1566c961..de6f801a21 100644 --- a/src/components/views/elements/MemberEventListSummary.js +++ b/src/components/views/elements/MemberEventListSummary.js @@ -302,7 +302,7 @@ module.exports = React.createClass({ return ""; } else if (items.length === 1) { return items[0]; - } else if (remaining >= 0) { + } else if (remaining > 0) { items = items.slice(0, itemLimit); return _t("%(items)s and %(count)s others", { items: items.join(', '), count: remaining } ) } else { From 15bafd6818ed732ef96e66abd879fa730b14aeac Mon Sep 17 00:00:00 2001 From: Stefan Parviainen Date: Wed, 1 Nov 2017 15:55:58 +0100 Subject: [PATCH 16/37] Convert from weblate to counterpart at runtime to make tests happy Signed-off-by: Stefan Parviainen --- src/languageHandler.js | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/languageHandler.js b/src/languageHandler.js index a90b78c40e..da62bfee56 100644 --- a/src/languageHandler.js +++ b/src/languageHandler.js @@ -252,6 +252,26 @@ function getLangsJson() { }); } +function weblateToCounterpart(inTrs) { + const outTrs = {}; + + for (const key of Object.keys(inTrs)) { + const keyParts = key.split('|', 2); + if (keyParts.length === 2) { + let obj = outTrs[keyParts[0]]; + if (obj === undefined) { + obj = {}; + outTrs[keyParts[0]] = obj; + } + obj[keyParts[1]] = inTrs[key]; + } else { + outTrs[key] = inTrs[key]; + } + } + + return outTrs; +} + function getLanguage(langPath) { return new Promise((resolve, reject) => { request( @@ -261,7 +281,7 @@ function getLanguage(langPath) { reject({err: err, response: response}); return; } - resolve(JSON.parse(body)); + resolve(weblateToCounterpart(JSON.parse(body))); }, ); }); From e1e4fc2dacb7d2979aecd5cf871f5d3f8ca3196f Mon Sep 17 00:00:00 2001 From: Stefan Parviainen Date: Wed, 1 Nov 2017 16:18:48 +0100 Subject: [PATCH 17/37] Make eslint happy Signed-off-by: Stefan Parviainen --- src/components/views/groups/GroupMemberInfo.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/views/groups/GroupMemberInfo.js b/src/components/views/groups/GroupMemberInfo.js index a36c32fb07..01270cd79d 100644 --- a/src/components/views/groups/GroupMemberInfo.js +++ b/src/components/views/groups/GroupMemberInfo.js @@ -85,7 +85,8 @@ module.exports = React.createClass({ Modal.createDialog(ConfirmUserActionDialog, { groupMember: this.props.groupMember, action: this.state.isUserInvited ? _t('Disinvite') : _t('Remove from community'), - title: this.state.isUserInvited ? _t('Disinvite this user from community?') : _t('Remove this user from community?'), + title: this.state.isUserInvited ? _t('Disinvite this user from community?') + : _t('Remove this user from community?'), danger: true, onFinished: (proceed) => { if (!proceed) return; From 0dcd52d88f9886860eba2d1b39a9a7379070f79b Mon Sep 17 00:00:00 2001 From: Luke Barnard Date: Wed, 1 Nov 2017 17:12:22 +0000 Subject: [PATCH 18/37] Fix some react warnings firing --- src/components/views/avatars/GroupAvatar.js | 4 ++-- src/components/views/rooms/RoomDetailList.js | 24 +++++++++++--------- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/src/components/views/avatars/GroupAvatar.js b/src/components/views/avatars/GroupAvatar.js index 4f34cc2c16..5a18213eec 100644 --- a/src/components/views/avatars/GroupAvatar.js +++ b/src/components/views/avatars/GroupAvatar.js @@ -54,11 +54,11 @@ export default React.createClass({ // extract the props we use from props so we can pass any others through // should consider adding this as a global rule in js-sdk? /*eslint no-unused-vars: ["error", { "ignoreRestSiblings": true }]*/ - const {groupId, groupAvatarUrl, ...otherProps} = this.props; + const {groupId, groupAvatarUrl, groupName, ...otherProps} = this.props; return ( Date: Wed, 1 Nov 2017 17:13:00 +0000 Subject: [PATCH 19/37] Implement simple GroupRoomInfo which replaces the "X" on the GroupRoomTile with "Remove from community" under Admin Tools. --- src/components/views/groups/GroupRoomInfo.js | 170 +++++++++++++++++++ src/components/views/groups/GroupRoomTile.js | 76 +-------- src/groups.js | 3 + 3 files changed, 178 insertions(+), 71 deletions(-) create mode 100644 src/components/views/groups/GroupRoomInfo.js diff --git a/src/components/views/groups/GroupRoomInfo.js b/src/components/views/groups/GroupRoomInfo.js new file mode 100644 index 0000000000..647651a0d8 --- /dev/null +++ b/src/components/views/groups/GroupRoomInfo.js @@ -0,0 +1,170 @@ +/* +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 PropTypes from 'prop-types'; +import React from 'react'; +import { MatrixClient } from 'matrix-js-sdk'; +import dis from '../../../dispatcher'; +import Modal from '../../../Modal'; +import sdk from '../../../index'; +import { _t } from '../../../languageHandler'; +import { GroupRoomType } from '../../../groups'; +import GroupStoreCache from '../../../stores/GroupStoreCache'; +import GeminiScrollbar from 'react-gemini-scrollbar'; + +module.exports = React.createClass({ + displayName: 'GroupRoomInfo', + + contextTypes: { + matrixClient: PropTypes.instanceOf(MatrixClient), + }, + + propTypes: { + groupId: PropTypes.string, + groupRoom: GroupRoomType, + }, + + getInitialState: function() { + return { + removingRoom: false, + isUserPrivilegedInGroup: null, + }; + }, + + componentWillMount: function() { + this._initGroupStore(this.props.groupId); + }, + + componentWillReceiveProps(newProps) { + if (newProps.groupId !== this.props.groupId) { + this._unregisterGroupStore(); + this._initGroupStore(newProps.groupId); + } + }, + + _initGroupStore(groupId) { + this._groupStore = GroupStoreCache.getGroupStore( + this.context.matrixClient, this.props.groupId, + ); + this._groupStore.registerListener(this.onGroupStoreUpdated); + }, + + _unregisterGroupStore() { + if (this._groupStore) { + this._groupStore.unregisterListener(this.onGroupStoreUpdated); + } + }, + + onGroupStoreUpdated: function() { + this.setState({ + isUserPrivilegedInGroup: this._groupStore.isUserPrivileged(), + }); + }, + + _onRemove: function(e) { + const groupId = this.props.groupId; + const roomName = this.props.groupRoom.displayname; + e.preventDefault(); + e.stopPropagation(); + const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); + Modal.createTrackedDialog('Confirm removal of group from room', '', QuestionDialog, { + title: _t("Are you sure you want to remove '%(roomName)s' from %(groupId)s?", {roomName, groupId}), + description: _t("Removing a room from the community will also remove it from the community page."), + button: _t("Remove"), + onFinished: (proceed) => { + if (!proceed) return; + this.setState({removingRoom: true}); + const groupId = this.props.groupId; + const roomId = this.props.groupRoom.roomId; + this._groupStore.removeRoomFromGroup(roomId).then(() => { + dis.dispatch({ + action: "view_group_room_list", + }); + }).catch((err) => { + console.error(`Error whilst removing ${roomId} from ${groupId}`, err); + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + Modal.createTrackedDialog('Failed to remove room from group', '', ErrorDialog, { + title: _t("Failed to remove room from community"), + description: _t( + "Failed to remove '%(roomName)s' from %(groupId)s", {groupId, roomName}, + ), + }); + }).finally(() => { + this.setState({removingRoom: false}); + }); + }, + }); + }, + + _onCancel: function(e) { + dis.dispatch({ + action: "view_group_room_list", + }); + }, + + render: function() { + const BaseAvatar = sdk.getComponent('avatars.BaseAvatar'); + const EmojiText = sdk.getComponent('elements.EmojiText'); + const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); + if (this.state.removingRoom) { + const Spinner = sdk.getComponent("elements.Spinner"); + return ; + } + + let adminTools; + if (this.state.isUserPrivilegedInGroup) { + adminTools = +
+

{ _t("Admin Tools") }

+
+ + { _t('Remove from community') } + +
+
; + } + + const avatarUrl = this.context.matrixClient.mxcUrlToHttp( + this.props.groupRoom.avatarUrl, + 36, 36, 'crop', + ); + + const groupRoomName = this.props.groupRoom.displayname; + const avatar = ; + return ( +
+ + + + +
+ { avatar } +
+ + { groupRoomName } + +
+
+ { this.props.groupRoom.canonical_alias } +
+
+ + { adminTools } +
+
+ ); + }, +}); diff --git a/src/components/views/groups/GroupRoomTile.js b/src/components/views/groups/GroupRoomTile.js index 94dc8e593f..e445f06044 100644 --- a/src/components/views/groups/GroupRoomTile.js +++ b/src/components/views/groups/GroupRoomTile.js @@ -16,13 +16,10 @@ limitations under the License. import React from 'react'; import {MatrixClient} from 'matrix-js-sdk'; -import { _t } from '../../../languageHandler'; import PropTypes from 'prop-types'; import sdk from '../../../index'; import dis from '../../../dispatcher'; import { GroupRoomType } from '../../../groups'; -import GroupStoreCache from '../../../stores/GroupStoreCache'; -import Modal from '../../../Modal'; const GroupRoomTile = React.createClass({ displayName: 'GroupRoomTile', @@ -32,68 +29,11 @@ const GroupRoomTile = React.createClass({ groupRoom: GroupRoomType.isRequired, }, - getInitialState: function() { - return { - name: this.calculateRoomName(this.props.groupRoom), - }; - }, - - componentWillReceiveProps: function(newProps) { - this.setState({ - name: this.calculateRoomName(newProps.groupRoom), - }); - }, - - calculateRoomName: function(groupRoom) { - return groupRoom.name || groupRoom.canonicalAlias || _t("Unnamed Room"); - }, - - removeRoomFromGroup: function() { - const groupId = this.props.groupId; - const groupStore = GroupStoreCache.getGroupStore(this.context.matrixClient, groupId); - const roomName = this.state.name; - const roomId = this.props.groupRoom.roomId; - groupStore.removeRoomFromGroup(roomId) - .catch((err) => { - console.error(`Error whilst removing ${roomId} from ${groupId}`, err); - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - Modal.createTrackedDialog('Failed to remove room from group', '', ErrorDialog, { - title: _t("Failed to remove room from community"), - description: _t("Failed to remove '%(roomName)s' from %(groupId)s", {groupId, roomName}), - }); - }); - }, - onClick: function(e) { - let roomId; - let roomAlias; - if (this.props.groupRoom.canonicalAlias) { - roomAlias = this.props.groupRoom.canonicalAlias; - } else { - roomId = this.props.groupRoom.roomId; - } dis.dispatch({ - action: 'view_room', - room_id: roomId, - room_alias: roomAlias, - }); - }, - - onDeleteClick: function(e) { - const groupId = this.props.groupId; - const roomName = this.state.name; - e.preventDefault(); - e.stopPropagation(); - const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); - Modal.createTrackedDialog('Confirm removal of group from room', '', QuestionDialog, { - title: _t("Are you sure you want to remove '%(roomName)s' from %(groupId)s?", {roomName, groupId}), - description: _t("Removing a room from the community will also remove it from the community page."), - button: _t("Remove"), - onFinished: (success) => { - if (success) { - this.removeRoomFromGroup(); - } - }, + action: 'view_group_room', + groupId: this.props.groupId, + groupRoom: this.props.groupRoom, }); }, @@ -106,7 +46,7 @@ const GroupRoomTile = React.createClass({ ); const av = ( - @@ -118,14 +58,8 @@ const GroupRoomTile = React.createClass({ { av }
- { this.state.name } + { this.props.groupRoom.displayname }
- - - ); }, diff --git a/src/groups.js b/src/groups.js index 06db5d067f..3c80677b0c 100644 --- a/src/groups.js +++ b/src/groups.js @@ -15,6 +15,7 @@ limitations under the License. */ import PropTypes from 'prop-types'; +import { _t } from './languageHandler.js'; export const GroupMemberType = PropTypes.shape({ userId: PropTypes.string.isRequired, @@ -23,6 +24,7 @@ export const GroupMemberType = PropTypes.shape({ }); export const GroupRoomType = PropTypes.shape({ + displayname: PropTypes.string, name: PropTypes.string, roomId: PropTypes.string.isRequired, canonicalAlias: PropTypes.string, @@ -39,6 +41,7 @@ export function groupMemberFromApiObject(apiObject) { export function groupRoomFromApiObject(apiObject) { return { + displayname: apiObject.name || apiObject.canonical_alias || _t("Unnamed Room"), name: apiObject.name, roomId: apiObject.room_id, canonicalAlias: apiObject.canonical_alias, From 80f79e6b8489d3343c03b4787989db598f73f61f Mon Sep 17 00:00:00 2001 From: Luke Barnard Date: Wed, 1 Nov 2017 17:58:45 +0000 Subject: [PATCH 20/37] Generate translations --- src/i18n/strings/en_EN.json | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index bffe3b3264..037f804447 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -158,6 +158,7 @@ "%(names)s and %(lastPerson)s are typing": "%(names)s and %(lastPerson)s are typing", "Failure to create room": "Failure to create room", "Server may be unavailable, overloaded, or you hit a bug.": "Server may be unavailable, overloaded, or you hit a bug.", + "Unnamed Room": "Unnamed Room", "Your browser does not support the required cryptography extensions": "Your browser does not support the required cryptography extensions", "Not a valid Riot keyfile": "Not a valid Riot keyfile", "Authentication check failed: incorrect password?": "Authentication check failed: incorrect password?", @@ -328,7 +329,6 @@ "Rooms": "Rooms", "Low priority": "Low priority", "Historical": "Historical", - "Unnamed Room": "Unnamed Room", "a room": "a room", "Unable to ascertain that the address this invite was sent to matches one associated with your account.": "Unable to ascertain that the address this invite was sent to matches one associated with your account.", "This invitation was sent to an email address which is not associated with this account:": "This invitation was sent to an email address which is not associated with this account:", @@ -491,13 +491,12 @@ "Failed to withdraw invitation": "Failed to withdraw invitation", "Failed to remove user from community": "Failed to remove user from community", "Filter community members": "Filter community members", - "Filter community rooms": "Filter community rooms", - "Failed to remove room from community": "Failed to remove room from community", - "Failed to remove '%(roomName)s' from %(groupId)s": "Failed to remove '%(roomName)s' from %(groupId)s", "Are you sure you want to remove '%(roomName)s' from %(groupId)s?": "Are you sure you want to remove '%(roomName)s' from %(groupId)s?", "Removing a room from the community will also remove it from the community page.": "Removing a room from the community will also remove it from the community page.", "Remove": "Remove", - "Remove this room from the community": "Remove this room from the community", + "Failed to remove room from community": "Failed to remove room from community", + "Failed to remove '%(roomName)s' from %(groupId)s": "Failed to remove '%(roomName)s' from %(groupId)s", + "Filter community rooms": "Filter community rooms", "Unknown Address": "Unknown Address", "NOTE: Apps are not end-to-end encrypted": "NOTE: Apps are not end-to-end encrypted", "Do you want to load widget from URL:": "Do you want to load widget from URL:", From 4f8d6d8fbed01df0c18518401a7c42243462dd11 Mon Sep 17 00:00:00 2001 From: David Baker Date: Wed, 1 Nov 2017 19:42:47 +0000 Subject: [PATCH 21/37] Pillify room notifs in the timeline This scans text nodes in the DOM for room notifications and turns them into pills. Changes the pillification code around a bit so it works with text nodes. Uses the push processor directly to test the event against the room notifiation rule so we know whether this event would actually trigger a room notification (needs to hook into push at a lower level because otherwise our own room notifications would not pillify since our own events never generate notifications). Requires https://github.com/matrix-org/matrix-js-sdk/pull/565 --- src/components/views/elements/Pill.js | 42 ++++++++++-- src/components/views/messages/TextualBody.js | 69 ++++++++++++++++++-- 2 files changed, 101 insertions(+), 10 deletions(-) diff --git a/src/components/views/elements/Pill.js b/src/components/views/elements/Pill.js index 51ae85ba5a..a85f83d78c 100644 --- a/src/components/views/elements/Pill.js +++ b/src/components/views/elements/Pill.js @@ -37,11 +37,20 @@ const Pill = React.createClass({ isMessagePillUrl: (url) => { return !!REGEX_LOCAL_MATRIXTO.exec(url); }, + roomNotifPos: (text) => { + return text.indexOf("@room"); + }, + roomNotifLen: () => { + return "@room".length; + }, TYPE_USER_MENTION: 'TYPE_USER_MENTION', TYPE_ROOM_MENTION: 'TYPE_ROOM_MENTION', + TYPE_AT_ROOM_MENTION: 'TYPE_AT_ROOM_MENTION', // '@room' mention }, props: { + // The Type of this Pill. If url is given, this is auto-detected. + type: PropTypes.string, // The URL to pillify (no validation is done, see isPillUrl and isMessagePillUrl) url: PropTypes.string, // Whether the pill is in a message @@ -72,14 +81,20 @@ const Pill = React.createClass({ regex = REGEX_LOCAL_MATRIXTO; } - // Default to the empty array if no match for simplicity - // resource and prefix will be undefined instead of throwing - const matrixToMatch = regex.exec(nextProps.url) || []; + let matrixToMatch; + let resourceId; + let prefix; - const resourceId = matrixToMatch[1]; // The room/user ID - const prefix = matrixToMatch[2]; // The first character of prefix + if (nextProps.url) { + // Default to the empty array if no match for simplicity + // resource and prefix will be undefined instead of throwing + matrixToMatch = regex.exec(nextProps.url) || []; - const pillType = { + resourceId = matrixToMatch[1]; // The room/user ID + prefix = matrixToMatch[2]; // The first character of prefix + } + + const pillType = this.props.type || { '@': Pill.TYPE_USER_MENTION, '#': Pill.TYPE_ROOM_MENTION, '!': Pill.TYPE_ROOM_MENTION, @@ -88,6 +103,10 @@ const Pill = React.createClass({ let member; let room; switch (pillType) { + case Pill.TYPE_AT_ROOM_MENTION: { + room = nextProps.room; + } + break; case Pill.TYPE_USER_MENTION: { const localMember = nextProps.room.getMember(resourceId); member = localMember; @@ -160,6 +179,17 @@ const Pill = React.createClass({ let href = this.props.url; let onClick; switch (this.state.pillType) { + case Pill.TYPE_AT_ROOM_MENTION: { + const room = this.props.room; + if (room) { + linkText = "@room"; + if (this.props.shouldShowPillAvatar) { + avatar = ; + } + pillClass = 'mx_AtRoomPill'; + } + } + break; case Pill.TYPE_USER_MENTION: { // If this user is not a member of this room, default to the empty member const member = this.state.member; diff --git a/src/components/views/messages/TextualBody.js b/src/components/views/messages/TextualBody.js index 64b23238e5..faa4d6cf77 100644 --- a/src/components/views/messages/TextualBody.js +++ b/src/components/views/messages/TextualBody.js @@ -34,6 +34,7 @@ import MatrixClientPeg from '../../../MatrixClientPeg'; import ContextualMenu from '../../structures/ContextualMenu'; import {RoomMember} from 'matrix-js-sdk'; import classNames from 'classnames'; +import PushProcessor from 'matrix-js-sdk/lib/pushprocessor'; linkifyMatrix(linkify); @@ -169,8 +170,10 @@ module.exports = React.createClass({ pillifyLinks: function(nodes) { const shouldShowPillAvatar = !UserSettingsStore.getSyncedSetting("Pill.shouldHidePillAvatar", false); - for (let i = 0; i < nodes.length; i++) { - const node = nodes[i]; + let node = nodes[0]; + while (node) { + let pillified = false; + if (node.tagName === "A" && node.getAttribute("href")) { const href = node.getAttribute("href"); @@ -189,10 +192,68 @@ module.exports = React.createClass({ ReactDOM.render(pill, pillContainer); node.parentNode.replaceChild(pillContainer, node); + // Pills within pills aren't going to go well, so move on + pillified = true; + } + } else if (node.nodeType == Node.TEXT_NODE) { + const Pill = sdk.getComponent('elements.Pill'); + + let currentTextNode = node; + const roomNotifTextNodes = []; + + // Take a textNode and break it up to make all the instances of @room their + // own textNode, adding those nodes to roomNotifTextNodes + while (currentTextNode !== null) { + const roomNotifPos = Pill.roomNotifPos(currentTextNode.textContent); + let nextTextNode = null; + if (roomNotifPos > -1) { + let roomTextNode = currentTextNode; + + if (roomNotifPos > 0) roomTextNode = roomTextNode.splitText(roomNotifPos); + if (roomTextNode.textContent.length > Pill.roomNotifLen()) { + nextTextNode = roomTextNode.splitText(Pill.roomNotifLen()); + } + roomNotifTextNodes.push(roomTextNode); + } + currentTextNode = nextTextNode; + } + + if (roomNotifTextNodes.length > 0) { + const pushProcessor = new PushProcessor(MatrixClientPeg.get()); + const atRoomRule = pushProcessor.getPushRuleById(".m.rule.roomnotif"); + if (pushProcessor.ruleMatchesEvent(atRoomRule, this.props.mxEvent)) { + // Now replace all those nodes with Pills + for (const roomNotifTextNode of roomNotifTextNodes) { + const pillContainer = document.createElement('span'); + const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId()); + const pill = ; + + ReactDOM.render(pill, pillContainer); + roomNotifTextNode.parentNode.replaceChild(pillContainer, roomNotifTextNode); + + // Set the next node to be processed to the one after the node + // we're adding now, since we've just inserted nodes into the structure + // we're iterating over. + // Note we've checked roomNotifTextNodes.length > 0 so we'll do this at least once + node = roomNotifTextNode.nextSibling; + } + // Nothing else to do for a text node (and we don't need to advance + // the loop pointer because we did it above) + continue; + } } - } else if (node.children && node.children.length) { - this.pillifyLinks(node.children); } + + if (node.childNodes && node.childNodes.length && !pillified) { + this.pillifyLinks(node.childNodes); + } + + node = node.nextSibling; } }, From 7d7cd30e46b3da81821127b5860cd22acf744175 Mon Sep 17 00:00:00 2001 From: Matthew Hodgson Date: Wed, 1 Nov 2017 22:10:03 +0000 Subject: [PATCH 22/37] turn NPE on flair resolution errors into a logged error --- src/stores/FlairStore.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/stores/FlairStore.js b/src/stores/FlairStore.js index 1ac518a4f6..d848ca7dda 100644 --- a/src/stores/FlairStore.js +++ b/src/stores/FlairStore.js @@ -129,7 +129,11 @@ class FlairStore extends EventEmitter { } const updatedUserGroups = resp.users; usersInFlight.forEach((userId) => { - this._usersPending[userId].resolve(updatedUserGroups[userId] || []); + if (this._usersPending[userId]) { + this._usersPending[userId].resolve(updatedUserGroups[userId] || []); + } else { + console.error("Promise vanished for resolving groups for " + userId); + } }); } From 790db94fd75ad29fa2e9f40ff948dd3d8c84ab7b Mon Sep 17 00:00:00 2001 From: Luke Barnard Date: Thu, 2 Nov 2017 13:25:55 +0000 Subject: [PATCH 23/37] Add toggle to alter the visibility of a room-group association --- src/components/views/groups/GroupRoomInfo.js | 94 +++++++++++++++++--- src/components/views/groups/GroupRoomTile.js | 2 +- src/groups.js | 1 + src/i18n/strings/en_EN.json | 12 +-- src/stores/GroupStore.js | 10 ++- 5 files changed, 98 insertions(+), 21 deletions(-) diff --git a/src/components/views/groups/GroupRoomInfo.js b/src/components/views/groups/GroupRoomInfo.js index 647651a0d8..bc1fc51853 100644 --- a/src/components/views/groups/GroupRoomInfo.js +++ b/src/components/views/groups/GroupRoomInfo.js @@ -21,7 +21,6 @@ import dis from '../../../dispatcher'; import Modal from '../../../Modal'; import sdk from '../../../index'; import { _t } from '../../../languageHandler'; -import { GroupRoomType } from '../../../groups'; import GroupStoreCache from '../../../stores/GroupStoreCache'; import GeminiScrollbar from 'react-gemini-scrollbar'; @@ -34,13 +33,15 @@ module.exports = React.createClass({ propTypes: { groupId: PropTypes.string, - groupRoom: GroupRoomType, + groupRoomId: PropTypes.string, }, getInitialState: function() { return { - removingRoom: false, isUserPrivilegedInGroup: null, + groupRoom: null, + groupRoomPublicityLoading: false, + groupRoomRemoveLoading: false, }; }, @@ -55,6 +56,10 @@ module.exports = React.createClass({ } }, + componentWillUnmount() { + this._unregisterGroupStore(); + }, + _initGroupStore(groupId) { this._groupStore = GroupStoreCache.getGroupStore( this.context.matrixClient, this.props.groupId, @@ -68,15 +73,24 @@ module.exports = React.createClass({ } }, + _updateGroupRoom() { + this.setState({ + groupRoom: this._groupStore.getGroupRooms().find( + (r) => r.roomId === this.props.groupRoomId, + ), + }); + }, + onGroupStoreUpdated: function() { this.setState({ isUserPrivilegedInGroup: this._groupStore.isUserPrivileged(), }); + this._updateGroupRoom(); }, _onRemove: function(e) { const groupId = this.props.groupId; - const roomName = this.props.groupRoom.displayname; + const roomName = this.state.groupRoom.displayname; e.preventDefault(); e.stopPropagation(); const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); @@ -86,9 +100,9 @@ module.exports = React.createClass({ button: _t("Remove"), onFinished: (proceed) => { if (!proceed) return; - this.setState({removingRoom: true}); + this.setState({groupRoomRemoveLoading: true}); const groupId = this.props.groupId; - const roomId = this.props.groupRoom.roomId; + const roomId = this.props.groupRoomId; this._groupStore.removeRoomFromGroup(roomId).then(() => { dis.dispatch({ action: "view_group_room_list", @@ -103,7 +117,7 @@ module.exports = React.createClass({ ), }); }).finally(() => { - this.setState({removingRoom: false}); + this.setState({groupRoomRemoveLoading: false}); }); }, }); @@ -115,13 +129,41 @@ module.exports = React.createClass({ }); }, + _changeGroupRoomPublicity(e) { + const isPublic = e.target.value === "public"; + this.setState({ + groupRoomPublicityLoading: true, + }); + const groupId = this.props.groupId; + const roomId = this.props.groupRoomId; + const roomName = this.state.groupRoom.displayname; + this._groupStore.updateGroupRoomAssociation(roomId, isPublic).catch((err) => { + console.error(`Error whilst changing visibility of ${roomId} in ${groupId} to ${isPublic}`, err); + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + Modal.createTrackedDialog('Failed to remove room from group', '', ErrorDialog, { + title: _t("Something went wrong!"), + description: _t( + "The visibility of '%(roomName)s' in %(groupId)s could not be updated.", + {roomName, groupId}, + ), + }); + }).finally(() => { + this.setState({ + groupRoomPublicityLoading: false, + }); + }); + }, + render: function() { const BaseAvatar = sdk.getComponent('avatars.BaseAvatar'); const EmojiText = sdk.getComponent('elements.EmojiText'); const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); - if (this.state.removingRoom) { + const InlineSpinner = sdk.getComponent('elements.InlineSpinner'); + if (this.state.groupRoomRemoveLoading || !this.state.groupRoom) { const Spinner = sdk.getComponent("elements.Spinner"); - return ; + return
+ +
; } let adminTools; @@ -134,20 +176,46 @@ module.exports = React.createClass({ { _t('Remove from community') }
+

+ { _t('Visibility in Room List') } + { this.state.groupRoomPublicityLoading ? + :
+ } +

+
+ +
+
+ +
; } const avatarUrl = this.context.matrixClient.mxcUrlToHttp( - this.props.groupRoom.avatarUrl, + this.state.groupRoom.avatarUrl, 36, 36, 'crop', ); - const groupRoomName = this.props.groupRoom.displayname; + const groupRoomName = this.state.groupRoom.displayname; const avatar = ; return (
- +
@@ -158,7 +226,7 @@ module.exports = React.createClass({
- { this.props.groupRoom.canonical_alias } + { this.state.groupRoom.canonical_alias }
diff --git a/src/components/views/groups/GroupRoomTile.js b/src/components/views/groups/GroupRoomTile.js index e445f06044..907ce93a4a 100644 --- a/src/components/views/groups/GroupRoomTile.js +++ b/src/components/views/groups/GroupRoomTile.js @@ -33,7 +33,7 @@ const GroupRoomTile = React.createClass({ dis.dispatch({ action: 'view_group_room', groupId: this.props.groupId, - groupRoom: this.props.groupRoom, + groupRoomId: this.props.groupRoom.roomId, }); }, diff --git a/src/groups.js b/src/groups.js index 3c80677b0c..6c266e0fb6 100644 --- a/src/groups.js +++ b/src/groups.js @@ -50,5 +50,6 @@ export function groupRoomFromApiObject(apiObject) { numJoinedMembers: apiObject.num_joined_members, worldReadable: apiObject.world_readable, guestCanJoin: apiObject.guest_can_join, + isPublic: apiObject.is_public !== false, }; } diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index bffe3b3264..70e1af4834 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -158,6 +158,7 @@ "%(names)s and %(lastPerson)s are typing": "%(names)s and %(lastPerson)s are typing", "Failure to create room": "Failure to create room", "Server may be unavailable, overloaded, or you hit a bug.": "Server may be unavailable, overloaded, or you hit a bug.", + "Unnamed Room": "Unnamed Room", "Your browser does not support the required cryptography extensions": "Your browser does not support the required cryptography extensions", "Not a valid Riot keyfile": "Not a valid Riot keyfile", "Authentication check failed: incorrect password?": "Authentication check failed: incorrect password?", @@ -328,7 +329,6 @@ "Rooms": "Rooms", "Low priority": "Low priority", "Historical": "Historical", - "Unnamed Room": "Unnamed Room", "a room": "a room", "Unable to ascertain that the address this invite was sent to matches one associated with your account.": "Unable to ascertain that the address this invite was sent to matches one associated with your account.", "This invitation was sent to an email address which is not associated with this account:": "This invitation was sent to an email address which is not associated with this account:", @@ -491,13 +491,15 @@ "Failed to withdraw invitation": "Failed to withdraw invitation", "Failed to remove user from community": "Failed to remove user from community", "Filter community members": "Filter community members", - "Filter community rooms": "Filter community rooms", - "Failed to remove room from community": "Failed to remove room from community", - "Failed to remove '%(roomName)s' from %(groupId)s": "Failed to remove '%(roomName)s' from %(groupId)s", "Are you sure you want to remove '%(roomName)s' from %(groupId)s?": "Are you sure you want to remove '%(roomName)s' from %(groupId)s?", "Removing a room from the community will also remove it from the community page.": "Removing a room from the community will also remove it from the community page.", "Remove": "Remove", - "Remove this room from the community": "Remove this room from the community", + "Failed to remove room from community": "Failed to remove room from community", + "Failed to remove '%(roomName)s' from %(groupId)s": "Failed to remove '%(roomName)s' from %(groupId)s", + "Visibility in Room List": "Visibility in Room List", + "Visible to everyone": "Visible to everyone", + "Only visible to group members": "Only visible to group members", + "Filter community rooms": "Filter community rooms", "Unknown Address": "Unknown Address", "NOTE: Apps are not end-to-end encrypted": "NOTE: Apps are not end-to-end encrypted", "Do you want to load widget from URL:": "Do you want to load widget from URL:", diff --git a/src/stores/GroupStore.js b/src/stores/GroupStore.js index 3afac3c049..2578d373a7 100644 --- a/src/stores/GroupStore.js +++ b/src/stores/GroupStore.js @@ -141,9 +141,15 @@ export default class GroupStore extends EventEmitter { return this._summary.user ? this._summary.user.is_privileged : null; } - addRoomToGroup(roomId) { + addRoomToGroup(roomId, isPublic) { return this._matrixClient - .addRoomToGroup(this.groupId, roomId) + .addRoomToGroup(this.groupId, roomId, isPublic) + .then(this._fetchRooms.bind(this)); + } + + updateGroupRoomAssociation(roomId, isPublic) { + return this._matrixClient + .updateGroupRoomAssociation(this.groupId, roomId, isPublic) .then(this._fetchRooms.bind(this)); } From 982e87e01c67008761f486d313f7d546187e27ad Mon Sep 17 00:00:00 2001 From: Luke Barnard Date: Thu, 2 Nov 2017 15:04:40 +0000 Subject: [PATCH 24/37] Communities are communities, wrap div for label alignment --- src/components/views/groups/GroupRoomInfo.js | 8 ++++++-- src/i18n/strings/en_EN.json | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/components/views/groups/GroupRoomInfo.js b/src/components/views/groups/GroupRoomInfo.js index bc1fc51853..3f0b0067d2 100644 --- a/src/components/views/groups/GroupRoomInfo.js +++ b/src/components/views/groups/GroupRoomInfo.js @@ -189,7 +189,9 @@ module.exports = React.createClass({ checked={this.state.groupRoom.isPublic} onClick={this._changeGroupRoomPublicity} /> - { _t('Visible to everyone') } +
+ { _t('Visible to everyone') } +
@@ -199,7 +201,9 @@ module.exports = React.createClass({ checked={!this.state.groupRoom.isPublic} onClick={this._changeGroupRoomPublicity} /> - { _t('Only visible to group members') } +
+ { _t('Only visible to community members') } +
; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 0b5fdf678d..bc2f0754a7 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -506,7 +506,7 @@ "The visibility of '%(roomName)s' in %(groupId)s could not be updated.": "The visibility of '%(roomName)s' in %(groupId)s could not be updated.", "Visibility in Room List": "Visibility in Room List", "Visible to everyone": "Visible to everyone", - "Only visible to group members": "Only visible to group members", + "Only visible to community members": "Only visible to community members", "Filter community rooms": "Filter community rooms", "Unknown Address": "Unknown Address", "NOTE: Apps are not end-to-end encrypted": "NOTE: Apps are not end-to-end encrypted", From 21e09840dc989646685546a909461d1bf5054a66 Mon Sep 17 00:00:00 2001 From: Luke Barnard Date: Thu, 2 Nov 2017 15:59:26 +0000 Subject: [PATCH 25/37] Fix multiple requests for publicised groups of given user Previously, a single user could end up in multiple batches, which would have been fine if the logic didn't assume otherwise. If a request took longer than 200ms, multiple batches would occur with intersecting sets of users, deleting promises that were then assumed to exist. The logic now takes all "in flight" users to also not be "pending". Pending now means that the user will be processed in the next batch. "In flight" means the user is part of an ongoing batch. --- src/stores/FlairStore.js | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/src/stores/FlairStore.js b/src/stores/FlairStore.js index d848ca7dda..9424503390 100644 --- a/src/stores/FlairStore.js +++ b/src/stores/FlairStore.js @@ -66,7 +66,7 @@ class FlairStore extends EventEmitter { } // Bulk lookup ongoing, return promise to resolve/reject - if (this._usersPending[userId]) { + if (this._usersPending[userId] || this._usersInFlight[userId]) { return this._usersPending[userId].prom; } @@ -91,7 +91,7 @@ class FlairStore extends EventEmitter { console.error('Could not get groups for user', this.props.userId, err); throw err; }).finally(() => { - delete this._usersPending[userId]; + delete this._usersInFlight[userId]; }); // This debounce will allow consecutive requests for the public groups of users that @@ -113,27 +113,25 @@ class FlairStore extends EventEmitter { } async _batchedGetPublicGroups(matrixClient) { - // Take the userIds from the keys of this._usersPending - const usersInFlight = Object.keys(this._usersPending); + // Move users pending to users in flight + this._usersInFlight = this._usersPending; + this._usersPending = {}; + let resp = { users: [], }; try { - resp = await matrixClient.getPublicisedGroups(usersInFlight); + resp = await matrixClient.getPublicisedGroups(Object.keys(this._usersInFlight)); } catch (err) { // Propagate the same error to all usersInFlight - usersInFlight.forEach((userId) => { - this._usersPending[userId].reject(err); + Object.keys(this._usersInFlight).forEach((userId) => { + this._usersInFlight[userId].reject(err); }); return; } const updatedUserGroups = resp.users; - usersInFlight.forEach((userId) => { - if (this._usersPending[userId]) { - this._usersPending[userId].resolve(updatedUserGroups[userId] || []); - } else { - console.error("Promise vanished for resolving groups for " + userId); - } + Object.keys(this._usersInFlight).forEach((userId) => { + this._usersInFlight[userId].resolve(updatedUserGroups[userId] || []); }); } From 4953d4de4d60f83290d101bee0f0f453c3ed2b4c Mon Sep 17 00:00:00 2001 From: David Baker Date: Thu, 2 Nov 2017 17:51:08 +0000 Subject: [PATCH 26/37] Give autocomplete providers the room they're in Removes the gut-wrenching that RoomView does to jam the user list into the user autocomplete provider. --- src/autocomplete/AutocompleteProvider.js | 3 + src/autocomplete/Autocompleter.js | 77 +++++++++++-------- src/autocomplete/CommandProvider.js | 8 -- src/autocomplete/DuckDuckGoProvider.js | 9 --- src/autocomplete/EmojiProvider.js | 7 -- src/autocomplete/RoomProvider.js | 10 --- src/autocomplete/UserProvider.js | 52 +++++++++---- src/components/structures/RoomView.js | 12 --- src/components/views/rooms/Autocomplete.js | 22 +++++- .../views/rooms/MessageComposerInput.js | 4 +- 10 files changed, 107 insertions(+), 97 deletions(-) diff --git a/src/autocomplete/AutocompleteProvider.js b/src/autocomplete/AutocompleteProvider.js index 4c7d039da4..ece833eb06 100644 --- a/src/autocomplete/AutocompleteProvider.js +++ b/src/autocomplete/AutocompleteProvider.js @@ -28,6 +28,9 @@ export default class AutocompleteProvider { } } + destroy() { + } + /** * Of the matched commands in the query, returns the first that contains or is contained by the selection, or null. */ diff --git a/src/autocomplete/Autocompleter.js b/src/autocomplete/Autocompleter.js index 5b10110f04..ca3ef2a55a 100644 --- a/src/autocomplete/Autocompleter.js +++ b/src/autocomplete/Autocompleter.js @@ -45,41 +45,56 @@ const PROVIDERS = [ EmojiProvider, CommandProvider, DuckDuckGoProvider, -].map((completer) => completer.getInstance()); +]; // Providers will get rejected if they take longer than this. const PROVIDER_COMPLETION_TIMEOUT = 3000; -export async function getCompletions(query: string, selection: SelectionRange, force: boolean = false): Array { - /* Note: That this waits for all providers to return is *intentional* - otherwise, we run into a condition where new completions are displayed - while the user is interacting with the list, which makes it difficult - to predict whether an action will actually do what is intended - */ - const completionsList = await Promise.all( - // Array of inspections of promises that might timeout. Instead of allowing a - // single timeout to reject the Promise.all, reflect each one and once they've all - // settled, filter for the fulfilled ones - PROVIDERS.map((provider) => { - return provider - .getCompletions(query, selection, force) - .timeout(PROVIDER_COMPLETION_TIMEOUT) - .reflect(); - }), - ); +export default class Autocompleter { + constructor(room) { + this.room = room; + this.providers = PROVIDERS.map((p) => { + return new p(room); + }); + } - return completionsList.filter( - (inspection) => inspection.isFulfilled(), - ).map((completionsState, i) => { - return { - completions: completionsState.value(), - provider: PROVIDERS[i], + destroy() { + this.providers.forEach((p) => { + p.destroy(); + }); + } - /* the currently matched "command" the completer tried to complete - * we pass this through so that Autocomplete can figure out when to - * re-show itself once hidden. - */ - command: PROVIDERS[i].getCurrentCommand(query, selection, force), - }; - }); + async getCompletions(query: string, selection: SelectionRange, force: boolean = false): Array { + /* Note: That this waits for all providers to return is *intentional* + otherwise, we run into a condition where new completions are displayed + while the user is interacting with the list, which makes it difficult + to predict whether an action will actually do what is intended + */ + const completionsList = await Promise.all( + // Array of inspections of promises that might timeout. Instead of allowing a + // single timeout to reject the Promise.all, reflect each one and once they've all + // settled, filter for the fulfilled ones + this.providers.map((provider) => { + return provider + .getCompletions(query, selection, force) + .timeout(PROVIDER_COMPLETION_TIMEOUT) + .reflect(); + }), + ); + + return completionsList.filter( + (inspection) => inspection.isFulfilled(), + ).map((completionsState, i) => { + return { + completions: completionsState.value(), + provider: this.providers[i], + + /* the currently matched "command" the completer tried to complete + * we pass this through so that Autocomplete can figure out when to + * re-show itself once hidden. + */ + command: this.providers[i].getCurrentCommand(query, selection, force), + }; + }); + } } diff --git a/src/autocomplete/CommandProvider.js b/src/autocomplete/CommandProvider.js index e85457e6aa..df24a6b991 100644 --- a/src/autocomplete/CommandProvider.js +++ b/src/autocomplete/CommandProvider.js @@ -109,8 +109,6 @@ const COMMANDS = [ const COMMAND_RE = /(^\/\w*)/g; -let instance = null; - export default class CommandProvider extends AutocompleteProvider { constructor() { super(COMMAND_RE); @@ -142,12 +140,6 @@ export default class CommandProvider extends AutocompleteProvider { return '*️⃣ ' + _t('Commands'); } - static getInstance(): CommandProvider { - if (instance === null) instance = new CommandProvider(); - - return instance; - } - renderCompletions(completions: [React.Component]): ?React.Component { return
{ completions } diff --git a/src/autocomplete/DuckDuckGoProvider.js b/src/autocomplete/DuckDuckGoProvider.js index b2e85c4668..fdf260e1a1 100644 --- a/src/autocomplete/DuckDuckGoProvider.js +++ b/src/autocomplete/DuckDuckGoProvider.js @@ -25,8 +25,6 @@ import {TextualCompletion} from './Components'; const DDG_REGEX = /\/ddg\s+(.+)$/g; const REFERRER = 'vector'; -let instance = null; - export default class DuckDuckGoProvider extends AutocompleteProvider { constructor() { super(DDG_REGEX); @@ -96,13 +94,6 @@ export default class DuckDuckGoProvider extends AutocompleteProvider { return '🔍 ' + _t('Results from DuckDuckGo'); } - static getInstance(): DuckDuckGoProvider { - if (instance == null) { - instance = new DuckDuckGoProvider(); - } - return instance; - } - renderCompletions(completions: [React.Component]): ?React.Component { return
{ completions } diff --git a/src/autocomplete/EmojiProvider.js b/src/autocomplete/EmojiProvider.js index a5b80e3b0e..eceaffeab4 100644 --- a/src/autocomplete/EmojiProvider.js +++ b/src/autocomplete/EmojiProvider.js @@ -70,8 +70,6 @@ const EMOJI_SHORTNAMES = Object.keys(EmojiData).map((key) => EmojiData[key]).sor }; }); -let instance = null; - function score(query, space) { const index = space.indexOf(query); if (index === -1) { @@ -151,11 +149,6 @@ export default class EmojiProvider extends AutocompleteProvider { return '😃 ' + _t('Emoji'); } - static getInstance() { - if (instance == null) {instance = new EmojiProvider();} - return instance; - } - renderCompletions(completions: [React.Component]): ?React.Component { return
{ completions } diff --git a/src/autocomplete/RoomProvider.js b/src/autocomplete/RoomProvider.js index cc04f54dda..11fd2618ac 100644 --- a/src/autocomplete/RoomProvider.js +++ b/src/autocomplete/RoomProvider.js @@ -27,8 +27,6 @@ import _sortBy from 'lodash/sortBy'; const ROOM_REGEX = /(?=#)(\S*)/g; -let instance = null; - function score(query, space) { const index = space.indexOf(query); if (index === -1) { @@ -96,14 +94,6 @@ export default class RoomProvider extends AutocompleteProvider { return '💬 ' + _t('Rooms'); } - static getInstance() { - if (instance == null) { - instance = new RoomProvider(); - } - - return instance; - } - renderCompletions(completions: [React.Component]): ?React.Component { return
{ completions } diff --git a/src/autocomplete/UserProvider.js b/src/autocomplete/UserProvider.js index 296399c06c..8656de28aa 100644 --- a/src/autocomplete/UserProvider.js +++ b/src/autocomplete/UserProvider.js @@ -30,20 +30,54 @@ import type {Room, RoomMember} from 'matrix-js-sdk'; const USER_REGEX = /@\S*/g; -let instance = null; - export default class UserProvider extends AutocompleteProvider { users: Array = null; room: Room = null; - constructor() { + constructor(room) { super(USER_REGEX, { keys: ['name'], }); + this.room = room; this.matcher = new FuzzyMatcher([], { keys: ['name', 'userId'], shouldMatchPrefix: true, }); + + this._onRoomTimelineBound = this._onRoomTimeline.bind(this); + this._onRoomStateMemberBound = this._onRoomStateMember.bind(this); + + MatrixClientPeg.get().on("Room.timeline", this._onRoomTimelineBound); + MatrixClientPeg.get().on("RoomState.members", this._onRoomStateMemberBound); + } + + destroy() { + MatrixClientPeg.get().removeListener("Room.timeline", this._onRoomTimelineBound); + MatrixClientPeg.get().removeListener("RoomState.members", this._onRoomStateMemberBound); + } + + _onRoomTimeline(ev, room, toStartOfTimeline, removed, data) { + if (!room) return; + if (room.roomId != this.room.roomId) return; + + // ignore events from filtered timelines + if (data.timeline.getTimelineSet() !== room.getUnfilteredTimelineSet()) return; + + // ignore anything but real-time updates at the end of the room: + // updates from pagination will happen when the paginate completes. + if (toStartOfTimeline || !data || !data.liveEvent) return; + + this.onUserSpoke(ev.sender); + } + + _onRoomStateMember(ev, state, member) { + // ignore members in other rooms + if (member.roomId !== this.room.roomId) { + return; + } + + // blow away the users cache + this.users = null; } async getCompletions(query: string, selection: {start: number, end: number}, force = false) { @@ -86,11 +120,6 @@ export default class UserProvider extends AutocompleteProvider { return '👥 ' + _t('Users'); } - setUserListFromRoom(room: Room) { - this.room = room; - this.users = null; - } - _makeUsers() { const events = this.room.getLiveTimeline().getEvents(); const lastSpoken = {}; @@ -123,13 +152,6 @@ export default class UserProvider extends AutocompleteProvider { this.matcher.setObjects(this.users); } - static getInstance(): UserProvider { - if (instance == null) { - instance = new UserProvider(); - } - return instance; - } - renderCompletions(completions: [React.Component]): ?React.Component { return
{ completions } diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index 9b6dbb4c27..a40fff274c 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -44,8 +44,6 @@ const Rooms = require('../../Rooms'); import KeyCode from '../../KeyCode'; -import UserProvider from '../../autocomplete/UserProvider'; - import RoomViewStore from '../../stores/RoomViewStore'; import RoomScrollStateStore from '../../stores/RoomScrollStateStore'; @@ -541,12 +539,6 @@ module.exports = React.createClass({ }); } } - - // update the tab complete list as it depends on who most recently spoke, - // and that has probably just changed - if (ev.sender) { - UserProvider.getInstance().onUserSpoke(ev.sender); - } }, onRoomName: function(room) { @@ -568,7 +560,6 @@ module.exports = React.createClass({ this._warnAboutEncryption(room); this._calculatePeekRules(room); this._updatePreviewUrlVisibility(room); - UserProvider.getInstance().setUserListFromRoom(room); }, _warnAboutEncryption: function(room) { @@ -722,9 +713,6 @@ module.exports = React.createClass({ // refresh the conf call notification state this._updateConfCallNotification(); - // refresh the tab complete list - UserProvider.getInstance().setUserListFromRoom(this.state.room); - // if we are now a member of the room, where we were not before, that // means we have finished joining a room we were previously peeking // into. diff --git a/src/components/views/rooms/Autocomplete.js b/src/components/views/rooms/Autocomplete.js index ecc908a02c..b877f388a8 100644 --- a/src/components/views/rooms/Autocomplete.js +++ b/src/components/views/rooms/Autocomplete.js @@ -1,5 +1,6 @@ import React from 'react'; import ReactDOM from 'react-dom'; +import PropTypes from 'prop-types'; import classNames from 'classnames'; import flatMap from 'lodash/flatMap'; import isEqual from 'lodash/isEqual'; @@ -8,7 +9,7 @@ import type {Completion} from '../../../autocomplete/Autocompleter'; import Promise from 'bluebird'; import UserSettingsStore from '../../../UserSettingsStore'; -import {getCompletions} from '../../../autocomplete/Autocompleter'; +import Autocompleter from '../../../autocomplete/Autocompleter'; const COMPOSER_SELECTED = 0; @@ -17,6 +18,7 @@ export default class Autocomplete extends React.Component { constructor(props) { super(props); + this.autocompleter = new Autocompleter(props.room); this.completionPromise = null; this.hide = this.hide.bind(this); this.onCompletionClicked = this.onCompletionClicked.bind(this); @@ -41,6 +43,11 @@ export default class Autocomplete extends React.Component { } componentWillReceiveProps(newProps, state) { + if (this.props.room.roomId !== newProps.room.roomId) { + this.autocompleter.destroy(); + this.autocompleter = new Autocompleter(); + } + // Query hasn't changed so don't try to complete it if (newProps.query === this.props.query) { return; @@ -49,6 +56,10 @@ export default class Autocomplete extends React.Component { this.complete(newProps.query, newProps.selection); } + componentWillUnmount() { + this.autocompleter.destroy(); + } + complete(query, selection) { this.queryRequested = query; if (this.debounceCompletionsRequest) { @@ -83,7 +94,7 @@ export default class Autocomplete extends React.Component { } processQuery(query, selection) { - return getCompletions( + return this.autocompleter.getCompletions( query, selection, this.state.forceComplete, ).then((completions) => { // Only ever process the completions for the most recent query being processed @@ -267,8 +278,11 @@ export default class Autocomplete extends React.Component { Autocomplete.propTypes = { // the query string for which to show autocomplete suggestions - query: React.PropTypes.string.isRequired, + query: PropTypes.string.isRequired, // method invoked with range and text content when completion is confirmed - onConfirm: React.PropTypes.func.isRequired, + onConfirm: PropTypes.func.isRequired, + + // The room in which we're autocompleting + room: PropTypes.object, }; diff --git a/src/components/views/rooms/MessageComposerInput.js b/src/components/views/rooms/MessageComposerInput.js index 4850428621..45499eae04 100644 --- a/src/components/views/rooms/MessageComposerInput.js +++ b/src/components/views/rooms/MessageComposerInput.js @@ -1130,10 +1130,12 @@ export default class MessageComposerInput extends React.Component {
this.autocomplete = e} + room={this.props.room} onConfirm={this.setDisplayedCompletion} onSelectionChange={this.setDisplayedCompletion} query={this.getAutocompleteQuery(content)} - selection={selection} /> + selection={selection} + />
Date: Thu, 2 Nov 2017 18:01:28 +0000 Subject: [PATCH 27/37] copyrights --- src/autocomplete/AutocompleteProvider.js | 1 + src/autocomplete/Autocompleter.js | 3 +++ src/autocomplete/CommandProvider.js | 1 + src/autocomplete/DuckDuckGoProvider.js | 1 + src/autocomplete/EmojiProvider.js | 1 + src/autocomplete/RoomProvider.js | 1 + src/autocomplete/UserProvider.js | 1 + src/components/views/rooms/Autocomplete.js | 17 +++++++++++++++++ .../views/rooms/MessageComposerInput.js | 1 + 9 files changed, 27 insertions(+) diff --git a/src/autocomplete/AutocompleteProvider.js b/src/autocomplete/AutocompleteProvider.js index ece833eb06..0477e964bf 100644 --- a/src/autocomplete/AutocompleteProvider.js +++ b/src/autocomplete/AutocompleteProvider.js @@ -1,6 +1,7 @@ /* Copyright 2016 Aviral Dasgupta 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. diff --git a/src/autocomplete/Autocompleter.js b/src/autocomplete/Autocompleter.js index ca3ef2a55a..13b078cfa8 100644 --- a/src/autocomplete/Autocompleter.js +++ b/src/autocomplete/Autocompleter.js @@ -1,5 +1,6 @@ /* Copyright 2016 Aviral Dasgupta +Copyright 2017 New Vector Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -22,6 +23,7 @@ import DuckDuckGoProvider from './DuckDuckGoProvider'; import RoomProvider from './RoomProvider'; import UserProvider from './UserProvider'; import EmojiProvider from './EmojiProvider'; +import NotifProvider from './NotifProvider'; import Promise from 'bluebird'; export type SelectionRange = { @@ -43,6 +45,7 @@ const PROVIDERS = [ UserProvider, RoomProvider, EmojiProvider, + NotifProvider, CommandProvider, DuckDuckGoProvider, ]; diff --git a/src/autocomplete/CommandProvider.js b/src/autocomplete/CommandProvider.js index df24a6b991..d47f1a161a 100644 --- a/src/autocomplete/CommandProvider.js +++ b/src/autocomplete/CommandProvider.js @@ -1,6 +1,7 @@ /* Copyright 2016 Aviral Dasgupta 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. diff --git a/src/autocomplete/DuckDuckGoProvider.js b/src/autocomplete/DuckDuckGoProvider.js index fdf260e1a1..68d4915f56 100644 --- a/src/autocomplete/DuckDuckGoProvider.js +++ b/src/autocomplete/DuckDuckGoProvider.js @@ -1,6 +1,7 @@ /* Copyright 2016 Aviral Dasgupta 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. diff --git a/src/autocomplete/EmojiProvider.js b/src/autocomplete/EmojiProvider.js index eceaffeab4..9f1f40dbe7 100644 --- a/src/autocomplete/EmojiProvider.js +++ b/src/autocomplete/EmojiProvider.js @@ -1,6 +1,7 @@ /* Copyright 2016 Aviral Dasgupta 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. diff --git a/src/autocomplete/RoomProvider.js b/src/autocomplete/RoomProvider.js index 11fd2618ac..1e1928a1ee 100644 --- a/src/autocomplete/RoomProvider.js +++ b/src/autocomplete/RoomProvider.js @@ -1,6 +1,7 @@ /* Copyright 2016 Aviral Dasgupta 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. diff --git a/src/autocomplete/UserProvider.js b/src/autocomplete/UserProvider.js index 8656de28aa..fced0ce7ff 100644 --- a/src/autocomplete/UserProvider.js +++ b/src/autocomplete/UserProvider.js @@ -2,6 +2,7 @@ /* Copyright 2016 Aviral Dasgupta 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. diff --git a/src/components/views/rooms/Autocomplete.js b/src/components/views/rooms/Autocomplete.js index b877f388a8..839679f5c4 100644 --- a/src/components/views/rooms/Autocomplete.js +++ b/src/components/views/rooms/Autocomplete.js @@ -1,3 +1,20 @@ +/* +Copyright 2016 Aviral Dasgupta +Copyright 2017 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + import React from 'react'; import ReactDOM from 'react-dom'; import PropTypes from 'prop-types'; diff --git a/src/components/views/rooms/MessageComposerInput.js b/src/components/views/rooms/MessageComposerInput.js index 45499eae04..43f3aa5d88 100644 --- a/src/components/views/rooms/MessageComposerInput.js +++ b/src/components/views/rooms/MessageComposerInput.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. From f7201e8dee0c81a8fb129fbd27adda9f60982fc6 Mon Sep 17 00:00:00 2001 From: David Baker Date: Thu, 2 Nov 2017 18:08:24 +0000 Subject: [PATCH 28/37] Revert unintentional changes --- src/autocomplete/Autocompleter.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/autocomplete/Autocompleter.js b/src/autocomplete/Autocompleter.js index 13b078cfa8..3d02765589 100644 --- a/src/autocomplete/Autocompleter.js +++ b/src/autocomplete/Autocompleter.js @@ -23,7 +23,6 @@ import DuckDuckGoProvider from './DuckDuckGoProvider'; import RoomProvider from './RoomProvider'; import UserProvider from './UserProvider'; import EmojiProvider from './EmojiProvider'; -import NotifProvider from './NotifProvider'; import Promise from 'bluebird'; export type SelectionRange = { @@ -45,7 +44,6 @@ const PROVIDERS = [ UserProvider, RoomProvider, EmojiProvider, - NotifProvider, CommandProvider, DuckDuckGoProvider, ]; From 42589281d12b0ef94705c5f739a5f73729d41f2d Mon Sep 17 00:00:00 2001 From: David Baker Date: Thu, 2 Nov 2017 18:10:13 +0000 Subject: [PATCH 29/37] comment stub method --- src/autocomplete/AutocompleteProvider.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/autocomplete/AutocompleteProvider.js b/src/autocomplete/AutocompleteProvider.js index 0477e964bf..c93ae4fb2a 100644 --- a/src/autocomplete/AutocompleteProvider.js +++ b/src/autocomplete/AutocompleteProvider.js @@ -30,6 +30,7 @@ export default class AutocompleteProvider { } destroy() { + // stub } /** From ee43c635d1952ce4324595fa67ffa35b87a2f959 Mon Sep 17 00:00:00 2001 From: David Baker Date: Thu, 2 Nov 2017 18:11:18 +0000 Subject: [PATCH 30/37] phrasing --- src/autocomplete/Autocompleter.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/autocomplete/Autocompleter.js b/src/autocomplete/Autocompleter.js index 3d02765589..94d2ed28de 100644 --- a/src/autocomplete/Autocompleter.js +++ b/src/autocomplete/Autocompleter.js @@ -66,7 +66,7 @@ export default class Autocompleter { } async getCompletions(query: string, selection: SelectionRange, force: boolean = false): Array { - /* Note: That this waits for all providers to return is *intentional* + /* Note: This intentionally waits for all providers to return, otherwise, we run into a condition where new completions are displayed while the user is interacting with the list, which makes it difficult to predict whether an action will actually do what is intended From 3b58f0ca2a00004e7423c79a7c876a6e294686c0 Mon Sep 17 00:00:00 2001 From: David Baker Date: Thu, 2 Nov 2017 18:14:21 +0000 Subject: [PATCH 31/37] Ignore removed events --- src/autocomplete/UserProvider.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/autocomplete/UserProvider.js b/src/autocomplete/UserProvider.js index fced0ce7ff..7c86ea7e4b 100644 --- a/src/autocomplete/UserProvider.js +++ b/src/autocomplete/UserProvider.js @@ -59,6 +59,7 @@ export default class UserProvider extends AutocompleteProvider { _onRoomTimeline(ev, room, toStartOfTimeline, removed, data) { if (!room) return; + if (removed) return; if (room.roomId != this.room.roomId) return; // ignore events from filtered timelines From 6ad4bb80dd366fe744f107b4cd5600e495d402ad Mon Sep 17 00:00:00 2001 From: David Baker Date: Thu, 2 Nov 2017 18:14:46 +0000 Subject: [PATCH 32/37] == --- src/autocomplete/UserProvider.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/autocomplete/UserProvider.js b/src/autocomplete/UserProvider.js index 7c86ea7e4b..8b43964b1a 100644 --- a/src/autocomplete/UserProvider.js +++ b/src/autocomplete/UserProvider.js @@ -60,7 +60,7 @@ export default class UserProvider extends AutocompleteProvider { _onRoomTimeline(ev, room, toStartOfTimeline, removed, data) { if (!room) return; if (removed) return; - if (room.roomId != this.room.roomId) return; + if (room.roomId !== this.room.roomId) return; // ignore events from filtered timelines if (data.timeline.getTimelineSet() !== room.getUnfilteredTimelineSet()) return; From 7f9967389d00e4bb40b5e43457cb059f90fb9201 Mon Sep 17 00:00:00 2001 From: David Baker Date: Thu, 2 Nov 2017 18:15:26 +0000 Subject: [PATCH 33/37] Pass room into Autocompleter --- src/components/views/rooms/Autocomplete.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/views/rooms/Autocomplete.js b/src/components/views/rooms/Autocomplete.js index 839679f5c4..db50ab8bf8 100644 --- a/src/components/views/rooms/Autocomplete.js +++ b/src/components/views/rooms/Autocomplete.js @@ -62,7 +62,7 @@ export default class Autocomplete extends React.Component { componentWillReceiveProps(newProps, state) { if (this.props.room.roomId !== newProps.room.roomId) { this.autocompleter.destroy(); - this.autocompleter = new Autocompleter(); + this.autocompleter = new Autocompleter(newProps.room); } // Query hasn't changed so don't try to complete it From 843d797ded2aa95d81d026a615bf9ac4a9d9ee50 Mon Sep 17 00:00:00 2001 From: David Baker Date: Thu, 2 Nov 2017 18:17:57 +0000 Subject: [PATCH 34/37] Better type checking --- src/components/views/rooms/Autocomplete.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/views/rooms/Autocomplete.js b/src/components/views/rooms/Autocomplete.js index db50ab8bf8..958d16073c 100644 --- a/src/components/views/rooms/Autocomplete.js +++ b/src/components/views/rooms/Autocomplete.js @@ -25,6 +25,7 @@ import sdk from '../../../index'; import type {Completion} from '../../../autocomplete/Autocompleter'; import Promise from 'bluebird'; import UserSettingsStore from '../../../UserSettingsStore'; +import { Room } from 'matrix-js-sdk'; import Autocompleter from '../../../autocomplete/Autocompleter'; @@ -301,5 +302,5 @@ Autocomplete.propTypes = { onConfirm: PropTypes.func.isRequired, // The room in which we're autocompleting - room: PropTypes.object, + room: PropTypes.instanceOf(Room), }; From 71c59eff2c439922bbc73b048d23f61023eb2275 Mon Sep 17 00:00:00 2001 From: Luke Barnard Date: Fri, 3 Nov 2017 11:19:29 +0000 Subject: [PATCH 35/37] Add a GeminiScrollbar to Your Communities --- src/components/structures/MyGroups.js | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/components/structures/MyGroups.js b/src/components/structures/MyGroups.js index b6a450fbb4..cc4783fdac 100644 --- a/src/components/structures/MyGroups.js +++ b/src/components/structures/MyGroups.js @@ -15,13 +15,14 @@ limitations under the License. */ import React from 'react'; +import PropTypes from 'prop-types'; +import GeminiScrollbar from 'react-gemini-scrollbar'; import {MatrixClient} from 'matrix-js-sdk'; import sdk from '../../index'; import { _t, _tJsx } from '../../languageHandler'; import withMatrixClient from '../../wrappers/withMatrixClient'; import AccessibleButton from '../views/elements/AccessibleButton'; import dis from '../../dispatcher'; -import PropTypes from 'prop-types'; import Modal from '../../Modal'; import FlairStore from '../../stores/FlairStore'; @@ -115,18 +116,17 @@ export default withMatrixClient(React.createClass({ const TintableSvg = sdk.getComponent("elements.TintableSvg"); let content; + let contentHeader; if (this.state.groups) { const groupNodes = []; this.state.groups.forEach((g) => { groupNodes.push(); }); + contentHeader = groupNodes.length > 0 ?

{ _t('Your Communities') }

:
; content = groupNodes.length > 0 ? -
-

{ _t('Your Communities') }

-
- { groupNodes } -
-
: + + { groupNodes } + :
{ _t( "You're not currently a member of any communities.", @@ -176,6 +176,7 @@ export default withMatrixClient(React.createClass({
+ { contentHeader } { content }
; From 151f9917b1232ec8658aef5821db3d59c5b01212 Mon Sep 17 00:00:00 2001 From: Luke Barnard Date: Fri, 3 Nov 2017 12:19:37 +0000 Subject: [PATCH 36/37] Fix group invites such that they look similar to room invites - Change GroupInviteTile to use RoomTile CSS - Give group invites their own sub list, with heading "Community Invites" --- src/components/views/groups/GroupInviteTile.js | 12 ++++++------ src/components/views/rooms/RoomList.js | 15 ++++++++++++--- src/i18n/strings/en_EN.json | 1 + 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/src/components/views/groups/GroupInviteTile.js b/src/components/views/groups/GroupInviteTile.js index d7a04247ec..fcc9acb00b 100644 --- a/src/components/views/groups/GroupInviteTile.js +++ b/src/components/views/groups/GroupInviteTile.js @@ -44,21 +44,21 @@ export default React.createClass({ const label = { groupName } ; - const badge =
!
; + const badge =
!
; return ( - -
+ +
{ av }
-
+
{ label } { badge }
diff --git a/src/components/views/rooms/RoomList.js b/src/components/views/rooms/RoomList.js index e689579650..1a9fa5d4e9 100644 --- a/src/components/views/rooms/RoomList.js +++ b/src/components/views/rooms/RoomList.js @@ -555,13 +555,23 @@ module.exports = React.createClass({ render: function() { const RoomSubList = sdk.getComponent('structures.RoomSubList'); - const inviteSectionExtraTiles = this._makeGroupInviteTiles(); - const self = this; return (
+ + to start a chat with someone": "Press to start a chat with someone", "You're not in any rooms yet! Press to make a room or to browse the directory": "You're not in any rooms yet! Press to make a room or to browse the directory", + "Community Invites": "Community Invites", "Invites": "Invites", "Favourites": "Favourites", "People": "People", From 802ab1674660c864edda59c0aa2aa8d4d6574e67 Mon Sep 17 00:00:00 2001 From: David Baker Date: Fri, 3 Nov 2017 14:06:59 +0000 Subject: [PATCH 37/37] Fix multiple pills on one line --- src/components/views/messages/TextualBody.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/components/views/messages/TextualBody.js b/src/components/views/messages/TextualBody.js index faa4d6cf77..911f2c98d1 100644 --- a/src/components/views/messages/TextualBody.js +++ b/src/components/views/messages/TextualBody.js @@ -194,6 +194,9 @@ module.exports = React.createClass({ node.parentNode.replaceChild(pillContainer, node); // Pills within pills aren't going to go well, so move on pillified = true; + + // update the current node with one that's now taken its place + node = pillContainer; } } else if (node.nodeType == Node.TEXT_NODE) { const Pill = sdk.getComponent('elements.Pill');