matrix-react-sdk
Version:
SDK for matrix.org using React
290 lines (238 loc) • 10.2 kB
text/typescript
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { Room, RelationType, MatrixEvent, Thread, M_POLL_START, RoomEvent } from "matrix-js-sdk/src/matrix";
import { isNullOrUndefined } from "matrix-js-sdk/src/utils";
import { ActionPayload } from "../../dispatcher/payloads";
import { AsyncStoreWithClient } from "../AsyncStoreWithClient";
import defaultDispatcher from "../../dispatcher/dispatcher";
import { MessageEventPreview } from "./previews/MessageEventPreview";
import { PollStartEventPreview } from "./previews/PollStartEventPreview";
import { TagID } from "./models";
import { LegacyCallInviteEventPreview } from "./previews/LegacyCallInviteEventPreview";
import { LegacyCallAnswerEventPreview } from "./previews/LegacyCallAnswerEventPreview";
import { LegacyCallHangupEvent } from "./previews/LegacyCallHangupEvent";
import { StickerEventPreview } from "./previews/StickerEventPreview";
import { ReactionEventPreview } from "./previews/ReactionEventPreview";
import { UPDATE_EVENT } from "../AsyncStore";
import { IPreview } from "./previews/IPreview";
import { VoiceBroadcastInfoEventType } from "../../voice-broadcast";
import { VoiceBroadcastPreview } from "./previews/VoiceBroadcastPreview";
import shouldHideEvent from "../../shouldHideEvent";
// Emitted event for when a room's preview has changed. First argument will the room for which
// the change happened.
const ROOM_PREVIEW_CHANGED = "room_preview_changed";
const PREVIEWS: Record<
string,
{
isState: boolean;
previewer: IPreview;
}
> = {
"m.room.message": {
isState: false,
previewer: new MessageEventPreview(),
},
"m.call.invite": {
isState: false,
previewer: new LegacyCallInviteEventPreview(),
},
"m.call.answer": {
isState: false,
previewer: new LegacyCallAnswerEventPreview(),
},
"m.call.hangup": {
isState: false,
previewer: new LegacyCallHangupEvent(),
},
"m.sticker": {
isState: false,
previewer: new StickerEventPreview(),
},
"m.reaction": {
isState: false,
previewer: new ReactionEventPreview(),
},
[M_POLL_START.name]: {
isState: false,
previewer: new PollStartEventPreview(),
},
[M_POLL_START.altName]: {
isState: false,
previewer: new PollStartEventPreview(),
},
[VoiceBroadcastInfoEventType]: {
isState: true,
previewer: new VoiceBroadcastPreview(),
},
};
// The maximum number of events we're willing to look back on to get a preview.
const MAX_EVENTS_BACKWARDS = 50;
// type merging ftw
type TAG_ANY = "im.vector.any"; // eslint-disable-line @typescript-eslint/naming-convention
const TAG_ANY: TAG_ANY = "im.vector.any";
interface IState {
// Empty because we don't actually use the state
}
export interface MessagePreview {
event: MatrixEvent;
isThreadReply: boolean;
text: string;
}
const isThreadReply = (event: MatrixEvent): boolean => {
// a thread root event cannot be a thread reply
if (event.isThreadRoot) return false;
const thread = event.getThread();
// it cannot be a thread reply if there is no thread
if (!thread) return false;
const relation = event.getRelation();
if (
!!relation &&
relation.rel_type === RelationType.Annotation &&
relation.event_id === thread.rootEvent?.getId()
) {
// annotations on the thread root are not a thread reply
return false;
}
return true;
};
const mkMessagePreview = (text: string, event: MatrixEvent): MessagePreview => {
return {
event,
text,
isThreadReply: isThreadReply(event),
};
};
export class MessagePreviewStore extends AsyncStoreWithClient<IState> {
private static readonly internalInstance = (() => {
const instance = new MessagePreviewStore();
instance.start();
return instance;
})();
/**
* @internal Public for test only
*/
public static testInstance(): MessagePreviewStore {
return new MessagePreviewStore();
}
// null indicates the preview is empty / irrelevant
private previews = new Map<string, Map<TagID | TAG_ANY, MessagePreview | null>>();
private constructor() {
super(defaultDispatcher, {});
}
public static get instance(): MessagePreviewStore {
return MessagePreviewStore.internalInstance;
}
public static getPreviewChangedEventName(room: Room): string {
return `${ROOM_PREVIEW_CHANGED}:${room?.roomId}`;
}
/**
* Gets the pre-translated preview for a given room
* @param room The room to get the preview for.
* @param inTagId The tag ID in which the room resides
* @returns The preview, or null if none present.
*/
public async getPreviewForRoom(room: Room, inTagId: TagID): Promise<MessagePreview | null> {
if (!room) return null; // invalid room, just return nothing
if (!this.previews.has(room.roomId)) await this.generatePreview(room, inTagId);
const previews = this.previews.get(room.roomId);
if (!previews) return null;
if (previews.has(inTagId)) {
return previews.get(inTagId)!;
}
return previews.get(TAG_ANY) ?? null;
}
public generatePreviewForEvent(event: MatrixEvent): string {
const previewDef = PREVIEWS[event.getType()];
return previewDef?.previewer.getTextFor(event, undefined, true) ?? "";
}
private async generatePreview(room: Room, tagId?: TagID): Promise<void> {
const events = [...room.getLiveTimeline().getEvents(), ...room.getPendingEvents()];
// add last reply from each thread
room.getThreads().forEach((thread: Thread): void => {
const lastReply = thread.lastReply();
if (lastReply) events.push(lastReply);
});
// sort events from oldest to newest
events.sort((a: MatrixEvent, b: MatrixEvent) => {
return a.getTs() - b.getTs();
});
if (!events) return; // should only happen in tests
let map = this.previews.get(room.roomId);
if (!map) {
map = new Map<TagID | TAG_ANY, MessagePreview | null>();
this.previews.set(room.roomId, map);
}
// Set the tags so we know what to generate
if (!map.has(TAG_ANY)) map.set(TAG_ANY, null);
if (tagId && !map.has(tagId)) map.set(tagId, null);
let changed = false;
for (let i = events.length - 1; i >= 0; i--) {
if (i === events.length - MAX_EVENTS_BACKWARDS) {
// limit reached - clear the preview by breaking out of the loop
break;
}
const event = events[i];
await this.matrixClient?.decryptEventIfNeeded(event);
const shouldHide = shouldHideEvent(event);
if (shouldHide) continue;
const previewDef = PREVIEWS[event.getType()];
if (!previewDef) continue;
if (previewDef.isState && isNullOrUndefined(event.getStateKey())) continue;
const anyPreviewText = previewDef.previewer.getTextFor(event);
if (!anyPreviewText) continue; // not previewable for some reason
changed = changed || anyPreviewText !== map.get(TAG_ANY)?.text;
map.set(TAG_ANY, mkMessagePreview(anyPreviewText, event));
const tagsToGenerate = Array.from(map.keys()).filter((t) => t !== TAG_ANY); // we did the any tag above
for (const genTagId of tagsToGenerate) {
const realTagId = genTagId === TAG_ANY ? undefined : genTagId;
const preview = previewDef.previewer.getTextFor(event, realTagId);
if (preview === anyPreviewText) {
changed = changed || anyPreviewText !== map.get(genTagId)?.text;
map.delete(genTagId);
} else {
changed = changed || preview !== map.get(genTagId)?.text;
map.set(genTagId, preview ? mkMessagePreview(anyPreviewText, event) : null);
}
}
if (changed) {
// We've muted the underlying Map, so just emit that we've changed.
this.previews.set(room.roomId, map);
this.emit(UPDATE_EVENT, this);
this.emit(MessagePreviewStore.getPreviewChangedEventName(room), room);
}
return; // we're done
}
// At this point, we didn't generate a preview so clear it
this.previews.set(room.roomId, new Map<TagID | TAG_ANY, MessagePreview | null>());
this.emit(UPDATE_EVENT, this);
this.emit(MessagePreviewStore.getPreviewChangedEventName(room), room);
}
protected async onAction(payload: ActionPayload): Promise<void> {
if (!this.matrixClient) return;
if (payload.action === "MatrixActions.Room.timeline" || payload.action === "MatrixActions.Event.decrypted") {
const event = payload.event; // TODO: Type out the dispatcher
const roomId = event.getRoomId();
const isHistoricalEvent = payload.hasOwnProperty("isLiveEvent") && !payload.isLiveEvent;
if (!roomId || !this.previews.has(roomId) || isHistoricalEvent) return;
const room = this.matrixClient.getRoom(roomId);
if (!room) return;
await this.generatePreview(room, TAG_ANY);
}
}
protected async onReady(): Promise<void> {
if (!this.matrixClient) return;
this.matrixClient.on(RoomEvent.LocalEchoUpdated, this.onLocalEchoUpdated);
}
protected async onNotReady(): Promise<void> {
if (!this.matrixClient) return;
this.matrixClient.off(RoomEvent.LocalEchoUpdated, this.onLocalEchoUpdated);
}
protected onLocalEchoUpdated = async (ev: MatrixEvent, room: Room): Promise<void> => {
if (!this.previews.has(room.roomId)) return;
await this.generatePreview(room, TAG_ANY);
};
}