-
-
Notifications
You must be signed in to change notification settings - Fork 2.6k
Expand file tree
/
Copy pathMessageEvent.tsx
More file actions
334 lines (295 loc) · 13.5 KB
/
MessageEvent.tsx
File metadata and controls
334 lines (295 loc) · 13.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
/*
Copyright 2024 New Vector Ltd.
Copyright 2015-2021 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 mime from "mime";
import React, { createRef } from "react";
import { logger } from "matrix-js-sdk/src/logger";
import {
EventType,
MsgType,
MatrixEventEvent,
M_BEACON_INFO,
M_LOCATION,
M_POLL_START,
type IContent,
} from "matrix-js-sdk/src/matrix";
import SettingsStore from "../../../settings/SettingsStore";
import { Mjolnir } from "../../../mjolnir/Mjolnir";
import UnknownBody from "./UnknownBody";
import { type IMediaBody } from "./IMediaBody";
import { MediaEventHelper } from "../../../utils/MediaEventHelper";
import { type IBodyProps } from "./IBodyProps";
import TextualBody from "./TextualBody";
import MImageBody from "./MImageBody";
import MVoiceOrAudioBody from "./MVoiceOrAudioBody";
import MStickerBody from "./MStickerBody";
import MPollBody from "./MPollBody";
import MLocationBody from "./MLocationBody";
import MjolnirBody from "./MjolnirBody";
import MBeaconBody from "./MBeaconBody";
import { type EventTileOps, type GetRelationsForEvent } from "../rooms/EventTile";
import {
DecryptionFailureBodyFactory,
FileBodyFactory,
RedactedBodyFactory,
VideoBodyFactory,
renderMBody,
} from "./MBodyFactory";
// onMessageAllowed is handled internally
interface IProps extends Omit<IBodyProps, "onMessageAllowed" | "mediaEventHelper"> {
/* overrides for the msgtype-specific components, used by ReplyTile to override file rendering */
overrideBodyTypes?: Record<string, React.ComponentType<IBodyProps>>;
overrideEventTypes?: Record<string, React.ComponentType<IBodyProps>>;
// helper function to access relations for this event
getRelationsForEvent?: GetRelationsForEvent;
isSeeingThroughMessageHiddenForModeration?: boolean;
/**
* Optional ID for the root element.
*/
id?: string;
}
export interface IOperableEventTile {
getEventTileOps(): EventTileOps | null;
}
const baseBodyTypes = new Map<string, React.ComponentType<IBodyProps>>([
[MsgType.Text, TextualBody],
[MsgType.Notice, TextualBody],
[MsgType.Emote, TextualBody],
[MsgType.Image, MImageBody],
[MsgType.File, (props: IBodyProps) => renderMBody(props, FileBodyFactory)!],
[MsgType.Audio, MVoiceOrAudioBody],
[MsgType.Video, VideoBodyFactory],
]);
const baseEvTypes = new Map<string, React.ComponentType<IBodyProps>>([
[EventType.Sticker, MStickerBody],
[M_POLL_START.name, MPollBody],
[M_POLL_START.altName, MPollBody],
[M_BEACON_INFO.name, MBeaconBody],
[M_BEACON_INFO.altName, MBeaconBody],
]);
export default class MessageEvent extends React.Component<IProps> implements IMediaBody, IOperableEventTile {
private body = createRef<React.Component | IOperableEventTile>();
private mediaHelper?: MediaEventHelper;
private bodyTypes = new Map<string, React.ComponentType<IBodyProps>>(baseBodyTypes.entries());
private evTypes = new Map<string, React.ComponentType<IBodyProps>>(baseEvTypes.entries());
public constructor(props: IProps) {
super(props);
if (MediaEventHelper.isEligible(this.props.mxEvent)) {
this.mediaHelper = new MediaEventHelper(this.props.mxEvent);
}
this.updateComponentMaps();
}
public componentDidMount(): void {
this.props.mxEvent.addListener(MatrixEventEvent.Decrypted, this.onDecrypted);
}
public componentWillUnmount(): void {
this.props.mxEvent.removeListener(MatrixEventEvent.Decrypted, this.onDecrypted);
this.mediaHelper?.destroy();
}
public componentDidUpdate(prevProps: Readonly<IProps>): void {
if (this.props.mxEvent !== prevProps.mxEvent && MediaEventHelper.isEligible(this.props.mxEvent)) {
this.mediaHelper?.destroy();
this.mediaHelper = new MediaEventHelper(this.props.mxEvent);
}
this.updateComponentMaps();
}
private updateComponentMaps(): void {
this.bodyTypes = new Map<string, React.ComponentType<IBodyProps>>(baseBodyTypes.entries());
for (const [bodyType, bodyComponent] of Object.entries(this.props.overrideBodyTypes ?? {})) {
this.bodyTypes.set(bodyType, bodyComponent);
}
this.evTypes = new Map<string, React.ComponentType<IBodyProps>>(baseEvTypes.entries());
for (const [evType, evComponent] of Object.entries(this.props.overrideEventTypes ?? {})) {
this.evTypes.set(evType, evComponent);
}
}
public getEventTileOps = (): EventTileOps | null => {
return (this.body.current as IOperableEventTile)?.getEventTileOps?.() || null;
};
public getMediaHelper(): MediaEventHelper | undefined {
return this.mediaHelper;
}
private onDecrypted = (): void => {
// Recheck MediaEventHelper eligibility as it can change when the event gets decrypted
if (MediaEventHelper.isEligible(this.props.mxEvent)) {
this.mediaHelper?.destroy();
this.mediaHelper = new MediaEventHelper(this.props.mxEvent);
}
};
private onTileUpdate = (): void => {
this.forceUpdate();
};
/**
* Validates that the filename extension and advertised mimetype
* of the supplied image/file message content match and are actuallly video/image content.
* For image/video messages with a thumbnail it also validates the mimetype is an image.
* @param content The mxEvent content of the message
* @returns A boolean indicating whether the validation passed
*/
private validateImageOrVideoMimetype = (content: IContent): boolean => {
// As per the spec if filename is not present the body represents the filename
const filename = content.filename ?? content.body;
if (!filename) {
logger.log("Failed to validate image/video content, filename null");
return false;
}
// Check mimetype of the thumbnail
if (!this.validateThumbnailMimetype(content)) {
logger.log("Failed to validate file/image thumbnail");
return false;
}
// if there is no mimetype from the extesion or the mimetype is not image/video validation fails
const typeFromExtension = mime.getType(filename) ?? undefined;
const extensionMajorMimetype = this.parseMajorMimetype(typeFromExtension);
if (!typeFromExtension || !this.validateAllowedMimetype(typeFromExtension, ["image", "video"])) {
logger.log("Failed to validate image/video content, invalid or missing extension");
return false;
}
// if the content mimetype is set check it is an image/video and that it matches the extesion mimetype otherwise validation fails
const contentMimetype = content.info?.mimetype;
if (contentMimetype) {
const contentMajorMimetype = this.parseMajorMimetype(contentMimetype);
if (
!this.validateAllowedMimetype(contentMimetype, ["image", "video"]) ||
extensionMajorMimetype !== contentMajorMimetype
) {
logger.log("Failed to validate image/video content, invalid or missing mimetype");
return false;
}
}
return true;
};
/**
* Validates that the advertised mimetype of the sticker content
* is an image.
* For stickers with a thumbnail it also validates the mimetype is an image.
* @param content The mxEvent content of the message
* @returns A boolean indicating whether the validation passed
*/
private validateStickerMimetype = (content: IContent): boolean => {
// Validate mimetype of the thumbnail
const thumbnailResult = this.validateThumbnailMimetype(content);
if (!thumbnailResult) {
logger.log("Failed to validate sticker thumbnail");
return false;
}
// Validate mimetype of the content info is valid if it is set
const contentMimetype = content.info?.mimetype;
if (contentMimetype && !this.validateAllowedMimetype(contentMimetype, ["image"])) {
logger.log("Failed to validate image/video content, invalid or missing mimetype/extensions");
return false;
}
return true;
};
/**
* For image/video messages or stickers that have a thumnail mimetype specified,
* validates that the major mimetime is image.
* @param content The mxEvent content of the message
* @returns A boolean indicating whether the validation passed
*/
private validateThumbnailMimetype = (content: IContent): boolean => {
const thumbnailMimetype = content.info?.thumbnail_info?.mimetype;
return !thumbnailMimetype || this.validateAllowedMimetype(thumbnailMimetype, ["image"]);
};
/**
* Validates that the major part of a mimetime from an allowed list.
* @param mimetype The mimetype to validate
* @param allowedMajorMimeTypes The list of allowed major mimetimes
* @returns A boolean indicating whether the validation passed
*/
private validateAllowedMimetype = (mimetype: string, allowedMajorMimeTypes: string[]): boolean => {
const majorMimetype = this.parseMajorMimetype(mimetype);
return !!majorMimetype && allowedMajorMimeTypes.includes(majorMimetype);
};
/**
* Parses and returns the the major part of a mimetype(before the "/").
* @param mimetype As optional mimetype string to parse
* @returns The major part of the mimetype string or undefined
*/
private parseMajorMimetype(mimetype?: string): string | undefined {
return mimetype?.split("/")[0];
}
public render(): React.ReactNode {
const content = this.props.mxEvent.getContent();
const type = this.props.mxEvent.getType();
const msgtype = content.msgtype;
let BodyType: React.ComponentType<IBodyProps> = RedactedBodyFactory;
if (!this.props.mxEvent.isRedacted()) {
// only resolve BodyType if event is not redacted
if (this.props.mxEvent.isDecryptionFailure()) {
BodyType = DecryptionFailureBodyFactory;
} else if (type && this.evTypes.has(type)) {
BodyType = this.evTypes.get(type)!;
} else if (msgtype && this.bodyTypes.has(msgtype)) {
BodyType = this.bodyTypes.get(msgtype)!;
} else if (content.url) {
// Fallback to file body if there's a content URL
BodyType = this.bodyTypes.get(MsgType.File)!;
} else {
// Fallback to UnknownBody otherwise if not redacted
BodyType = UnknownBody;
}
if (
((BodyType === MImageBody || BodyType === VideoBodyFactory) &&
!this.validateImageOrVideoMimetype(content)) ||
(BodyType === MStickerBody && !this.validateStickerMimetype(content))
) {
BodyType = this.bodyTypes.get(MsgType.File)!;
}
// TODO: move to eventTypes when location sharing spec stabilises
if (M_LOCATION.matches(type) || (type === EventType.RoomMessage && msgtype === MsgType.Location)) {
BodyType = MLocationBody;
}
}
if (SettingsStore.getValue("feature_mjolnir")) {
const key = `mx_mjolnir_render_${this.props.mxEvent.getRoomId()}__${this.props.mxEvent.getId()}`;
const allowRender = localStorage.getItem(key) === "true";
if (!allowRender) {
const userDomain = this.props.mxEvent.getSender()?.split(":").slice(1).join(":");
const userBanned = Mjolnir.sharedInstance().isUserBanned(this.props.mxEvent.getSender()!);
const serverBanned = userDomain && Mjolnir.sharedInstance().isServerBanned(userDomain);
if (userBanned || serverBanned) {
BodyType = MjolnirBody;
}
}
}
const hasCaption =
[MsgType.Image, MsgType.File, MsgType.Audio, MsgType.Video].includes(msgtype as MsgType) &&
content.filename &&
content.filename !== content.body;
const bodyProps: IBodyProps = {
ref: this.body,
mxEvent: this.props.mxEvent,
highlights: this.props.highlights,
highlightLink: this.props.highlightLink,
showUrlPreview: this.props.showUrlPreview,
forExport: this.props.forExport,
maxImageHeight: this.props.maxImageHeight,
replacingEventId: this.props.replacingEventId,
editState: this.props.editState,
onMessageAllowed: this.onTileUpdate,
permalinkCreator: this.props.permalinkCreator,
mediaEventHelper: this.mediaHelper,
getRelationsForEvent: this.props.getRelationsForEvent,
isSeeingThroughMessageHiddenForModeration: this.props.isSeeingThroughMessageHiddenForModeration,
inhibitInteraction: this.props.inhibitInteraction,
id: this.props.id,
};
if (hasCaption) {
return <CaptionBody {...bodyProps} WrappedBodyType={BodyType} />;
}
return BodyType ? <BodyType {...bodyProps} /> : null;
}
}
const CaptionBody: React.FunctionComponent<IBodyProps & { WrappedBodyType: React.ComponentType<IBodyProps> }> = ({
WrappedBodyType,
...props
}) => (
<div className="mx_EventTile_content">
<WrappedBodyType {...props} />
<TextualBody {...{ ...props, ref: undefined }} />
</div>
);