MSC3531 - Implementing message hiding pending moderation (#7518)

Signed-off-by: David Teller <davidt@element.io>
This commit is contained in:
David Teller 2022-01-17 16:04:37 +01:00 committed by GitHub
parent c612014936
commit 6b870ba1a9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 345 additions and 22 deletions

View file

@ -25,7 +25,7 @@ that room administrators cannot force account-only settings upon participants.
## Settings ## Settings
Settings are the different options a user may set or experience in the application. These are pre-defined in 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 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): settings, like the "theme" setting, are special cased in the config file):

View file

@ -183,6 +183,7 @@
@import "./views/messages/_CreateEvent.scss"; @import "./views/messages/_CreateEvent.scss";
@import "./views/messages/_DateSeparator.scss"; @import "./views/messages/_DateSeparator.scss";
@import "./views/messages/_EventTileBubble.scss"; @import "./views/messages/_EventTileBubble.scss";
@import "./views/messages/_HiddenBody.scss";
@import "./views/messages/_MEmoteBody.scss"; @import "./views/messages/_MEmoteBody.scss";
@import "./views/messages/_MFileBody.scss"; @import "./views/messages/_MFileBody.scss";
@import "./views/messages/_MImageBody.scss"; @import "./views/messages/_MImageBody.scss";

View file

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

View file

