1
0
mirror of https://github.com/matrix-org/matrix-js-sdk.git synced 2025-08-06 12:02:40 +03:00

MSC3531: Hiding messages during moderation (#2041)

This commit is contained in:
David Teller
2022-01-12 11:27:33 +01:00
committed by GitHub
parent 6fc586598a
commit 96d1b30012
6 changed files with 390 additions and 11 deletions

View File

@@ -177,6 +177,16 @@ export const UNSTABLE_ELEMENT_FUNCTIONAL_USERS = new UnstableValue(
"io.element.functional_members", "io.element.functional_members",
"io.element.functional_members"); "io.element.functional_members");
/**
* A type of message that affects visibility of a message,
* as per https://github.com/matrix-org/matrix-doc/pull/3531
*
* @experimental
*/
export const EVENT_VISIBILITY_CHANGE_TYPE = new UnstableValue(
"m.visibility",
"org.matrix.msc3531.visibility");
export interface IEncryptedFile { export interface IEncryptedFile {
url: string; url: string;
mimetype?: string; mimetype?: string;

View File

@@ -41,7 +41,7 @@ export function eventMapperFor(client: MatrixClient, options: MapperOpts): Event
} }
} }
if (!preventReEmit) { if (!preventReEmit) {
client.reEmitter.reEmit(event, ["Event.replaced"]); client.reEmitter.reEmit(event, ["Event.replaced", "Event.visibilityChange"]);
} }
return event; return event;
} }

View File

