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/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/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/components/structures/TagPanel.js b/src/components/structures/TagPanel.js index 3d76a967a2..4bfebc59b8 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; @@ -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({ { - 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) { @@ -145,7 +154,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 f1dbb75988..c1554cd9ed 100644 --- a/src/components/views/groups/GroupTile.js +++ b/src/components/views/groups/GroupTile.js @@ -59,7 +59,7 @@ const GroupTile = React.createClass({ }); }, - onClick: function(e) { + onMouseDown: function(e) { e.preventDefault(); dis.dispatch({ action: 'view_group', @@ -79,7 +79,9 @@ const GroupTile = React.createClass({ const httpUrl = profile.avatarUrl ? this.context.matrixClient.mxcUrlToHttp( profile.avatarUrl, avatarHeight, avatarHeight, "crop", ) : null; - return + // 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) => (
@@ -97,13 +99,23 @@ const GroupTile = React.createClass({ {...provided.dragHandleProps} >
- +
{ /* Instead of a blank placeholder, use a copy of the avatar itself. */ } { provided.placeholder ?
- +
:
} diff --git a/src/components/views/rooms/RoomSettings.js b/src/components/views/rooms/RoomSettings.js index ea4a29615e..e2dad93698 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() { @@ -132,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 @@ -151,7 +151,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) => { @@ -272,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, "", )); @@ -384,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) { @@ -633,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(); @@ -668,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}) } +
  • ); }) } @@ -689,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') }

    @@ -711,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 = ( @@ -800,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 (
    @@ -899,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/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index f144ba0554..a1539cc665 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", @@ -438,6 +439,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", @@ -449,6 +457,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", @@ -460,14 +469,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", @@ -795,6 +796,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.", 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", diff --git a/src/shouldHideEvent.js b/src/shouldHideEvent.js index 1ecd1ac051..3aad05a976 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 isMembershipChanged = content.membership !== prevContent.membership; + diff.isJoin = isMembershipChanged && content.membership === 'join'; + diff.isPart = isMembershipChanged && content.membership === 'leave' && ev.getStateKey() === ev.getSender(); - const isJoinToJoin = content.membership === prevContent.membership && content.membership === 'join'; + const isJoinToJoin = !isMembershipChanged && content.membership === 'join'; diff.isDisplaynameChange = isJoinToJoin && content.displayname !== prevContent.displayname; diff.isAvatarChange = isJoinToJoin && content.avatar_url !== prevContent.avatar_url; return diff; diff --git a/src/stores/FlairStore.js b/src/stores/FlairStore.js index 7a3aa31e4e..4ef29ae4e1 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(matrixClient, 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) { diff --git a/test/components/views/rooms/RoomSettings-test.js b/test/components/views/rooms/RoomSettings-test.js new file mode 100644 index 0000000000..ffcecf1725 --- /dev/null +++ b/test/components/views/rooms/RoomSettings-test.js @@ -0,0 +1,190 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import expect, {createSpy} from 'expect'; +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;