diff --git a/src/SlashCommands.js b/src/SlashCommands.js index e5378d4347..5939ceb98e 100644 --- a/src/SlashCommands.js +++ b/src/SlashCommands.js @@ -240,6 +240,59 @@ const commands = { return reject(this.getUsage()); }), + ignore: new Command("ignore", "", function(roomId, args) { + if (args) { + const matches = args.match(/^(\S+)$/); + if (matches) { + const userId = matches[1]; + const ignoredUsers = MatrixClientPeg.get().getIgnoredUsers(); + ignoredUsers.push(userId); // de-duped internally in the js-sdk + return success( + MatrixClientPeg.get().setIgnoredUsers(ignoredUsers).then(() => { + const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); + Modal.createTrackedDialog('Slash Commands', 'User ignored', QuestionDialog, { + title: _t("Ignored user"), + description: ( +
+

{_t("You are now ignoring %(userId)s", {userId: userId})}

+
+ ), + hasCancelButton: false, + }); + }), + ); + } + } + return reject(this.getUsage()); + }), + + unignore: new Command("unignore", "", function(roomId, args) { + if (args) { + const matches = args.match(/^(\S+)$/); + if (matches) { + const userId = matches[1]; + const ignoredUsers = MatrixClientPeg.get().getIgnoredUsers(); + const index = ignoredUsers.indexOf(userId); + if (index !== -1) ignoredUsers.splice(index, 1); + return success( + MatrixClientPeg.get().setIgnoredUsers(ignoredUsers).then(() => { + const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); + Modal.createTrackedDialog('Slash Commands', 'User unignored', QuestionDialog, { + title: _t("Unignored user"), + description: ( +
+

{_t("You are no longer ignoring %(userId)s", {userId: userId})}

+
+ ), + hasCancelButton: false, + }); + }), + ); + } + } + return reject(this.getUsage()); + }), + // Define the power level of a user op: new Command("op", " []", function(roomId, args) { if (args) { diff --git a/src/WhoIsTyping.js b/src/WhoIsTyping.js index f3d89f0ff2..2a12703a27 100644 --- a/src/WhoIsTyping.js +++ b/src/WhoIsTyping.js @@ -18,6 +18,12 @@ var MatrixClientPeg = require("./MatrixClientPeg"); import { _t } from './languageHandler'; module.exports = { + usersTypingApartFromMeAndIgnored: function(room) { + return this.usersTyping( + room, [MatrixClientPeg.get().credentials.userId].concat(MatrixClientPeg.get().getIgnoredUsers()) + ); + }, + usersTypingApartFromMe: function(room) { return this.usersTyping( room, [MatrixClientPeg.get().credentials.userId] diff --git a/src/autocomplete/CommandProvider.js b/src/autocomplete/CommandProvider.js index 6f2f68b121..011ad0a7dc 100644 --- a/src/autocomplete/CommandProvider.js +++ b/src/autocomplete/CommandProvider.js @@ -94,6 +94,16 @@ const COMMANDS = [ args: ' ', description: 'Verifies a user, device, and pubkey tuple', }, + { + command: '/ignore', + args: '', + description: 'Ignores a user, hiding their messages from you', + }, + { + command: '/unignore', + args: '', + description: 'Stops ignoring a user, showing their messages going forward', + }, // Omitting `/markdown` as it only seems to apply to OldComposer ]; diff --git a/src/components/structures/LoggedInView.js b/src/components/structures/LoggedInView.js index 147707b6fc..6adea56a23 100644 --- a/src/components/structures/LoggedInView.js +++ b/src/components/structures/LoggedInView.js @@ -131,6 +131,9 @@ export default React.createClass({ useCompactLayout: event.getContent().useCompactLayout, }); } + if (event.getType() === "m.ignored_user_list") { + dis.dispatch({action: "ignore_state_changed"}); + } }, _onKeyDown: function(ev) { diff --git a/src/components/structures/MessagePanel.js b/src/components/structures/MessagePanel.js index e5884973c6..ff0be6d7fe 100644 --- a/src/components/structures/MessagePanel.js +++ b/src/components/structures/MessagePanel.js @@ -241,6 +241,10 @@ module.exports = React.createClass({ // TODO: Implement granular (per-room) hide options _shouldShowEvent: function(mxEv) { + if (mxEv.sender && MatrixClientPeg.get().isUserIgnored(mxEv.sender.userId)) { + return false; // ignored = no show (only happens if the ignore happens after an event was received) + } + const EventTile = sdk.getComponent('rooms.EventTile'); if (!EventTile.haveTileForEvent(mxEv)) { return false; // no tile = no show @@ -549,6 +553,9 @@ module.exports = React.createClass({ if (!r.userId || r.type !== "m.read" || r.userId === myUserId) { return; // ignore non-read receipts and receipts from self. } + if (MatrixClientPeg.get().isUserIgnored(r.userId)) { + return; // ignore ignored users + } let member = room.getMember(r.userId); if (!member) { return; // ignore unknown user IDs diff --git a/src/components/structures/RoomStatusBar.js b/src/components/structures/RoomStatusBar.js index 2a81605a78..68b7249d3b 100644 --- a/src/components/structures/RoomStatusBar.js +++ b/src/components/structures/RoomStatusBar.js @@ -121,7 +121,7 @@ module.exports = React.createClass({ onRoomMemberTyping: function(ev, member) { this.setState({ - usersTyping: WhoIsTyping.usersTypingApartFromMe(this.props.room), + usersTyping: WhoIsTyping.usersTypingApartFromMeAndIgnored(this.props.room), }); }, diff --git a/src/components/structures/TimelinePanel.js b/src/components/structures/TimelinePanel.js index 862c3f46d0..c4723f515d 100644 --- a/src/components/structures/TimelinePanel.js +++ b/src/components/structures/TimelinePanel.js @@ -384,6 +384,9 @@ var TimelinePanel = React.createClass({ this.sendReadReceipt(); this.updateReadMarker(); break; + case 'ignore_state_changed': + this.forceUpdate(); + break; } }, diff --git a/src/components/structures/UserSettings.js b/src/components/structures/UserSettings.js index 9293bf24ae..d2f27b63e1 100644 --- a/src/components/structures/UserSettings.js +++ b/src/components/structures/UserSettings.js @@ -176,6 +176,34 @@ const THEMES = [ }, ]; +const IgnoredUser = React.createClass({ + propTypes: { + userId: React.PropTypes.string.isRequired, + onUnignored: React.PropTypes.func.isRequired, + }, + + _onUnignoreClick: function() { + const ignoredUsers = MatrixClientPeg.get().getIgnoredUsers(); + const index = ignoredUsers.indexOf(this.props.userId); + if (index !== -1) { + ignoredUsers.splice(index, 1); + MatrixClientPeg.get().setIgnoredUsers(ignoredUsers) + .then(() => this.props.onUnignored(this.props.userId)); + } else this.props.onUnignored(this.props.userId); + }, + + render: function() { + return ( +
  • + + { _t("Unignore") } + + { this.props.userId } +
  • + ); + }, +}); + module.exports = React.createClass({ displayName: 'UserSettings', @@ -211,6 +239,7 @@ module.exports = React.createClass({ vectorVersion: undefined, rejectingInvites: false, mediaDevices: null, + ignoredUsers: [], }; }, @@ -232,6 +261,7 @@ module.exports = React.createClass({ } this._refreshMediaDevices(); + this._refreshIgnoredUsers(); // Bulk rejecting invites: // /sync won't have had time to return when UserSettings re-renders from state changes, so getRooms() @@ -350,9 +380,22 @@ module.exports = React.createClass({ }); }, + _refreshIgnoredUsers: function(userIdUnignored=null) { + const users = MatrixClientPeg.get().getIgnoredUsers(); + if (userIdUnignored) { + const index = users.indexOf(userIdUnignored); + if (index !== -1) users.splice(index, 1); + } + this.setState({ + ignoredUsers: users, + }); + }, + onAction: function(payload) { if (payload.action === "notifier_enabled") { this.forceUpdate(); + } else if (payload.action === "ignore_state_changed") { + this._refreshIgnoredUsers(); } }, @@ -800,6 +843,26 @@ module.exports = React.createClass({ ); }, + _renderIgnoredUsers: function() { + if (this.state.ignoredUsers.length > 0) { + const updateHandler = this._refreshIgnoredUsers; + return ( +
    +

    { _t("Ignored Users") }

    +
    +
      + {this.state.ignoredUsers.map(function(userId) { + return (); + })} +
    +
    +
    + ); + } else return (
    ); + }, + _renderLocalSetting: function(setting) { // TODO: this ought to be a separate component so that we don't need // to rebind the onChange each time we render @@ -1306,6 +1369,7 @@ module.exports = React.createClass({ {this._renderWebRtcSettings()} {this._renderDevicesPanel()} {this._renderCryptoInfo()} + {this._renderIgnoredUsers()} {this._renderBulkOptions()} {this._renderBugReport()} diff --git a/src/components/views/rooms/MemberInfo.js b/src/components/views/rooms/MemberInfo.js index 64eeddb406..e21fd22e64 100644 --- a/src/components/views/rooms/MemberInfo.js +++ b/src/components/views/rooms/MemberInfo.js @@ -62,6 +62,7 @@ module.exports = withMatrixClient(React.createClass({ updating: 0, devicesLoading: true, devices: null, + isIgnoring: false, }; }, @@ -81,6 +82,8 @@ module.exports = withMatrixClient(React.createClass({ cli.on("RoomState.events", this.onRoomStateEvents); cli.on("RoomMember.name", this.onRoomMemberName); cli.on("accountData", this.onAccountData); + + this._checkIgnoreState(); }, componentDidMount: function() { @@ -111,6 +114,11 @@ module.exports = withMatrixClient(React.createClass({ } }, + _checkIgnoreState: function() { + const isIgnoring = this.props.matrixClient.isUserIgnored(this.props.member.userId); + this.setState({isIgnoring: isIgnoring}); + }, + _disambiguateDevices: function(devices) { var names = Object.create(null); for (var i = 0; i < devices.length; i++) { @@ -225,6 +233,18 @@ module.exports = withMatrixClient(React.createClass({ }); }, + onIgnoreToggle: function() { + const ignoredUsers = this.props.matrixClient.getIgnoredUsers(); + if (this.state.isIgnoring) { + const index = ignoredUsers.indexOf(this.props.member.userId); + if (index !== -1) ignoredUsers.splice(index, 1); + } else { + ignoredUsers.push(this.props.member.userId); + } + + this.props.matrixClient.setIgnoredUsers(ignoredUsers).then(() => this.setState({isIgnoring: !this.state.isIgnoring})); + }, + onKick: function() { const membership = this.props.member.membership; const kickLabel = membership === "invite" ? _t("Disinvite") : _t("Kick"); @@ -607,6 +627,29 @@ module.exports = withMatrixClient(React.createClass({ ); }, + _renderUserOptions: function() { + // Only allow the user to ignore the user if its not ourselves + let ignoreButton = null; + if (this.props.member.userId !== this.props.matrixClient.getUserId()) { + ignoreButton = ( + + {this.state.isIgnoring ? _t("Unignore") : _t("Ignore")} + + ); + } + + if (!ignoreButton) return null; + + return ( +
    +

    { _t("User Options") }

    +
    + {ignoreButton} +
    +
    + ); + }, + render: function() { var startChat, kickButton, banButton, muteButton, giveModButton, spinner; if (this.props.member.userId !== this.props.matrixClient.credentials.userId) { @@ -756,6 +799,8 @@ module.exports = withMatrixClient(React.createClass({
    + { this._renderUserOptions() } + { adminTools } { startChat } diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 6d2ba1b1a2..54af3ab11f 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -260,6 +260,16 @@ "Kick": "Kick", "Kicks user with given id": "Kicks user with given id", "Labs": "Labs", + "Ignored Users": "Ignored Users", + "Ignore": "Ignore", + "Unignore": "Unignore", + "User Options": "User Options", + "You are now ignoring %(userId)s": "You are now ignoring %(userId)s", + "You are no longer ignoring %(userId)s": "You are no longer ignoring %(userId)s", + "Unignored user": "Unignored user", + "Ignored user": "Ignored user", + "Stops ignoring a user, showing their messages going forward": "Stops ignoring a user, showing their messages going forward", + "Ignores a user, hiding their messages from you": "Ignores a user, hiding their messages from you", "Last seen": "Last seen", "Leave room": "Leave room", "left and rejoined": "left and rejoined", diff --git a/src/i18n/strings/en_US.json b/src/i18n/strings/en_US.json index 2cec2f7b9b..4d67b5438b 100644 --- a/src/i18n/strings/en_US.json +++ b/src/i18n/strings/en_US.json @@ -227,6 +227,16 @@ "Kick": "Kick", "Kicks user with given id": "Kicks user with given id", "Labs": "Labs", + "Ignored Users": "Ignored Users", + "Ignore": "Ignore", + "Unignore": "Unignore", + "User Options": "User Options", + "You are now ignoring %(userId)s": "You are now ignoring %(userId)s", + "You are no longer ignoring %(userId)s": "You are no longer ignoring %(userId)s", + "Unignored user": "Unignored user", + "Ignored user": "Ignored user", + "Stops ignoring a user, showing their messages going forward": "Stops ignoring a user, showing their messages going forward", + "Ignores a user, hiding their messages from you": "Ignores a user, hiding their messages from you", "Leave room": "Leave room", "left and rejoined": "left and rejoined", "left": "left", diff --git a/test/components/structures/MessagePanel-test.js b/test/components/structures/MessagePanel-test.js index ad7d9c15c7..8254dd4126 100644 --- a/test/components/structures/MessagePanel-test.js +++ b/test/components/structures/MessagePanel-test.js @@ -24,6 +24,7 @@ var sdk = require('matrix-react-sdk'); var MessagePanel = sdk.getComponent('structures.MessagePanel'); import UserSettingsStore from '../../../src/UserSettingsStore'; +import MatrixClientPeg from '../../../src/MatrixClientPeg'; var test_utils = require('test-utils'); var mockclock = require('mock-clock'); @@ -51,16 +52,19 @@ describe('MessagePanel', function () { var clock = mockclock.clock(); var realSetTimeout = window.setTimeout; var events = mkEvents(); + var sandbox = null; beforeEach(function() { test_utils.beforeEach(this); - client = test_utils.createTestClient(); + sandbox = test_utils.stubClient(); + client = MatrixClientPeg.get(); client.credentials = {userId: '@me:here'}; UserSettingsStore.getSyncedSettings = sinon.stub().returns({}); }); afterEach(function() { clock.uninstall(); + sandbox.restore(); }); function mkEvents() {