From 773fef7f1d0261d472b21bba4fe60b8d1d736db6 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 22 Feb 2018 16:14:56 +0000 Subject: [PATCH 01/15] fix shouldHideEvent saying an event is a leave/join when a profile change Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/shouldHideEvent.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/shouldHideEvent.js b/src/shouldHideEvent.js index 1ecd1ac051..ae7be56c2e 100644 --- a/src/shouldHideEvent.js +++ b/src/shouldHideEvent.js @@ -27,10 +27,11 @@ function memberEventDiff(ev) { const content = ev.getContent(); const prevContent = ev.getPrevContent(); - diff.isJoin = content.membership === 'join' && prevContent.membership !== 'ban'; - diff.isPart = content.membership === 'leave' && ev.getStateKey() === ev.getSender(); - const isJoinToJoin = content.membership === prevContent.membership && content.membership === 'join'; + + diff.isJoin = !isJoinToJoin && content.membership === 'join' && prevContent.membership !== 'ban'; + diff.isPart = !isJoinToJoin && content.membership === 'leave' && ev.getStateKey() === ev.getSender(); + diff.isDisplaynameChange = isJoinToJoin && content.displayname !== prevContent.displayname; diff.isAvatarChange = isJoinToJoin && content.avatar_url !== prevContent.avatar_url; return diff; From 2b49947e1efca44a3106bd2afad551081adb974c Mon Sep 17 00:00:00 2001 From: Luke Barnard Date: Tue, 27 Feb 2018 09:20:06 +0000 Subject: [PATCH 02/15] Add "Did you know:..." microcopy to groups view To explain how to drag group avatars into the TagPanel --- src/components/structures/MyGroups.js | 15 +++++++++++++++ src/i18n/strings/en_EN.json | 2 ++ 2 files changed, 17 insertions(+) diff --git a/src/components/structures/MyGroups.js b/src/components/structures/MyGroups.js index 116607fb08..da7bebd16a 100644 --- a/src/components/structures/MyGroups.js +++ b/src/components/structures/MyGroups.js @@ -74,6 +74,21 @@ export default withMatrixClient(React.createClass({ contentHeader = groupNodes.length > 0 ?

{ _t('Your Communities') }

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

+ { _t( + "Did you know: you can use communities to filter your Riot.im experience!", + ) } +

+

+ { _t( + "To set up a filter, drag a community avatar over to the filter panel on " + + "the far left hand side of the screen. You can click on an avatar in the " + + "filter panel at any time to see only the rooms and people associated " + + "with that community.", + ) } +

+
{ groupNodes }
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index ef4bdf343e..5b90973a65 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -787,6 +787,8 @@ "Data from an older version of Riot has been detected. This will have caused end-to-end cryptography to malfunction in the older version. End-to-end encrypted messages exchanged recently whilst using the older version may not be decryptable in this version. This may also cause messages exchanged with this version to fail. If you experience problems, log out and back in again. To retain message history, export and re-import your keys.": "Data from an older version of Riot has been detected. This will have caused end-to-end cryptography to malfunction in the older version. End-to-end encrypted messages exchanged recently whilst using the older version may not be decryptable in this version. This may also cause messages exchanged with this version to fail. If you experience problems, log out and back in again. To retain message history, export and re-import your keys.", "Logout": "Logout", "Your Communities": "Your Communities", + "Did you know: you can use communities to filter your Riot.im experience!": "Did you know: you can use communities to filter your Riot.im experience!", + "To set up a filter, drag a community avatar over to the filter panel on the far left hand side of the screen. You can click on an avatar in the filter panel at any time to see only the rooms and people associated with that community.": "To set up a filter, drag a community avatar over to the filter panel on the far left hand side of the screen. You can click on an avatar in the filter panel at any time to see only the rooms and people associated with that community.", "Error whilst fetching joined communities": "Error whilst fetching joined communities", "Create a new community": "Create a new community", "Create a community to group together users and rooms! Build a custom homepage to mark out your space in the Matrix universe.": "Create a community to group together users and rooms! Build a custom homepage to mark out your space in the Matrix universe.", From bd4b40309dbb07814b1a505aecb1699559188fca Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 28 Feb 2018 00:49:14 +0000 Subject: [PATCH 03/15] fix based on PR review Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/shouldHideEvent.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/shouldHideEvent.js b/src/shouldHideEvent.js index ae7be56c2e..3aad05a976 100644 --- a/src/shouldHideEvent.js +++ b/src/shouldHideEvent.js @@ -27,11 +27,11 @@ function memberEventDiff(ev) { const content = ev.getContent(); const prevContent = ev.getPrevContent(); - const isJoinToJoin = content.membership === prevContent.membership && content.membership === 'join'; - - diff.isJoin = !isJoinToJoin && content.membership === 'join' && prevContent.membership !== 'ban'; - diff.isPart = !isJoinToJoin && content.membership === 'leave' && ev.getStateKey() === ev.getSender(); + const isMembershipChanged = content.membership !== prevContent.membership; + diff.isJoin = isMembershipChanged && content.membership === 'join'; + diff.isPart = isMembershipChanged && content.membership === 'leave' && ev.getStateKey() === ev.getSender(); + const isJoinToJoin = !isMembershipChanged && content.membership === 'join'; diff.isDisplaynameChange = isJoinToJoin && content.displayname !== prevContent.displayname; diff.isAvatarChange = isJoinToJoin && content.avatar_url !== prevContent.avatar_url; return diff; From d91d1932f48cc641bf9acc50875a616b1232fa4d Mon Sep 17 00:00:00 2001 From: Luke Barnard Date: Wed, 28 Feb 2018 14:52:53 +0000 Subject: [PATCH 04/15] Add tests for RoomSettings For setting: - name - topic - history visibility - power levels Testing RoomSettings required more stubbing on the matrix client. The power level tests should be failing at this commit, with fixes being made in upcoming commits. Some tests are marked as known failures that we should fix but aren't necessarily bugs: - SettingStore.setValue is used when saving despite the user not having made a change. - Testing directory publicity changes cannot be tested because we update state asynchronously in componentWillMount (which we do not block on in beforeEach). Also, we needed to use `export default` to make sure everything uses the same client peg and client. --- src/MatrixClientPeg.js | 2 +- .../views/rooms/RoomSettings-test.js | 192 ++++++++++++++++++ test/test-utils.js | 14 +- 3 files changed, 206 insertions(+), 2 deletions(-) create mode 100644 test/components/views/rooms/RoomSettings-test.js diff --git a/src/MatrixClientPeg.js b/src/MatrixClientPeg.js index 14dfa91fa4..99841c986e 100644 --- a/src/MatrixClientPeg.js +++ b/src/MatrixClientPeg.js @@ -175,4 +175,4 @@ class MatrixClientPeg { if (!global.mxMatrixClientPeg) { global.mxMatrixClientPeg = new MatrixClientPeg(); } -module.exports = global.mxMatrixClientPeg; +export default global.mxMatrixClientPeg; diff --git a/test/components/views/rooms/RoomSettings-test.js b/test/components/views/rooms/RoomSettings-test.js new file mode 100644 index 0000000000..12fb734bf4 --- /dev/null +++ b/test/components/views/rooms/RoomSettings-test.js @@ -0,0 +1,192 @@ +import React from 'react'; +import ReactTestUtils from 'react-addons-test-utils'; +import ReactDOM from 'react-dom'; +import expect, {createSpy} from 'expect'; +import sinon from 'sinon'; +import Promise from 'bluebird'; +import * as testUtils from '../../../test-utils'; +import sdk from 'matrix-react-sdk'; +const WrappedRoomSettings = testUtils.wrapInMatrixClientContext(sdk.getComponent('views.rooms.RoomSettings')); +import MatrixClientPeg from '../../../../src/MatrixClientPeg'; +import SettingsStore from '../../../../src/settings/SettingsStore'; + + +describe('RoomSettings', () => { + let parentDiv = null; + let sandbox = null; + let client = null; + let roomSettings = null; + const room = testUtils.mkStubRoom('!DdJkzRliezrwpNebLk:matrix.org'); + + function expectSentStateEvent(roomId, eventType, expectedEventContent) { + let found = false; + for (const call of client.sendStateEvent.calls) { + const [ + actualRoomId, + actualEventType, + actualEventContent, + ] = call.arguments.slice(0, 3); + + if (roomId === actualRoomId && actualEventType === eventType) { + expect(actualEventContent).toEqual(expectedEventContent); + found = true; + break; + } + } + expect(found).toBe(true); + } + + beforeEach(function(done) { + testUtils.beforeEach(this); + sandbox = testUtils.stubClient(); + client = MatrixClientPeg.get(); + client.credentials = {userId: '@me:domain.com'}; + + client.setRoomName = createSpy().andReturn(Promise.resolve()); + client.setRoomTopic = createSpy().andReturn(Promise.resolve()); + client.setRoomDirectoryVisibility = createSpy().andReturn(Promise.resolve()); + + // Covers any room state event (e.g. name, avatar, topic) + client.sendStateEvent = createSpy().andReturn(Promise.resolve()); + + // Covers room tagging + client.setRoomTag = createSpy().andReturn(Promise.resolve()); + client.deleteRoomTag = createSpy().andReturn(Promise.resolve()); + + // Covers any setting in the SettingsStore + // (including local client settings not stored via matrix) + SettingsStore.setValue = createSpy().andReturn(Promise.resolve()); + + parentDiv = document.createElement('div'); + document.body.appendChild(parentDiv); + + const gatherWrappedRef = (r) => {roomSettings = r;}; + + // get use wrappedRef because we're using wrapInMatrixClientContext + ReactDOM.render( + , + parentDiv, + done, + ); + }); + + afterEach((done) => { + if (parentDiv) { + ReactDOM.unmountComponentAtNode(parentDiv); + parentDiv.remove(); + parentDiv = null; + } + sandbox.restore(); + done(); + }); + + it('should not set when no setting is changed', (done) => { + roomSettings.save().then(() => { + expect(client.sendStateEvent).toNotHaveBeenCalled(); + expect(client.setRoomTag).toNotHaveBeenCalled(); + expect(client.deleteRoomTag).toNotHaveBeenCalled(); + done(); + }); + }); + + // XXX: Apparently we do call SettingsStore.setValue + xit('should not settings via the SettingsStore when no setting is changed', (done) => { + roomSettings.save().then(() => { + expect(SettingsStore.setValue).toNotHaveBeenCalled(); + done(); + }); + }); + + it('should set room name when it has changed', (done) => { + const name = "My Room Name"; + roomSettings.setName(name); + + roomSettings.save().then(() => { + expect(client.setRoomName.calls[0].arguments.slice(0, 2)) + .toEqual(['!DdJkzRliezrwpNebLk:matrix.org', name]); + + done(); + }); + }); + + it('should set room topic when it has changed', (done) => { + const topic = "this is a topic"; + roomSettings.setTopic(topic); + + roomSettings.save().then(() => { + expect(client.setRoomTopic.calls[0].arguments.slice(0, 2)) + .toEqual(['!DdJkzRliezrwpNebLk:matrix.org', topic]); + + done(); + }); + }); + + it('should set history visibility when it has changed', (done) => { + const historyVisibility = "translucent"; + roomSettings.setState({ + history_visibility: historyVisibility, + }); + + roomSettings.save().then(() => { + expectSentStateEvent( + "!DdJkzRliezrwpNebLk:matrix.org", + "m.room.history_visibility", {history_visibility: historyVisibility}, + ); + done(); + }); + }); + + // XXX: Can't test this because we `getRoomDirectoryVisibility` in `componentWillMount` + xit('should set room directory publicity when set to true', (done) => { + const isRoomPublished = true; + roomSettings.setState({ + isRoomPublished, + }, () => { + roomSettings.save().then(() => { + expect(client.setRoomDirectoryVisibility.calls[0].arguments.slice(0, 2)) + .toEqual("!DdJkzRliezrwpNebLk:matrix.org", isRoomPublished ? "public" : "private"); + done(); + }); + }); + }); + + it('should set power levels when changed', (done) => { + roomSettings.onPowerLevelsChanged(42, "invite"); + + roomSettings.save().then(() => { + expectSentStateEvent( + "!DdJkzRliezrwpNebLk:matrix.org", + "m.room.power_levels", { invite: 42 }, + ); + done(); + }); + }); + + it('should set event power levels when changed', (done) => { + roomSettings.onPowerLevelsChanged(42, "event_levels_m.room.message"); + + roomSettings.save().then(() => { + // We expect all state events to be set to the state_default (50) + // See powerLevelDescriptors in RoomSettings + expectSentStateEvent( + "!DdJkzRliezrwpNebLk:matrix.org", + "m.room.power_levels", { + events: { + 'm.room.message': 42, + 'm.room.avatar': 50, + 'm.room.name': 50, + 'm.room.canonical_alias': 50, + 'm.room.history_visibility': 50, + 'm.room.power_levels': 50, + 'm.room.topic': 50, + 'im.vector.modular.widgets': 50, + }, + }, + ); + done(); + }); + }); +}); diff --git a/test/test-utils.js b/test/test-utils.js index 5753c02665..b593761bd4 100644 --- a/test/test-utils.js +++ b/test/test-utils.js @@ -68,6 +68,8 @@ export function createTestClient() { return { getHomeserverUrl: sinon.stub(), getIdentityServerUrl: sinon.stub(), + getDomain: sinon.stub().returns("matrix.rog"), + getUserId: sinon.stub().returns("@userId:matrix.rog"), getPushActionsForEvent: sinon.stub(), getRoom: sinon.stub().returns(mkStubRoom()), @@ -81,6 +83,7 @@ export function createTestClient() { paginateEventTimeline: sinon.stub().returns(Promise.resolve()), sendReadReceipt: sinon.stub().returns(Promise.resolve()), getRoomIdForAlias: sinon.stub().returns(Promise.resolve()), + getRoomDirectoryVisibility: sinon.stub().returns(Promise.resolve()), getProfileInfo: sinon.stub().returns(Promise.resolve({})), getAccountData: (type) => { return mkEvent({ @@ -244,6 +247,7 @@ export function mkStubRoom(roomId = null) { roomId: roomId, getAvatarUrl: () => 'mxc://avatar.url/image.png', }), + getMembersWithMembership: sinon.stub().returns([]), getJoinedMembers: sinon.stub().returns([]), getPendingEvents: () => [], getLiveTimeline: () => stubTimeline, @@ -252,8 +256,16 @@ export function mkStubRoom(roomId = null) { hasMembershipState: () => null, currentState: { getStateEvents: sinon.stub(), + mayClientSendStateEvent: sinon.stub().returns(true), + maySendStateEvent: sinon.stub().returns(true), members: [], }, + tags: { + "m.favourite": { + order: 0.5, + }, + }, + setBlacklistUnverifiedDevices: sinon.stub(), }; } @@ -284,7 +296,7 @@ export function wrapInMatrixClientContext(WrappedComponent) { } render() { - return ; + return ; } } return Wrapper; From dd529791fbbf1e4fdcdf508e8ba79ede75db4234 Mon Sep 17 00:00:00 2001 From: Luke Barnard Date: Wed, 28 Feb 2018 16:13:01 +0000 Subject: [PATCH 05/15] Remove unused prop --- src/components/views/rooms/RoomSettings.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/views/rooms/RoomSettings.js b/src/components/views/rooms/RoomSettings.js index ea4a29615e..3a5075ba92 100644 --- a/src/components/views/rooms/RoomSettings.js +++ b/src/components/views/rooms/RoomSettings.js @@ -117,7 +117,6 @@ module.exports = React.createClass({ propTypes: { room: PropTypes.object.isRequired, - onSaveClick: PropTypes.func, }, getInitialState: function() { From 9a72e69a43b092ff4425934cfe10fb1e291500be Mon Sep 17 00:00:00 2001 From: Luke Barnard Date: Wed, 28 Feb 2018 16:14:18 +0000 Subject: [PATCH 06/15] Handle lack of room directory visibility response --- src/components/views/rooms/RoomSettings.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/views/rooms/RoomSettings.js b/src/components/views/rooms/RoomSettings.js index 3a5075ba92..2bd2fbb8f1 100644 --- a/src/components/views/rooms/RoomSettings.js +++ b/src/components/views/rooms/RoomSettings.js @@ -150,7 +150,7 @@ module.exports = React.createClass({ MatrixClientPeg.get().getRoomDirectoryVisibility( this.props.room.roomId, - ).done((result) => { + ).done((result = {}) => { this.setState({ isRoomPublished: result.visibility === "public" }); this._originalIsRoomPublished = result.visibility === "public"; }, (err) => { From 567d83ba52719ea0f05ed01d2dcd301c713fad6e Mon Sep 17 00:00:00 2001 From: Luke Barnard Date: Wed, 28 Feb 2018 16:15:20 +0000 Subject: [PATCH 07/15] Update PowerSelector to support powerLevelKey prop As a key to send as second argument to onChange. This is useful when passing the same callback to multiple PowerSelectors. --- src/components/views/elements/PowerSelector.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/components/views/elements/PowerSelector.js b/src/components/views/elements/PowerSelector.js index f8443c6ecd..d1f102d9fe 100644 --- a/src/components/views/elements/PowerSelector.js +++ b/src/components/views/elements/PowerSelector.js @@ -42,6 +42,9 @@ module.exports = React.createClass({ // should the user be able to change the value? false by default. disabled: PropTypes.bool, onChange: PropTypes.func, + + // Optional key to pass as the second argument to `onChange` + powerLevelKey: PropTypes.string, }, getInitialState: function() { @@ -84,17 +87,17 @@ module.exports = React.createClass({ onSelectChange: function(event) { this.setState({ custom: event.target.value === "SELECT_VALUE_CUSTOM" }); if (event.target.value !== "SELECT_VALUE_CUSTOM") { - this.props.onChange(event.target.value); + this.props.onChange(event.target.value, this.props.powerLevelKey); } }, onCustomBlur: function(event) { - this.props.onChange(parseInt(this.refs.custom.value)); + this.props.onChange(parseInt(this.refs.custom.value), this.props.powerLevelKey); }, onCustomKeyDown: function(event) { if (event.key == "Enter") { - this.props.onChange(parseInt(this.refs.custom.value)); + this.props.onChange(parseInt(this.refs.custom.value), this.props.powerLevelKey); } }, From d3dc2a33b48861139b3ecee0eb31537b6a58b4cf Mon Sep 17 00:00:00 2001 From: Luke Barnard Date: Wed, 28 Feb 2018 16:16:44 +0000 Subject: [PATCH 08/15] Fix bug preventing setting room power levels - don't use refs, use onChange of PowerSelector - store power levels as state in the RoomSetting component --- src/components/views/rooms/RoomSettings.js | 222 ++++++++++-------- .../views/rooms/RoomSettings-test.js | 2 - 2 files changed, 128 insertions(+), 96 deletions(-) diff --git a/src/components/views/rooms/RoomSettings.js b/src/components/views/rooms/RoomSettings.js index 2bd2fbb8f1..e2dad93698 100644 --- a/src/components/views/rooms/RoomSettings.js +++ b/src/components/views/rooms/RoomSettings.js @@ -131,7 +131,8 @@ module.exports = React.createClass({ join_rule: this._yankValueFromEvent("m.room.join_rules", "join_rule"), history_visibility: this._yankValueFromEvent("m.room.history_visibility", "history_visibility"), guest_access: this._yankValueFromEvent("m.room.guest_access", "guest_access"), - power_levels_changed: false, + powerLevels: this._yankContentFromEvent("m.room.power_levels", {}), + powerLevelsChanged: false, tags_changed: false, tags: tags, // isRoomPublished is loaded async in componentWillMount so when the component @@ -271,8 +272,8 @@ module.exports = React.createClass({ // power levels - const powerLevels = this._getPowerLevels(); - if (powerLevels) { + const powerLevels = this.state.powerLevels; + if (this.state.powerLevelsChanged) { promises.push(MatrixClientPeg.get().sendStateEvent( roomId, "m.room.power_levels", powerLevels, "", )); @@ -383,36 +384,32 @@ module.exports = React.createClass({ return strA !== strB; }, - _getPowerLevels: function() { - if (!this.state.power_levels_changed) return undefined; + onPowerLevelsChanged: function(value, powerLevelKey) { + const powerLevels = Object.assign({}, this.state.powerLevels); + const eventsLevelPrefix = "event_levels_"; - let powerLevels = this.props.room.currentState.getStateEvents('m.room.power_levels', ''); - powerLevels = powerLevels ? powerLevels.getContent() : {}; + value = parseInt(value); - for (const key of Object.keys(this.refs).filter((k) => k.startsWith("event_levels_"))) { - const eventType = key.substring("event_levels_".length); - powerLevels.events[eventType] = parseInt(this.refs[key].getValue()); + if (powerLevelKey.startsWith(eventsLevelPrefix)) { + // deep copy "events" object, Object.assign itself won't deep copy + powerLevels["events"] = Object.assign({}, this.state.powerLevels["events"] || {}); + powerLevels["events"][powerLevelKey.slice(eventsLevelPrefix.length)] = value; + } else { + powerLevels[powerLevelKey] = value; } - - const newPowerLevels = { - ban: parseInt(this.refs.ban.getValue()), - kick: parseInt(this.refs.kick.getValue()), - redact: parseInt(this.refs.redact.getValue()), - invite: parseInt(this.refs.invite.getValue()), - events_default: parseInt(this.refs.events_default.getValue()), - state_default: parseInt(this.refs.state_default.getValue()), - users_default: parseInt(this.refs.users_default.getValue()), - users: powerLevels.users, - events: powerLevels.events, - }; - - return newPowerLevels; + this.setState({ + powerLevels, + powerLevelsChanged: true, + }); }, - onPowerLevelsChanged: function() { - this.setState({ - power_levels_changed: true, - }); + _yankContentFromEvent: function(stateEventType, defaultValue) { + // E.g.("m.room.name") would yank the content of "m.room.name" + const event = this.props.room.currentState.getStateEvents(stateEventType, ''); + if (!event) { + return defaultValue; + } + return event.getContent() || defaultValue; }, _yankValueFromEvent: function(stateEventType, keyName, defaultValue) { @@ -632,29 +629,61 @@ module.exports = React.createClass({ const cli = MatrixClientPeg.get(); const roomState = this.props.room.currentState; - const user_id = cli.credentials.userId; + const myUserId = cli.credentials.userId; - const power_level_event = roomState.getStateEvents('m.room.power_levels', ''); - const power_levels = power_level_event ? power_level_event.getContent() : {}; - const events_levels = power_levels.events || {}; - const user_levels = power_levels.users || {}; + const powerLevels = this.state.powerLevels; + const eventsLevels = powerLevels.events || {}; + const userLevels = powerLevels.users || {}; - const ban_level = parseIntWithDefault(power_levels.ban, 50); - const kick_level = parseIntWithDefault(power_levels.kick, 50); - const redact_level = parseIntWithDefault(power_levels.redact, 50); - const invite_level = parseIntWithDefault(power_levels.invite, 50); - const send_level = parseIntWithDefault(power_levels.events_default, 0); - const state_level = power_level_event ? parseIntWithDefault(power_levels.state_default, 50) : 0; - const default_user_level = parseIntWithDefault(power_levels.users_default, 0); + const powerLevelDescriptors = { + users_default: { + desc: _t('The default role for new room members is'), + defaultValue: 0, + }, + events_default: { + desc: _t('To send messages, you must be a'), + defaultValue: 0, + }, + invite: { + desc: _t('To invite users into the room, you must be a'), + defaultValue: 50, + }, + state_default: { + desc: _t('To configure the room, you must be a'), + defaultValue: 50, + }, + kick: { + desc: _t('To kick users, you must be a'), + defaultValue: 50, + }, + ban: { + desc: _t('To ban users, you must be a'), + defaultValue: 50, + }, + redact: { + desc: _t('To remove other users\' messages, you must be a'), + defaultValue: 50, + }, + }; - this._populateDefaultPlEvents(events_levels, state_level, send_level); + const banLevel = parseIntWithDefault(powerLevels.ban, powerLevelDescriptors.ban.defaultValue); + const defaultUserLevel = parseIntWithDefault( + powerLevels.users_default, + powerLevelDescriptors.users_default.defaultValue, + ); - let current_user_level = user_levels[user_id]; - if (current_user_level === undefined) { - current_user_level = default_user_level; + this._populateDefaultPlEvents( + eventsLevels, + parseIntWithDefault(powerLevels.state_default, powerLevelDescriptors.state_default.defaultValue), + parseIntWithDefault(powerLevels.events_default, powerLevelDescriptors.events_default.defaultValue), + ); + + let currentUserLevel = userLevels[myUserId]; + if (currentUserLevel === undefined) { + currentUserLevel = defaultUserLevel; } - const can_change_levels = roomState.mayClientSendStateEvent("m.room.power_levels", cli); + const canChangeLevels = roomState.mayClientSendStateEvent("m.room.power_levels", cli); const canSetTag = !cli.isGuest(); @@ -667,15 +696,16 @@ module.exports = React.createClass({ />; let userLevelsSection; - if (Object.keys(user_levels).length) { + if (Object.keys(userLevels).length) { userLevelsSection =

{ _t('Privileged Users') }

    - { Object.keys(user_levels).map(function(user, i) { + { Object.keys(userLevels).map(function(user, i) { return (
  • - { _t("%(user)s is a", {user: user}) } + { _t("%(user)s is a", {user: user}) } +
  • ); }) } @@ -688,7 +718,7 @@ module.exports = React.createClass({ const banned = this.props.room.getMembersWithMembership("ban"); let bannedUsersSection; if (banned.length) { - const canBanUsers = current_user_level >= ban_level; + const canBanUsers = currentUserLevel >= banLevel; bannedUsersSection =

    { _t('Banned users') }

    @@ -710,13 +740,13 @@ module.exports = React.createClass({ if (this._yankValueFromEvent("m.room.create", "m.federate", true) === false) { unfederatableSection = (
    - { _t('This room is not accessible by remote Matrix servers') }. + { _t('This room is not accessible by remote Matrix servers') }.
    ); } let leaveButton = null; - const myMember = this.props.room.getMember(user_id); + const myMember = this.props.room.getMember(myUserId); if (myMember) { if (myMember.membership === "join") { leaveButton = ( @@ -799,6 +829,50 @@ module.exports = React.createClass({
    ; } + const powerSelectors = Object.keys(powerLevelDescriptors).map((key, index) => { + const descriptor = powerLevelDescriptors[key]; + + const value = parseIntWithDefault(powerLevels[key], descriptor.defaultValue); + return
    + + { descriptor.desc } + + +
    ; + }); + + const eventPowerSelectors = Object.keys(eventsLevels).map(function(eventType, i) { + let label = plEventsToLabels[eventType]; + if (label) { + label = _t(label); + } else { + label = _t( + "To send events of type , you must be a", {}, + { 'eventType': { eventType } }, + ); + } + return ( +
    + { label } + +
    + ); + }); + return (
    @@ -898,49 +972,9 @@ module.exports = React.createClass({

    { _t('Permissions') }

    -
    - { _t('The default role for new room members is') } - -
    -
    - { _t('To send messages, you must be a') } - -
    -
    - { _t('To invite users into the room, you must be a') } - -
    -
    - { _t('To configure the room, you must be a') } - -
    -
    - { _t('To kick users, you must be a') } - -
    -
    - { _t('To ban users, you must be a') } - -
    -
    - { _t('To remove other users\' messages, you must be a') } - -
    - - { Object.keys(events_levels).map(function(event_type, i) { - let label = plEventsToLabels[event_type]; - if (label) label = _t(label); - else label = _t("To send events of type , you must be a", {}, { 'eventType': { event_type } }); - return ( -
    - { label } - -
    - ); - }) } - - { unfederatableSection } + { powerSelectors } + { eventPowerSelectors } + { unfederatableSection }
    { userLevelsSection } diff --git a/test/components/views/rooms/RoomSettings-test.js b/test/components/views/rooms/RoomSettings-test.js index 12fb734bf4..ffcecf1725 100644 --- a/test/components/views/rooms/RoomSettings-test.js +++ b/test/components/views/rooms/RoomSettings-test.js @@ -1,8 +1,6 @@ import React from 'react'; -import ReactTestUtils from 'react-addons-test-utils'; import ReactDOM from 'react-dom'; import expect, {createSpy} from 'expect'; -import sinon from 'sinon'; import Promise from 'bluebird'; import * as testUtils from '../../../test-utils'; import sdk from 'matrix-react-sdk'; From 27c71f15b9a00fd61200070842870510bc9af557 Mon Sep 17 00:00:00 2001 From: Luke Barnard Date: Wed, 28 Feb 2018 18:16:01 +0000 Subject: [PATCH 09/15] Add setting to disable TagPanel --- src/components/structures/UserSettings.js | 1 + src/settings/Settings.js | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/src/components/structures/UserSettings.js b/src/components/structures/UserSettings.js index b1eedd1a90..51b6ff5bc1 100644 --- a/src/components/structures/UserSettings.js +++ b/src/components/structures/UserSettings.js @@ -79,6 +79,7 @@ const SIMPLE_SETTINGS = [ { id: "Pill.shouldHidePillAvatar" }, { id: "TextualBody.disableBigEmoji" }, { id: "VideoView.flipVideoHorizontally" }, + { id: "TagPanel.disableTagPanel" }, ]; // These settings must be defined in SettingsStore diff --git a/src/settings/Settings.js b/src/settings/Settings.js index 519d2dd39a..cd37c00fa8 100644 --- a/src/settings/Settings.js +++ b/src/settings/Settings.js @@ -188,6 +188,11 @@ export const SETTINGS = { displayName: _td('Mirror local video feed'), default: false, }, + "TagPanel.disableTagPanel": { + supportedLevels: LEVELS_ACCOUNT_SETTINGS, + displayName: _td('Disable Community Filter Panel'), + default: false, + }, "theme": { supportedLevels: LEVELS_ACCOUNT_SETTINGS, default: "light", From f29918b766c345ec1c146ca67b56223ed522bec2 Mon Sep 17 00:00:00 2001 From: Luke Barnard Date: Wed, 28 Feb 2018 18:18:59 +0000 Subject: [PATCH 10/15] gen-i18n --- src/i18n/strings/en_EN.json | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 5b90973a65..6a8ec64ffa 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -203,6 +203,7 @@ "Don't send typing notifications": "Don't send typing notifications", "Automatically replace plain text Emoji": "Automatically replace plain text Emoji", "Mirror local video feed": "Mirror local video feed", + "Disable Community Filter Panel": "Disable Community Filter Panel", "Disable Peer-to-Peer for 1:1 calls": "Disable Peer-to-Peer for 1:1 calls", "Opt out of analytics": "Opt out of analytics", "Never send encrypted messages to unverified devices from this device": "Never send encrypted messages to unverified devices from this device", @@ -431,6 +432,13 @@ "(warning: cannot be disabled again!)": "(warning: cannot be disabled again!)", "Encryption is enabled in this room": "Encryption is enabled in this room", "Encryption is not enabled in this room": "Encryption is not enabled in this room", + "The default role for new room members is": "The default role for new room members is", + "To send messages, you must be a": "To send messages, you must be a", + "To invite users into the room, you must be a": "To invite users into the room, you must be a", + "To configure the room, you must be a": "To configure the room, you must be a", + "To kick users, you must be a": "To kick users, you must be a", + "To ban users, you must be a": "To ban users, you must be a", + "To remove other users' messages, you must be a": "To remove other users' messages, you must be a", "Privileged Users": "Privileged Users", "%(user)s is a": "%(user)s is a", "No users have specific privileges in this room": "No users have specific privileges in this room", @@ -442,6 +450,7 @@ "To link to a room it must have an address.": "To link to a room it must have an address.", "Guests cannot join this room even if explicitly invited.": "Guests cannot join this room even if explicitly invited.", "Click here to fix": "Click here to fix", + "To send events of type , you must be a": "To send events of type , you must be a", "Who can access this room?": "Who can access this room?", "Only people who have been invited": "Only people who have been invited", "Anyone who knows the room's link, apart from guests": "Anyone who knows the room's link, apart from guests", @@ -453,14 +462,6 @@ "Members only (since they were invited)": "Members only (since they were invited)", "Members only (since they joined)": "Members only (since they joined)", "Permissions": "Permissions", - "The default role for new room members is": "The default role for new room members is", - "To send messages, you must be a": "To send messages, you must be a", - "To invite users into the room, you must be a": "To invite users into the room, you must be a", - "To configure the room, you must be a": "To configure the room, you must be a", - "To kick users, you must be a": "To kick users, you must be a", - "To ban users, you must be a": "To ban users, you must be a", - "To remove other users' messages, you must be a": "To remove other users' messages, you must be a", - "To send events of type , you must be a": "To send events of type , you must be a", "Advanced": "Advanced", "This room's internal ID is": "This room's internal ID is", "Add a topic": "Add a topic", From 64332b302178abce5163c36205046f8e62047127 Mon Sep 17 00:00:00 2001 From: Luke Barnard Date: Wed, 28 Feb 2018 18:31:45 +0000 Subject: [PATCH 11/15] Workaround for atlassian/react-beautiful-dnd#273 Fix two bugs in Riot due to a single bug in react-b-dnd Fixes vector-im/riot-web#6253 Fixes vector-im/riot-web#6156 --- src/components/structures/TagPanel.js | 6 ++++-- src/components/views/groups/GroupTile.js | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/components/structures/TagPanel.js b/src/components/structures/TagPanel.js index 3d76a967a2..8db2a12aff 100644 --- a/src/components/structures/TagPanel.js +++ b/src/components/structures/TagPanel.js @@ -84,7 +84,7 @@ const TagPanel = React.createClass({ } }, - onClick(e) { + onMouseDown(e) { dis.dispatch({action: 'deselect_tags'}); }, @@ -128,7 +128,9 @@ const TagPanel = React.createClass({ + // XXX: Use onMouseDown as a workaround for https://github.com/atlassian/react-beautiful-dnd/issues/273 + // instead of onClick. Otherwise we experience https://github.com/vector-im/riot-web/issues/6156 + return { (droppableProvided, droppableSnapshot) => (
    From 4037a22492dcde5383ef6d3570b1ed8819e27abf Mon Sep 17 00:00:00 2001 From: Luke Barnard Date: Thu, 1 Mar 2018 14:40:03 +0000 Subject: [PATCH 12/15] Fix bug where avatar change not reflected in LLP Fixes vector-im/riot-web#6189 --- src/components/structures/GroupView.js | 15 +++++++++++++ src/components/views/elements/TagTile.js | 27 ++++++++++++++++-------- src/stores/FlairStore.js | 17 +++++++++++++-- 3 files changed, 48 insertions(+), 11 deletions(-) diff --git a/src/components/structures/GroupView.js b/src/components/structures/GroupView.js index de96935838..4a28faaac4 100644 --- a/src/components/structures/GroupView.js +++ b/src/components/structures/GroupView.js @@ -29,6 +29,7 @@ import classnames from 'classnames'; import GroupStoreCache from '../../stores/GroupStoreCache'; import GroupStore from '../../stores/GroupStore'; +import FlairStore from '../../stores/FlairStore'; import { showGroupAddRoomDialog } from '../../GroupAddressPicker'; import GeminiScrollbar from 'react-gemini-scrollbar'; import {makeGroupPermalink, makeUserPermalink} from "../../matrix-to"; @@ -429,6 +430,7 @@ export default React.createClass({ editing: false, saving: false, uploadingAvatar: false, + avatarChanged: false, membershipBusy: false, publicityBusy: false, inviterProfile: null, @@ -590,6 +592,10 @@ export default React.createClass({ this.setState({ uploadingAvatar: false, profileForm: newProfileForm, + + // Indicate that FlairStore needs to be poked to show this change + // in TagTile (TagPanel), Flair and GroupTile (MyGroups). + avatarChanged: true, }); }).catch((e) => { this.setState({uploadingAvatar: false}); @@ -615,6 +621,11 @@ export default React.createClass({ }); dis.dispatch({action: 'panel_disable'}); this._initGroupStore(this.props.groupId); + + if (this.state.avatarChanged) { + // XXX: Evil - poking a store should be done from an async action + FlairStore.refreshGroupProfile(this._matrixClient, this.props.groupId); + } }).catch((e) => { this.setState({ saving: false, @@ -625,6 +636,10 @@ export default React.createClass({ title: _t('Error'), description: _t('Failed to update community'), }); + }).finally(() => { + this.setState({ + avatarChanged: false, + }); }).done(); }, diff --git a/src/components/views/elements/TagTile.js b/src/components/views/elements/TagTile.js index 8d801d986d..0a328bed57 100644 --- a/src/components/views/elements/TagTile.js +++ b/src/components/views/elements/TagTile.js @@ -55,20 +55,29 @@ export default React.createClass({ componentWillMount() { this.unmounted = false; if (this.props.tag[0] === '+') { - FlairStore.getGroupProfileCached( - this.context.matrixClient, - this.props.tag, - ).then((profile) => { - if (this.unmounted) return; - this.setState({profile}); - }).catch((err) => { - console.warn('Could not fetch group profile for ' + this.props.tag, err); - }); + FlairStore.addListener('updateGroupProfile', this._onFlairStoreUpdated); + this._onFlairStoreUpdated(); } }, componentWillUnmount() { this.unmounted = true; + if (this.props.tag[0] === '+') { + FlairStore.removeListener('updateGroupProfile', this._onFlairStoreUpdated); + } + }, + + _onFlairStoreUpdated() { + if (this.unmounted) return; + FlairStore.getGroupProfileCached( + this.context.matrixClient, + this.props.tag, + ).then((profile) => { + if (this.unmounted) return; + this.setState({profile}); + }).catch((err) => { + console.warn('Could not fetch group profile for ' + this.props.tag, err); + }); }, onClick: function(e) { diff --git a/src/stores/FlairStore.js b/src/stores/FlairStore.js index 7a3aa31e4e..5d3a43b222 100644 --- a/src/stores/FlairStore.js +++ b/src/stores/FlairStore.js @@ -14,6 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ +import EventEmitter from 'events'; import Promise from 'bluebird'; const BULK_REQUEST_DEBOUNCE_MS = 200; @@ -28,8 +29,9 @@ const GROUP_PROFILES_CACHE_BUST_MS = 1800000; // 30 mins /** * Stores data used by */ -class FlairStore { +class FlairStore extends EventEmitter { constructor(matrixClient) { + super(); this._matrixClient = matrixClient; this._userGroups = { // $userId: ['+group1:domain', '+group2:domain', ...] @@ -175,12 +177,23 @@ class FlairStore { }; delete this._groupProfilesPromise[groupId]; + /// XXX: This is verging on recreating a third "Flux"-looking Store. We really + /// should replace FlairStore with a Flux store and some async actions. + this.emit('updateGroupProfile'); + setTimeout(() => { - delete this._groupProfiles[groupId]; + this.refreshGroupProfile(groupId); }, GROUP_PROFILES_CACHE_BUST_MS); return this._groupProfiles[groupId]; } + + refreshGroupProfile(matrixClient, groupId) { + // Invalidate the cache + delete this._groupProfiles[groupId]; + // Fetch new profile data, and cache it + return this.getGroupProfileCached(matrixClient, groupId); + } } if (global.singletonFlairStore === undefined) { From 61a6f140f593dfee51d3c283e7bab6d5cb9ad83d Mon Sep 17 00:00:00 2001 From: Luke Barnard Date: Thu, 1 Mar 2018 14:43:26 +0000 Subject: [PATCH 13/15] onClientSync -> _onClientSync --- src/components/structures/TagPanel.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/structures/TagPanel.js b/src/components/structures/TagPanel.js index 3d76a967a2..74089d2161 100644 --- a/src/components/structures/TagPanel.js +++ b/src/components/structures/TagPanel.js @@ -45,7 +45,7 @@ const TagPanel = React.createClass({ componentWillMount: function() { this.unmounted = false; this.context.matrixClient.on("Group.myMembership", this._onGroupMyMembership); - this.context.matrixClient.on("sync", this.onClientSync); + this.context.matrixClient.on("sync", this._onClientSync); this._tagOrderStoreToken = TagOrderStore.addListener(() => { if (this.unmounted) { @@ -63,7 +63,7 @@ const TagPanel = React.createClass({ componentWillUnmount() { this.unmounted = true; this.context.matrixClient.removeListener("Group.myMembership", this._onGroupMyMembership); - this.context.matrixClient.removeListener("sync", this.onClientSync); + this.context.matrixClient.removeListener("sync", this._onClientSync); if (this._filterStoreToken) { this._filterStoreToken.remove(); } @@ -74,7 +74,7 @@ const TagPanel = React.createClass({ dis.dispatch(GroupActions.fetchJoinedGroups(this.context.matrixClient)); }, - onClientSync(syncState, prevState) { + _onClientSync(syncState, prevState) { // Consider the client reconnected if there is no error with syncing. // This means the state could be RECONNECTING, SYNCING or PREPARED. const reconnected = syncState !== "ERROR" && prevState !== syncState; From d5908fbc1ef8b5535cf9921e334beb4cfc11bc20 Mon Sep 17 00:00:00 2001 From: Luke Barnard Date: Thu, 1 Mar 2018 15:06:05 +0000 Subject: [PATCH 14/15] Fix FlairStore cache busting --- src/stores/FlairStore.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/stores/FlairStore.js b/src/stores/FlairStore.js index 5d3a43b222..4ef29ae4e1 100644 --- a/src/stores/FlairStore.js +++ b/src/stores/FlairStore.js @@ -182,7 +182,7 @@ class FlairStore extends EventEmitter { this.emit('updateGroupProfile'); setTimeout(() => { - this.refreshGroupProfile(groupId); + this.refreshGroupProfile(matrixClient, groupId); }, GROUP_PROFILES_CACHE_BUST_MS); return this._groupProfiles[groupId]; From 94c1baa25d78afa1f6a58d4f40be2660f3587b14 Mon Sep 17 00:00:00 2001 From: Luke Barnard Date: Thu, 1 Mar 2018 15:42:22 +0000 Subject: [PATCH 15/15] Fix varying default group avatar colour for given group --- src/components/views/elements/TagTile.js | 8 +++++++- src/components/views/groups/GroupTile.js | 14 ++++++++++++-- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/components/views/elements/TagTile.js b/src/components/views/elements/TagTile.js index 8d801d986d..b69571f23b 100644 --- a/src/components/views/elements/TagTile.js +++ b/src/components/views/elements/TagTile.js @@ -145,7 +145,13 @@ export default React.createClass({
    :
    ; return
    - + { tip } { contextButton }
    diff --git a/src/components/views/groups/GroupTile.js b/src/components/views/groups/GroupTile.js index 8794fe4f36..c1554cd9ed 100644 --- a/src/components/views/groups/GroupTile.js +++ b/src/components/views/groups/GroupTile.js @@ -99,13 +99,23 @@ const GroupTile = React.createClass({ {...provided.dragHandleProps} >
    - +
    { /* Instead of a blank placeholder, use a copy of the avatar itself. */ } { provided.placeholder ?
    - +
    :
    }