diff --git a/package.json b/package.json index 8693e33471..0ae6fd999b 100644 --- a/package.json +++ b/package.json @@ -101,7 +101,9 @@ "eslint-plugin-babel": "^4.0.1", "eslint-plugin-flowtype": "^2.30.0", "eslint-plugin-react": "^7.4.0", + "estree-walker": "^0.5.0", "expect": "^1.16.0", + "flow-parser": "^0.57.3", "json-loader": "^0.5.3", "karma": "^1.7.0", "karma-chrome-launcher": "^0.2.3", @@ -121,6 +123,7 @@ "rimraf": "^2.4.3", "sinon": "^1.17.3", "source-map-loader": "^0.1.5", + "walk": "^2.3.9", "webpack": "^1.12.14" } } diff --git a/scripts/gen-i18n.js b/scripts/gen-i18n.js new file mode 100644 index 0000000000..3ce5aeb312 --- /dev/null +++ b/scripts/gen-i18n.js @@ -0,0 +1,153 @@ +/* +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. +*/ + +/** + * Regenerates the translations en_EN file by walking the source tree and + * parsing each file with flow-parser. Emits a JSON file with the + * translatable strings mapped to themselves in the order they appeared + * in the files and grouped by the file they appeared in. + * + * Usage: node scripts/gen-i18n.js + */ +const fs = require('fs'); +const path = require('path'); + +const walk = require('walk'); + +const flowParser = require('flow-parser'); +const estreeWalker = require('estree-walker'); + +const TRANSLATIONS_FUNCS = ['_t', '_td', '_tJsx']; + +const INPUT_TRANSLATIONS_FILE = 'src/i18n/strings/en_EN.json'; + +const FLOW_PARSER_OPTS = { + esproposal_class_instance_fields: true, + esproposal_class_static_fields: true, + esproposal_decorators: true, + esproposal_export_star_as: true, + types: true, +}; + +function getObjectValue(obj, key) { + for (const prop of obj.properties) { + if (prop.key.type == 'Identifier' && prop.key.name == key) { + return prop.value; + } + } + return null; +} + +function getTKey(arg) { + if (arg.type == 'Literal') { + return arg.value; + } else if (arg.type == 'BinaryExpression' && arg.operator == '+') { + return getTKey(arg.left) + getTKey(arg.right); + } else if (arg.type == 'TemplateLiteral') { + return arg.quasis.map((q) => { + return q.value.raw; + }).join(''); + } + return null; +} + +function getTranslations(file) { + const tree = flowParser.parse(fs.readFileSync(file, { encoding: 'utf8' }), FLOW_PARSER_OPTS); + + const trs = new Set(); + + estreeWalker.walk(tree, { + enter: function(node, parent) { + if ( + node.type == 'CallExpression' && + TRANSLATIONS_FUNCS.includes(node.callee.name) + ) { + const tKey = getTKey(node.arguments[0]); + // This happens whenever we call _t with non-literals (ie. whenever we've + // had to use a _td to compensate) so is expected. + if (tKey === null) return; + + let isPlural = false; + if (node.arguments.length > 1 && node.arguments[1].type == 'ObjectExpression') { + const countVal = getObjectValue(node.arguments[1], 'count'); + if (countVal) { + isPlural = true; + } + } + + if (isPlural) { + trs.add(tKey + "|other"); + const plurals = enPlurals[tKey]; + if (plurals) { + for (const pluralType of Object.keys(plurals)) { + trs.add(tKey + "|" + pluralType); + } + } + } else { + trs.add(tKey); + } + } + } + }); + + return trs; +} + +// gather en_EN plural strings from the input translations file: +// the en_EN strings are all in the source with the exception of +// pluralised strings, which we need to pull in from elsewhere. +const inputTranslationsRaw = JSON.parse(fs.readFileSync(INPUT_TRANSLATIONS_FILE, { encoding: 'utf8' })); +const enPlurals = {}; + +for (const key of Object.keys(inputTranslationsRaw)) { + const parts = key.split("|"); + if (parts.length > 1) { + const plurals = enPlurals[parts[0]] || {}; + plurals[parts[1]] = inputTranslationsRaw[key]; + enPlurals[parts[0]] = plurals; + } +} + +const translatables = new Set(); + +walk.walkSync("src", { + listeners: { + file: function(root, fileStats, next) { + if (!fileStats.name.endsWith('.js')) return; + + const fullPath = path.join(root, fileStats.name); + const trs = getTranslations(fullPath); + console.log(`${fullPath} (${trs.size} strings)`); + for (const tr of trs.values()) { + translatables.add(tr); + } + }, + } +}); + +const trObj = {}; +for (const tr of translatables) { + trObj[tr] = tr; + if (tr.includes("|")) { + trObj[tr] = inputTranslationsRaw[tr]; + } +} + +fs.writeFileSync( + "src/i18n/strings/en_EN.json", + JSON.stringify(trObj, translatables.values(), 4) + "\n" +); + diff --git a/src/GroupAddressPicker.js b/src/GroupAddressPicker.js index ca495de16d..b710524ce3 100644 --- a/src/GroupAddressPicker.js +++ b/src/GroupAddressPicker.js @@ -94,13 +94,30 @@ function _onGroupInviteFinished(groupId, addrs) { } function _onGroupAddRoomFinished(groupId, addrs) { - const groupStore = GroupStoreCache.getGroupStore(MatrixClientPeg.get(), groupId); + const matrixClient = MatrixClientPeg.get(); + const groupStore = GroupStoreCache.getGroupStore(matrixClient, groupId); const errorList = []; return Promise.all(addrs.map((addr) => { return groupStore .addRoomToGroup(addr.address) .catch(() => { errorList.push(addr.address); }) - .reflect(); + .then(() => { + const roomId = addr.address; + const room = matrixClient.getRoom(roomId); + // Can the user change related groups? + if (!room || !room.currentState.mayClientSendStateEvent("m.room.related_groups", matrixClient)) { + return; + } + // Get the related groups + const relatedGroupsEvent = room.currentState.getStateEvents('m.room.related_groups', ''); + const groups = relatedGroupsEvent ? relatedGroupsEvent.getContent().groups || [] : []; + + // Add this group as related + if (!groups.includes(groupId)) { + groups.push(groupId); + return MatrixClientPeg.get().sendStateEvent(roomId, 'm.room.related_groups', {groups}, ''); + } + }).reflect(); })).then(() => { if (errorList.length === 0) { return; diff --git a/src/components/structures/GroupView.js b/src/components/structures/GroupView.js index 17610ae059..b19edc8c6e 100644 --- a/src/components/structures/GroupView.js +++ b/src/components/structures/GroupView.js @@ -448,8 +448,18 @@ export default React.createClass({ _initGroupStore: function(groupId) { this._groupStore = GroupStoreCache.getGroupStore(MatrixClientPeg.get(), groupId); this._groupStore.on('update', () => { + const summary = this._groupStore.getSummary(); + if (summary.profile) { + // Default profile fields should be "" for later sending to the server (which + // requires that the fields are strings, not null) + ["avatar_url", "long_description", "name", "short_description"].forEach((k) => { + summary.profile[k] = summary.profile[k] || ""; + }); + } this.setState({ - summary: this._groupStore.getSummary(), + summary, + isGroupPublicised: this._groupStore.getGroupPublicity(), + isUserPrivileged: this._groupStore.isUserPrivileged(), error: null, }); }); @@ -598,22 +608,15 @@ export default React.createClass({ }); }, - _onPubliciseOffClick: function() { - this._setPublicity(false); - }, - - _onPubliciseOnClick: function() { - this._setPublicity(true); - }, - _onAddRoomsClick: function() { showGroupAddRoomDialog(this.props.groupId); }, - _setPublicity: function(publicity) { + _onPublicityToggle: function() { this.setState({ publicityBusy: true, }); + const publicity = !this.state.isGroupPublicised; this._groupStore.setGroupPublicity(publicity).then(() => { this.setState({ publicityBusy: false, @@ -737,100 +740,113 @@ export default React.createClass({ ; } - return
-
- { _t("%(inviter)s has invited you to join this community", {inviter: group.inviter.userId}) } -
-
- - { _t("Accept") } - - - { _t("Decline") } - -
-
; - } else if (group.myMembership === 'join') { - let youAreAMemberText = _t("You are a member of this community"); - if (this.state.summary.user && this.state.summary.user.is_privileged) { - youAreAMemberText = _t("You are an administrator of this community"); - } - - let publicisedButton; - if (this.state.publicityBusy) { - publicisedButton = ; - } - - let publicisedSection; - if (this.state.summary.user && this.state.summary.user.is_publicised) { - if (!this.state.publicityBusy) { - publicisedButton = - { _t("Unpublish") } - ; - } - publicisedSection =
- { _t("This community is published on your profile") } -
- { publicisedButton } -
-
; - } else { - if (!this.state.publicityBusy) { - publicisedButton = - { _t("Publish") } - ; - } - publicisedSection =
- { _t("This community is not published on your profile") } -
- { publicisedButton } -
-
; - } - - return
- { youAreAMemberText } + { _t("%(inviter)s has invited you to join this group", {inviter: group.inviter.userId}) }
+ { _t("Accept") } + + + { _t("Decline") } + +
+
+
; + } else if (group.myMembership === 'join' && this.state.editing) { + const leaveButtonTooltip = this.state.isUserPrivileged ? + _t("You are a member of this group") : + _t("You are an administrator of this group"); + const leaveButtonClasses = classnames({ + "mx_RoomHeader_textButton": true, + "mx_GroupView_textButton": true, + "mx_GroupView_leaveButton": true, + "mx_RoomHeader_textButton_danger": this.state.isUserPrivileged, + }); + return
+
+ { /* Empty div for flex alignment */ } +
+
+ { _t("Leave") }
- { publicisedSection }
; } - return null; }, + _getMemberSettingsSection: function() { + return
+

{ _t("Community Member Settings") }

+
+ + +
+
; + }, + + _getLongDescriptionNode: function() { + const summary = this.state.summary; + let description = null; + if (summary.profile && summary.profile.long_description) { + description = sanitizedHtmlNode(summary.profile.long_description); + } + return this.state.editing && this.state.isUserPrivileged ? +
+

{ _t("Long Description (HTML)") }

+