Refactor EventPreview to shared MVVM (#33646)

* Refactor EventPreview to shared MVVM

* Fix EventPreviewView export formatting

* Snapshots images for stories

* Deduplicate event preview formatting

* Handle event preview update failures

* Coalesce event preview updates

* Wait for event preview test expectations
This commit is contained in:
Zack
2026-06-02 08:36:28 +00:00
committed by GitHub
parent 1b52311f2e
commit 1c04814c2b
21 changed files with 764 additions and 270 deletions
@@ -1,138 +0,0 @@
/*
* Copyright 2024 New Vector Ltd.
* Copyright 2024 The Matrix.org Foundation C.I.C.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, { type HTMLProps, type JSX, useContext, useState } from "react";
import { type IContent, M_POLL_START, type MatrixEvent, MatrixEventEvent, MsgType } from "matrix-js-sdk/src/matrix";
import classNames from "classnames";
import { _t } from "../../../languageHandler";
import { MessagePreviewStore } from "../../../stores/message-preview";
import { useAsyncMemo } from "../../../hooks/useAsyncMemo";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { useTypedEventEmitter } from "../../../hooks/useEventEmitter.ts";
/**
* The props for the {@link EventPreview} component.
*/
interface Props extends HTMLProps<HTMLSpanElement> {
/**
* The event to display the preview for
*/
mxEvent: MatrixEvent;
}
/**
* A component that displays a preview for the given event.
* Wraps both `useEventPreview` & `EventPreviewTile`.
*/
export function EventPreview({ mxEvent, className, ...props }: Props): JSX.Element | null {
const preview = useEventPreview(mxEvent);
if (!preview) return null;
return <EventPreviewTile {...props} preview={preview} className={className} />;
}
/**
* The props for the {@link EventPreviewTile} component.
*/
interface EventPreviewTileProps extends HTMLProps<HTMLSpanElement> {
/**
* The preview to display
*/
preview: Preview;
}
/**
* A component that displays a preview given the output from `useEventPreview`.
*/
export function EventPreviewTile({
preview: [preview, prefix],
className,
...props
}: EventPreviewTileProps): JSX.Element | null {
const classes = classNames("mx_EventPreview", className);
if (!prefix)
return (
<span {...props} className={classes} title={preview}>
{preview}
</span>
);
return (
<span {...props} className={classes}>
{_t(
"event_preview|preview",
{
prefix,
preview,
},
{
bold: (sub) => <span className="mx_EventPreview_prefix">{sub}</span>,
},
)}
</span>
);
}
type Preview = [preview: string, prefix: string | null];
/**
* Hooks to generate a preview for the event.
* @param mxEvent
*/
export function useEventPreview(mxEvent: MatrixEvent | undefined): Preview | null {
const cli = useContext(MatrixClientContext);
// track the content as a means to regenerate the preview upon edits & decryption
const [content, setContent] = useState<IContent | undefined>(mxEvent?.getContent());
useTypedEventEmitter(mxEvent ?? undefined, MatrixEventEvent.Replaced, () => {
setContent(mxEvent!.getContent());
});
const awaitDecryption = mxEvent?.shouldAttemptDecryption() || mxEvent?.isBeingDecrypted();
useTypedEventEmitter(awaitDecryption ? (mxEvent ?? undefined) : undefined, MatrixEventEvent.Decrypted, () => {
setContent(mxEvent!.getContent());
});
return useAsyncMemo(
async () => {
if (!mxEvent || mxEvent.isRedacted() || mxEvent.isDecryptionFailure()) return null;
await cli.decryptEventIfNeeded(mxEvent);
return [
MessagePreviewStore.instance.generatePreviewForEvent(mxEvent),
getPreviewPrefix(mxEvent.getType(), content?.msgtype as MsgType),
];
},
[mxEvent, content],
null,
);
}
/**
* Get the prefix for the preview based on the type and the message type.
* @param type
* @param msgType
*/
function getPreviewPrefix(type: string, msgType: MsgType): string | null {
switch (type) {
case M_POLL_START.name:
return _t("event_preview|prefix|poll");
default:
}
switch (msgType) {
case MsgType.Audio:
return _t("event_preview|prefix|audio");
case MsgType.Image:
return _t("event_preview|prefix|image");
case MsgType.Video:
return _t("event_preview|prefix|video");
case MsgType.File:
return _t("event_preview|prefix|file");
default:
return null;
}
}
@@ -62,10 +62,10 @@ import { type ShowThreadPayload } from "../../../dispatcher/payloads/ShowThreadP
import { UnreadNotificationBadge } from "./NotificationBadge/UnreadNotificationBadge";
import { getLateEventInfo } from "../../structures/grouper/LateEventGrouper";
import PinningUtils from "../../../utils/PinningUtils";
import { EventPreview } from "./EventPreview";
import { ActionBarAdapter } from "./EventTile/ActionBarAdapter";
import { E2eStandardPadlockIcon } from "./EventTile/E2eStandardPadlockIcon";
import { E2eMessageSharedIconAdapter } from "./EventTile/E2eMessageSharedIconAdapter";
import { EventPreviewAdapter } from "./EventTile/EventPreviewAdapter";
import { MessageTimestampAdapter } from "./EventTile/MessageTimestampAdapter";
import { ReactionsRowAdapter } from "./EventTile/ReactionsRowAdapter";
import { ReceiptAdapter } from "./EventTile/ReceiptAdapter";
@@ -1259,7 +1259,10 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
) : this.props.mxEvent.isDecryptionFailure() ? (
<DecryptionFailureBodyFactory mxEvent={this.props.mxEvent} />
) : (
<EventPreview mxEvent={this.props.mxEvent} />
<EventPreviewAdapter
eventTileViewModel={this.viewModel}
mxEvent={this.props.mxEvent}
/>
)}
</div>
{this.renderThreadPanelSummary(threadState)}
@@ -0,0 +1,50 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React, { useEffect, type JSX } from "react";
import { type MatrixEvent } from "matrix-js-sdk/src/matrix";
import { EventPreviewView } from "@element-hq/web-shared-components";
import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext";
import { type EventTileViewModel } from "../../../../viewmodels/room/timeline/event-tile/EventTileViewModel";
/**
* Props for the {@link EventPreviewAdapter} component.
*/
interface EventPreviewAdapterProps extends Omit<React.ComponentPropsWithoutRef<"span">, "children" | "title"> {
/** View model backing the event tile. */
eventTileViewModel: EventTileViewModel;
/** Matrix event whose preview is rendered. */
mxEvent: MatrixEvent;
}
/**
* Renders an event preview inside notification timeline tiles.
*/
export function EventPreviewAdapter({
eventTileViewModel,
mxEvent,
...props
}: Readonly<EventPreviewAdapterProps>): JSX.Element {
const cli = useMatrixClientContext();
const vm = eventTileViewModel.getEventPreviewViewModel({ cli, mxEvent });
useEffect(() => {
// This child VM owns Matrix listeners, so release it when the view using it leaves the tree.
return () => eventTileViewModel.releaseEventPreviewViewModel();
}, [eventTileViewModel]);
useEffect(() => {
vm.setClient(cli);
}, [cli, vm]);
useEffect(() => {
vm.setEvent(mxEvent);
}, [mxEvent, vm]);
return <EventPreviewView {...props} vm={vm} />;
}
@@ -11,6 +11,7 @@ import PinIcon from "@vector-im/compound-design-tokens/assets/web/icons/pin-soli
import { Button } from "@vector-im/compound-web";
import { type MatrixEvent, type Room } from "matrix-js-sdk/src/matrix";
import classNames from "classnames";
import { EventPreviewView, useCreateAutoDisposedViewModel } from "@element-hq/web-shared-components";
import { usePinnedEvents, useSortedFetchedPinnedEvents } from "../../../hooks/usePinnedEvents";
import { _t } from "../../../languageHandler";
@@ -24,8 +25,9 @@ import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPaylo
import { Action } from "../../../dispatcher/actions";
import MessageEvent from "../messages/MessageEvent";
import PosthogTrackers from "../../../PosthogTrackers.ts";
import { EventPreview } from "./EventPreview.tsx";
import { SDKContext } from "../../../contexts/SDKContext.ts";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { EventPreviewViewModel } from "../../../viewmodels/room/timeline/event-tile/EventPreviewViewModel";
/**
* The props for the {@link PinnedMessageBanner} component.
@@ -118,7 +120,7 @@ export function PinnedMessageBanner({ room, permalinkCreator }: PinnedMessageBan
)}
</div>
)}
<EventPreview
<EventPreviewWrapper
mxEvent={pinnedEvent}
className="mx_PinnedMessageBanner_message"
data-testid="banner-message"
@@ -141,6 +143,25 @@ export function PinnedMessageBanner({ room, permalinkCreator }: PinnedMessageBan
);
}
type EventPreviewWrapperProps = Omit<React.ComponentPropsWithoutRef<"span">, "children" | "title"> & {
mxEvent: MatrixEvent;
};
function EventPreviewWrapper({ mxEvent, ...props }: Readonly<EventPreviewWrapperProps>): JSX.Element {
const cli = useContext(MatrixClientContext);
const vm = useCreateAutoDisposedViewModel(() => new EventPreviewViewModel({ cli, mxEvent }));
useEffect(() => {
vm.setEvent(mxEvent);
}, [mxEvent, vm]);
useEffect(() => {
vm.setClient(cli);
}, [cli, vm]);
return <EventPreviewView {...props} vm={vm} />;
}
/**
* When the banner is displayed or hidden, we want to notify the timeline to resize itself.
* @param pinnedEvent
@@ -0,0 +1,111 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, { type ReactNode } from "react";
import { M_POLL_START, type MatrixEvent, MatrixEventEvent, MsgType } from "matrix-js-sdk/src/matrix";
import { Disposables } from "@element-hq/web-shared-components";
import { _t } from "../../../../languageHandler";
import { MessagePreviewStore } from "../../../../stores/message-preview";
export interface EventPreviewContent {
previewContent: ReactNode;
previewTooltip?: string;
}
export class EventPreviewContentCache {
private key?: string;
private content?: ReactNode;
public get(preview: string, prefix: string | null): ReactNode {
const key = `${prefix ?? ""}\u0000${preview}`;
if (this.key === key && this.content !== undefined) {
return this.content;
}
this.key = key;
this.content = prefix
? _t(
"event_preview|preview",
{
prefix,
preview,
},
{
bold: (sub) => <strong>{sub}</strong>,
},
)
: preview;
return this.content;
}
}
export class MatrixEventContentChangeListener {
private mxEvent?: MatrixEvent;
private callback?: () => void;
private disposables?: Disposables;
public setEvent(mxEvent: MatrixEvent | undefined, callback: () => void): void {
if (this.mxEvent === mxEvent && this.callback === callback) return;
this.teardown();
this.mxEvent = mxEvent;
this.callback = callback;
if (!mxEvent) return;
this.disposables = new Disposables();
this.disposables.trackListener(mxEvent, MatrixEventEvent.Replaced, callback);
this.disposables.trackListener(mxEvent, MatrixEventEvent.Decrypted, callback);
}
public teardown(): void {
this.disposables?.dispose();
this.disposables = undefined;
this.mxEvent = undefined;
this.callback = undefined;
}
}
export function getEventPreviewContent(
mxEvent: MatrixEvent,
cache: EventPreviewContentCache,
): EventPreviewContent | null {
const preview = MessagePreviewStore.instance.generatePreviewForEvent(mxEvent);
if (!preview) {
return null;
}
const prefix = getEventPreviewPrefix(mxEvent.getType(), mxEvent.getContent().msgtype as MsgType | undefined);
return {
previewContent: cache.get(preview, prefix),
previewTooltip: prefix ? undefined : preview,
};
}
function getEventPreviewPrefix(type: string, msgType?: MsgType): string | null {
switch (type) {
case M_POLL_START.name:
return _t("event_preview|prefix|poll");
default:
}
switch (msgType) {
case MsgType.Audio:
return _t("event_preview|prefix|audio");
case MsgType.Image:
return _t("event_preview|prefix|image");
case MsgType.Video:
return _t("event_preview|prefix|video");
case MsgType.File:
return _t("event_preview|prefix|file");
default:
return null;
}
}
@@ -0,0 +1,160 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { type MatrixClient, type MatrixEvent } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import {
BaseViewModel,
type EventPreviewViewModel as EventPreviewViewModelInterface,
type EventPreviewViewSnapshot,
} from "@element-hq/web-shared-components";
import {
EventPreviewContentCache,
getEventPreviewContent,
MatrixEventContentChangeListener,
} from "./EventPreviewUtils";
export interface EventPreviewViewModelProps {
/**
* Matrix client used to decrypt event content before preview generation.
*/
cli: MatrixClient;
/**
* The event to display the preview for.
*/
mxEvent?: MatrixEvent;
}
export class EventPreviewViewModel
extends BaseViewModel<EventPreviewViewSnapshot, EventPreviewViewModelProps>
implements EventPreviewViewModelInterface
{
private readonly eventContentListener = new MatrixEventContentChangeListener();
private readonly previewContentCache = new EventPreviewContentCache();
private previewRequestId = 0;
private updatePreviewInFlight = false;
private updatePreviewQueued = false;
private static readonly hiddenSnapshot: EventPreviewViewSnapshot = {
isVisible: false,
};
public constructor(props: EventPreviewViewModelProps) {
super(props, EventPreviewViewModel.hiddenSnapshot);
this.disposables.track(() => this.eventContentListener.teardown());
this.eventContentListener.setEvent(props.mxEvent, this.onEventContentChanged);
this.updatePreviewSafely();
}
public setEvent(mxEvent?: MatrixEvent): void {
if (this.props.mxEvent === mxEvent) return;
this.props = {
...this.props,
mxEvent,
};
this.eventContentListener.setEvent(mxEvent, this.onEventContentChanged);
this.updatePreviewSafely();
}
public setClient(cli: MatrixClient): void {
if (this.props.cli === cli) return;
this.props = {
...this.props,
cli,
};
this.updatePreviewSafely();
}
private onEventContentChanged = (): void => {
this.updatePreviewSafely();
};
private updatePreviewSafely(): void {
if (this.updatePreviewInFlight) {
this.updatePreviewQueued = true;
return;
}
void this.drainPreviewUpdates();
}
private async drainPreviewUpdates(): Promise<void> {
this.updatePreviewInFlight = true;
try {
do {
this.updatePreviewQueued = false;
try {
await this.updatePreview();
} catch (error) {
logger.error("Failed to update event preview", error);
}
} while (this.updatePreviewQueued && !this.isDisposed);
} finally {
this.updatePreviewInFlight = false;
}
}
private async updatePreview(): Promise<void> {
const { cli, mxEvent } = this.props;
const requestId = ++this.previewRequestId;
if (!mxEvent || mxEvent.isRedacted() || mxEvent.isDecryptionFailure()) {
this.setHidden();
return;
}
try {
await cli.decryptEventIfNeeded(mxEvent);
} catch (error) {
logger.error("Failed to decrypt event preview", error);
if (this.isCurrentPreviewRequest(requestId, cli, mxEvent)) {
this.setHidden();
}
return;
}
if (!this.isCurrentPreviewRequest(requestId, cli, mxEvent)) return;
if (mxEvent.isRedacted() || mxEvent.isDecryptionFailure()) {
this.setHidden();
return;
}
const previewContent = getEventPreviewContent(mxEvent, this.previewContentCache);
if (!previewContent) {
this.setHidden();
return;
}
this.snapshot.merge({
isVisible: true,
...previewContent,
});
}
private isCurrentPreviewRequest(requestId: number, cli: MatrixClient, mxEvent: MatrixEvent): boolean {
return (
!this.isDisposed &&
requestId === this.previewRequestId &&
this.props.cli === cli &&
this.props.mxEvent === mxEvent
);
}
private setHidden(): void {
this.snapshot.merge({
isVisible: false,
previewContent: undefined,
previewTooltip: undefined,
});
}
}
@@ -44,6 +44,7 @@ import {
E2eMessageSharedIconViewModel,
type E2eMessageSharedIconViewModelProps,
} from "./E2eMessageSharedIconViewModel";
import { EventPreviewViewModel, type EventPreviewViewModelProps } from "./EventPreviewViewModel";
import {
ThreadListActionBarViewModel,
type ThreadListActionBarViewModelProps,
@@ -301,6 +302,7 @@ export class EventTileViewModel extends BaseViewModel<EventTileRenderState, Even
private threadSummaryViewModel?: ThreadSummaryViewModel;
private threadListActionBarViewModel?: ThreadListActionBarViewModel;
private e2eMessageSharedIconViewModel?: E2eMessageSharedIconViewModel;
private eventPreviewViewModel?: EventPreviewViewModel;
private actionBarViewModel?: EventTileActionBarViewModel;
private reactionsRowViewModel?: ReactionsRowViewModel;
@@ -323,6 +325,7 @@ export class EventTileViewModel extends BaseViewModel<EventTileRenderState, Even
this.threadSummaryViewModel?.dispose();
this.threadListActionBarViewModel?.dispose();
this.e2eMessageSharedIconViewModel?.dispose();
this.eventPreviewViewModel?.dispose();
this.actionBarViewModel?.dispose();
this.reactionsRowViewModel?.dispose();
super.dispose();
@@ -382,6 +385,18 @@ export class EventTileViewModel extends BaseViewModel<EventTileRenderState, Even
this.e2eMessageSharedIconViewModel = undefined;
}
/** Lazily creates and returns the event preview child view model. */
public getEventPreviewViewModel(props: EventPreviewViewModelProps): EventPreviewViewModel {
this.eventPreviewViewModel ??= new EventPreviewViewModel(props);
return this.eventPreviewViewModel;
}
/** Releases the event preview child view model when its adapter unmounts. */
public releaseEventPreviewViewModel(): void {
this.eventPreviewViewModel?.dispose();
this.eventPreviewViewModel = undefined;
}
/** Lazily creates and returns the event action bar child view model. */
public getActionBarViewModel(props: EventTileActionBarViewModelProps): EventTileActionBarViewModel {
this.actionBarViewModel ??= new EventTileActionBarViewModel(props);
@@ -7,13 +7,10 @@
* Please see LICENSE files in the repository root for full details.
*/
import React, { type MouseEvent, type ReactNode } from "react";
import { type MouseEvent } from "react";
import {
M_POLL_START,
type MatrixClient,
type MatrixEvent,
MatrixEventEvent,
MsgType,
type NotificationCount,
RoomEvent,
type Room,
@@ -40,11 +37,15 @@ import { type ShowThreadPayload } from "../../../../dispatcher/payloads/ShowThre
import PosthogTrackers from "../../../../PosthogTrackers";
import { determineUnreadState } from "../../../../RoomNotifs";
import { notificationLevelToIndicator } from "../../../../utils/notifications";
import { MessagePreviewStore } from "../../../../stores/message-preview";
import { mediaFromMxc } from "../../../../customisations/Media";
import UserIdentifierCustomisations from "../../../../customisations/UserIdentifier";
import { TimelineRenderingType } from "../../../../contexts/RoomContext";
import { keepIfSame } from "../../../../utils/keepIfSame";
import {
EventPreviewContentCache,
getEventPreviewContent,
MatrixEventContentChangeListener,
} from "./EventPreviewUtils";
const AVATAR_SIZE_PX = 24;
const THREAD_PROFILE_CONTEXTS = new Set<TimelineRenderingType>([
@@ -92,14 +93,12 @@ export class ThreadMessagePreviewViewModel
implements ThreadMessagePreviewViewModelInterface
{
private threadListenerCleanups: Array<() => void> = [];
private eventListenerCleanups: Array<() => void> = [];
private memberListenerCleanups: Array<() => void> = [];
private watchedEvent?: MatrixEvent;
private readonly eventContentListener = new MatrixEventContentChangeListener();
private readonly previewContentCache = new EventPreviewContentCache();
private watchedMemberRoom?: Room;
private watchedMemberUserId?: string;
private previewRequestId = 0;
private previewContentKey?: string;
private previewContent?: ReactNode;
public constructor(props: ThreadMessagePreviewViewModelProps) {
super(props, {
@@ -113,7 +112,7 @@ export class ThreadMessagePreviewViewModel
public dispose(): void {
this.teardownThreadListener();
this.teardownEventListeners();
this.eventContentListener.teardown();
this.teardownMemberListener();
super.dispose();
}
@@ -176,30 +175,6 @@ export class ThreadMessagePreviewViewModel
this.threadListenerCleanups = [];
}
private setupEventListeners(mxEvent?: MatrixEvent): void {
if (this.watchedEvent === mxEvent) return;
this.teardownEventListeners();
this.watchedEvent = mxEvent;
if (!mxEvent) return;
mxEvent.on(MatrixEventEvent.Replaced, this.onEventContentChanged);
mxEvent.on(MatrixEventEvent.Decrypted, this.onEventContentChanged);
this.eventListenerCleanups.push(() => {
mxEvent.off(MatrixEventEvent.Replaced, this.onEventContentChanged);
mxEvent.off(MatrixEventEvent.Decrypted, this.onEventContentChanged);
});
}
private teardownEventListeners(): void {
for (const cleanup of this.eventListenerCleanups) {
cleanup();
}
this.eventListenerCleanups = [];
this.watchedEvent = undefined;
}
private setupMemberListener(mxEvent?: MatrixEvent): void {
const userId = ThreadMessagePreviewViewModel.getProfileUserId(mxEvent);
const room = this.getProfileRoom();
@@ -253,7 +228,7 @@ export class ThreadMessagePreviewViewModel
private async updateFromThread(): Promise<void> {
const requestId = ++this.previewRequestId;
const lastReply = this.props.thread.replyToEvent ?? undefined;
this.setupEventListeners(lastReply);
this.eventContentListener.setEvent(lastReply, this.onEventContentChanged);
this.setupMemberListener(lastReply);
if (!lastReply) {
@@ -303,22 +278,15 @@ export class ThreadMessagePreviewViewModel
return;
}
const preview = MessagePreviewStore.instance.generatePreviewForEvent(mxEvent);
const preview = getEventPreviewContent(mxEvent, this.previewContentCache);
if (!preview) {
this.setHidden();
return;
}
const prefix = ThreadMessagePreviewViewModel.getPreviewPrefix(
mxEvent.getType(),
mxEvent.getContent().msgtype as MsgType | undefined,
);
const previewContent = this.getPreviewContent(preview, prefix);
this.snapshot.merge({
...baseSnapshot,
previewContent,
previewTooltip: prefix ? undefined : preview,
...preview,
isVisible: true,
});
}
@@ -372,50 +340,6 @@ export class ThreadMessagePreviewViewModel
});
}
private getPreviewContent(preview: string, prefix: string | null): ReactNode {
const key = `${prefix ?? ""}\u0000${preview}`;
if (this.previewContentKey === key) {
return this.previewContent;
}
this.previewContentKey = key;
this.previewContent = prefix
? _t(
"event_preview|preview",
{
prefix,
preview,
},
{
bold: (sub) => <strong>{sub}</strong>,
},
)
: preview;
return this.previewContent;
}
private static getPreviewPrefix(type: string, msgType?: MsgType): string | null {
switch (type) {
case M_POLL_START.name:
return _t("event_preview|prefix|poll");
default:
}
switch (msgType) {
case MsgType.Audio:
return _t("event_preview|prefix|audio");
case MsgType.Image:
return _t("event_preview|prefix|image");
case MsgType.Video:
return _t("event_preview|prefix|video");
case MsgType.File:
return _t("event_preview|prefix|file");
default:
return null;
}
}
private static getDisplayMember(
props: ThreadMessagePreviewViewModelProps,
mxEvent: MatrixEvent,