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