diff --git a/docs/settings.md b/docs/settings.md index 891877a57a..379f3c5dcd 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 265eef7495..3aa127e03a 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -183,6 +183,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 0000000000..14d003e669 --- /dev/null +++ b/res/css/views/messages/_HiddenBody.scss @@ -0,0 +1,37 @@ +/* +Copyright 2022 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 2b4be3d4ad..4bb171044d 100644 --- a/res/css/views/rooms/_EventTile.scss +++ b/res/css/views/rooms/_EventTile.scss @@ -396,6 +396,14 @@ $left-gutter: 64px; cursor: pointer; } +.mx_EventTile_content .mx_EventTile_pendingModeration { + user-select: none; + font-size: $font-12px; + color: $roomtopic-color; + display: inline-block; + margin-left: 9px; +} + .mx_EventTile_e2eIcon { position: relative; width: 14px; @@ -909,12 +917,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 a03f0b38cf..c2f19eff2d 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 4e9723a3a4..660a97ef55 100644 --- a/src/components/structures/MessagePanel.tsx +++ b/src/components/structures/MessagePanel.tsx @@ -250,7 +250,7 @@ export default class MessagePanel extends React.Component<IProps, IState> { private scrollPanel = createRef<ScrollPanel>(); private readonly showTypingNotificationsWatcherRef: string; - private eventNodes: Record<string, HTMLElement>; + private eventTiles: Record<string, EventTile> = {}; // A map of <callId, CallEventGrouper> private callEventGroupers = new Map<string, CallEventGrouper>(); @@ -324,11 +324,18 @@ export default class MessagePanel extends React.Component<IProps, IState> { /* 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<IProps, IState> { } 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<IProps, IState> { } } 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<IProps, IState> { <TileErrorBoundary key={mxEv.getTxnId() || eventId} mxEvent={mxEv}> <EventTile as="li" - ref={this.collectEventNode.bind(this, eventId)} + ref={this.collectEventTile.bind(this, eventId)} alwaysShowTimestamps={this.props.alwaysShowTimestamps} mxEvent={mxEv} continuation={continuation} @@ -909,8 +914,8 @@ export default class MessagePanel extends React.Component<IProps, IState> { 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 30d7231936..1f93fc89f7 100644 --- a/src/components/structures/TimelinePanel.tsx +++ b/src/components/structures/TimelinePanel.tsx @@ -1,5 +1,5 @@ /* -Copyright 2016 - 2021 The Matrix.org Foundation C.I.C. +Copyright 2016 - 2022 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. @@ -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<IProps, IState> { 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<IProps, IState> { 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,50 @@ class TimelinePanel extends React.Component<IProps, IState> { 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 => { + if (this.unmounted) { + return; + } + + // ignore events for other rooms + const roomId = ev.getRoomId(); + 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) { + tile.forceUpdate(); + } + }; + + private onVisibilityPowerLevelChange = (ev: MatrixEvent, member: RoomMember): void => { + 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 0000000000..dc309877ca --- /dev/null +++ b/src/components/views/messages/HiddenBody.tsx @@ -0,0 +1,55 @@ +/* +Copyright 2022 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<any, IProps | IBodyProps>(({ 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 ( + <span className="mx_HiddenBody" ref={ref}> + { text } + </span> + ); +}); + +export default HiddenBody; diff --git a/src/components/views/messages/IBodyProps.ts b/src/components/views/messages/IBodyProps.ts index 4e424fcc3e..c39dfa4798 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 a8ad1a98f9..57aea41707 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<IBodyProps, "onMessageAllowed"> { @@ -40,6 +41,8 @@ interface IProps extends Omit<IBodyProps, "onMessageAllowed"> { // 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<IProps> implements IMe private body: React.RefObject<React.Component | IOperableEventTile> = createRef(); private mediaHelper: MediaEventHelper; - public constructor(props: IProps) { + static contextType = MatrixClientContext; + public context!: React.ContextType<typeof MatrixClientContext>; + + public constructor(props: IProps, context: React.ContextType<typeof MatrixClientContext>) { super(props); if (MediaEventHelper.isEligible(this.props.mxEvent)) { @@ -171,6 +177,7 @@ export default class MessageEvent extends React.Component<IProps> 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 a622d55aa0..874d1f8ea1 100644 --- a/src/components/views/messages/TextualBody.tsx +++ b/src/components/views/messages/TextualBody.tsx @@ -297,7 +297,9 @@ export default class TextualBody extends React.Component<IBodyProps, IState> { 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 { @@ -504,6 +506,29 @@ export default class TextualBody extends React.Component<IBodyProps, IState> { ); } + /** + * Render a marker informing the user that, while they can see the message, + * it is hidden for other users. + */ + private renderPendingModerationMarker() { + let text; + const visibility = this.props.mxEvent.messageVisibility(); + switch (visibility.visible) { + case true: + throw new Error("renderPendingModerationMarker 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 ( + <span className="mx_EventTile_pendingModeration">{ `(${text})` }</span> + ); + } + render() { if (this.props.editState) { return <EditMessageComposer editState={this.props.editState} className="mx_EventTile_content" />; @@ -554,6 +579,12 @@ export default class TextualBody extends React.Component<IBodyProps, IState> { { this.renderEditedMarker() } </>; } + if (this.props.isSeeingThroughMessageHiddenForModeration) { + body = <> + { body } + { this.renderPendingModerationMarker() } + </>; + } if (this.props.highlightLink) { body = <a href={this.props.highlightLink}>{ body }</a>; diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx index 17513dde76..1aae2be9a1 100644 --- a/src/components/views/rooms/EventTile.tsx +++ b/src/components/views/rooms/EventTile.tsx @@ -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 { @@ -1038,7 +1044,6 @@ export default class EventTile extends React.Component<IProps, IState> { private onActionBarFocusChange = (actionBarFocused: boolean) => { this.setState({ actionBarFocused }); }; - // TODO: Types private getTile: () => any | null = () => this.tile.current; @@ -1074,13 +1079,15 @@ export default class EventTile extends React.Component<IProps, IState> { render() { const msgtype = this.props.mxEvent.getContent().msgtype; const eventType = this.props.mxEvent.getType() as EventType; + const eventDisplayInfo = getEventDisplayInfo(this.props.mxEvent); const { tileHandler, isBubbleMessage, isInfoMessage, isLeftAlignedBubbleMessage, noBubbleEvent, - } = getEventDisplayInfo(this.props.mxEvent); + isSeeingThroughMessageHiddenForModeration, + } = eventDisplayInfo; const { isQuoteExpanded } = this.state; // This shouldn't happen: the caller should check we support this type @@ -1371,6 +1378,7 @@ export default class EventTile extends React.Component<IProps, IState> { tileShape={this.props.tileShape} editState={this.props.editState} getRelationsForEvent={this.props.getRelationsForEvent} + isSeeingThroughMessageHiddenForModeration={isSeeingThroughMessageHiddenForModeration} /> </div>, ]); @@ -1413,6 +1421,7 @@ export default class EventTile extends React.Component<IProps, IState> { editState={this.props.editState} replacingEventId={this.props.replacingEventId} getRelationsForEvent={this.props.getRelationsForEvent} + isSeeingThroughMessageHiddenForModeration={isSeeingThroughMessageHiddenForModeration} /> { actionBar } { timestamp } @@ -1486,6 +1495,7 @@ export default class EventTile extends React.Component<IProps, IState> { onHeightChanged={this.props.onHeightChanged} editState={this.props.editState} getRelationsForEvent={this.props.getRelationsForEvent} + isSeeingThroughMessageHiddenForModeration={isSeeingThroughMessageHiddenForModeration} /> </div>, <a @@ -1538,6 +1548,7 @@ export default class EventTile extends React.Component<IProps, IState> { onHeightChanged={this.props.onHeightChanged} callEventGrouper={this.props.callEventGrouper} getRelationsForEvent={this.props.getRelationsForEvent} + isSeeingThroughMessageHiddenForModeration={isSeeingThroughMessageHiddenForModeration} /> { keyRequestInfo } { actionBar } diff --git a/src/components/views/rooms/ReplyTile.tsx b/src/components/views/rooms/ReplyTile.tsx index e66207d918..8a9f0c46eb 100644 --- a/src/components/views/rooms/ReplyTile.tsx +++ b/src/components/views/rooms/ReplyTile.tsx @@ -109,7 +109,7 @@ export default class ReplyTile extends React.PureComponent<IProps> { const msgType = mxEvent.getContent().msgtype; const evType = mxEvent.getType() as EventType; - const { tileHandler, isInfoMessage } = getEventDisplayInfo(mxEvent); + const { tileHandler, isInfoMessage, isSeeingThroughMessageHiddenForModeration } = getEventDisplayInfo(mxEvent); // This shouldn't happen: the caller should check we support this type // before trying to instantiate us if (!tileHandler) { @@ -174,7 +174,9 @@ export default class ReplyTile extends React.PureComponent<IProps> { overrideEventTypes={evOverrides} replacingEventId={mxEvent.replacingEventId()} maxImageHeight={96} - getRelationsForEvent={this.props.getRelationsForEvent} /> + getRelationsForEvent={this.props.getRelationsForEvent} + isSeeingThroughMessageHiddenForModeration={isSeeingThroughMessageHiddenForModeration} + /> </a> </div> ); diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index f5e257a811..d7cae3acaf 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -863,6 +863,7 @@ "Encryption": "Encryption", "Experimental": "Experimental", "Developer": "Developer", + "Let moderators hide messages pending moderation.": "Let moderators hide messages pending moderation.", "Report to moderators prototype. In rooms that support moderation, the `report` button will let you report abuse to room moderators": "Report to moderators prototype. In rooms that support moderation, the `report` button will let you report abuse to room moderators", "Show options to enable 'Do not disturb' mode": "Show options to enable 'Do not disturb' mode", "Render LaTeX maths in messages": "Render LaTeX maths in messages", @@ -2085,6 +2086,8 @@ "Ignored attempt to disable encryption": "Ignored attempt to disable encryption", "Encryption not enabled": "Encryption not enabled", "The encryption used by this room isn't supported.": "The encryption used by this room isn't supported.", + "Message pending moderation: %(reason)s": "Message pending moderation: %(reason)s", + "Message pending moderation": "Message pending moderation", "Error processing audio message": "Error processing audio message", "React": "React", "Edit": "Edit", diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index bc7e3d1bb3..58bebc2821 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -175,6 +175,13 @@ export interface IFeature extends Omit<IBaseSetting, "isFeature"> { export type ISetting = IBaseSetting | IFeature; export const SETTINGS: {[setting: string]: ISetting} = { + "feature_msc3531_hide_messages_pending_moderation": { + isFeature: true, + labsGroup: LabGroup.Moderation, + displayName: _td("Let moderators hide messages pending moderation."), + supportedLevels: LEVELS_FEATURE, + default: false, + }, "feature_report_to_moderators": { isFeature: true, labsGroup: LabGroup.Moderation, diff --git a/src/utils/EventUtils.ts b/src/utils/EventUtils.ts index f48e720e70..21f75fab80 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 { diff --git a/src/utils/exportUtils/exportCustomCSS.css b/src/utils/exportUtils/exportCustomCSS.css index aa403be5e8..0a0a2c2005 100644 --- a/src/utils/exportUtils/exportCustomCSS.css +++ b/src/utils/exportUtils/exportCustomCSS.css @@ -124,7 +124,9 @@ a.mx_reply_anchor:hover { margin-bottom: 5px; } -.mx_RedactedBody { +.mx_RedactedBody, +.mx_HiddenBody { + padding-left: unset; }