@@ -28,6 +28,7 @@ import {
EventType, EventType,
MsgType, MsgType,
RelationType, RelationType,
EVENT_VISIBILITY_CHANGE_TYPE,
} from "../@types/event"; } from "../@types/event";
import { Crypto, IEventDecryptionResult } from "../crypto"; import { Crypto, IEventDecryptionResult } from "../crypto";
import { deepSortedObjectEntries } from "../utils"; import { deepSortedObjectEntries } from "../utils";
@@ -125,6 +126,35 @@ export interface IEventRelation {
key?: string; key?: string;
} }
export interface IVisibilityEventRelation extends IEventRelation {
visibility: "visible" | "hidden";
reason?: string;
}
/**
* When an event is a visibility change event, as per MSC3531,
* the visibility change implied by the event.
*/
export interface IVisibilityChange {
/**
* If `true`, the target event should be made visible.
* Otherwise, it should be hidden.
*/
visible: boolean;
/**
* The event id affected.
*/
eventId: string;
/**
* Optionally, a human-readable reason explaining why
* the event was hidden. Ignored if the event was made
* visible.
*/
reason: string | null;
}
export interface IClearEvent { export interface IClearEvent {
room_id?: string; room_id?: string;
type: string; type: string;
@@ -143,6 +173,30 @@ export interface IDecryptOptions {
isRetry?: boolean; isRetry?: boolean;
} }
/**
* Message hiding, as specified by https://github.com/matrix-org/matrix-doc/pull/3531.
*/
export type MessageVisibility = IMessageVisibilityHidden | IMessageVisibilityVisible;
/**
* Variant of `MessageVisibility` for the case in which the message should be displayed.
*/
export interface IMessageVisibilityVisible {
readonly visible: true;
}
/**
* Variant of `MessageVisibility` for the case in which the message should be hidden.
*/
export interface IMessageVisibilityHidden {
readonly visible: false;
/**
* Optionally, a human-readable reason to show to the user indicating why the
* message has been hidden (e.g. "Message Pending Moderation").
*/
readonly reason: string | null;
}
// A singleton implementing `IMessageVisibilityVisible`.
const MESSAGE_VISIBLE: IMessageVisibilityVisible = Object.freeze({ visible: true });
export class MatrixEvent extends EventEmitter { export class MatrixEvent extends EventEmitter {
private pushActions: IActionsObject = null; private pushActions: IActionsObject = null;
private _replacingEvent: MatrixEvent = null; private _replacingEvent: MatrixEvent = null;
@@ -150,6 +204,12 @@ export class MatrixEvent extends EventEmitter {
private _isCancelled = false; private _isCancelled = false;
private clearEvent?: IClearEvent; private clearEvent?: IClearEvent;
/* Message hiding, as specified by https://github.com/matrix-org/matrix-doc/pull/3531.
Note: We're returning this object, so any value stored here MUST be frozen.
*/
private visibility: MessageVisibility = MESSAGE_VISIBLE;
/* curve25519 key which we believe belongs to the sender of the event. See /* curve25519 key which we believe belongs to the sender of the event. See
* getSenderKey() * getSenderKey()
*/ */
@@ -923,6 +983,53 @@ export class MatrixEvent extends EventEmitter {
this.event.unsigned.redacted_because = redactionEvent.event as IEvent; this.event.unsigned.redacted_because = redactionEvent.event as IEvent;
} }
/**
* Change the visibility of an event, as per https://github.com/matrix-org/matrix-doc/pull/3531 .
*
* @fires module:models/event.MatrixEvent#"Event.visibilityChange" if `visibilityEvent`
* caused a change in the actual visibility of this event, either by making it
* visible (if it was hidden), by making it hidden (if it was visible) or by
* changing the reason (if it was hidden).
* @param visibilityEvent event holding a hide/unhide payload, or nothing
* if the event is being reset to its original visibility (presumably
* by a visibility event being redacted).
*/
public applyVisibilityEvent(visibilityChange?: IVisibilityChange): void {
const visible = visibilityChange ? visibilityChange.visible : true;
const reason = visibilityChange ? visibilityChange.reason : null;
let change = false;
if (this.visibility.visible !== visibilityChange.visible) {
change = true;
} else if (!this.visibility.visible && this.visibility["reason"] !== reason) {
change = true;
}
if (change) {
if (visible) {
this.visibility = MESSAGE_VISIBLE;
} else {
this.visibility = Object.freeze({
visible: false,
reason: reason,
});
}
if (change) {
this.emit("Event.visibilityChange", this, visible);
}
}
}
/**
* Return instructions to display or hide the message.
*
* @returns Instructions determining whether the message
* should be displayed.
*/
public messageVisibility(): MessageVisibility {
// Note: We may return `this.visibility` without fear, as
// this is a shallow frozen object.
return this.visibility;
}
/** /**
* Update the content of an event in the same way it would be by the server * Update the content of an event in the same way it would be by the server
* if it were redacted before it was sent to us * if it were redacted before it was sent to us
@@ -992,6 +1099,54 @@ export class MatrixEvent extends EventEmitter {
return this.getType() === EventType.RoomRedaction; return this.getType() === EventType.RoomRedaction;
} }
/**
* Return the visibility change caused by this event,
* as per https://github.com/matrix-org/matrix-doc/pull/3531.
*
* @returns If the event is a well-formed visibility change event,
* an instance of `IVisibilityChange`, otherwise `null`.
*/
public asVisibilityChange(): IVisibilityChange | null {
if (!EVENT_VISIBILITY_CHANGE_TYPE.matches(this.getType())) {
// Not a visibility change event.
return null;
}
const relation = this.getRelation();
if (!relation || relation.rel_type != "m.reference") {
// Ill-formed, ignore this event.
return null;
}
const eventId = relation.event_id;
if (!eventId) {
// Ill-formed, ignore this event.
return null;
}
const content = this.getWireContent();
const visible = !!content.visible;
const reason = content.reason;
if (reason && typeof reason != "string") {
// Ill-formed, ignore this event.
return null;
}
// Well-formed visibility change event.
return {
visible,
reason,
eventId,
};
}
/**
* Check if this event alters the visibility of another event,
* as per https://github.com/matrix-org/matrix-doc/pull/3531.
*
* @returns {boolean} True if this event alters the visibility
* of another event.
*/
public isVisibilityEvent(): boolean {
return EVENT_VISIBILITY_CHANGE_TYPE.matches(this.getType());
}
/** /**
* Get the (decrypted, if necessary) redaction event JSON * Get the (decrypted, if necessary) redaction event JSON
* if event was redacted * if event was redacted

View File

@@ -618,14 +618,14 @@ export class RoomState extends EventEmitter {
} }
/** /**
* Returns true if the given MatrixClient has permission to send a state * Returns true if the given MatrixClient has permission to send a state
* event of type `stateEventType` into this room. * event of type `stateEventType` into this room.
* @param {string} stateEventType The type of state events to test * @param {string} stateEventType The type of state events to test
* @param {MatrixClient} cli The client to test permission for * @param {MatrixClient} cli The client to test permission for
* @return {boolean} true if the given client should be permitted to send * @return {boolean} true if the given client should be permitted to send
* the given type of state event into this room, * the given type of state event into this room,
* according to the room's state. * according to the room's state.
*/ */
public mayClientSendStateEvent(stateEventType: EventType | string, cli: MatrixClient): boolean { public mayClientSendStateEvent(stateEventType: EventType | string, cli: MatrixClient): boolean {
if (cli.isGuest()) { if (cli.isGuest()) {
return false; return false;

View File

@@ -30,7 +30,10 @@ import { RoomMember } from "./room-member";
import { IRoomSummary, RoomSummary } from "./room-summary"; import { IRoomSummary, RoomSummary } from "./room-summary";
import { logger } from '../logger'; import { logger } from '../logger';
import { ReEmitter } from '../ReEmitter'; import { ReEmitter } from '../ReEmitter';
import { EventType, RoomCreateTypeField, RoomType, UNSTABLE_ELEMENT_FUNCTIONAL_USERS } from "../@types/event"; import {
EventType, RoomCreateTypeField, RoomType, UNSTABLE_ELEMENT_FUNCTIONAL_USERS,
EVENT_VISIBILITY_CHANGE_TYPE,
} from "../@types/event";
import { IRoomVersionsCapability, MatrixClient, PendingEventOrdering, RoomVersionStability } from "../client"; import { IRoomVersionsCapability, MatrixClient, PendingEventOrdering, RoomVersionStability } from "../client";
import { GuestAccess, HistoryVisibility, JoinRule, ResizeMethod } from "../@types/partials"; import { GuestAccess, HistoryVisibility, JoinRule, ResizeMethod } from "../@types/partials";
import { Filter } from "../filter"; import { Filter } from "../filter";
@@ -105,6 +108,22 @@ interface IReceiptContent {
type Receipts = Record<string, Record<string, IWrappedReceipt>>; type Receipts = Record<string, Record<string, IWrappedReceipt>>;
// When inserting a visibility event affecting event `eventId`, we
// need to scan through existing visibility events for `eventId`.
// In theory, this could take an unlimited amount of time if:
//
// - the visibility event was sent by a moderator; and
// - `eventId` already has many visibility changes (usually, it should
// be 2 or less); and
// - for some reason, the visibility changes are received out of order
// (usually, this shouldn't happen at all).
//
// For this reason, we limit the number of events to scan through,
// expecting that a broken visibility change for a single event in
// an extremely uncommon case (possibly a DoS) is a small
// price to pay to keep matrix-js-sdk responsive.
const MAX_NUMBER_OF_VISIBILITY_EVENTS_TO_SCAN_THROUGH = 30;
export enum NotificationCountType { export enum NotificationCountType {
Highlight = "highlight", Highlight = "highlight",
Total = "total", Total = "total",
@@ -193,6 +212,24 @@ export class Room extends EventEmitter {
*/ */
public threads = new Map<string, Thread>(); public threads = new Map<string, Thread>();
/**
* A mapping of eventId to all visibility changes to apply
* to the event, by chronological order, as per
* https://github.com/matrix-org/matrix-doc/pull/3531
*
* # Invariants
*
* - within each list, all events are classed by
* chronological order;
* - all events are events such that
* `asVisibilityEvent()` returns a non-null `IVisibilityChange`;
* - within each list with key `eventId`, all events
* are in relation to `eventId`.
*
* @experimental
*/
private visibilityEvents = new Map<string, MatrixEvent[]>();
/** /**
* Construct a new Room. * Construct a new Room.
* *
@@ -253,7 +290,9 @@ export class Room extends EventEmitter {
// all our per-room timeline sets. the first one is the unfiltered ones; // all our per-room timeline sets. the first one is the unfiltered ones;
// the subsequent ones are the filtered ones in no particular order. // the subsequent ones are the filtered ones in no particular order.
this.timelineSets = [new EventTimelineSet(this, opts)]; this.timelineSets = [new EventTimelineSet(this, opts)];
this.reEmitter.reEmit(this.getUnfilteredTimelineSet(), ["Room.timeline", "Room.timelineReset"]); this.reEmitter.reEmit(this.getUnfilteredTimelineSet(), [
"Room.timeline", "Room.timelineReset",
]);
this.fixUpLegacyTimelineFields(); this.fixUpLegacyTimelineFields();
@@ -1409,8 +1448,26 @@ export class Room extends EventEmitter {
// NB: We continue to add the redaction event to the timeline so // NB: We continue to add the redaction event to the timeline so
// clients can say "so and so redacted an event" if they wish to. Also // clients can say "so and so redacted an event" if they wish to. Also
// this may be needed to trigger an update. // this may be needed to trigger an update.
// Remove any visibility change on this event.
this.visibilityEvents.delete(redactId);
// If this event is a visibility change event, remove it from the
// list of visibility changes and update any event affected by it.
if (redactedEvent.isVisibilityEvent()) {
this.redactVisibilityChangeEvent(event);
}
} }
// Implement MSC3531: hiding messages.
if (event.isVisibilityEvent()) {
// This event changes the visibility of another event, record
// the visibility change, inform clients if necessary.
this.applyNewVisibilityEvent(event);
}
// If any pending visibility change is waiting for this (older) event,
this.applyPendingVisibilityEvents(event);
if (event.getUnsigned().transaction_id) { if (event.getUnsigned().transaction_id) {
const existingEvent = this.txnToEvent[event.getUnsigned().transaction_id]; const existingEvent = this.txnToEvent[event.getUnsigned().transaction_id];
if (existingEvent) { if (existingEvent) {
@@ -2292,6 +2349,161 @@ export class Room extends EventEmitter {
return "Empty room"; return "Empty room";
} }
} }
/**
* When we receive a new visibility change event:
*
* - store this visibility change alongside the timeline, in case we
* later need to apply it to an event that we haven't received yet;
* - if we have already received the event whose visibility has changed,
* patch it to reflect the visibility change and inform listeners.
*/
private applyNewVisibilityEvent(event: MatrixEvent): void {
const visibilityChange = event.asVisibilityChange();
if (!visibilityChange) {
// The event is ill-formed.
return;
}
// Ignore visibility change events that are not emitted by moderators.
const userId = event.getSender();
if (!userId) {
return;
}
const isPowerSufficient =
(
EVENT_VISIBILITY_CHANGE_TYPE.name
&& this.currentState.maySendStateEvent(EVENT_VISIBILITY_CHANGE_TYPE.name, userId)
)
|| (
EVENT_VISIBILITY_CHANGE_TYPE.altName
&& this.currentState.maySendStateEvent(EVENT_VISIBILITY_CHANGE_TYPE.altName, userId)
);
if (!isPowerSufficient) {
// Powerlevel is insufficient.
return;
}
// Record this change in visibility.
// If the event is not in our timeline and we only receive it later,
// we may need to apply the visibility change at a later date.
const visibilityEventsOnOriginalEvent = this.visibilityEvents.get(visibilityChange.eventId);
if (visibilityEventsOnOriginalEvent) {
// It would be tempting to simply erase the latest visibility change
// but we need to record all of the changes in case the latest change
// is ever redacted.
//
// In practice, linear scans through `visibilityEvents` should be fast.
// However, to protect against a potential DoS attack, we limit the
// number of iterations in this loop.
let index = visibilityEventsOnOriginalEvent.length - 1;
const min = Math.max(0,
visibilityEventsOnOriginalEvent.length - MAX_NUMBER_OF_VISIBILITY_EVENTS_TO_SCAN_THROUGH);
for (; index >= min; --index) {
const target = visibilityEventsOnOriginalEvent[index];
if (target.getTs() < event.getTs()) {
break;
}
}
if (index === -1) {
visibilityEventsOnOriginalEvent.unshift(event);
} else {
visibilityEventsOnOriginalEvent.splice(index + 1, 0, event);
}
} else {
this.visibilityEvents.set(visibilityChange.eventId, [event]);
}
// Finally, let's check if the event is already in our timeline.
// If so, we need to patch it and inform listeners.
const originalEvent = this.findEventById(visibilityChange.eventId);
if (!originalEvent) {
return;
}
originalEvent.applyVisibilityEvent(visibilityChange);
}
private redactVisibilityChangeEvent(event: MatrixEvent) {
// Sanity checks.
if (!event.isVisibilityEvent) {
throw new Error("expected a visibility change event");
}
const relation = event.getRelation();
const originalEventId = relation.event_id;
const visibilityEventsOnOriginalEvent = this.visibilityEvents.get(originalEventId);
if (!visibilityEventsOnOriginalEvent) {
// No visibility changes on the original event.
// In particular, this change event was not recorded,
// most likely because it was ill-formed.
return;
}
const index = visibilityEventsOnOriginalEvent.findIndex(change => change.getId() === event.getId());
if (index === -1) {
// This change event was not recorded, most likely because
// it was ill-formed.
return;
}
// Remove visibility change.
visibilityEventsOnOriginalEvent.splice(index, 1);
// If we removed the latest visibility change event, propagate changes.
if (index === visibilityEventsOnOriginalEvent.length) {
const originalEvent = this.findEventById(originalEventId);
if (!originalEvent) {
return;
}
if (index === 0) {
// We have just removed the only visibility change event.
this.visibilityEvents.delete(originalEventId);
originalEvent.applyVisibilityEvent();
} else {
const newEvent = visibilityEventsOnOriginalEvent[visibilityEventsOnOriginalEvent.length - 1];
const newVisibility = newEvent.asVisibilityChange();
if (!newVisibility) {
// Event is ill-formed.
// This breaks our invariant.
throw new Error("at this stage, visibility changes should be well-formed");
}
originalEvent.applyVisibilityEvent(newVisibility);
}
}
}
/**
* When we receive an event whose visibility has been altered by
* a (more recent) visibility change event, patch the event in
* place so that clients now not to display it.
*
* @param event Any matrix event. If this event has at least one a
* pending visibility change event, apply the latest visibility
* change event.
*/
private applyPendingVisibilityEvents(event: MatrixEvent): void {
const visibilityEvents = this.visibilityEvents.get(event.getId());
if (!visibilityEvents || visibilityEvents.length == 0) {
// No pending visibility change in store.
return;
}
const visibilityEvent = visibilityEvents[visibilityEvents.length - 1];
const visibilityChange = visibilityEvent.asVisibilityChange();
if (!visibilityChange) {
return;
}
if (visibilityChange.visible) {
// Events are visible by default, no need to apply a visibility change.
// Note that we need to keep the visibility changes in `visibilityEvents`,
// in case we later fetch an older visibility change event that is superseded
// by `visibilityChange`.
}
if (visibilityEvent.getTs() < event.getTs()) {
// Something is wrong, the visibility change cannot happen before the
// event. Presumably an ill-formed event.
return;
}
event.applyVisibilityEvent(visibilityChange);
}
} }
/** /**
@@ -2460,3 +2672,4 @@ function memberNamesToRoomName(names: string[], count = (names.length + 1)) {
* @param {string} membership The new membership value * @param {string} membership The new membership value
* @param {string} prevMembership The previous membership value * @param {string} prevMembership The previous membership value
*/ */

View File

@@ -201,6 +201,7 @@ export class SyncApi {
"Room.accountData", "Room.accountData",
"Room.myMembership", "Room.myMembership",
"Room.replaceEvent", "Room.replaceEvent",
"Room.visibilityChange",
]); ]);
this.registerStateListeners(room); this.registerStateListeners(room);
return room; return room;