You've already forked matrix-js-sdk
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:
@@ -177,6 +177,16 @@ export const UNSTABLE_ELEMENT_FUNCTIONAL_USERS = new UnstableValue(
|
||||
"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 {
|
||||
url: string;
|
||||
mimetype?: string;
|
||||
|
@@ -41,7 +41,7 @@ export function eventMapperFor(client: MatrixClient, options: MapperOpts): Event
|
||||
}
|
||||
}
|
||||
if (!preventReEmit) {
|
||||
client.reEmitter.reEmit(event, ["Event.replaced"]);
|
||||
client.reEmitter.reEmit(event, ["Event.replaced", "Event.visibilityChange"]);
|
||||
}
|
||||
return event;
|
||||
}
|
||||
|
@@ -28,6 +28,7 @@ import {
|
||||
EventType,
|
||||
MsgType,
|
||||
RelationType,
|
||||
EVENT_VISIBILITY_CHANGE_TYPE,
|
||||
} from "../@types/event";
|
||||
import { Crypto, IEventDecryptionResult } from "../crypto";
|
||||
import { deepSortedObjectEntries } from "../utils";
|
||||
@@ -125,6 +126,35 @@ export interface IEventRelation {
|
||||
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 {
|
||||
room_id?: string;
|
||||
type: string;
|
||||
@@ -143,6 +173,30 @@ export interface IDecryptOptions {
|
||||
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 {
|
||||
private pushActions: IActionsObject = null;
|
||||
private _replacingEvent: MatrixEvent = null;
|
||||
@@ -150,6 +204,12 @@ export class MatrixEvent extends EventEmitter {
|
||||
private _isCancelled = false;
|
||||
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
|
||||
* getSenderKey()
|
||||
*/
|
||||
@@ -923,6 +983,53 @@ export class MatrixEvent extends EventEmitter {
|
||||
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
|
||||
* 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 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
|
||||
* if event was redacted
|
||||
|
@@ -618,14 +618,14 @@ export class RoomState extends EventEmitter {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given MatrixClient has permission to send a state
|
||||
* event of type `stateEventType` into this room.
|
||||
* @param {string} stateEventType The type of state events to test
|
||||
* @param {MatrixClient} cli The client to test permission for
|
||||
* @return {boolean} true if the given client should be permitted to send
|
||||
* the given type of state event into this room,
|
||||
* according to the room's state.
|
||||
*/
|
||||
* Returns true if the given MatrixClient has permission to send a state
|
||||
* event of type `stateEventType` into this room.
|
||||
* @param {string} stateEventType The type of state events to test
|
||||
* @param {MatrixClient} cli The client to test permission for
|
||||
* @return {boolean} true if the given client should be permitted to send
|
||||
* the given type of state event into this room,
|
||||
* according to the room's state.
|
||||
*/
|
||||
public mayClientSendStateEvent(stateEventType: EventType | string, cli: MatrixClient): boolean {
|
||||
if (cli.isGuest()) {
|
||||
return false;
|
||||
|
@@ -30,7 +30,10 @@ import { RoomMember } from "./room-member";
|
||||
import { IRoomSummary, RoomSummary } from "./room-summary";
|
||||
import { logger } from '../logger';
|
||||
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 { GuestAccess, HistoryVisibility, JoinRule, ResizeMethod } from "../@types/partials";
|
||||
import { Filter } from "../filter";
|
||||
@@ -105,6 +108,22 @@ interface IReceiptContent {
|
||||
|
||||
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 {
|
||||
Highlight = "highlight",
|
||||
Total = "total",
|
||||
@@ -193,6 +212,24 @@ export class Room extends EventEmitter {
|
||||
*/
|
||||
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.
|
||||
*
|
||||
@@ -253,7 +290,9 @@ export class Room extends EventEmitter {
|
||||
// all our per-room timeline sets. the first one is the unfiltered ones;
|
||||
// the subsequent ones are the filtered ones in no particular order.
|
||||
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();
|
||||
|
||||
@@ -1409,8 +1448,26 @@ export class Room extends EventEmitter {
|
||||
// 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
|
||||
// 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) {
|
||||
const existingEvent = this.txnToEvent[event.getUnsigned().transaction_id];
|
||||
if (existingEvent) {
|
||||
@@ -2292,6 +2349,161 @@ export class Room extends EventEmitter {
|
||||
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} prevMembership The previous membership value
|
||||
*/
|
||||
|
||||
|
@@ -201,6 +201,7 @@ export class SyncApi {
|
||||
"Room.accountData",
|
||||
"Room.myMembership",
|
||||
"Room.replaceEvent",
|
||||
"Room.visibilityChange",
|
||||
]);
|
||||
this.registerStateListeners(room);
|
||||
return room;
|
||||
|
Reference in New Issue
Block a user