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

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

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");
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;

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

View file

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

View file

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

View file

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

View file

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