@ -396,6 +396,14 @@ $left-gutter: 64px;
cursor: pointer; 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 { .mx_EventTile_e2eIcon {
position: relative; position: relative;
width: 14px; width: 14px;
@ -909,12 +917,14 @@ $left-gutter: 64px;
width: 100%; width: 100%;
.mx_EventTile_content, .mx_EventTile_content,
.mx_HiddenBody,
.mx_RedactedBody, .mx_RedactedBody,
.mx_ReplyChain_wrapper { .mx_ReplyChain_wrapper {
margin-left: 36px; margin-left: 36px;
margin-right: 50px; margin-right: 50px;
.mx_EventTile_content, .mx_EventTile_content,
.mx_HiddenBody,
.mx_RedactedBody, .mx_RedactedBody,
.mx_MImageBody { .mx_MImageBody {
margin: 0; margin: 0;

View file

@ -45,7 +45,9 @@ limitations under the License.
color: $primary-content; color: $primary-content;
} }
.mx_RedactedBody { .mx_RedactedBody,
.mx_HiddenBody {
padding: 4px 0 2px 20px; padding: 4px 0 2px 20px;
&::before { &::before {

View file

@ -250,7 +250,7 @@ export default class MessagePanel extends React.Component<IProps, IState> {
private scrollPanel = createRef<ScrollPanel>(); private scrollPanel = createRef<ScrollPanel>();
private readonly showTypingNotificationsWatcherRef: string; private readonly showTypingNotificationsWatcherRef: string;
private eventNodes: Record<string, HTMLElement>; private eventTiles: Record<string, EventTile> = {};
// A map of <callId, CallEventGrouper> // A map of <callId, CallEventGrouper>
private callEventGroupers = new Map<string, 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 */ /* get the DOM node representing the given event */
public getNodeForEventId(eventId: string): HTMLElement { public getNodeForEventId(eventId: string): HTMLElement {
if (!this.eventNodes) { if (!this.eventTiles) {
return undefined; 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. /* 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 { public scrollToEventIfNeeded(eventId: string): void {
const node = this.eventNodes[eventId]; const node = this.getNodeForEventId(eventId);
if (node) { if (node) {
node.scrollIntoView({ node.scrollIntoView({
block: "nearest", block: "nearest",
@ -584,8 +591,6 @@ export default class MessagePanel extends React.Component<IProps, IState> {
} }
} }
private getEventTiles(): ReactNode[] { private getEventTiles(): ReactNode[] {
this.eventNodes = {};
let i; let i;
// first figure out which is the last event in the list which we're // 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}> <TileErrorBoundary key={mxEv.getTxnId() || eventId} mxEvent={mxEv}>
<EventTile <EventTile
as="li" as="li"
ref={this.collectEventNode.bind(this, eventId)} ref={this.collectEventTile.bind(this, eventId)}
alwaysShowTimestamps={this.props.alwaysShowTimestamps} alwaysShowTimestamps={this.props.alwaysShowTimestamps}
mxEvent={mxEv} mxEvent={mxEv}
continuation={continuation} continuation={continuation}
@ -909,8 +914,8 @@ export default class MessagePanel extends React.Component<IProps, IState> {
return receiptsByEvent; return receiptsByEvent;
} }
private collectEventNode = (eventId: string, node: EventTile): void => { private collectEventTile = (eventId: string, node: EventTile): void => {
this.eventNodes[eventId] = node?.ref?.current; this.eventTiles[eventId] = node;
}; };
// once dynamic content in the events load, make the scrollPanel check the // once dynamic content in the events load, make the scrollPanel check the

View file

@ -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"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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 { TimelineWindow } from "matrix-js-sdk/src/timeline-window";
import { EventType, RelationType } from 'matrix-js-sdk/src/@types/event'; import { EventType, RelationType } from 'matrix-js-sdk/src/@types/event';
import { SyncState } from 'matrix-js-sdk/src/sync'; import { SyncState } from 'matrix-js-sdk/src/sync';
import { RoomMember } from 'matrix-js-sdk';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import { logger } from "matrix-js-sdk/src/logger"; 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.timeline", this.onRoomTimeline);
cli.on("Room.timelineReset", this.onRoomTimelineReset); cli.on("Room.timelineReset", this.onRoomTimelineReset);
cli.on("Room.redaction", this.onRoomRedaction); 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 // same event handler as Room.redaction as for both we just do forceUpdate
cli.on("Room.redactionCancelled", this.onRoomRedaction); cli.on("Room.redactionCancelled", this.onRoomRedaction);
cli.on("Room.receipt", this.onRoomReceipt); 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.receipt", this.onRoomReceipt);
client.removeListener("Room.localEchoUpdated", this.onLocalEchoUpdated); client.removeListener("Room.localEchoUpdated", this.onLocalEchoUpdated);
client.removeListener("Room.accountData", this.onAccountData); client.removeListener("Room.accountData", this.onAccountData);
client.removeListener("RoomMember.powerLevel", this.onVisibilityPowerLevelChange);
client.removeListener("Event.decrypted", this.onEventDecrypted); client.removeListener("Event.decrypted", this.onEventDecrypted);
client.removeListener("Event.replaced", this.onEventReplaced); client.removeListener("Event.replaced", this.onEventReplaced);
client.removeListener("Event.visibilityChange", this.onEventVisibilityChange);
client.removeListener("sync", this.onSync); client.removeListener("sync", this.onSync);
} }
} }
@ -619,6 +627,50 @@ class TimelinePanel extends React.Component<IProps, IState> {
this.forceUpdate(); 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 => { private onEventReplaced = (replacedEvent: MatrixEvent, room: Room): void => {
if (this.unmounted) return; if (this.unmounted) return;

View file

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

View file

@ -44,6 +44,14 @@ export interface IBodyProps {
permalinkCreator: RoomPermalinkCreator; permalinkCreator: RoomPermalinkCreator;
mediaEventHelper: MediaEventHelper; 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 // helper function to access relations for this event
getRelationsForEvent?: (eventId: string, relationType: string, eventType: string) => Relations; getRelationsForEvent?: (eventId: string, relationType: string, eventType: string) => Relations;
} }

View file

@ -31,6 +31,7 @@ import { IOperableEventTile } from "../context_menus/MessageContextMenu";
import { MediaEventHelper } from "../../../utils/MediaEventHelper"; import { MediaEventHelper } from "../../../utils/MediaEventHelper";
import { ReactAnyComponent } from "../../../@types/common"; import { ReactAnyComponent } from "../../../@types/common";
import { IBodyProps } from "./IBodyProps"; import { IBodyProps } from "./IBodyProps";
import MatrixClientContext from '../../../contexts/MatrixClientContext';
// onMessageAllowed is handled internally // onMessageAllowed is handled internally
interface IProps extends Omit<IBodyProps, "onMessageAllowed"> { interface IProps extends Omit<IBodyProps, "onMessageAllowed"> {
@ -40,6 +41,8 @@ interface IProps extends Omit<IBodyProps, "onMessageAllowed"> {
// helper function to access relations for this event // helper function to access relations for this event
getRelationsForEvent?: (eventId: string, relationType: string, eventType: string) => Relations; getRelationsForEvent?: (eventId: string, relationType: string, eventType: string) => Relations;
isSeeingThroughMessageHiddenForModeration?: boolean;
} }
@replaceableComponent("views.messages.MessageEvent") @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 body: React.RefObject<React.Component | IOperableEventTile> = createRef();
private mediaHelper: MediaEventHelper; 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); super(props);
if (MediaEventHelper.isEligible(this.props.mxEvent)) { if (MediaEventHelper.isEligible(this.props.mxEvent)) {
@ -171,6 +177,7 @@ export default class MessageEvent extends React.Component<IProps> implements IMe
permalinkCreator={this.props.permalinkCreator} permalinkCreator={this.props.permalinkCreator}
mediaEventHelper={this.mediaHelper} mediaEventHelper={this.mediaHelper}
getRelationsForEvent={this.props.getRelationsForEvent} getRelationsForEvent={this.props.getRelationsForEvent}
isSeeingThroughMessageHiddenForModeration={this.props.isSeeingThroughMessageHiddenForModeration}
/> : null; /> : null;
} }
} }

View file

@ -297,7 +297,9 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
nextProps.showUrlPreview !== this.props.showUrlPreview || nextProps.showUrlPreview !== this.props.showUrlPreview ||
nextProps.editState !== this.props.editState || nextProps.editState !== this.props.editState ||
nextState.links !== this.state.links || nextState.links !== this.state.links ||
nextState.widgetHidden !== this.state.widgetHidden); nextState.widgetHidden !== this.state.widgetHidden ||
nextProps.isSeeingThroughMessageHiddenForModeration
!== this.props.isSeeingThroughMessageHiddenForModeration);
} }
private calculateUrlPreview(): void { 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() { render() {
if (this.props.editState) { if (this.props.editState) {
return <EditMessageComposer editState={this.props.editState} className="mx_EventTile_content" />; 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() } { this.renderEditedMarker() }
</>; </>;
} }
if (this.props.isSeeingThroughMessageHiddenForModeration) {
body = <>
{ body }
{ this.renderPendingModerationMarker() }
</>;
}
if (this.props.highlightLink) { if (this.props.highlightLink) {
body = <a href={this.props.highlightLink}>{ body }</a>; body = <a href={this.props.highlightLink}>{ body }</a>;

View file

@ -333,6 +333,12 @@ interface IProps {
showThreadInfo?: boolean; showThreadInfo?: boolean;
timelineRenderingType?: TimelineRenderingType; 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 { interface IState {
@ -1038,7 +1044,6 @@ export default class EventTile extends React.Component<IProps, IState> {
private onActionBarFocusChange = (actionBarFocused: boolean) => { private onActionBarFocusChange = (actionBarFocused: boolean) => {
this.setState({ actionBarFocused }); this.setState({ actionBarFocused });
}; };
// TODO: Types // TODO: Types
private getTile: () => any | null = () => this.tile.current; private getTile: () => any | null = () => this.tile.current;
@ -1074,13 +1079,15 @@ export default class EventTile extends React.Component<IProps, IState> {
render() { render() {
const msgtype = this.props.mxEvent.getContent().msgtype; const msgtype = this.props.mxEvent.getContent().msgtype;
const eventType = this.props.mxEvent.getType() as EventType; const eventType = this.props.mxEvent.getType() as EventType;
const eventDisplayInfo = getEventDisplayInfo(this.props.mxEvent);
const { const {
tileHandler, tileHandler,
isBubbleMessage, isBubbleMessage,
isInfoMessage, isInfoMessage,
isLeftAlignedBubbleMessage, isLeftAlignedBubbleMessage,
noBubbleEvent, noBubbleEvent,
} = getEventDisplayInfo(this.props.mxEvent); isSeeingThroughMessageHiddenForModeration,
} = eventDisplayInfo;
const { isQuoteExpanded } = this.state; const { isQuoteExpanded } = this.state;
// This shouldn't happen: the caller should check we support this type // 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} tileShape={this.props.tileShape}
editState={this.props.editState} editState={this.props.editState}
getRelationsForEvent={this.props.getRelationsForEvent} getRelationsForEvent={this.props.getRelationsForEvent}
isSeeingThroughMessageHiddenForModeration={isSeeingThroughMessageHiddenForModeration}
/> />
</div>, </div>,
]); ]);
@ -1413,6 +1421,7 @@ export default class EventTile extends React.Component<IProps, IState> {
editState={this.props.editState} editState={this.props.editState}
replacingEventId={this.props.replacingEventId} replacingEventId={this.props.replacingEventId}
getRelationsForEvent={this.props.getRelationsForEvent} getRelationsForEvent={this.props.getRelationsForEvent}
isSeeingThroughMessageHiddenForModeration={isSeeingThroughMessageHiddenForModeration}
/> />
{ actionBar } { actionBar }
{ timestamp } { timestamp }
@ -1486,6 +1495,7 @@ export default class EventTile extends React.Component<IProps, IState> {
onHeightChanged={this.props.onHeightChanged} onHeightChanged={this.props.onHeightChanged}
editState={this.props.editState} editState={this.props.editState}
getRelationsForEvent={this.props.getRelationsForEvent} getRelationsForEvent={this.props.getRelationsForEvent}
isSeeingThroughMessageHiddenForModeration={isSeeingThroughMessageHiddenForModeration}
/> />
</div>, </div>,
<a <a
@ -1538,6 +1548,7 @@ export default class EventTile extends React.Component<IProps, IState> {
onHeightChanged={this.props.onHeightChanged} onHeightChanged={this.props.onHeightChanged}
callEventGrouper={this.props.callEventGrouper} callEventGrouper={this.props.callEventGrouper}
getRelationsForEvent={this.props.getRelationsForEvent} getRelationsForEvent={this.props.getRelationsForEvent}
isSeeingThroughMessageHiddenForModeration={isSeeingThroughMessageHiddenForModeration}
/> />
{ keyRequestInfo } { keyRequestInfo }
{ actionBar } { actionBar }

View file

@ -109,7 +109,7 @@ export default class ReplyTile extends React.PureComponent<IProps> {
const msgType = mxEvent.getContent().msgtype; const msgType = mxEvent.getContent().msgtype;
const evType = mxEvent.getType() as EventType; 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 // This shouldn't happen: the caller should check we support this type
// before trying to instantiate us // before trying to instantiate us
if (!tileHandler) { if (!tileHandler) {
@ -174,7 +174,9 @@ export default class ReplyTile extends React.PureComponent<IProps> {
overrideEventTypes={evOverrides} overrideEventTypes={evOverrides}
replacingEventId={mxEvent.replacingEventId()} replacingEventId={mxEvent.replacingEventId()}
maxImageHeight={96} maxImageHeight={96}
getRelationsForEvent={this.props.getRelationsForEvent} /> getRelationsForEvent={this.props.getRelationsForEvent}
isSeeingThroughMessageHiddenForModeration={isSeeingThroughMessageHiddenForModeration}
/>
</a> </a>
</div> </div>
); );

View file

@ -863,6 +863,7 @@
"Encryption": "Encryption", "Encryption": "Encryption",
"Experimental": "Experimental", "Experimental": "Experimental",
"Developer": "Developer", "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", "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", "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", "Render LaTeX maths in messages": "Render LaTeX maths in messages",
@ -2085,6 +2086,8 @@
"Ignored attempt to disable encryption": "Ignored attempt to disable encryption", "Ignored attempt to disable encryption": "Ignored attempt to disable encryption",
"Encryption not enabled": "Encryption not enabled", "Encryption not enabled": "Encryption not enabled",
"The encryption used by this room isn't supported.": "The encryption used by this room isn't supported.", "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", "Error processing audio message": "Error processing audio message",
"React": "React", "React": "React",
"Edit": "Edit", "Edit": "Edit",

View file

@ -175,6 +175,13 @@ export interface IFeature extends Omit<IBaseSetting, "isFeature"> {
export type ISetting = IBaseSetting | IFeature; export type ISetting = IBaseSetting | IFeature;
export const SETTINGS: {[setting: string]: ISetting} = { 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": { "feature_report_to_moderators": {
isFeature: true, isFeature: true,
labsGroup: LabGroup.Moderation, labsGroup: LabGroup.Moderation,

View file

@ -15,7 +15,7 @@ limitations under the License.
*/ */
import { EventStatus, MatrixEvent } from 'matrix-js-sdk/src/models/event'; 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 { MatrixClient } from 'matrix-js-sdk/src/client';
import { logger } from 'matrix-js-sdk/src/logger'; import { logger } from 'matrix-js-sdk/src/logger';
import { POLL_START_EVENT_TYPE } from "matrix-js-sdk/src/@types/polls"; 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): { export function getEventDisplayInfo(mxEvent: MatrixEvent): {
isInfoMessage: boolean; isInfoMessage: boolean;
tileHandler: string; tileHandler: string;
isBubbleMessage: boolean; isBubbleMessage: boolean;
isLeftAlignedBubbleMessage: boolean; isLeftAlignedBubbleMessage: boolean;
noBubbleEvent: boolean; noBubbleEvent: boolean;
isSeeingThroughMessageHiddenForModeration: boolean;
} { } {
const content = mxEvent.getContent(); const content = mxEvent.getContent();
const msgtype = content.msgtype; const msgtype = content.msgtype;
const eventType = mxEvent.getType(); 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 // Info messages are basically information about commands processed on a room
let isBubbleMessage = ( let isBubbleMessage = (
@ -168,7 +251,14 @@ export function getEventDisplayInfo(mxEvent: MatrixEvent): {
isInfoMessage = true; isInfoMessage = true;
} }
return { tileHandler, isInfoMessage, isBubbleMessage, isLeftAlignedBubbleMessage, noBubbleEvent }; return {
tileHandler,
isInfoMessage,
isBubbleMessage,
isLeftAlignedBubbleMessage,
noBubbleEvent,
isSeeingThroughMessageHiddenForModeration,
};
} }
export function isVoiceMessage(mxEvent: MatrixEvent): boolean { export function isVoiceMessage(mxEvent: MatrixEvent): boolean {

View file

@ -124,7 +124,9 @@ a.mx_reply_anchor:hover {
margin-bottom: 5px; margin-bottom: 5px;
} }
.mx_RedactedBody { .mx_RedactedBody,
.mx_HiddenBody {
padding-left: unset; padding-left: unset;
} }