diff --git a/res/css/_components.scss b/res/css/_components.scss index d30684993d3..036c7bce970 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -108,6 +108,7 @@ @import "./views/groups/_GroupPublicityToggle.scss"; @import "./views/groups/_GroupRoomList.scss"; @import "./views/groups/_GroupUserSettings.scss"; +@import "./views/messages/_BridgeError.scss"; @import "./views/messages/_CreateEvent.scss"; @import "./views/messages/_DateSeparator.scss"; @import "./views/messages/_MEmoteBody.scss"; diff --git a/res/css/views/messages/_BridgeError.scss b/res/css/views/messages/_BridgeError.scss new file mode 100644 index 00000000000..9464affe685 --- /dev/null +++ b/res/css/views/messages/_BridgeError.scss @@ -0,0 +1,33 @@ +/* +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_BridgeError { + font-size: 0.8em; + line-height: 1em; +} + +.mx_BridgeError_message { + margin-left: 1.5em; + opacity: 0.5; +} + +.mx_BridgeError_icon { + mask-image: url('$(res)/img/warning.svg'); + background-color: $warning-color; + float: left; + width: 1em; + height: 1em; +} diff --git a/src/components/views/messages/BridgeError.js b/src/components/views/messages/BridgeError.js new file mode 100644 index 00000000000..3366da6e996 --- /dev/null +++ b/src/components/views/messages/BridgeError.js @@ -0,0 +1,192 @@ +/* +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 { EventTimeline, MatrixEvent, Room } from 'matrix-js-sdk'; + +import { _t, _td } from '../../../languageHandler'; +import { withRelation } from '../../../wrappers/withRelation.js'; + + +/** + * No-op if str is a string, else returns the empty string. + * @param {Any} str + * @returns {string} + */ +function assureString(str) { + return typeof str === 'string' ? str : ""; +} + +/** + * No-op if arr is an Array, else returns an empty Array. + * @param {Any} arr + * @returns {Array} + */ +function assureArray(arr) { + return Array.isArray(arr) ? arr : []; +} + +/** + * In case there are bridge errors related to the given event, show them. + */ +class BridgeError extends React.PureComponent { + // export? BridgeError is not the class getting exported! See end of file. + static propTypes = { + mxEvent: PropTypes.instanceOf(MatrixEvent).isRequired, + room: PropTypes.instanceOf(Room).isRequired, + relations: PropTypes.arrayOf(PropTypes.instanceOf(MatrixEvent)).isRequired, + }; + + constructor(props) { + super(props); + } + + /** + * Returns a list of all users matched by the regex at the time of the event. + * + * @param {string} regexStr + * @returns {RoomMember[]} + */ + _findMembersFromRegex(regexStr) { + const { room } = this.props; + const regex = new RegExp(regexStr); + + // TODO[V02460@gmail.com]: Get room state at the proper point in time + const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS); + const members = roomState.getMembers(); + + return members.filter(m => regex.test(m.userId)); + } + + /** + * Sanitized infos from a relation. + * @typedef {Object} RelationInfo + * @property {RoomMember[]} affectedUsers + * @property {string} eventID + * @property {string} networkName + * @property {string} reason + */ + + /** + * Returns the network name and the affected users for the given relation. + * + * @param {MatrixEvent} relation + * @returns {RelationInfo} + */ + _getRelationInfo(relation) { + const content = relation.getContent(); + + const affectedUsersRegex = assureArray(content.affected_users); + const affectedUsers = affectedUsersRegex.flatMap(u => + this._findMembersFromRegex(u), + ); + + return { + affectedUsers: affectedUsers, + eventID: assureString(content.event_id), + networkName: assureString(content.network_name), + reason: assureString(content.reason), + }; + } + + _errorMessages = { + "m.event_not_handled": _td( + "Not delivered to people on %(networkName)s (%(affectedUsers)s)", + ), + "m.event_too_old": _td( + "It took so long. Gave up sending to people on %(networkName)s " + + "(%(affectedUsers)s)", + ), + "m.internal_error": _td( + "Unexpected error while sending to people on %(networkName)s " + + "(%(affectedUsers)s)", + ), + "m.foreign_network_error": _td( + "%(networkName)s did not deliver the message to the people " + + "there (%(affectedUsers)s)", + ), + "m.event_unknown": _td( + "Was not understood by %(networkName)s, so people there didn't " + + "get this message (%(affectedUsers)s)", + ), + } + + /** + * Returns an error message for the given reason. + * + * Defaults to a generic message if the reason is unknown. + * @param {string} reason + * @returns {string} + */ + _getErrorMessage(reason) { + return ( + this._errorMessages[reason] || this._errorMessages["m.event_not_handled"] + ); + } + + /** + * Returns the rendered element for the given relation. + * + * @param {RelationInfo} relationInfo + * @return {React.Element<'div'>} + */ + _renderInfo(relationInfo) { + const usernames = relationInfo.affectedUsers.map(u => u.name).join(", "); + const message = _t( + this._getErrorMessage(relationInfo.reason), + { + affectedUsers: usernames, + // count == 0 to add translations for when the network name is missing. + count: relationInfo.networkName ? 1 : 0, + networkName: relationInfo.networkName, + }, + ); + + return ( +
+ { message } +
+ ); + } + + render() { + const errorEvents = this.props.relations; + const isBridgeError = !!errorEvents.length; + + if (!isBridgeError) { + return null; + } + + const errorInfos = errorEvents.map(e => this._getRelationInfo(e)); + const renderedInfos = errorInfos.map(e => this._renderInfo(e)); + + return ( +
+
+ { renderedInfos } +
+ ); + } +} + +const BridgeErrorWithRelation = withRelation( + BridgeError, + "m.reference", + "de.nasnotfound.bridge_error", +); + +export default BridgeErrorWithRelation; diff --git a/src/components/views/rooms/EventTile.js b/src/components/views/rooms/EventTile.js index ae8b1ee5d3e..935d8ad7d2d 100644 --- a/src/components/views/rooms/EventTile.js +++ b/src/components/views/rooms/EventTile.js @@ -645,6 +645,14 @@ module.exports = withMatrixClient(React.createClass({ const timestamp = this.props.mxEvent.getTs() ? : null; + const room = this.props.matrixClient.getRoom(this.props.mxEvent.getRoomId()); + + const BridgeError = sdk.getComponent('messages.BridgeError'); + const bridgeError = (SettingsStore.isFeatureEnabled("feature_bridge_errors") ? + : + null + ); + const keyRequestHelpText =

@@ -690,7 +698,6 @@ module.exports = withMatrixClient(React.createClass({ switch (this.props.tileShape) { case 'notif': { - const room = this.props.matrixClient.getRoom(this.props.mxEvent.getRoomId()); return (

@@ -800,6 +807,7 @@ module.exports = withMatrixClient(React.createClass({ highlightLink={this.props.highlightLink} showUrlPreview={this.props.showUrlPreview} onHeightChanged={this.props.onHeightChanged} /> + { bridgeError } { keyRequestInfo } { reactionsRow } { actionBar } diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 75ab80a837d..d32836d9048 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -311,6 +311,7 @@ "Sorry, your homeserver is too old to participate in this room.": "Sorry, your homeserver is too old to participate in this room.", "Please contact your homeserver administrator.": "Please contact your homeserver administrator.", "Failed to join room": "Failed to join room", + "Show Bridge Errors": "Show Bridge Errors", "Message Pinning": "Message Pinning", "Custom user status messages": "Custom user status messages", "Group & filter rooms by custom tags (refresh to apply changes)": "Group & filter rooms by custom tags (refresh to apply changes)", @@ -916,6 +917,11 @@ "When someone puts a URL in their message, a URL preview can be shown to give more information about that link such as the title, description, and an image from the website.": "When someone puts a URL in their message, a URL preview can be shown to give more information about that link such as the title, description, and an image from the website.", "Members": "Members", "Files": "Files", + "Not delivered to people on %(networkName)s (%(affectedUsers)s)": "Not delivered to people on %(networkName)s (%(affectedUsers)s)", + "It took so long. Gave up sending to people on %(networkName)s (%(affectedUsers)s)": "It took so long. Gave up sending to people on %(networkName)s (%(affectedUsers)s)", + "Unexpected error while sending to people on %(networkName)s (%(affectedUsers)s)": "Unexpected error while sending to people on %(networkName)s (%(affectedUsers)s)", + "%(networkName)s did not deliver the message to the people there (%(affectedUsers)s)": "%(networkName)s did not deliver the message to the people there (%(affectedUsers)s)", + "Was not understood by %(networkName)s, so people there didn't get this message (%(affectedUsers)s)": "Was not understood by %(networkName)s, so people there didn't get this message (%(affectedUsers)s)", "Sunday": "Sunday", "Monday": "Monday", "Tuesday": "Tuesday", diff --git a/src/settings/Settings.js b/src/settings/Settings.js index 55085963d1a..17c2b6b02de 100644 --- a/src/settings/Settings.js +++ b/src/settings/Settings.js @@ -89,6 +89,12 @@ export const SETTINGS = { // // not use this for new settings. // invertedSettingName: "my-negative-setting", // }, + "feature_bridge_errors": { + isFeature: true, + displayName: _td("Show Bridge Errors"), + supportedLevels: LEVELS_FEATURE, + default: false, + }, "feature_pinning": { isFeature: true, displayName: _td("Message Pinning"), diff --git a/src/wrappers/withRelation.js b/src/wrappers/withRelation.js new file mode 100644 index 00000000000..54b1b2b85a9 --- /dev/null +++ b/src/wrappers/withRelation.js @@ -0,0 +1,174 @@ + +import { strict as assert } from 'assert'; + +import React from 'react'; +import PropTypes from 'prop-types'; +import { MatrixEvent, Room } from 'matrix-js-sdk'; + +/** + * Wraps a componenets to provide it the `relations` prop. + * + * This wrapper only provides one type of relation to its child component. + * To deliver the right type of relation to its child this function requires + * the `relationType` and `eventType` arguments to be passed and the wrapping + * compnent requires the `mxEvent` and `room` props to be set. These two props + * are passed down besides the `relations` prop. + * + * Props: + * - `mxEvent`: The event for whicht to get the relations. + * - `room`: The room in which `mxEvent` was emitted. + * + * This component requires its key attribute to be set (e.g. to + * mxEvent.getId()). This is due the fact that it does not have an update logic + * for changing props implemented. For more details see + * https://reactjs.org/blog/2018/06/07/you-probably-dont-need-derived-state.html#recommendation-fully-uncontrolled-component-with-a-key + * + * @param {typeof React.Component} WrappedComponent The component to wrap and + * provide the relations to. + * @param {string} relationType The type of relation to filter for. + * @param {string} eventType The type of event to filter for. + * @returns {typeof React.Component} + */ +export function withRelation(WrappedComponent, relationType, eventType) { +class WithRelation extends React.PureComponent { + static propTypes = { + mxEvent: PropTypes.instanceOf(MatrixEvent).isRequired, + room: PropTypes.instanceOf(Room).isRequired, + }; + + constructor(props) { + super(props); + + this.listenersAdded = false; + this.creationListenerTarget = null; + this.relations = null; + + this.onChangeCallback = (e) => {}; + + this.state = {relations: []}; + + this._setup(); + } + + componentDidMount() { + this.onChangeCallback = (e) => this.setState({relations: e}); + + if (this.relations) { + this.onChangeCallback(this._getEvents()); + } + } + + componentWillUnmount() { + this._removeCreationListener(); + + if (this.relations) { + this._removeListeners(this.relations); + this.relations = null; + } + + assert(!this.relations); + assert(!this.listenersAdded); + assert(!this.creationListenerTarget); + } + + _setup() { + const { mxEvent, room } = this.props; + + assert(!this.relations); + assert(!this.listenersAdded); + + this.relations = this._getRelations(mxEvent, room); + + if (!this.relations) { + // No setup happened. Wait for relations to appear. + this._addCreationListener(mxEvent); + return; + } + this._removeCreationListener(); + + this._addListeners(this.relations); + + assert(this.relations); + assert(this.listenersAdded); + assert(!this.creationListenerTarget); + } + + _getRelations(mxEvent, room) { + const timelineSet = room.getUnfilteredTimelineSet(); + return timelineSet.getRelationsForEvent( + mxEvent.getId(), + relationType, + eventType, + ) || null; + } + + _getEvents() { + return this.relations.getRelations() || []; + } + + // Relations creation + + _creationCallback = (relationTypeArg, eventTypeArg) => { + if (relationTypeArg != relationType || eventTypeArg != eventType) { + return; + } + this._removeCreationListener(); + this._setup(); + } + + _addCreationListener(mxEvent) { + mxEvent.on("Event.relationsCreated", this._creationCallback); + this.creationListenerTarget = mxEvent; + } + + _removeCreationListener() { + if (!this.creationListenerTarget) { + return; + } + this.creationListenerTarget.removeListener( + "Event.relationsCreated", + this._creationCallback, + ); + this.creationListenerTarget = null; + } + + // Relations changes + + _notify = () => { + this.onChangeCallback(this._getEvents()); + } + + _addListeners(relations) { + if (this.listenersAdded) { + return; + } + relations.on("Relations.add", this._notify); + relations.on("Relations.remove", this._notify); + relations.on("Relations.redaction", this._notify); + this.listenersAdded = true; + } + + _removeListeners(relations) { + if (!this.listenersAdded) { + return; + } + relations.removeListener("Relations.add", this._notify); + relations.removeListener("Relations.remove", this._notify); + relations.removeListener("Relations.redaction", this._notify); + this.listenersAdded = false; + } + + render() { + return ( + + ); + } +} + +WithRelation.displayName = `WithRelation(${getDisplayName(WrappedComponent)})`; +return WithRelation; +} + +function getDisplayName(WrappedComponent) { + return WrappedComponent.displayName || WrappedComponent.name || 'Component'; +}