diff --git a/res/css/views/dialogs/_MessageEditHistoryDialog.scss b/res/css/views/dialogs/_MessageEditHistoryDialog.scss index 951b863e6a..38188974ac 100644 --- a/res/css/views/dialogs/_MessageEditHistoryDialog.scss +++ b/res/css/views/dialogs/_MessageEditHistoryDialog.scss @@ -42,5 +42,10 @@ limitations under the License. .mx_EventTile_line, .mx_EventTile_content { margin-right: 0px; } + + .mx_MessageActionBar .mx_AccessibleButton { + font-size: 10px; + padding: 0 8px; + } } diff --git a/res/css/views/messages/_MessageActionBar.scss b/res/css/views/messages/_MessageActionBar.scss index 7ac0e95e81..b7ba2ef27d 100644 --- a/res/css/views/messages/_MessageActionBar.scss +++ b/res/css/views/messages/_MessageActionBar.scss @@ -30,9 +30,9 @@ limitations under the License. z-index: 1; > * { + white-space: nowrap; display: inline-block; position: relative; - width: 27px; border: 1px solid $message-action-bar-border-color; margin-left: -1px; @@ -55,6 +55,11 @@ limitations under the License. } } + +.mx_MessageActionBar_maskButton { + width: 27px; +} + .mx_MessageActionBar_maskButton::after { content: ''; position: absolute; diff --git a/src/components/views/dialogs/ConfirmAndWaitRedactDialog.js b/src/components/views/dialogs/ConfirmAndWaitRedactDialog.js new file mode 100644 index 0000000000..db00f445a8 --- /dev/null +++ b/src/components/views/dialogs/ConfirmAndWaitRedactDialog.js @@ -0,0 +1,90 @@ +/* +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 sdk from '../../../index'; +import { _t } from '../../../languageHandler'; + +/* + * A dialog for confirming a redaction. + * Also shows a spinner (and possible error) while the redaction is ongoing, + * and only closes the dialog when the redaction is done or failed. + * + * This is done to prevent the edit history dialog racing with the redaction: + * if this dialog closes and the MessageEditHistoryDialog is shown again, + * it will fetch the relations again, which will race with the ongoing /redact request. + * which will cause the edit to appear unredacted. + * + * To avoid this, we keep the dialog open as long as /redact is in progress. + */ +export default class ConfirmAndWaitRedactDialog extends React.PureComponent { + constructor(props) { + super(props); + this.state = { + isRedacting: false, + redactionErrorCode: null, + }; + } + + onParentFinished = async (proceed) => { + if (proceed) { + this.setState({isRedacting: true}); + try { + await this.props.redact(); + this.props.onFinished(true); + } catch (error) { + const code = error.errcode || error.statusCode; + if (typeof code !== "undefined") { + this.setState({redactionErrorCode: code}); + } else { + this.props.onFinished(true); + } + } + } else { + this.props.onFinished(false); + } + }; + + render() { + if (this.state.isRedacting) { + if (this.state.redactionErrorCode) { + const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + const code = this.state.redactionErrorCode; + return ( + + ); + } else { + const BaseDialog = sdk.getComponent("dialogs.BaseDialog"); + const Spinner = sdk.getComponent('elements.Spinner'); + return ( + + + + ); + } + } else { + const ConfirmRedactDialog = sdk.getComponent("dialogs.ConfirmRedactDialog"); + return ; + } + } +} diff --git a/src/components/views/dialogs/MessageEditHistoryDialog.js b/src/components/views/dialogs/MessageEditHistoryDialog.js index b30891488b..53dd6b2a1b 100644 --- a/src/components/views/dialogs/MessageEditHistoryDialog.js +++ b/src/components/views/dialogs/MessageEditHistoryDialog.js @@ -46,12 +46,13 @@ export default class MessageEditHistoryDialog extends React.PureComponent { const opts = {from: this.state.nextBatch}; const roomId = this.props.mxEvent.getRoomId(); const eventId = this.props.mxEvent.getId(); + const client = MatrixClientPeg.get(); let result; let resolve; let reject; const promise = new Promise((_resolve, _reject) => {resolve = _resolve; reject = _reject;}); try { - result = await MatrixClientPeg.get().relations( + result = await client.relations( roomId, eventId, "m.replace", "m.room.message", opts); } catch (error) { // log if the server returned an error @@ -61,8 +62,11 @@ export default class MessageEditHistoryDialog extends React.PureComponent { this.setState({error}, () => reject(error)); return promise; } + + const newEvents = result.events; + this._locallyRedactEventsIfNeeded(newEvents); this.setState({ - events: this.state.events.concat(result.events), + events: this.state.events.concat(newEvents), nextBatch: result.nextBatch, isLoading: false, }, () => { @@ -72,6 +76,21 @@ export default class MessageEditHistoryDialog extends React.PureComponent { return promise; } + _locallyRedactEventsIfNeeded(newEvents) { + const roomId = this.props.mxEvent.getRoomId(); + const client = MatrixClientPeg.get(); + const room = client.getRoom(roomId); + const pendingEvents = room.getPendingEvents(); + for (const e of newEvents) { + const pendingRedaction = pendingEvents.find(pe => { + return pe.getType() === "m.room.redaction" && pe.getAssociatedId() === e.getId(); + }); + if (pendingRedaction) { + e.markLocallyRedacted(pendingRedaction); + } + } + } + componentDidMount() { this.loadMoreEdits(); } diff --git a/src/components/views/messages/EditHistoryMessage.js b/src/components/views/messages/EditHistoryMessage.js index fef9c362c6..797e010aae 100644 --- a/src/components/views/messages/EditHistoryMessage.js +++ b/src/components/views/messages/EditHistoryMessage.js @@ -20,6 +20,11 @@ import * as HtmlUtils from '../../../HtmlUtils'; import {formatTime} from '../../../DateUtils'; import {MatrixEvent} from 'matrix-js-sdk'; import {pillifyLinks} from '../../../utils/pillify'; +import { _t } from '../../../languageHandler'; +import sdk from '../../../index'; +import MatrixClientPeg from '../../../MatrixClientPeg'; +import Modal from '../../../Modal'; +import classNames from 'classnames'; export default class EditHistoryMessage extends React.PureComponent { static propTypes = { @@ -27,12 +32,87 @@ export default class EditHistoryMessage extends React.PureComponent { mxEvent: PropTypes.instanceOf(MatrixEvent).isRequired, }; + constructor(props) { + super(props); + const cli = MatrixClientPeg.get(); + const {userId} = cli.credentials; + const event = this.props.mxEvent; + const room = cli.getRoom(event.getRoomId()); + if (event.localRedactionEvent()) { + event.localRedactionEvent().on("status", this._onAssociatedStatusChanged); + } + const canRedact = room.currentState.maySendRedactionForEvent(event, userId); + this.state = {canRedact, sendStatus: event.getAssociatedStatus()}; + } + + _onAssociatedStatusChanged = () => { + this.setState({sendStatus: this.props.mxEvent.getAssociatedStatus()}); + }; + + _onRedactClick = async () => { + const event = this.props.mxEvent; + const cli = MatrixClientPeg.get(); + const ConfirmAndWaitRedactDialog = sdk.getComponent("dialogs.ConfirmAndWaitRedactDialog"); + + Modal.createTrackedDialog('Confirm Redact Dialog', 'Edit history', ConfirmAndWaitRedactDialog, { + redact: () => cli.redactEvent(event.getRoomId(), event.getId()), + }, 'mx_Dialog_confirmredact'); + }; + + _onViewSourceClick = () => { + const ViewSource = sdk.getComponent('structures.ViewSource'); + Modal.createTrackedDialog('View Event Source', 'Edit history', ViewSource, { + roomId: this.props.mxEvent.getRoomId(), + eventId: this.props.mxEvent.getId(), + content: this.props.mxEvent.event, + }, 'mx_Dialog_viewsource'); + }; + + pillifyLinks() { + // not present for redacted events + if (this.refs.content) { + pillifyLinks(this.refs.content.children, this.props.mxEvent); + } + } + componentDidMount() { - pillifyLinks(this.refs.content.children, this.props.mxEvent); + this.pillifyLinks(); + } + + componentWillUnmount() { + const event = this.props.mxEvent; + if (event.localRedactionEvent()) { + event.localRedactionEvent().off("status", this._onAssociatedStatusChanged); + } } componentDidUpdate() { - pillifyLinks(this.refs.content.children, this.props.mxEvent); + this.pillifyLinks(); + } + + _renderActionBar() { + const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); + // hide the button when already redacted + let redactButton; + if (!this.props.mxEvent.isRedacted()) { + redactButton = ( + + {_t("Remove")} + + ); + } + const viewSourceButton = ( + + {_t("View Source")} + + ); + // disabled remove button when not allowed + return ( +
+ {redactButton} + {viewSourceButton} +
+ ); } render() { @@ -41,21 +121,38 @@ export default class EditHistoryMessage extends React.PureComponent { const content = originalContent["m.new_content"] || originalContent; const contentElements = HtmlUtils.bodyToHtml(content); let contentContainer; - if (mxEvent.getContent().msgtype === "m.emote") { + if (mxEvent.isRedacted()) { + const UnknownBody = sdk.getComponent('messages.UnknownBody'); + contentContainer = ; + } else if (mxEvent.getContent().msgtype === "m.emote") { const name = mxEvent.sender ? mxEvent.sender.name : mxEvent.getSender(); - contentContainer = (
*  - { name } -  {contentElements} -
); + contentContainer = ( +
*  + { name } +  {contentElements} +
+ ); } else { - contentContainer = (
{contentElements}
); + contentContainer =
{contentElements}
; } const timestamp = formatTime(new Date(mxEvent.getTs()), this.props.isTwelveHour); - return
  • -
    - {timestamp} - { contentContainer } -
    -
  • ; + const isSending = (['sending', 'queued', 'encrypting'].indexOf(this.state.sendStatus) !== -1); + const classes = classNames({ + "mx_EventTile": true, + "mx_EventTile_redacted": mxEvent.isRedacted(), + "mx_EventTile_sending": isSending, + "mx_EventTile_notSent": this.state.sendStatus === 'not_sent', + }); + return ( +
  • +
    +
    + {timestamp} + { contentContainer } + { this._renderActionBar() } +
    +
    +
  • + ); } } diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index a8febdf2e4..5d100bcfb0 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -93,6 +93,7 @@ "Failed to add the following rooms to %(groupId)s:": "Failed to add the following rooms to %(groupId)s:", "Unnamed Room": "Unnamed Room", "Error": "Error", + "You cannot delete this message. (%(code)s)": "You cannot delete this message. (%(code)s)", "Unable to load! Check your network connectivity and try again.": "Unable to load! Check your network connectivity and try again.", "Dismiss": "Dismiss", "Riot does not have permission to send you notifications - please check your browser settings": "Riot does not have permission to send you notifications - please check your browser settings", @@ -1126,6 +1127,7 @@ "Start chatting": "Start chatting", "Click on the button below to start chatting!": "Click on the button below to start chatting!", "Start Chatting": "Start Chatting", + "Removing…": "Removing…", "Confirm Removal": "Confirm Removal", "Are you sure you wish to remove (delete) this event? Note that if you delete a room name or topic change, it could undo the change.": "Are you sure you wish to remove (delete) this event? Note that if you delete a room name or topic change, it could undo the change.", "Clear all data on this device?": "Clear all data on this device?", @@ -1310,7 +1312,6 @@ "Reject invitation": "Reject invitation", "Are you sure you want to reject the invitation?": "Are you sure you want to reject the invitation?", "Unable to reject invite": "Unable to reject invite", - "You cannot delete this message. (%(code)s)": "You cannot delete this message. (%(code)s)", "Resend": "Resend", "Resend edit": "Resend edit", "Resend %(unsentCount)s reaction(s)": "Resend %(unsentCount)s reaction(s)",