Merge pull request #3585 from matrix-org/travis/mjolnir

Add Mjolnir ban list support
This commit is contained in:
Travis Ralston 2019-11-12 10:01:50 -07:00 committed by GitHub
commit d0cbcb85f5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 961 additions and 5 deletions

View file

@ -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",

View file

@ -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";

View file

@ -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');
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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) {

View file

@ -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 {
<LabsUserSettingsTab />,
));
}
if (this.state.mjolnirEnabled) {
tabs.push(new Tab(
_td("Ignored users"),
"mx_UserSettingsDialog_mjolnirIcon",
<MjolnirUserSettingsTab />,
));
}
tabs.push(new Tab(
_td("Help & About"),
"mx_UserSettingsDialog_helpIcon",

View file

@ -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 <BodyType
ref="body" mxEvent={this.props.mxEvent}
highlights={this.props.highlights}
@ -90,6 +111,8 @@ module.exports = createReactClass({
maxImageHeight={this.props.maxImageHeight}
replacingEventId={this.props.replacingEventId}
editState={this.props.editState}
onHeightChanged={this.props.onHeightChanged} />;
onHeightChanged={this.props.onHeightChanged}
onMessageAllowed={this.onTileUpdate}
/>;
},
});

View file

@ -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 (
<div className='mx_MjolnirBody'><i>{_t(
"You have ignored this user, so their message is hidden. <a>Show anyways.</a>",
{}, {a: (sub) => <a href="#" onClick={this._onAllowClick}>{sub}</a>},
)}</i></div>
);
}
}

View file

@ -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 <i>{_t("None")}</i>;
const tiles = [];
for (const rule of rules) {
tiles.push(<li key={rule.kind + rule.entity}><code>{rule.entity}</code></li>);
}
return <ul>{tiles}</ul>;
};
Modal.createTrackedDialog('View Mjolnir list rules', '', QuestionDialog, {
title: _t("Ban list rules - %(roomName)s", {roomName: name}),
description: (
<div>
<h3>{_t("Server rules")}</h3>
{renderRules(list.serverRules)}
<h3>{_t("User rules")}</h3>
{renderRules(list.userRules)}
</div>
),
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 <i>{_t("You have not ignored anyone.")}</i>;
const tiles = [];
for (const rule of rules) {
tiles.push(
<li key={rule.entity} className="mx_MjolnirUserSettingsTab_listItem">
<AccessibleButton
kind="danger_sm"
onClick={() => this._removePersonalRule(rule)}
disabled={this.state.busy}
>
{_t("Remove")}
</AccessibleButton>&nbsp;
<code>{rule.entity}</code>
</li>,
);
}
return (
<div>
<p>{_t("You are currently ignoring:")}</p>
<ul>{tiles}</ul>
</div>
);
}
_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 <i>{_t("You are not subscribed to any lists")}</i>;
const tiles = [];
for (const list of lists) {
const room = MatrixClientPeg.get().getRoom(list.roomId);
const name = room ? <span>{room.name} (<code>{list.roomId}</code>)</span> : <code>list.roomId</code>;
tiles.push(
<li key={list.roomId} className="mx_MjolnirUserSettingsTab_listItem">
<AccessibleButton
kind="danger_sm"
onClick={() => this._unsubscribeFromList(list)}
disabled={this.state.busy}
>
{_t("Unsubscribe")}
</AccessibleButton>&nbsp;
<AccessibleButton
kind="primary_sm"
onClick={() => this._viewListRules(list)}
disabled={this.state.busy}
>
{_t("View rules")}
</AccessibleButton>&nbsp;
{name}
</li>,
);
}
return (
<div>
<p>{_t("You are currently subscribed to:")}</p>
<ul>{tiles}</ul>
</div>
);
}
render() {
const Field = sdk.getComponent('elements.Field');
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
return (
<div className="mx_SettingsTab mx_MjolnirUserSettingsTab">
<div className="mx_SettingsTab_heading">{_t("Ignored users")}</div>
<div className="mx_SettingsTab_section">
<div className='mx_SettingsTab_subsectionText'>
<span className='warning'>{_t("⚠ These settings are meant for advanced users.")}</span><br />
<br />
{_t(
"Add users and servers you want to ignore here. Use asterisks " +
"to have Riot match any characters. For example, <code>@bot:*</code> " +
"would ignore all users that have the name 'bot' on any server.",
{}, {code: (s) => <code>{s}</code>},
)}<br />
<br />
{_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.",
)}
</div>
</div>
<div className="mx_SettingsTab_section">
<span className="mx_SettingsTab_subheading">{_t("Personal ban list")}</span>
<div className='mx_SettingsTab_subsectionText'>
{_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.",
)}
</div>
<div>
{this._renderPersonalBanListRules()}
</div>
<div>
<form onSubmit={this._onAddPersonalRule} autoComplete="off">
<Field
id="mx_MjolnirUserSettingsTab_personalAdd"
type="text"
label={_t("Server or user ID to ignore")}
placeholder={_t("eg: @bot:* or example.org")}
value={this.state.newPersonalRule}
onChange={this._onPersonalRuleChanged}
/>
<AccessibleButton
type="submit"
kind="primary"
onClick={this._onAddPersonalRule}
disabled={this.state.busy}
>
{_t("Ignore")}
</AccessibleButton>
</form>
</div>
</div>
<div className="mx_SettingsTab_section">
<span className="mx_SettingsTab_subheading">{_t("Subscribed lists")}</span>
<div className='mx_SettingsTab_subsectionText'>
<span className='warning'>{_t("Subscribing to a ban list will cause you to join it!")}</span>
&nbsp;
<span>{_t(
"If this isn't what you want, please use a different tool to ignore users.",
)}</span>
</div>
<div>
{this._renderSubscribedBanLists()}
</div>
<div>
<form onSubmit={this._onSubscribeList} autoComplete="off">
<Field
id="mx_MjolnirUserSettingsTab_subscriptionAdd"
type="text"
label={_t("Room ID or alias of ban list")}
value={this.state.newList}
onChange={this._onNewListChanged}
/>
<AccessibleButton
type="submit"
kind="primary"
onClick={this._onSubscribeList}
disabled={this.state.busy}
>
{_t("Subscribe")}
</AccessibleButton>
</form>
</div>
</div>
</div>
);
}
}

View file

@ -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, <code>@bot:*</code> 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, <code>@bot:*</code> 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. <a>Show anyways.</a>": "You have ignored this user, so their message is hidden. <a>Show anyways.</a>",
"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",

110
src/mjolnir/BanList.js Normal file
View file

@ -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<any> {
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<any> {
// 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));
}
}
}
}

65
src/mjolnir/ListRule.js Normal file
View file

@ -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);
}
}

189
src/mjolnir/Mjolnir.js Normal file
View file

@ -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<BanList> {
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;
}
}

View file

@ -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"),

53
src/utils/MatrixGlob.js Normal file
View file

@ -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);
}
}

View file

@ -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"