You've already forked matrix-js-sdk
mirror of
https://github.com/matrix-org/matrix-js-sdk.git
synced 2025-08-09 10:22:46 +03:00
Redact on ban: Client implementation (#4867)
* First pass implementation * fix naming/docs * apply lint * Add test for existing behaviour * Add happy path tests * Fix bug identified by tests * ... and this is why we add negative tests too * Add some sanity tests * Apply linter
This commit is contained in:
188
spec/unit/models/room.spec.ts
Normal file
188
spec/unit/models/room.spec.ts
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2025 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 { Direction, type MatrixClient, MatrixEvent, Room } from "../../../src";
|
||||||
|
import type { MockedObject } from "jest-mock";
|
||||||
|
|
||||||
|
const CREATOR_USER_ID = "@creator:example.org";
|
||||||
|
const MODERATOR_USER_ID = "@moderator:example.org";
|
||||||
|
|
||||||
|
describe("Room", () => {
|
||||||
|
function createMockClient(): MatrixClient {
|
||||||
|
return {
|
||||||
|
supportsThreads: jest.fn().mockReturnValue(true),
|
||||||
|
decryptEventIfNeeded: jest.fn().mockReturnThis(),
|
||||||
|
getUserId: jest.fn().mockReturnValue(CREATOR_USER_ID),
|
||||||
|
} as unknown as MockedObject<MatrixClient>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEvent(eventId: string): MatrixEvent {
|
||||||
|
return new MatrixEvent({
|
||||||
|
type: "m.room.message",
|
||||||
|
content: {
|
||||||
|
body: eventId, // we do this for ease of use, not practicality
|
||||||
|
},
|
||||||
|
event_id: eventId,
|
||||||
|
sender: CREATOR_USER_ID,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRedaction(redactsEventId: string): MatrixEvent {
|
||||||
|
return new MatrixEvent({
|
||||||
|
type: "m.room.redaction",
|
||||||
|
redacts: redactsEventId,
|
||||||
|
event_id: "$redacts_" + redactsEventId.substring(1),
|
||||||
|
sender: CREATOR_USER_ID,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNonStateMainTimelineLiveEvents(room: Room): Array<MatrixEvent> {
|
||||||
|
return room
|
||||||
|
.getLiveTimeline()
|
||||||
|
.getEvents()
|
||||||
|
.filter((e) => !e.isState());
|
||||||
|
}
|
||||||
|
|
||||||
|
it("should apply redactions locally", async () => {
|
||||||
|
const mockClient = createMockClient();
|
||||||
|
const room = new Room("!room:example.org", mockClient, CREATOR_USER_ID);
|
||||||
|
const messageEvent = createEvent("$message_event");
|
||||||
|
|
||||||
|
// Set up the room
|
||||||
|
await room.addLiveEvents([messageEvent], { addToState: false });
|
||||||
|
let timeline = getNonStateMainTimelineLiveEvents(room);
|
||||||
|
expect(timeline.length).toEqual(1);
|
||||||
|
expect(timeline[0].getId()).toEqual(messageEvent.getId());
|
||||||
|
expect(timeline[0].isRedacted()).toEqual(false); // "should never happen"
|
||||||
|
|
||||||
|
// Now redact
|
||||||
|
const redactionEvent = createRedaction(messageEvent.getId()!);
|
||||||
|
await room.addLiveEvents([redactionEvent], { addToState: false });
|
||||||
|
timeline = getNonStateMainTimelineLiveEvents(room);
|
||||||
|
expect(timeline.length).toEqual(2);
|
||||||
|
expect(timeline[0].getId()).toEqual(messageEvent.getId());
|
||||||
|
expect(timeline[0].isRedacted()).toEqual(true); // test case
|
||||||
|
expect(timeline[1].getId()).toEqual(redactionEvent.getId());
|
||||||
|
expect(timeline[1].isRedacted()).toEqual(false); // "should never happen"
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("MSC4293: Redact on ban", () => {
|
||||||
|
async function setupRoom(andGrantPermissions: boolean): Promise<{ room: Room; messageEvents: MatrixEvent[] }> {
|
||||||
|
const mockClient = createMockClient();
|
||||||
|
const room = new Room("!room:example.org", mockClient, CREATOR_USER_ID);
|
||||||
|
|
||||||
|
// Pre-populate room
|
||||||
|
const messageEvents: MatrixEvent[] = [];
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
messageEvents.push(createEvent(`$message_${i}`));
|
||||||
|
}
|
||||||
|
await room.addLiveEvents(messageEvents, { addToState: false });
|
||||||
|
|
||||||
|
if (andGrantPermissions) {
|
||||||
|
room.getLiveTimeline().getState(Direction.Forward)!.maySendRedactionForEvent = (ev, userId) => {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { room, messageEvents };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRedactOnMembershipChange(
|
||||||
|
targetUserId: string,
|
||||||
|
senderUserId: string,
|
||||||
|
membership: string,
|
||||||
|
): MatrixEvent {
|
||||||
|
return new MatrixEvent({
|
||||||
|
type: "m.room.member",
|
||||||
|
state_key: targetUserId,
|
||||||
|
content: {
|
||||||
|
"membership": membership,
|
||||||
|
"org.matrix.msc4293.redact_events": true,
|
||||||
|
},
|
||||||
|
sender: senderUserId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectRedacted(messageEvents: MatrixEvent[], room: Room, shouldAllBeRedacted: boolean) {
|
||||||
|
const actualEvents = getNonStateMainTimelineLiveEvents(room).filter((e) =>
|
||||||
|
messageEvents.find((e2) => e2.getId() === e.getId()),
|
||||||
|
);
|
||||||
|
expect(actualEvents.length).toEqual(messageEvents.length);
|
||||||
|
const redactedEvents = actualEvents.filter((e) => e.isRedacted());
|
||||||
|
if (shouldAllBeRedacted) {
|
||||||
|
expect(redactedEvents.length).toEqual(messageEvents.length);
|
||||||
|
} else {
|
||||||
|
expect(redactedEvents.length).toEqual(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
it("should apply on ban", async () => {
|
||||||
|
const { room, messageEvents } = await setupRoom(true);
|
||||||
|
const banEvent = createRedactOnMembershipChange(CREATOR_USER_ID, MODERATOR_USER_ID, "ban");
|
||||||
|
await room.addLiveEvents([banEvent], { addToState: true });
|
||||||
|
|
||||||
|
expectRedacted(messageEvents, room, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should apply on kick", async () => {
|
||||||
|
const { room, messageEvents } = await setupRoom(true);
|
||||||
|
const kickEvent = createRedactOnMembershipChange(CREATOR_USER_ID, MODERATOR_USER_ID, "leave");
|
||||||
|
await room.addLiveEvents([kickEvent], { addToState: true });
|
||||||
|
|
||||||
|
expectRedacted(messageEvents, room, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not apply if the user doesn't have permission to redact", async () => {
|
||||||
|
const { room, messageEvents } = await setupRoom(false); // difference from other tests here
|
||||||
|
const banEvent = createRedactOnMembershipChange(CREATOR_USER_ID, MODERATOR_USER_ID, "ban");
|
||||||
|
await room.addLiveEvents([banEvent], { addToState: true });
|
||||||
|
|
||||||
|
expectRedacted(messageEvents, room, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not apply to self-leaves", async () => {
|
||||||
|
const { room, messageEvents } = await setupRoom(true);
|
||||||
|
const leaveEvent = createRedactOnMembershipChange(CREATOR_USER_ID, CREATOR_USER_ID, "leave");
|
||||||
|
await room.addLiveEvents([leaveEvent], { addToState: true });
|
||||||
|
|
||||||
|
expectRedacted(messageEvents, room, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not apply to invites", async () => {
|
||||||
|
const { room, messageEvents } = await setupRoom(true);
|
||||||
|
const leaveEvent = createRedactOnMembershipChange(CREATOR_USER_ID, CREATOR_USER_ID, "invite");
|
||||||
|
await room.addLiveEvents([leaveEvent], { addToState: true });
|
||||||
|
|
||||||
|
expectRedacted(messageEvents, room, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not apply to joins", async () => {
|
||||||
|
const { room, messageEvents } = await setupRoom(true);
|
||||||
|
const leaveEvent = createRedactOnMembershipChange(CREATOR_USER_ID, CREATOR_USER_ID, "join");
|
||||||
|
await room.addLiveEvents([leaveEvent], { addToState: true });
|
||||||
|
|
||||||
|
expectRedacted(messageEvents, room, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not apply to knocks", async () => {
|
||||||
|
const { room, messageEvents } = await setupRoom(true);
|
||||||
|
const leaveEvent = createRedactOnMembershipChange(CREATOR_USER_ID, CREATOR_USER_ID, "knock");
|
||||||
|
await room.addLiveEvents([leaveEvent], { addToState: true });
|
||||||
|
|
||||||
|
expectRedacted(messageEvents, room, false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@@ -17,15 +17,15 @@ limitations under the License.
|
|||||||
import { M_POLL_START, type Optional } from "matrix-events-sdk";
|
import { M_POLL_START, type Optional } from "matrix-events-sdk";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
EventTimelineSet,
|
|
||||||
DuplicateStrategy,
|
DuplicateStrategy,
|
||||||
type IAddLiveEventOptions,
|
EventTimelineSet,
|
||||||
type EventTimelineSetHandlerMap,
|
type EventTimelineSetHandlerMap,
|
||||||
|
type IAddLiveEventOptions,
|
||||||
} from "./event-timeline-set.ts";
|
} from "./event-timeline-set.ts";
|
||||||
import { Direction, EventTimeline } from "./event-timeline.ts";
|
import { Direction, EventTimeline } from "./event-timeline.ts";
|
||||||
import { getHttpUriForMxc } from "../content-repo.ts";
|
import { getHttpUriForMxc } from "../content-repo.ts";
|
||||||
import { removeElement } from "../utils.ts";
|
import * as utils from "../utils.ts";
|
||||||
import { normalize, noUnsafeEventProps } from "../utils.ts";
|
import { normalize, noUnsafeEventProps, removeElement } from "../utils.ts";
|
||||||
import {
|
import {
|
||||||
type IEvent,
|
type IEvent,
|
||||||
type IThreadBundledRelationship,
|
type IThreadBundledRelationship,
|
||||||
@@ -35,17 +35,17 @@ import {
|
|||||||
} from "./event.ts";
|
} from "./event.ts";
|
||||||
import { EventStatus } from "./event-status.ts";
|
import { EventStatus } from "./event-status.ts";
|
||||||
import { RoomMember } from "./room-member.ts";
|
import { RoomMember } from "./room-member.ts";
|
||||||
import { type IRoomSummary, type Hero, RoomSummary } from "./room-summary.ts";
|
import { type Hero, type IRoomSummary, RoomSummary } from "./room-summary.ts";
|
||||||
import { logger } from "../logger.ts";
|
import { logger } from "../logger.ts";
|
||||||
import { TypedReEmitter } from "../ReEmitter.ts";
|
import { TypedReEmitter } from "../ReEmitter.ts";
|
||||||
import {
|
import {
|
||||||
|
EVENT_VISIBILITY_CHANGE_TYPE,
|
||||||
EventType,
|
EventType,
|
||||||
|
RelationType,
|
||||||
RoomCreateTypeField,
|
RoomCreateTypeField,
|
||||||
RoomType,
|
RoomType,
|
||||||
UNSTABLE_ELEMENT_FUNCTIONAL_USERS,
|
|
||||||
EVENT_VISIBILITY_CHANGE_TYPE,
|
|
||||||
RelationType,
|
|
||||||
UNSIGNED_THREAD_ID_FIELD,
|
UNSIGNED_THREAD_ID_FIELD,
|
||||||
|
UNSTABLE_ELEMENT_FUNCTIONAL_USERS,
|
||||||
} from "../@types/event.ts";
|
} from "../@types/event.ts";
|
||||||
import { type MatrixClient, PendingEventOrdering } from "../client.ts";
|
import { type MatrixClient, PendingEventOrdering } from "../client.ts";
|
||||||
import { type GuestAccess, type HistoryVisibility, type JoinRule, type ResizeMethod } from "../@types/partials.ts";
|
import { type GuestAccess, type HistoryVisibility, type JoinRule, type ResizeMethod } from "../@types/partials.ts";
|
||||||
@@ -53,12 +53,12 @@ import { Filter, type IFilterDefinition } from "../filter.ts";
|
|||||||
import { type RoomState, RoomStateEvent, type RoomStateEventHandlerMap } from "./room-state.ts";
|
import { type RoomState, RoomStateEvent, type RoomStateEventHandlerMap } from "./room-state.ts";
|
||||||
import { BeaconEvent, type BeaconEventHandlerMap } from "./beacon.ts";
|
import { BeaconEvent, type BeaconEventHandlerMap } from "./beacon.ts";
|
||||||
import {
|
import {
|
||||||
|
FILTER_RELATED_BY_REL_TYPES,
|
||||||
|
FILTER_RELATED_BY_SENDERS,
|
||||||
Thread,
|
Thread,
|
||||||
|
THREAD_RELATION_TYPE,
|
||||||
ThreadEvent,
|
ThreadEvent,
|
||||||
type ThreadEventHandlerMap as ThreadHandlerMap,
|
type ThreadEventHandlerMap as ThreadHandlerMap,
|
||||||
FILTER_RELATED_BY_REL_TYPES,
|
|
||||||
THREAD_RELATION_TYPE,
|
|
||||||
FILTER_RELATED_BY_SENDERS,
|
|
||||||
ThreadFilterType,
|
ThreadFilterType,
|
||||||
} from "./thread.ts";
|
} from "./thread.ts";
|
||||||
import {
|
import {
|
||||||
@@ -74,7 +74,6 @@ import { ReadReceipt, synthesizeReceipt } from "./read-receipt.ts";
|
|||||||
import { isPollEvent, Poll, PollEvent } from "./poll.ts";
|
import { isPollEvent, Poll, PollEvent } from "./poll.ts";
|
||||||
import { RoomReceipts } from "./room-receipts.ts";
|
import { RoomReceipts } from "./room-receipts.ts";
|
||||||
import { compareEventOrdering } from "./compare-event-ordering.ts";
|
import { compareEventOrdering } from "./compare-event-ordering.ts";
|
||||||
import * as utils from "../utils.ts";
|
|
||||||
import { KnownMembership, type Membership } from "../@types/membership.ts";
|
import { KnownMembership, type Membership } from "../@types/membership.ts";
|
||||||
import { type Capabilities, type IRoomVersionsCapability, RoomVersionStability } from "../serverCapabilities.ts";
|
import { type Capabilities, type IRoomVersionsCapability, RoomVersionStability } from "../serverCapabilities.ts";
|
||||||
import { type MSC4186Hero } from "../sliding-sync.ts";
|
import { type MSC4186Hero } from "../sliding-sync.ts";
|
||||||
@@ -2469,7 +2468,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
|||||||
* Adds events to a thread's timeline. Will fire "Thread.update"
|
* Adds events to a thread's timeline. Will fire "Thread.update"
|
||||||
*/
|
*/
|
||||||
public processThreadedEvents(events: MatrixEvent[], toStartOfTimeline: boolean): void {
|
public processThreadedEvents(events: MatrixEvent[], toStartOfTimeline: boolean): void {
|
||||||
events.forEach(this.applyRedaction);
|
events.forEach(this.tryApplyRedaction);
|
||||||
|
|
||||||
const eventsByThread: { [threadId: string]: MatrixEvent[] } = {};
|
const eventsByThread: { [threadId: string]: MatrixEvent[] } = {};
|
||||||
for (const event of events) {
|
for (const event of events) {
|
||||||
@@ -2580,55 +2579,105 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
|
|||||||
return thread;
|
return thread;
|
||||||
}
|
}
|
||||||
|
|
||||||
private applyRedaction = (event: MatrixEvent): void => {
|
/**
|
||||||
|
* Applies an event as a redaction of another event, regardless of whether the redacting
|
||||||
|
* event is actually a redaction.
|
||||||
|
*
|
||||||
|
* Callers should use tryApplyRedaction instead.
|
||||||
|
*
|
||||||
|
* @param redactionEvent The event which redacts an event.
|
||||||
|
* @param redactedEvent The event being redacted.
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private applyEventAsRedaction(redactionEvent: MatrixEvent, redactedEvent: MatrixEvent): void {
|
||||||
|
const threadRootId = redactedEvent.threadRootId;
|
||||||
|
redactedEvent.makeRedacted(redactionEvent, this);
|
||||||
|
|
||||||
|
// If this is in the current state, replace it with the redacted version
|
||||||
|
if (redactedEvent.isState()) {
|
||||||
|
const currentStateEvent = this.currentState.getStateEvents(
|
||||||
|
redactedEvent.getType(),
|
||||||
|
redactedEvent.getStateKey()!,
|
||||||
|
);
|
||||||
|
if (currentStateEvent?.getId() === redactedEvent.getId()) {
|
||||||
|
this.currentState.setStateEvents([redactedEvent]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emit(RoomEvent.Redaction, redactionEvent, this, threadRootId);
|
||||||
|
|
||||||
|
// TODO: we stash user displaynames (among other things) in
|
||||||
|
// RoomMember objects which are then attached to other events
|
||||||
|
// (in the sender and target fields). We should get those
|
||||||
|
// RoomMember objects to update themselves when the events that
|
||||||
|
// they are based on are changed.
|
||||||
|
|
||||||
|
// Remove any visibility change on this event.
|
||||||
|
this.visibilityEvents.delete(redactedEvent.getId()!);
|
||||||
|
|
||||||
|
// 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(redactionEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private tryApplyRedaction = (event: MatrixEvent): void => {
|
||||||
|
// FIXME: apply redactions to notification list
|
||||||
|
|
||||||
|
// NB: We continue to add the redaction event to the timeline at the
|
||||||
|
// end of this function so clients can say "so and so redacted an event"
|
||||||
|
// if they wish to. Also this may be needed to trigger an update.
|
||||||
|
|
||||||
if (event.isRedaction()) {
|
if (event.isRedaction()) {
|
||||||
const redactId = event.event.redacts;
|
const redactId = event.event.redacts;
|
||||||
|
|
||||||
// if we know about this event, redact its contents now.
|
// if we know about this event, redact its contents now.
|
||||||
const redactedEvent = redactId ? this.findEventById(redactId) : undefined;
|
const redactedEvent = redactId ? this.findEventById(redactId) : undefined;
|
||||||
if (redactedEvent) {
|
if (redactedEvent) {
|
||||||
const threadRootId = redactedEvent.threadRootId;
|
this.applyEventAsRedaction(event, redactedEvent);
|
||||||
redactedEvent.makeRedacted(event, this);
|
}
|
||||||
|
} else if (event.getType() === EventType.RoomMember) {
|
||||||
// If this is in the current state, replace it with the redacted version
|
const membership = event.getContent()["membership"];
|
||||||
if (redactedEvent.isState()) {
|
if (
|
||||||
const currentStateEvent = this.currentState.getStateEvents(
|
membership !== KnownMembership.Ban &&
|
||||||
redactedEvent.getType(),
|
!(membership === KnownMembership.Leave && event.getStateKey() !== event.getSender())
|
||||||
redactedEvent.getStateKey()!,
|
) {
|
||||||
);
|
// Not a ban or kick, therefore not a membership event we care about here.
|
||||||
if (currentStateEvent?.getId() === redactedEvent.getId()) {
|
return;
|
||||||
this.currentState.setStateEvents([redactedEvent]);
|
}
|
||||||
}
|
const redactEvents = event.getContent()["org.matrix.msc4293.redact_events"];
|
||||||
}
|
if (redactEvents !== true) {
|
||||||
|
// Invalid or not set - nothing to redact.
|
||||||
this.emit(RoomEvent.Redaction, event, this, threadRootId);
|
return;
|
||||||
|
}
|
||||||
// TODO: we stash user displaynames (among other things) in
|
const state = this.getLiveTimeline().getState(Direction.Forward)!;
|
||||||
// RoomMember objects which are then attached to other events
|
if (!state.maySendRedactionForEvent(event, event.getSender()!)) {
|
||||||
// (in the sender and target fields). We should get those
|
// If the sender can't redact the membership event, then they won't be able to
|
||||||
// RoomMember objects to update themselves when the events that
|
// redact any of the target's events either, so skip.
|
||||||
// they are based on are changed.
|
return;
|
||||||
|
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// FIXME: apply redactions to notification list
|
// The redaction is possible, so let's find all the events and apply it.
|
||||||
|
const events = this.getTimelineSets()
|
||||||
// NB: We continue to add the redaction event to the timeline so
|
.map((s) => s.getTimelines())
|
||||||
// clients can say "so and so redacted an event" if they wish to. Also
|
.reduce((p, c) => {
|
||||||
// this may be needed to trigger an update.
|
p.push(...c);
|
||||||
|
return p;
|
||||||
|
}, [])
|
||||||
|
.map((t) => t.getEvents().filter((e) => e.getSender() === event.getStateKey()))
|
||||||
|
.reduce((p, c) => {
|
||||||
|
p.push(...c);
|
||||||
|
return c;
|
||||||
|
}, []);
|
||||||
|
for (const toRedact of events) {
|
||||||
|
this.applyEventAsRedaction(event, toRedact);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
private processLiveEvent(event: MatrixEvent): void {
|
private processLiveEvent(event: MatrixEvent): void {
|
||||||
this.applyRedaction(event);
|
this.tryApplyRedaction(event);
|
||||||
|
|
||||||
// Implement MSC3531: hiding messages.
|
// Implement MSC3531: hiding messages.
|
||||||
if (event.isVisibilityEvent()) {
|
if (event.isVisibilityEvent()) {
|
||||||
|
Reference in New Issue
Block a user