diff --git a/package.json b/package.json
index 95b294c8a5..eb234e0573 100644
--- a/package.json
+++ b/package.json
@@ -82,6 +82,7 @@
"gemini-scrollbar": "github:matrix-org/gemini-scrollbar#91e1e566",
"gfm.css": "^1.1.1",
"glob": "^5.0.14",
+ "glob-to-regexp": "^0.4.1",
"highlight.js": "^9.15.8",
"is-ip": "^2.0.0",
"isomorphic-fetch": "^2.2.1",
diff --git a/res/css/_components.scss b/res/css/_components.scss
index 5d26185393..c8ea237dcd 100644
--- a/res/css/_components.scss
+++ b/res/css/_components.scss
@@ -124,6 +124,7 @@
@import "./views/messages/_MTextBody.scss";
@import "./views/messages/_MessageActionBar.scss";
@import "./views/messages/_MessageTimestamp.scss";
+@import "./views/messages/_MjolnirBody.scss";
@import "./views/messages/_ReactionsRow.scss";
@import "./views/messages/_ReactionsRowButton.scss";
@import "./views/messages/_ReactionsRowButtonTooltip.scss";
@@ -183,6 +184,7 @@
@import "./views/settings/tabs/room/_SecurityRoomSettingsTab.scss";
@import "./views/settings/tabs/user/_GeneralUserSettingsTab.scss";
@import "./views/settings/tabs/user/_HelpUserSettingsTab.scss";
+@import "./views/settings/tabs/user/_MjolnirUserSettingsTab.scss";
@import "./views/settings/tabs/user/_NotificationUserSettingsTab.scss";
@import "./views/settings/tabs/user/_PreferencesUserSettingsTab.scss";
@import "./views/settings/tabs/user/_SecurityUserSettingsTab.scss";
diff --git a/res/css/views/dialogs/_UserSettingsDialog.scss b/res/css/views/dialogs/_UserSettingsDialog.scss
index 2a046ff501..4d831d7858 100644
--- a/res/css/views/dialogs/_UserSettingsDialog.scss
+++ b/res/css/views/dialogs/_UserSettingsDialog.scss
@@ -45,6 +45,10 @@ limitations under the License.
mask-image: url('$(res)/img/feather-customised/flag.svg');
}
+.mx_UserSettingsDialog_mjolnirIcon::before {
+ mask-image: url('$(res)/img/feather-customised/face.svg');
+}
+
.mx_UserSettingsDialog_flairIcon::before {
mask-image: url('$(res)/img/feather-customised/flair.svg');
}
diff --git a/res/css/views/messages/_MjolnirBody.scss b/res/css/views/messages/_MjolnirBody.scss
new file mode 100644
index 0000000000..2760adfd7e
--- /dev/null
+++ b/res/css/views/messages/_MjolnirBody.scss
@@ -0,0 +1,19 @@
+/*
+Copyright 2019 The Matrix.org Foundation C.I.C.
+
+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.
+*/
+
+.mx_MjolnirBody {
+ opacity: 0.4;
+}
diff --git a/res/css/views/settings/tabs/user/_MjolnirUserSettingsTab.scss b/res/css/views/settings/tabs/user/_MjolnirUserSettingsTab.scss
new file mode 100644
index 0000000000..2a3fd12f31
--- /dev/null
+++ b/res/css/views/settings/tabs/user/_MjolnirUserSettingsTab.scss
@@ -0,0 +1,23 @@
+/*
+Copyright 2019 The Matrix.org Foundation C.I.C.
+
+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.
+*/
+
+.mx_MjolnirUserSettingsTab .mx_Field {
+ @mixin mx_Settings_fullWidthField;
+}
+
+.mx_MjolnirUserSettingsTab_listItem {
+ margin-bottom: 2px;
+}
diff --git a/src/Lifecycle.js b/src/Lifecycle.js
index 13f3abccb1..f2b50d7f2d 100644
--- a/src/Lifecycle.js
+++ b/src/Lifecycle.js
@@ -36,6 +36,7 @@ import * as StorageManager from './utils/StorageManager';
import SettingsStore from "./settings/SettingsStore";
import TypingStore from "./stores/TypingStore";
import {IntegrationManagers} from "./integrations/IntegrationManagers";
+import {Mjolnir} from "./mjolnir/Mjolnir";
/**
* Called at startup, to attempt to build a logged-in Matrix session. It tries
@@ -585,6 +586,11 @@ async function startMatrixClient(startSyncing=true) {
IntegrationManagers.sharedInstance().startWatching();
ActiveWidgetStore.start();
+ // Start Mjolnir even though we haven't checked the feature flag yet. Starting
+ // the thing just wastes CPU cycles, but should result in no actual functionality
+ // being exposed to the user.
+ Mjolnir.sharedInstance().start();
+
if (startSyncing) {
await MatrixClientPeg.start();
} else {
@@ -645,6 +651,7 @@ export function stopMatrixClient(unsetClient=true) {
Presence.stop();
ActiveWidgetStore.stop();
IntegrationManagers.sharedInstance().stopWatching();
+ Mjolnir.sharedInstance().stop();
if (DMRoomMap.shared()) DMRoomMap.shared().stop();
const cli = MatrixClientPeg.get();
if (cli) {
diff --git a/src/components/views/dialogs/UserSettingsDialog.js b/src/components/views/dialogs/UserSettingsDialog.js
index fb9045f05a..d3ab2b8722 100644
--- a/src/components/views/dialogs/UserSettingsDialog.js
+++ b/src/components/views/dialogs/UserSettingsDialog.js
@@ -1,5 +1,6 @@
/*
Copyright 2019 New Vector Ltd
+Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -29,12 +30,34 @@ import HelpUserSettingsTab from "../settings/tabs/user/HelpUserSettingsTab";
import FlairUserSettingsTab from "../settings/tabs/user/FlairUserSettingsTab";
import sdk from "../../../index";
import SdkConfig from "../../../SdkConfig";
+import MjolnirUserSettingsTab from "../settings/tabs/user/MjolnirUserSettingsTab";
export default class UserSettingsDialog extends React.Component {
static propTypes = {
onFinished: PropTypes.func.isRequired,
};
+ constructor() {
+ super();
+
+ this.state = {
+ mjolnirEnabled: SettingsStore.isFeatureEnabled("feature_mjolnir"),
+ };
+ }
+
+ componentDidMount(): void {
+ this._mjolnirWatcher = SettingsStore.watchSetting("feature_mjolnir", null, this._mjolnirChanged.bind(this));
+ }
+
+ componentWillUnmount(): void {
+ SettingsStore.unwatchSetting(this._mjolnirWatcher);
+ }
+
+ _mjolnirChanged(settingName, roomId, atLevel, newValue) {
+ // We can cheat because we know what levels a feature is tracked at, and how it is tracked
+ this.setState({mjolnirEnabled: newValue});
+ }
+
_getTabs() {
const tabs = [];
@@ -75,6 +98,13 @@ export default class UserSettingsDialog extends React.Component {
,
));
}
+ if (this.state.mjolnirEnabled) {
+ tabs.push(new Tab(
+ _td("Ignored users"),
+ "mx_UserSettingsDialog_mjolnirIcon",
+ ,
+ ));
+ }
tabs.push(new Tab(
_td("Help & About"),
"mx_UserSettingsDialog_helpIcon",
diff --git a/src/components/views/messages/MessageEvent.js b/src/components/views/messages/MessageEvent.js
index a616dd96ed..e75bcc4332 100644
--- a/src/components/views/messages/MessageEvent.js
+++ b/src/components/views/messages/MessageEvent.js
@@ -18,6 +18,8 @@ import React from 'react';
import PropTypes from 'prop-types';
import createReactClass from 'create-react-class';
import sdk from '../../../index';
+import SettingsStore from "../../../settings/SettingsStore";
+import {Mjolnir} from "../../../mjolnir/Mjolnir";
module.exports = createReactClass({
displayName: 'MessageEvent',
@@ -49,6 +51,10 @@ module.exports = createReactClass({
return this.refs.body && this.refs.body.getEventTileOps ? this.refs.body.getEventTileOps() : null;
},
+ onTileUpdate: function() {
+ this.forceUpdate();
+ },
+
render: function() {
const UnknownBody = sdk.getComponent('messages.UnknownBody');
@@ -81,6 +87,21 @@ module.exports = createReactClass({
}
}
+ if (SettingsStore.isFeatureEnabled("feature_mjolnir")) {
+ const key = `mx_mjolnir_render_${this.props.mxEvent.getRoomId()}__${this.props.mxEvent.getId()}`;
+ const allowRender = localStorage.getItem(key) === "true";
+
+ if (!allowRender) {
+ const userDomain = this.props.mxEvent.getSender().split(':').slice(1).join(':');
+ const userBanned = Mjolnir.sharedInstance().isUserBanned(this.props.mxEvent.getSender());
+ const serverBanned = Mjolnir.sharedInstance().isServerBanned(userDomain);
+
+ if (userBanned || serverBanned) {
+ BodyType = sdk.getComponent('messages.MjolnirBody');
+ }
+ }
+ }
+
return ;
+ onHeightChanged={this.props.onHeightChanged}
+ onMessageAllowed={this.onTileUpdate}
+ />;
},
});
diff --git a/src/components/views/messages/MjolnirBody.js b/src/components/views/messages/MjolnirBody.js
new file mode 100644
index 0000000000..baaee91657
--- /dev/null
+++ b/src/components/views/messages/MjolnirBody.js
@@ -0,0 +1,48 @@
+/*
+Copyright 2019 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import React from 'react';
+import PropTypes from 'prop-types';
+import {_t} from '../../../languageHandler';
+
+export default class MjolnirBody extends React.Component {
+ static propTypes = {
+ mxEvent: PropTypes.object.isRequired,
+ onMessageAllowed: PropTypes.func.isRequired,
+ };
+
+ constructor() {
+ super();
+ }
+
+ _onAllowClick = (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ const key = `mx_mjolnir_render_${this.props.mxEvent.getRoomId()}__${this.props.mxEvent.getId()}`;
+ localStorage.setItem(key, "true");
+ this.props.onMessageAllowed();
+ };
+
+ render() {
+ return (
+
{_t(
+ "You have ignored this user, so their message is hidden. Show anyways.",
+ {}, {a: (sub) => {sub}},
+ )}
+ );
+ }
+}
diff --git a/src/components/views/settings/tabs/user/MjolnirUserSettingsTab.js b/src/components/views/settings/tabs/user/MjolnirUserSettingsTab.js
new file mode 100644
index 0000000000..608be0b129
--- /dev/null
+++ b/src/components/views/settings/tabs/user/MjolnirUserSettingsTab.js
@@ -0,0 +1,329 @@
+/*
+Copyright 2019 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import React from 'react';
+import {_t} from "../../../../../languageHandler";
+import {Mjolnir} from "../../../../../mjolnir/Mjolnir";
+import {ListRule} from "../../../../../mjolnir/ListRule";
+import {BanList, RULE_SERVER, RULE_USER} from "../../../../../mjolnir/BanList";
+import Modal from "../../../../../Modal";
+import MatrixClientPeg from "../../../../../MatrixClientPeg";
+
+const sdk = require("../../../../..");
+
+export default class MjolnirUserSettingsTab extends React.Component {
+ constructor() {
+ super();
+
+ this.state = {
+ busy: false,
+ newPersonalRule: "",
+ newList: "",
+ };
+ }
+
+ _onPersonalRuleChanged = (e) => {
+ this.setState({newPersonalRule: e.target.value});
+ };
+
+ _onNewListChanged = (e) => {
+ this.setState({newList: e.target.value});
+ };
+
+ _onAddPersonalRule = async (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ let kind = RULE_SERVER;
+ if (this.state.newPersonalRule.startsWith("@")) {
+ kind = RULE_USER;
+ }
+
+ this.setState({busy: true});
+ try {
+ const list = await Mjolnir.sharedInstance().getOrCreatePersonalList();
+ await list.banEntity(kind, this.state.newPersonalRule, _t("Ignored/Blocked"));
+ this.setState({newPersonalRule: ""}); // this will also cause the new rule to be rendered
+ } catch (e) {
+ console.error(e);
+
+ const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
+ Modal.createTrackedDialog('Failed to add Mjolnir rule', '', ErrorDialog, {
+ title: _t('Error adding ignored user/server'),
+ description: _t('Something went wrong. Please try again or view your console for hints.'),
+ });
+ } finally {
+ this.setState({busy: false});
+ }
+ };
+
+ _onSubscribeList = async (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ this.setState({busy: true});
+ try {
+ const room = await MatrixClientPeg.get().joinRoom(this.state.newList);
+ await Mjolnir.sharedInstance().subscribeToList(room.roomId);
+ this.setState({newList: ""}); // this will also cause the new rule to be rendered
+ } catch (e) {
+ console.error(e);
+
+ const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
+ Modal.createTrackedDialog('Failed to subscribe to Mjolnir list', '', ErrorDialog, {
+ title: _t('Error subscribing to list'),
+ description: _t('Please verify the room ID or alias and try again.'),
+ });
+ } finally {
+ this.setState({busy: false});
+ }
+ };
+
+ async _removePersonalRule(rule: ListRule) {
+ this.setState({busy: true});
+ try {
+ const list = Mjolnir.sharedInstance().getPersonalList();
+ await list.unbanEntity(rule.kind, rule.entity);
+ } catch (e) {
+ console.error(e);
+
+ const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
+ Modal.createTrackedDialog('Failed to remove Mjolnir rule', '', ErrorDialog, {
+ title: _t('Error removing ignored user/server'),
+ description: _t('Something went wrong. Please try again or view your console for hints.'),
+ });
+ } finally {
+ this.setState({busy: false});
+ }
+ }
+
+ async _unsubscribeFromList(list: BanList) {
+ this.setState({busy: true});
+ try {
+ await Mjolnir.sharedInstance().unsubscribeFromList(list.roomId);
+ await MatrixClientPeg.get().leave(list.roomId);
+ } catch (e) {
+ console.error(e);
+
+ const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
+ Modal.createTrackedDialog('Failed to unsubscribe from Mjolnir list', '', ErrorDialog, {
+ title: _t('Error unsubscribing from list'),
+ description: _t('Please try again or view your console for hints.'),
+ });
+ } finally {
+ this.setState({busy: false});
+ }
+ }
+
+ _viewListRules(list: BanList) {
+ const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
+
+ const room = MatrixClientPeg.get().getRoom(list.roomId);
+ const name = room ? room.name : list.roomId;
+
+ const renderRules = (rules: ListRule[]) => {
+ if (rules.length === 0) return {_t("None")};
+
+ const tiles = [];
+ for (const rule of rules) {
+ tiles.push({rule.entity}
);
+ }
+ return ;
+ };
+
+ Modal.createTrackedDialog('View Mjolnir list rules', '', QuestionDialog, {
+ title: _t("Ban list rules - %(roomName)s", {roomName: name}),
+ description: (
+
+
{_t("Server rules")}
+ {renderRules(list.serverRules)}
+ {_t("User rules")}
+ {renderRules(list.userRules)}
+
+ ),
+ button: _t("Close"),
+ hasCancelButton: false,
+ });
+ }
+
+ _renderPersonalBanListRules() {
+ const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
+
+ const list = Mjolnir.sharedInstance().getPersonalList();
+ const rules = list ? [...list.userRules, ...list.serverRules] : [];
+ if (!list || rules.length <= 0) return {_t("You have not ignored anyone.")};
+
+ const tiles = [];
+ for (const rule of rules) {
+ tiles.push(
+
+ this._removePersonalRule(rule)}
+ disabled={this.state.busy}
+ >
+ {_t("Remove")}
+
+ {rule.entity}
+ ,
+ );
+ }
+
+ return (
+
+
{_t("You are currently ignoring:")}
+
+
+ );
+ }
+
+ _renderSubscribedBanLists() {
+ const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
+
+ const personalList = Mjolnir.sharedInstance().getPersonalList();
+ const lists = Mjolnir.sharedInstance().lists.filter(b => {
+ return personalList? personalList.roomId !== b.roomId : true;
+ });
+ if (!lists || lists.length <= 0) return {_t("You are not subscribed to any lists")};
+
+ const tiles = [];
+ for (const list of lists) {
+ const room = MatrixClientPeg.get().getRoom(list.roomId);
+ const name = room ? {room.name} ({list.roomId}
) : list.roomId
;
+ tiles.push(
+
+ this._unsubscribeFromList(list)}
+ disabled={this.state.busy}
+ >
+ {_t("Unsubscribe")}
+
+ this._viewListRules(list)}
+ disabled={this.state.busy}
+ >
+ {_t("View rules")}
+
+ {name}
+ ,
+ );
+ }
+
+ return (
+
+
{_t("You are currently subscribed to:")}
+
+
+ );
+ }
+
+ render() {
+ const Field = sdk.getComponent('elements.Field');
+ const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
+
+ return (
+
+
{_t("Ignored users")}
+
+
+ {_t("⚠ These settings are meant for advanced users.")}
+
+ {_t(
+ "Add users and servers you want to ignore here. Use asterisks " +
+ "to have Riot match any characters. For example, @bot:*
" +
+ "would ignore all users that have the name 'bot' on any server.",
+ {}, {code: (s) => {s}
},
+ )}
+
+ {_t(
+ "Ignoring people is done through ban lists which contain rules for " +
+ "who to ban. Subscribing to a ban list means the users/servers blocked by " +
+ "that list will be hidden from you.",
+ )}
+
+
+
+
{_t("Personal ban list")}
+
+ {_t(
+ "Your personal ban list holds all the users/servers you personally don't " +
+ "want to see messages from. After ignoring your first user/server, a new room " +
+ "will show up in your room list named 'My Ban List' - stay in this room to keep " +
+ "the ban list in effect.",
+ )}
+
+
+ {this._renderPersonalBanListRules()}
+
+
+
+
+
{_t("Subscribed lists")}
+
+ {_t("Subscribing to a ban list will cause you to join it!")}
+
+ {_t(
+ "If this isn't what you want, please use a different tool to ignore users.",
+ )}
+
+
+ {this._renderSubscribedBanLists()}
+
+
+
+
+ );
+ }
+}
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index 5f6e327944..dc9773ad21 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -340,6 +340,7 @@
"Render simple counters in room header": "Render simple counters in room header",
"Multiple integration managers": "Multiple integration managers",
"Use the new, consistent UserInfo panel for Room Members and Group Members": "Use the new, consistent UserInfo panel for Room Members and Group Members",
+ "Try out new ways to ignore people (experimental)": "Try out new ways to ignore people (experimental)",
"Send verification requests in direct message": "Send verification requests in direct message",
"Use the new, faster, composer for writing messages": "Use the new, faster, composer for writing messages",
"Enable Emoji suggestions while typing": "Enable Emoji suggestions while typing",
@@ -394,6 +395,8 @@
"Call invitation": "Call invitation",
"Messages sent by bot": "Messages sent by bot",
"When rooms are upgraded": "When rooms are upgraded",
+ "My Ban List": "My Ban List",
+ "This is your list of users/servers you have blocked - don't leave the room!": "This is your list of users/servers you have blocked - don't leave the room!",
"Active call (%(roomName)s)": "Active call (%(roomName)s)",
"unknown caller": "unknown caller",
"Incoming voice call from %(name)s": "Incoming voice call from %(name)s",
@@ -641,6 +644,40 @@
"Access Token:": "Access Token:",
"click to reveal": "click to reveal",
"Labs": "Labs",
+ "Ignored/Blocked": "Ignored/Blocked",
+ "Error adding ignored user/server": "Error adding ignored user/server",
+ "Something went wrong. Please try again or view your console for hints.": "Something went wrong. Please try again or view your console for hints.",
+ "Error subscribing to list": "Error subscribing to list",
+ "Please verify the room ID or alias and try again.": "Please verify the room ID or alias and try again.",
+ "Error removing ignored user/server": "Error removing ignored user/server",
+ "Error unsubscribing from list": "Error unsubscribing from list",
+ "Please try again or view your console for hints.": "Please try again or view your console for hints.",
+ "None": "None",
+ "Ban list rules - %(roomName)s": "Ban list rules - %(roomName)s",
+ "Server rules": "Server rules",
+ "User rules": "User rules",
+ "Close": "Close",
+ "You have not ignored anyone.": "You have not ignored anyone.",
+ "Remove": "Remove",
+ "You are currently ignoring:": "You are currently ignoring:",
+ "You are not subscribed to any lists": "You are not subscribed to any lists",
+ "Unsubscribe": "Unsubscribe",
+ "View rules": "View rules",
+ "You are currently subscribed to:": "You are currently subscribed to:",
+ "Ignored users": "Ignored users",
+ "⚠ These settings are meant for advanced users.": "⚠ These settings are meant for advanced users.",
+ "Add users and servers you want to ignore here. Use asterisks to have Riot match any characters. For example, @bot:*
would ignore all users that have the name 'bot' on any server.": "Add users and servers you want to ignore here. Use asterisks to have Riot match any characters. For example, @bot:*
would ignore all users that have the name 'bot' on any server.",
+ "Ignoring people is done through ban lists which contain rules for who to ban. Subscribing to a ban list means the users/servers blocked by that list will be hidden from you.": "Ignoring people is done through ban lists which contain rules for who to ban. Subscribing to a ban list means the users/servers blocked by that list will be hidden from you.",
+ "Personal ban list": "Personal ban list",
+ "Your personal ban list holds all the users/servers you personally don't want to see messages from. After ignoring your first user/server, a new room will show up in your room list named 'My Ban List' - stay in this room to keep the ban list in effect.": "Your personal ban list holds all the users/servers you personally don't want to see messages from. After ignoring your first user/server, a new room will show up in your room list named 'My Ban List' - stay in this room to keep the ban list in effect.",
+ "Server or user ID to ignore": "Server or user ID to ignore",
+ "eg: @bot:* or example.org": "eg: @bot:* or example.org",
+ "Ignore": "Ignore",
+ "Subscribed lists": "Subscribed lists",
+ "Subscribing to a ban list will cause you to join it!": "Subscribing to a ban list will cause you to join it!",
+ "If this isn't what you want, please use a different tool to ignore users.": "If this isn't what you want, please use a different tool to ignore users.",
+ "Room ID or alias of ban list": "Room ID or alias of ban list",
+ "Subscribe": "Subscribe",
"Notifications": "Notifications",
"Start automatically after system login": "Start automatically after system login",
"Always show the window menu bar": "Always show the window menu bar",
@@ -658,7 +695,6 @@
"Cryptography": "Cryptography",
"Device ID:": "Device ID:",
"Device key:": "Device key:",
- "Ignored users": "Ignored users",
"Bulk options": "Bulk options",
"Accept all %(invitedRooms)s invites": "Accept all %(invitedRooms)s invites",
"Reject all %(invitedRooms)s invites": "Reject all %(invitedRooms)s invites",
@@ -769,7 +805,6 @@
"Discovery options will appear once you have added a phone number above.": "Discovery options will appear once you have added a phone number above.",
"Unable to remove contact information": "Unable to remove contact information",
"Remove %(email)s?": "Remove %(email)s?",
- "Remove": "Remove",
"Invalid Email Address": "Invalid Email Address",
"This doesn't appear to be a valid email address": "This doesn't appear to be a valid email address",
"Unable to add email address": "Unable to add email address",
@@ -836,7 +871,6 @@
"You will not be able to undo this change as you are promoting the user to have the same power level as yourself.": "You will not be able to undo this change as you are promoting the user to have the same power level as yourself.",
"Are you sure?": "Are you sure?",
"No devices with registered encryption keys": "No devices with registered encryption keys",
- "Ignore": "Ignore",
"Jump to read receipt": "Jump to read receipt",
"Mention": "Mention",
"Invite": "Invite",
@@ -849,7 +883,6 @@
"Revoke Moderator": "Revoke Moderator",
"Make Moderator": "Make Moderator",
"Admin Tools": "Admin Tools",
- "Close": "Close",
"and %(count)s others...|other": "and %(count)s others...",
"and %(count)s others...|one": "and one other...",
"Invite to this room": "Invite to this room",
@@ -1066,6 +1099,7 @@
"Invalid file%(extra)s": "Invalid file%(extra)s",
"Error decrypting image": "Error decrypting image",
"Show image": "Show image",
+ "You have ignored this user, so their message is hidden. Show anyways.": "You have ignored this user, so their message is hidden. Show anyways.",
"You verified %(name)s": "You verified %(name)s",
"You cancelled verifying %(name)s": "You cancelled verifying %(name)s",
"%(name)s cancelled verifying": "%(name)s cancelled verifying",
diff --git a/src/mjolnir/BanList.js b/src/mjolnir/BanList.js
new file mode 100644
index 0000000000..60a924a52b
--- /dev/null
+++ b/src/mjolnir/BanList.js
@@ -0,0 +1,110 @@
+/*
+Copyright 2019 The Matrix.org Foundation C.I.C.
+
+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.
+*/
+
+// Inspiration largely taken from Mjolnir itself
+
+import {ListRule, RECOMMENDATION_BAN, recommendationToStable} from "./ListRule";
+import MatrixClientPeg from "../MatrixClientPeg";
+
+export const RULE_USER = "m.room.rule.user";
+export const RULE_ROOM = "m.room.rule.room";
+export const RULE_SERVER = "m.room.rule.server";
+
+export const USER_RULE_TYPES = [RULE_USER, "org.matrix.mjolnir.rule.user"];
+export const ROOM_RULE_TYPES = [RULE_ROOM, "org.matrix.mjolnir.rule.room"];
+export const SERVER_RULE_TYPES = [RULE_SERVER, "org.matrix.mjolnir.rule.server"];
+export const ALL_RULE_TYPES = [...USER_RULE_TYPES, ...ROOM_RULE_TYPES, ...SERVER_RULE_TYPES];
+
+export function ruleTypeToStable(rule: string, unstable = true): string {
+ if (USER_RULE_TYPES.includes(rule)) {
+ return unstable ? USER_RULE_TYPES[USER_RULE_TYPES.length - 1] : RULE_USER;
+ }
+ if (ROOM_RULE_TYPES.includes(rule)) {
+ return unstable ? ROOM_RULE_TYPES[ROOM_RULE_TYPES.length - 1] : RULE_ROOM;
+ }
+ if (SERVER_RULE_TYPES.includes(rule)) {
+ return unstable ? SERVER_RULE_TYPES[SERVER_RULE_TYPES.length - 1] : RULE_SERVER;
+ }
+ return null;
+}
+
+export class BanList {
+ _rules: ListRule[] = [];
+ _roomId: string;
+
+ constructor(roomId: string) {
+ this._roomId = roomId;
+ this.updateList();
+ }
+
+ get roomId(): string {
+ return this._roomId;
+ }
+
+ get serverRules(): ListRule[] {
+ return this._rules.filter(r => r.kind === RULE_SERVER);
+ }
+
+ get userRules(): ListRule[] {
+ return this._rules.filter(r => r.kind === RULE_USER);
+ }
+
+ get roomRules(): ListRule[] {
+ return this._rules.filter(r => r.kind === RULE_ROOM);
+ }
+
+ async banEntity(kind: string, entity: string, reason: string): Promise {
+ await MatrixClientPeg.get().sendStateEvent(this._roomId, ruleTypeToStable(kind, true), {
+ entity: entity,
+ reason: reason,
+ recommendation: recommendationToStable(RECOMMENDATION_BAN, true),
+ }, "rule:" + entity);
+ this._rules.push(new ListRule(entity, RECOMMENDATION_BAN, reason, ruleTypeToStable(kind, false)));
+ }
+
+ async unbanEntity(kind: string, entity: string): Promise {
+ // Empty state event is effectively deleting it.
+ await MatrixClientPeg.get().sendStateEvent(this._roomId, ruleTypeToStable(kind, true), {}, "rule:" + entity);
+ this._rules = this._rules.filter(r => {
+ if (r.kind !== ruleTypeToStable(kind, false)) return true;
+ if (r.entity !== entity) return true;
+ return false; // we just deleted this rule
+ });
+ }
+
+ updateList() {
+ this._rules = [];
+
+ const room = MatrixClientPeg.get().getRoom(this._roomId);
+ if (!room) return;
+
+ for (const eventType of ALL_RULE_TYPES) {
+ const events = room.currentState.getStateEvents(eventType, undefined);
+ for (const ev of events) {
+ if (!ev.getStateKey()) continue;
+
+ const kind = ruleTypeToStable(eventType, false);
+
+ const entity = ev.getContent()['entity'];
+ const recommendation = ev.getContent()['recommendation'];
+ const reason = ev.getContent()['reason'];
+ if (!entity || !recommendation || !reason) continue;
+
+ this._rules.push(new ListRule(entity, recommendation, reason, kind));
+ }
+ }
+ }
+}
diff --git a/src/mjolnir/ListRule.js b/src/mjolnir/ListRule.js
new file mode 100644
index 0000000000..1d472e06d6
--- /dev/null
+++ b/src/mjolnir/ListRule.js
@@ -0,0 +1,65 @@
+/*
+Copyright 2019 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import {MatrixGlob} from "../utils/MatrixGlob";
+
+// Inspiration largely taken from Mjolnir itself
+
+export const RECOMMENDATION_BAN = "m.ban";
+export const RECOMMENDATION_BAN_TYPES = [RECOMMENDATION_BAN, "org.matrix.mjolnir.ban"];
+
+export function recommendationToStable(recommendation: string, unstable = true): string {
+ if (RECOMMENDATION_BAN_TYPES.includes(recommendation)) {
+ return unstable ? RECOMMENDATION_BAN_TYPES[RECOMMENDATION_BAN_TYPES.length - 1] : RECOMMENDATION_BAN;
+ }
+ return null;
+}
+
+export class ListRule {
+ _glob: MatrixGlob;
+ _entity: string;
+ _action: string;
+ _reason: string;
+ _kind: string;
+
+ constructor(entity: string, action: string, reason: string, kind: string) {
+ this._glob = new MatrixGlob(entity);
+ this._entity = entity;
+ this._action = recommendationToStable(action, false);
+ this._reason = reason;
+ this._kind = kind;
+ }
+
+ get entity(): string {
+ return this._entity;
+ }
+
+ get reason(): string {
+ return this._reason;
+ }
+
+ get kind(): string {
+ return this._kind;
+ }
+
+ get recommendation(): string {
+ return this._action;
+ }
+
+ isMatch(entity: string): boolean {
+ return this._glob.test(entity);
+ }
+}
diff --git a/src/mjolnir/Mjolnir.js b/src/mjolnir/Mjolnir.js
new file mode 100644
index 0000000000..7539dfafb0
--- /dev/null
+++ b/src/mjolnir/Mjolnir.js
@@ -0,0 +1,189 @@
+/*
+Copyright 2019 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import MatrixClientPeg from "../MatrixClientPeg";
+import {ALL_RULE_TYPES, BanList} from "./BanList";
+import SettingsStore, {SettingLevel} from "../settings/SettingsStore";
+import {_t} from "../languageHandler";
+import dis from "../dispatcher";
+
+// TODO: Move this and related files to the js-sdk or something once finalized.
+
+export class Mjolnir {
+ static _instance: Mjolnir = null;
+
+ _lists: BanList[] = [];
+ _roomIds: string[] = [];
+ _mjolnirWatchRef = null;
+ _dispatcherRef = null;
+
+ constructor() {
+ }
+
+ get roomIds(): string[] {
+ return this._roomIds;
+ }
+
+ get lists(): BanList[] {
+ return this._lists;
+ }
+
+ start() {
+ this._mjolnirWatchRef = SettingsStore.watchSetting("mjolnirRooms", null, this._onListsChanged.bind(this));
+
+ this._dispatcherRef = dis.register(this._onAction);
+ dis.dispatch({
+ action: 'do_after_sync_prepared',
+ deferred_action: {action: 'setup_mjolnir'},
+ });
+ }
+
+ _onAction = (payload) => {
+ if (payload['action'] === 'setup_mjolnir') {
+ console.log("Setting up Mjolnir: after sync");
+ this.setup();
+ }
+ };
+
+ setup() {
+ if (!MatrixClientPeg.get()) return;
+ this._updateLists(SettingsStore.getValue("mjolnirRooms"));
+ MatrixClientPeg.get().on("RoomState.events", this._onEvent.bind(this));
+ }
+
+ stop() {
+ SettingsStore.unwatchSetting(this._mjolnirWatchRef);
+
+ try {
+ if (this._dispatcherRef) dis.unregister(this._dispatcherRef);
+ } catch (e) {
+ console.error(e);
+ // Only the tests cause problems with this particular block of code. We should
+ // never be here in production.
+ }
+
+ if (!MatrixClientPeg.get()) return;
+ MatrixClientPeg.get().removeListener("RoomState.events", this._onEvent.bind(this));
+ }
+
+ async getOrCreatePersonalList(): Promise {
+ let personalRoomId = SettingsStore.getValue("mjolnirPersonalRoom");
+ if (!personalRoomId) {
+ const resp = await MatrixClientPeg.get().createRoom({
+ name: _t("My Ban List"),
+ topic: _t("This is your list of users/servers you have blocked - don't leave the room!"),
+ preset: "private_chat",
+ });
+ personalRoomId = resp['room_id'];
+ await SettingsStore.setValue(
+ "mjolnirPersonalRoom", null, SettingLevel.ACCOUNT, personalRoomId);
+ await SettingsStore.setValue(
+ "mjolnirRooms", null, SettingLevel.ACCOUNT, [personalRoomId, ...this._roomIds]);
+ }
+ if (!personalRoomId) {
+ throw new Error("Error finding a room ID to use");
+ }
+
+ let list = this._lists.find(b => b.roomId === personalRoomId);
+ if (!list) list = new BanList(personalRoomId);
+ // we don't append the list to the tracked rooms because it should already be there.
+ // we're just trying to get the caller some utility access to the list
+
+ return list;
+ }
+
+ // get without creating the list
+ getPersonalList(): BanList {
+ const personalRoomId = SettingsStore.getValue("mjolnirPersonalRoom");
+ if (!personalRoomId) return null;
+
+ let list = this._lists.find(b => b.roomId === personalRoomId);
+ if (!list) list = new BanList(personalRoomId);
+ // we don't append the list to the tracked rooms because it should already be there.
+ // we're just trying to get the caller some utility access to the list
+
+ return list;
+ }
+
+ async subscribeToList(roomId: string) {
+ const roomIds = [...this._roomIds, roomId];
+ await SettingsStore.setValue("mjolnirRooms", null, SettingLevel.ACCOUNT, roomIds);
+ this._lists.push(new BanList(roomId));
+ }
+
+ async unsubscribeFromList(roomId: string) {
+ const roomIds = this._roomIds.filter(r => r !== roomId);
+ await SettingsStore.setValue("mjolnirRooms", null, SettingLevel.ACCOUNT, roomIds);
+ this._lists = this._lists.filter(b => b.roomId !== roomId);
+ }
+
+ _onEvent(event) {
+ if (!MatrixClientPeg.get()) return;
+ if (!this._roomIds.includes(event.getRoomId())) return;
+ if (!ALL_RULE_TYPES.includes(event.getType())) return;
+
+ this._updateLists(this._roomIds);
+ }
+
+ _onListsChanged(settingName, roomId, atLevel, newValue) {
+ // We know that ban lists are only recorded at one level so we don't need to re-eval them
+ this._updateLists(newValue);
+ }
+
+ _updateLists(listRoomIds: string[]) {
+ if (!MatrixClientPeg.get()) return;
+
+ console.log("Updating Mjolnir ban lists to: " + listRoomIds);
+ this._lists = [];
+ this._roomIds = listRoomIds || [];
+ if (!listRoomIds) return;
+
+ for (const roomId of listRoomIds) {
+ // Creating the list updates it
+ this._lists.push(new BanList(roomId));
+ }
+ }
+
+ isServerBanned(serverName: string): boolean {
+ for (const list of this._lists) {
+ for (const rule of list.serverRules) {
+ if (rule.isMatch(serverName)) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ isUserBanned(userId: string): boolean {
+ for (const list of this._lists) {
+ for (const rule of list.userRules) {
+ if (rule.isMatch(userId)) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ static sharedInstance(): Mjolnir {
+ if (!Mjolnir._instance) {
+ Mjolnir._instance = new Mjolnir();
+ }
+ return Mjolnir._instance;
+ }
+}
+
diff --git a/src/settings/Settings.js b/src/settings/Settings.js
index b169a0f29c..973d389ba6 100644
--- a/src/settings/Settings.js
+++ b/src/settings/Settings.js
@@ -126,6 +126,20 @@ export const SETTINGS = {
supportedLevels: LEVELS_FEATURE,
default: false,
},
+ "feature_mjolnir": {
+ isFeature: true,
+ displayName: _td("Try out new ways to ignore people (experimental)"),
+ supportedLevels: LEVELS_FEATURE,
+ default: false,
+ },
+ "mjolnirRooms": {
+ supportedLevels: ['account'],
+ default: [],
+ },
+ "mjolnirPersonalRoom": {
+ supportedLevels: ['account'],
+ default: null,
+ },
"feature_dm_verification": {
isFeature: true,
displayName: _td("Send verification requests in direct message"),
diff --git a/src/utils/MatrixGlob.js b/src/utils/MatrixGlob.js
new file mode 100644
index 0000000000..e07aaab541
--- /dev/null
+++ b/src/utils/MatrixGlob.js
@@ -0,0 +1,53 @@
+/*
+Copyright 2019 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import globToRegexp from "glob-to-regexp";
+
+// Taken with permission from matrix-bot-sdk:
+// https://github.com/turt2live/matrix-js-bot-sdk/blob/eb148c2ecec7bf3ade801d73deb43df042d55aef/src/MatrixGlob.ts
+
+/**
+ * Represents a common Matrix glob. This is commonly used
+ * for server ACLs and similar functions.
+ */
+export class MatrixGlob {
+ _regex: RegExp;
+
+ /**
+ * Creates a new Matrix Glob
+ * @param {string} glob The glob to convert. Eg: "*.example.org"
+ */
+ constructor(glob: string) {
+ const globRegex = globToRegexp(glob, {
+ extended: false,
+ globstar: false,
+ });
+
+ // We need to convert `?` manually because globToRegexp's extended mode
+ // does more than we want it to.
+ const replaced = globRegex.toString().replace(/\\\?/g, ".");
+ this._regex = new RegExp(replaced.substring(1, replaced.length - 1));
+ }
+
+ /**
+ * Tests the glob against a value, returning true if it matches.
+ * @param {string} val The value to test.
+ * @returns {boolean} True if the value matches the glob, false otherwise.
+ */
+ test(val: string): boolean {
+ return this._regex.test(val);
+ }
+}
diff --git a/yarn.lock b/yarn.lock
index 60fef54c16..3e43c29ef6 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3674,6 +3674,11 @@ glob-to-regexp@^0.3.0:
resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.3.0.tgz#8c5a1494d2066c570cc3bfe4496175acc4d502ab"
integrity sha1-jFoUlNIGbFcMw7/kSWF1rMTVAqs=
+glob-to-regexp@^0.4.1:
+ version "0.4.1"
+ resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz#c75297087c851b9a578bd217dd59a92f59fe546e"
+ integrity sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==
+
glob@7.1.2:
version "7.1.2"
resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.2.tgz#c19c9df9a028702d678612384a6552404c636d15"