From 1b2b3534982e0cfa7ec9245a6b38770ba00ec480 Mon Sep 17 00:00:00 2001 From: David Teller Date: Wed, 12 Jan 2022 11:55:32 +0100 Subject: [PATCH 1/7] MSC3531 - Implementing message hiding pending moderation Signed-off-by: David Teller --- docs/settings.md | 2 +- res/css/_components.scss | 1 + res/css/views/messages/_HiddenBody.scss | 37 +++++++ res/css/views/rooms/_EventTile.scss | 10 ++ res/css/views/rooms/_ReplyTile.scss | 4 +- src/components/structures/MessagePanel.tsx | 23 +++-- src/components/structures/TimelinePanel.tsx | 56 +++++++++++ src/components/views/messages/HiddenBody.tsx | 55 +++++++++++ src/components/views/messages/IBodyProps.ts | 8 ++ .../views/messages/MessageEvent.tsx | 9 +- src/components/views/messages/TextualBody.tsx | 33 ++++++- src/components/views/rooms/EventTile.tsx | 98 ++++++++++++++++++- src/i18n/strings/en_EN.json | 5 +- src/settings/Settings.tsx | 7 ++ src/utils/exportUtils/exportCustomCSS.css | 4 +- 15 files changed, 333 insertions(+), 19 deletions(-) create mode 100644 res/css/views/messages/_HiddenBody.scss create mode 100644 src/components/views/messages/HiddenBody.tsx diff --git a/docs/settings.md b/docs/settings.md index 891877a57af..379f3c5dcd4 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -25,7 +25,7 @@ that room administrators cannot force account-only settings upon participants. ## Settings Settings are the different options a user may set or experience in the application. These are pre-defined in -`src/settings/Settings.ts` under the `SETTINGS` constant, and match the `ISetting` interface as defined there. +`src/settings/Settings.tsx` under the `SETTINGS` constant, and match the `ISetting` interface as defined there. Settings that support the config level can be set in the config file under the `settingDefaults` key (note that some settings, like the "theme" setting, are special cased in the config file): diff --git a/res/css/_components.scss b/res/css/_components.scss index 8972cdb4b57..39a773c7c5e 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -184,6 +184,7 @@ @import "./views/messages/_CreateEvent.scss"; @import "./views/messages/_DateSeparator.scss"; @import "./views/messages/_EventTileBubble.scss"; +@import "./views/messages/_HiddenBody.scss"; @import "./views/messages/_MEmoteBody.scss"; @import "./views/messages/_MFileBody.scss"; @import "./views/messages/_MImageBody.scss"; diff --git a/res/css/views/messages/_HiddenBody.scss b/res/css/views/messages/_HiddenBody.scss new file mode 100644 index 00000000000..928344bf36c --- /dev/null +++ b/res/css/views/messages/_HiddenBody.scss @@ -0,0 +1,37 @@ +/* +Copyright 2020 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_HiddenBody { + white-space: pre-wrap; + color: $muted-fg-color; + vertical-align: middle; + + padding-left: 20px; + position: relative; + + &::before { + height: 14px; + width: 14px; + background-color: $muted-fg-color; + mask-image: url('$(res)/img/element-icons/hide.svg'); + + mask-repeat: no-repeat; + mask-position: center; + mask-size: contain; + content: ''; + position: absolute; + top: 1px; + left: 0; + } +} diff --git a/res/css/views/rooms/_EventTile.scss b/res/css/views/rooms/_EventTile.scss index e90b3dd3d90..25c5f0ce247 100644 --- a/res/css/views/rooms/_EventTile.scss +++ b/res/css/views/rooms/_EventTile.scss @@ -395,6 +395,14 @@ $left-gutter: 64px; cursor: pointer; } +.mx_EventTile_content .mx_EventTile_hidden { + user-select: none; + font-size: $font-12px; + color: $roomtopic-color; + display: inline-block; + margin-left: 9px; +} + .mx_EventTile_e2eIcon { position: relative; width: 14px; @@ -895,12 +903,14 @@ $left-gutter: 64px; width: 100%; .mx_EventTile_content, + .mx_HiddenBody, .mx_RedactedBody, .mx_ReplyChain_wrapper { margin-left: 36px; margin-right: 50px; .mx_EventTile_content, + .mx_HiddenBody, .mx_RedactedBody, .mx_MImageBody { margin: 0; diff --git a/res/css/views/rooms/_ReplyTile.scss b/res/css/views/rooms/_ReplyTile.scss index a03f0b38cff..c2f19eff2d1 100644 --- a/res/css/views/rooms/_ReplyTile.scss +++ b/res/css/views/rooms/_ReplyTile.scss @@ -45,7 +45,9 @@ limitations under the License. color: $primary-content; } - .mx_RedactedBody { + .mx_RedactedBody, + .mx_HiddenBody { + padding: 4px 0 2px 20px; &::before { diff --git a/src/components/structures/MessagePanel.tsx b/src/components/structures/MessagePanel.tsx index 4e9723a3a40..660a97ef552 100644 --- a/src/components/structures/MessagePanel.tsx +++ b/src/components/structures/MessagePanel.tsx @@ -250,7 +250,7 @@ export default class MessagePanel extends React.Component { private scrollPanel = createRef(); private readonly showTypingNotificationsWatcherRef: string; - private eventNodes: Record; + private eventTiles: Record = {}; // A map of private callEventGroupers = new Map(); @@ -324,11 +324,18 @@ export default class MessagePanel extends React.Component { /* get the DOM node representing the given event */ public getNodeForEventId(eventId: string): HTMLElement { - if (!this.eventNodes) { + if (!this.eventTiles) { return undefined; } - return this.eventNodes[eventId]; + return this.eventTiles[eventId]?.ref?.current; + } + + public getTileForEventId(eventId: string): EventTile { + if (!this.eventTiles) { + return undefined; + } + return this.eventTiles[eventId]; } /* return true if the content is fully scrolled down right now; else false. @@ -429,7 +436,7 @@ export default class MessagePanel extends React.Component { } public scrollToEventIfNeeded(eventId: string): void { - const node = this.eventNodes[eventId]; + const node = this.getNodeForEventId(eventId); if (node) { node.scrollIntoView({ block: "nearest", @@ -584,8 +591,6 @@ export default class MessagePanel extends React.Component { } } private getEventTiles(): ReactNode[] { - this.eventNodes = {}; - let i; // first figure out which is the last event in the list which we're @@ -776,7 +781,7 @@ export default class MessagePanel extends React.Component { { return receiptsByEvent; } - private collectEventNode = (eventId: string, node: EventTile): void => { - this.eventNodes[eventId] = node?.ref?.current; + private collectEventTile = (eventId: string, node: EventTile): void => { + this.eventTiles[eventId] = node; }; // once dynamic content in the events load, make the scrollPanel check the diff --git a/src/components/structures/TimelinePanel.tsx b/src/components/structures/TimelinePanel.tsx index 30d7231936e..6862af9e4d2 100644 --- a/src/components/structures/TimelinePanel.tsx +++ b/src/components/structures/TimelinePanel.tsx @@ -23,6 +23,7 @@ import { Direction, EventTimeline } from "matrix-js-sdk/src/models/event-timelin import { TimelineWindow } from "matrix-js-sdk/src/timeline-window"; import { EventType, RelationType } from 'matrix-js-sdk/src/@types/event'; import { SyncState } from 'matrix-js-sdk/src/sync'; +import { RoomMember } from 'matrix-js-sdk'; import { debounce } from 'lodash'; import { logger } from "matrix-js-sdk/src/logger"; @@ -276,6 +277,11 @@ class TimelinePanel extends React.Component { cli.on("Room.timeline", this.onRoomTimeline); cli.on("Room.timelineReset", this.onRoomTimelineReset); cli.on("Room.redaction", this.onRoomRedaction); + if (SettingsStore.getValue("feature_msc3531_hide_messages_pending_moderation")) { + // Make sure that events are re-rendered when their visibility-pending-moderation changes. + cli.on("Event.visibilityChange", this.onEventVisibilityChange); + cli.on("RoomMember.powerLevel", this.onVisibilityPowerLevelChange); + } // same event handler as Room.redaction as for both we just do forceUpdate cli.on("Room.redactionCancelled", this.onRoomRedaction); cli.on("Room.receipt", this.onRoomReceipt); @@ -352,8 +358,10 @@ class TimelinePanel extends React.Component { client.removeListener("Room.receipt", this.onRoomReceipt); client.removeListener("Room.localEchoUpdated", this.onLocalEchoUpdated); client.removeListener("Room.accountData", this.onAccountData); + client.removeListener("RoomMember.powerLevel", this.onVisibilityPowerLevelChange); client.removeListener("Event.decrypted", this.onEventDecrypted); client.removeListener("Event.replaced", this.onEventReplaced); + client.removeListener("Event.visibilityChange", this.onEventVisibilityChange); client.removeListener("sync", this.onSync); } } @@ -619,6 +627,54 @@ class TimelinePanel extends React.Component { this.forceUpdate(); }; + // Called whenever the visibility of an event changes, as per + // MSC3531. We typically need to re-render the tile. + private onEventVisibilityChange = (ev: MatrixEvent): void => { + const roomId = ev.getRoomId(); + if (this.unmounted) { + return; + } + + // ignore events for other rooms + if (roomId !== this.props.timelineSet.room.roomId) { + return; + } + + // we could skip an update if the event isn't in our timeline, + // but that's probably an early optimisation. + const tile = this.messagePanel.current?.getTileForEventId(ev.getId()); + if (!tile) { + // The event is not visible, nothing to re-render. + return; + } + tile.forceUpdate(); + }; + + private onVisibilityPowerLevelChange = (ev: MatrixEvent, member: RoomMember): void => { + logger.debug("TimelinePanel.onVisibilityPowerLevelChange", + member.userId, MatrixClientPeg.get().credentials.userId); + if (this.unmounted) return; + + // ignore events for other rooms + if (member.roomId !== this.props.timelineSet.room.roomId) return; + + // ignore events for other users + if (member.userId != MatrixClientPeg.get().credentials.userId) return; + + // We could skip an update if the power level change didn't cross the + // threshold for `VISIBILITY_CHANGE_TYPE`. + for (const event of this.state.events) { + const tile = this.messagePanel.current?.getTileForEventId(event.getId()); + if (!tile) { + // The event is not visible, nothing to re-render. + continue; + } + tile.forceUpdate(); + } + + this.forceUpdate(); + }; + private onEventReplaced = (replacedEvent: MatrixEvent, room: Room): void => { if (this.unmounted) return; diff --git a/src/components/views/messages/HiddenBody.tsx b/src/components/views/messages/HiddenBody.tsx new file mode 100644 index 00000000000..2b28907b437 --- /dev/null +++ b/src/components/views/messages/HiddenBody.tsx @@ -0,0 +1,55 @@ +/* +Copyright 2020 - 2021 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 { MatrixEvent } from "matrix-js-sdk/src/models/event"; + +import { _t } from "../../../languageHandler"; +import { IBodyProps } from "./IBodyProps"; + +interface IProps { + mxEvent: MatrixEvent; +} + +/** + * A message hidden from the user pending moderation. + * + * Note: This component must not be used when the user is the author of the message + * or has a sufficient powerlevel to see the message. + */ +const HiddenBody = React.forwardRef(({ mxEvent }, ref) => { + let text; + const visibility = mxEvent.messageVisibility(); + switch (visibility.visible) { + case true: + throw new Error("HiddenBody should only be applied to hidden messages"); + case false: + if (visibility.reason) { + text = _t("Message pending moderation: %(reason)s", { reason: visibility.reason }); + } else { + text = _t("Message pending moderation"); + } + break; + } + + return ( + + { text } + + ); +}); + +export default HiddenBody; diff --git a/src/components/views/messages/IBodyProps.ts b/src/components/views/messages/IBodyProps.ts index 4e424fcc3e8..c39dfa47987 100644 --- a/src/components/views/messages/IBodyProps.ts +++ b/src/components/views/messages/IBodyProps.ts @@ -44,6 +44,14 @@ export interface IBodyProps { permalinkCreator: RoomPermalinkCreator; mediaEventHelper: MediaEventHelper; + /* + If present and `true`, the message has been marked as hidden pending moderation + (see MSC3531) **but** the current user can see the message nevertheless (with + a marker), either because they are a moderator or because they are the original + author of the message. + */ + isSeeingThroughMessageHiddenForModeration?: boolean; + // helper function to access relations for this event getRelationsForEvent?: (eventId: string, relationType: string, eventType: string) => Relations; } diff --git a/src/components/views/messages/MessageEvent.tsx b/src/components/views/messages/MessageEvent.tsx index a8ad1a98f94..57aea41707d 100644 --- a/src/components/views/messages/MessageEvent.tsx +++ b/src/components/views/messages/MessageEvent.tsx @@ -31,6 +31,7 @@ import { IOperableEventTile } from "../context_menus/MessageContextMenu"; import { MediaEventHelper } from "../../../utils/MediaEventHelper"; import { ReactAnyComponent } from "../../../@types/common"; import { IBodyProps } from "./IBodyProps"; +import MatrixClientContext from '../../../contexts/MatrixClientContext'; // onMessageAllowed is handled internally interface IProps extends Omit { @@ -40,6 +41,8 @@ interface IProps extends Omit { // helper function to access relations for this event getRelationsForEvent?: (eventId: string, relationType: string, eventType: string) => Relations; + + isSeeingThroughMessageHiddenForModeration?: boolean; } @replaceableComponent("views.messages.MessageEvent") @@ -47,7 +50,10 @@ export default class MessageEvent extends React.Component implements IMe private body: React.RefObject = createRef(); private mediaHelper: MediaEventHelper; - public constructor(props: IProps) { + static contextType = MatrixClientContext; + public context!: React.ContextType; + + public constructor(props: IProps, context: React.ContextType) { super(props); if (MediaEventHelper.isEligible(this.props.mxEvent)) { @@ -171,6 +177,7 @@ export default class MessageEvent extends React.Component implements IMe permalinkCreator={this.props.permalinkCreator} mediaEventHelper={this.mediaHelper} getRelationsForEvent={this.props.getRelationsForEvent} + isSeeingThroughMessageHiddenForModeration={this.props.isSeeingThroughMessageHiddenForModeration} /> : null; } } diff --git a/src/components/views/messages/TextualBody.tsx b/src/components/views/messages/TextualBody.tsx index 30b7edf7cfd..a36db1e3526 100644 --- a/src/components/views/messages/TextualBody.tsx +++ b/src/components/views/messages/TextualBody.tsx @@ -296,7 +296,9 @@ export default class TextualBody extends React.Component { nextProps.showUrlPreview !== this.props.showUrlPreview || nextProps.editState !== this.props.editState || nextState.links !== this.state.links || - nextState.widgetHidden !== this.state.widgetHidden); + nextState.widgetHidden !== this.state.widgetHidden || + nextProps.isSeeingThroughMessageHiddenForModeration + !== this.props.isSeeingThroughMessageHiddenForModeration); } private calculateUrlPreview(): void { @@ -503,6 +505,29 @@ export default class TextualBody extends React.Component { ); } + /** + * Render a marker informing the user that, while they can see the message, + * it is hidden for other users. + */ + private renderHiddenMessageMarker() { + let text; + const visibility = this.props.mxEvent.messageVisibility(); + switch (visibility.visible) { + case true: + throw new Error("renderHiddenMessageMarker should only be applied to hidden messages"); + case false: + if (visibility.reason) { + text = _t("Message pending moderation: %(reason)s", { reason: visibility.reason }); + } else { + text = _t("Message pending moderation"); + } + break; + } + return ( + { `(${text})` } + ); + } + render() { if (this.props.editState) { return ; @@ -526,6 +551,12 @@ export default class TextualBody extends React.Component { { this.renderEditedMarker() } ; } + if (this.props.isSeeingThroughMessageHiddenForModeration) { + body = <> + { body } + { this.renderHiddenMessageMarker() } + ; + } if (this.props.highlightLink) { body = { body }; diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx index 467d0d45790..e91c88e2e94 100644 --- a/src/components/views/rooms/EventTile.tsx +++ b/src/components/views/rooms/EventTile.tsx @@ -17,7 +17,7 @@ limitations under the License. import React, { createRef } from 'react'; import classNames from "classnames"; -import { EventType, MsgType } from "matrix-js-sdk/src/@types/event"; +import { EventType, MsgType, EVENT_VISIBILITY_CHANGE_TYPE } from "matrix-js-sdk/src/@types/event"; import { EventStatus, MatrixEvent } from "matrix-js-sdk/src/models/event"; import { Relations } from "matrix-js-sdk/src/models/relations"; import { RoomMember } from "matrix-js-sdk/src/models/room-member"; @@ -333,6 +333,12 @@ interface IProps { showThreadInfo?: boolean; timelineRenderingType?: TimelineRenderingType; + + // if specified and `true`, the message his behing + // hidden for moderation from other users but is + // displayed to the current user either because they're + // the author or they are a moderator + isSeeingThroughMessageHiddenForModeration?: boolean; } interface IState { @@ -1030,7 +1036,6 @@ export default class EventTile extends React.Component { private onActionBarFocusChange = (actionBarFocused: boolean) => { this.setState({ actionBarFocused }); }; - // TODO: Types private getTile: () => any | null = () => this.tile.current; @@ -1066,13 +1071,16 @@ export default class EventTile extends React.Component { render() { const msgtype = this.props.mxEvent.getContent().msgtype; const eventType = this.props.mxEvent.getType() as EventType; - const { + const eventDisplayInfo = getEventDisplayInfo(this.props.mxEvent); + let { tileHandler, + } = eventDisplayInfo; + const { isBubbleMessage, isInfoMessage, isLeftAlignedBubbleMessage, noBubbleEvent, - } = getEventDisplayInfo(this.props.mxEvent); + } = eventDisplayInfo; const { isQuoteExpanded } = this.state; // This shouldn't happen: the caller should check we support this type @@ -1087,6 +1095,23 @@ export default class EventTile extends React.Component { ; } + let isSeeingThroughMessageHiddenForModeration = false; + if (SettingsStore.getValue("feature_msc3531_hide_messages_pending_moderation")) { + switch (this.getMessageModerationState()) { + case MessageModerationState.VISIBLE_FOR_ALL: + // Default behavior, nothing to do. + break; + case MessageModerationState.HIDDEN_TO_CURRENT_USER: + // Hide message. + tileHandler = "messages.HiddenBody"; + break; + case MessageModerationState.SEE_THROUGH_FOR_CURRENT_USER: + // Show message with a marker. + isSeeingThroughMessageHiddenForModeration = true; + break; + } + } + const EventTileType = sdk.getComponent(tileHandler); const isProbablyMedia = MediaEventHelper.isEligible(this.props.mxEvent); @@ -1363,6 +1388,7 @@ export default class EventTile extends React.Component { tileShape={this.props.tileShape} editState={this.props.editState} getRelationsForEvent={this.props.getRelationsForEvent} + isSeeingThroughMessageHiddenForModeration={isSeeingThroughMessageHiddenForModeration} /> , ]); @@ -1405,6 +1431,7 @@ export default class EventTile extends React.Component { editState={this.props.editState} replacingEventId={this.props.replacingEventId} getRelationsForEvent={this.props.getRelationsForEvent} + isSeeingThroughMessageHiddenForModeration={isSeeingThroughMessageHiddenForModeration} /> { actionBar } { timestamp } @@ -1478,6 +1505,7 @@ export default class EventTile extends React.Component { onHeightChanged={this.props.onHeightChanged} editState={this.props.editState} getRelationsForEvent={this.props.getRelationsForEvent} + isSeeingThroughMessageHiddenForModeration={isSeeingThroughMessageHiddenForModeration} /> , { onHeightChanged={this.props.onHeightChanged} callEventGrouper={this.props.callEventGrouper} getRelationsForEvent={this.props.getRelationsForEvent} + isSeeingThroughMessageHiddenForModeration={isSeeingThroughMessageHiddenForModeration} /> { keyRequestInfo } { actionBar } @@ -1548,6 +1577,46 @@ export default class EventTile extends React.Component { } } } + + // Determine whether this message should be rendered as + // visible to all, hidden from this user, hidden from some + // users but not current user. + private getMessageModerationState(): MessageModerationState { + if (!SettingsStore.getValue("feature_msc3531_hide_messages_pending_moderation")) { + throw new Error( + "This method should only be called when feature_msc3531_hide_messages_pending_moderation is on", + ); + } + const visibility = this.props.mxEvent.messageVisibility(); + if (visibility.visible) { + return MessageModerationState.VISIBLE_FOR_ALL; + } + + // At this point, we know that the message is marked as hidden + // pending moderation. However, if we're the author or a moderator, + // we still need to display it. + + if (this.props.mxEvent.sender.userId === this.context.getUserId()) { + // We're the author, show the message. + return MessageModerationState.SEE_THROUGH_FOR_CURRENT_USER; + } + + const room = this.context.getRoom(this.props.mxEvent.getRoomId()); + if (EVENT_VISIBILITY_CHANGE_TYPE.name + && room.currentState.maySendStateEvent(EVENT_VISIBILITY_CHANGE_TYPE.name, this.context.getUserId()) + ) { + // We're a moderator (as indicated by prefixed event name), show the message. + return MessageModerationState.SEE_THROUGH_FOR_CURRENT_USER; + } + if (EVENT_VISIBILITY_CHANGE_TYPE.altName + && room.currentState.maySendStateEvent(EVENT_VISIBILITY_CHANGE_TYPE.altName, this.context.getUserId()) + ) { + // We're a moderator (as indicated by unprefixed event name), show the message. + return MessageModerationState.SEE_THROUGH_FOR_CURRENT_USER; + } + // For everybody else, hide the message. + return MessageModerationState.HIDDEN_TO_CURRENT_USER; + } } // XXX this'll eventually be dynamic based on the fields once we have extensible event types @@ -1723,3 +1792,24 @@ class SentReceipt extends React.PureComponent ); diff --git a/src/utils/EventUtils.ts b/src/utils/EventUtils.ts index f48e720e702..21f75fab80d 100644 --- a/src/utils/EventUtils.ts +++ b/src/utils/EventUtils.ts @@ -15,7 +15,7 @@ limitations under the License. */ import { EventStatus, MatrixEvent } from 'matrix-js-sdk/src/models/event'; -import { EventType, MsgType, RelationType } from "matrix-js-sdk/src/@types/event"; +import { EventType, EVENT_VISIBILITY_CHANGE_TYPE, MsgType, RelationType } from "matrix-js-sdk/src/@types/event"; import { MatrixClient } from 'matrix-js-sdk/src/client'; import { logger } from 'matrix-js-sdk/src/logger'; import { POLL_START_EVENT_TYPE } from "matrix-js-sdk/src/@types/polls"; @@ -114,18 +114,101 @@ export function findEditableEvent({ } } +/** + * How we should render a message depending on its moderation state. + */ +enum MessageModerationState { + /** + * The message is visible to all. + */ + VISIBLE_FOR_ALL = "VISIBLE_FOR_ALL", + /** + * The message is hidden pending moderation and we're not a user who should + * see it nevertheless. + */ + HIDDEN_TO_CURRENT_USER = "HIDDEN_TO_CURRENT_USER", + /** + * The message is hidden pending moderation and we're either the author of + * the message or a moderator. In either case, we need to see the message + * with a marker. + */ + SEE_THROUGH_FOR_CURRENT_USER = "SEE_THROUGH_FOR_CURRENT_USER", +} + +/** + * Determine whether a message should be displayed as hidden pending moderation. + * + * If MSC3531 is deactivated in settings, all messages are considered visible + * to all. + */ +export function getMessageModerationState(mxEvent: MatrixEvent): MessageModerationState { + if (!SettingsStore.getValue("feature_msc3531_hide_messages_pending_moderation")) { + return MessageModerationState.VISIBLE_FOR_ALL; + } + const visibility = mxEvent.messageVisibility(); + if (visibility.visible) { + return MessageModerationState.VISIBLE_FOR_ALL; + } + + // At this point, we know that the message is marked as hidden + // pending moderation. However, if we're the author or a moderator, + // we still need to display it. + + const client = MatrixClientPeg.get(); + if (mxEvent.sender?.userId === client.getUserId()) { + // We're the author, show the message. + return MessageModerationState.SEE_THROUGH_FOR_CURRENT_USER; + } + + const room = client.getRoom(mxEvent.getRoomId()); + if (EVENT_VISIBILITY_CHANGE_TYPE.name + && room.currentState.maySendStateEvent(EVENT_VISIBILITY_CHANGE_TYPE.name, client.getUserId()) + ) { + // We're a moderator (as indicated by prefixed event name), show the message. + return MessageModerationState.SEE_THROUGH_FOR_CURRENT_USER; + } + if (EVENT_VISIBILITY_CHANGE_TYPE.altName + && room.currentState.maySendStateEvent(EVENT_VISIBILITY_CHANGE_TYPE.altName, client.getUserId()) + ) { + // We're a moderator (as indicated by unprefixed event name), show the message. + return MessageModerationState.SEE_THROUGH_FOR_CURRENT_USER; + } + // For everybody else, hide the message. + return MessageModerationState.HIDDEN_TO_CURRENT_USER; +} + export function getEventDisplayInfo(mxEvent: MatrixEvent): { isInfoMessage: boolean; tileHandler: string; isBubbleMessage: boolean; isLeftAlignedBubbleMessage: boolean; noBubbleEvent: boolean; + isSeeingThroughMessageHiddenForModeration: boolean; } { const content = mxEvent.getContent(); const msgtype = content.msgtype; const eventType = mxEvent.getType(); - let tileHandler = getHandlerTile(mxEvent); + let isSeeingThroughMessageHiddenForModeration = false; + let tileHandler; + if (SettingsStore.getValue("feature_msc3531_hide_messages_pending_moderation")) { + switch (getMessageModerationState(mxEvent)) { + case MessageModerationState.VISIBLE_FOR_ALL: + // Default behavior, nothing to do. + break; + case MessageModerationState.HIDDEN_TO_CURRENT_USER: + // Hide message. + tileHandler = "messages.HiddenBody"; + break; + case MessageModerationState.SEE_THROUGH_FOR_CURRENT_USER: + // Show message with a marker. + isSeeingThroughMessageHiddenForModeration = true; + break; + } + } + if (!tileHandler) { + tileHandler = getHandlerTile(mxEvent); + } // Info messages are basically information about commands processed on a room let isBubbleMessage = ( @@ -168,7 +251,14 @@ export function getEventDisplayInfo(mxEvent: MatrixEvent): { isInfoMessage = true; } - return { tileHandler, isInfoMessage, isBubbleMessage, isLeftAlignedBubbleMessage, noBubbleEvent }; + return { + tileHandler, + isInfoMessage, + isBubbleMessage, + isLeftAlignedBubbleMessage, + noBubbleEvent, + isSeeingThroughMessageHiddenForModeration, + }; } export function isVoiceMessage(mxEvent: MatrixEvent): boolean {