From 6ccb56658722473c7277069fed444c6e04554b8c Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Wed, 10 Jun 2020 18:37:59 -0600 Subject: [PATCH] Show message previews on the new room list tiles They're heavily cached. --- src/TextForEvent.js | 23 ++-- src/components/views/rooms/RoomTile2.tsx | 14 ++- src/i18n/strings/en_EN.json | 2 + src/stores/AsyncStore.ts | 6 +- src/stores/MessagePreviewStore.ts | 132 +++++++++++++++++++++++ 5 files changed, 166 insertions(+), 11 deletions(-) create mode 100644 src/stores/MessagePreviewStore.ts diff --git a/src/TextForEvent.js b/src/TextForEvent.js index 3607d7a676..09cfb67de7 100644 --- a/src/TextForEvent.js +++ b/src/TextForEvent.js @@ -265,13 +265,22 @@ function textForServerACLEvent(ev) { return text + changes.join(" "); } -function textForMessageEvent(ev) { +function textForMessageEvent(ev, skipUserPrefix) { const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); let message = senderDisplayName + ': ' + ev.getContent().body; - if (ev.getContent().msgtype === "m.emote") { - message = "* " + senderDisplayName + " " + message; - } else if (ev.getContent().msgtype === "m.image") { - message = _t('%(senderDisplayName)s sent an image.', {senderDisplayName}); + if (skipUserPrefix) { + message = ev.getContent().body; + if (ev.getContent().msgtype === "m.emote") { + message = senderDisplayName + " " + message; + } else if (ev.getContent().msgtype === "m.image") { + message = _t('sent an image.'); + } + } else { + if (ev.getContent().msgtype === "m.emote") { + message = "* " + senderDisplayName + " " + message; + } else if (ev.getContent().msgtype === "m.image") { + message = _t('%(senderDisplayName)s sent an image.', {senderDisplayName}); + } } return message; } @@ -612,8 +621,8 @@ for (const evType of ALL_RULE_TYPES) { stateHandlers[evType] = textForMjolnirEvent; } -export function textForEvent(ev) { +export function textForEvent(ev, skipUserPrefix) { const handler = (ev.isState() ? stateHandlers : handlers)[ev.getType()]; - if (handler) return handler(ev); + if (handler) return handler(ev, skipUserPrefix); return ''; } diff --git a/src/components/views/rooms/RoomTile2.tsx b/src/components/views/rooms/RoomTile2.tsx index e8056349e2..2b1c418294 100644 --- a/src/components/views/rooms/RoomTile2.tsx +++ b/src/components/views/rooms/RoomTile2.tsx @@ -30,6 +30,7 @@ import NotificationBadge, { INotificationState, NotificationColor, RoomNotificat import { _t } from "../../../languageHandler"; import { ContextMenu, ContextMenuButton } from "../../structures/ContextMenu"; import { DefaultTagID, TagID } from "../../../stores/room-list/models"; +import { MessagePreviewStore } from "../../../stores/MessagePreviewStore"; /******************************************************************* * CAUTION * @@ -253,8 +254,17 @@ export default class RoomTile2 extends React.Component { let messagePreview = null; if (this.props.showMessagePreview) { - // TODO: Actually get the real message preview from state - messagePreview =
I just ate a pie.
; + // The preview store heavily caches this info, so should be safe to hammer. + const text = MessagePreviewStore.instance.getPreviewForRoom(this.props.room); + + // Only show the preview if there is one to show. + if (text) { + messagePreview = ( +
+ {text} +
+ ); + } } const nameClasses = classNames({ diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index dcd6819368..81577d740e 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -246,6 +246,7 @@ "%(senderDisplayName)s enabled flair for %(groups)s in this room.": "%(senderDisplayName)s enabled flair for %(groups)s in this room.", "%(senderDisplayName)s disabled flair for %(groups)s in this room.": "%(senderDisplayName)s disabled flair for %(groups)s in this room.", "%(senderDisplayName)s enabled flair for %(newGroups)s and disabled flair for %(oldGroups)s in this room.": "%(senderDisplayName)s enabled flair for %(newGroups)s and disabled flair for %(oldGroups)s in this room.", + "sent an image.": "sent an image.", "%(senderDisplayName)s sent an image.": "%(senderDisplayName)s sent an image.", "%(senderName)s set the main address for this room to %(address)s.": "%(senderName)s set the main address for this room to %(address)s.", "%(senderName)s removed the main address for this room.": "%(senderName)s removed the main address for this room.", @@ -419,6 +420,7 @@ "Restart": "Restart", "Upgrade your Riot": "Upgrade your Riot", "A new version of Riot is available!": "A new version of Riot is available!", + "You: %(message)s": "You: %(message)s", "There was an error joining the room": "There was an error joining the room", "Sorry, your homeserver is too old to participate in this room.": "Sorry, your homeserver is too old to participate in this room.", "Please contact your homeserver administrator.": "Please contact your homeserver administrator.", diff --git a/src/stores/AsyncStore.ts b/src/stores/AsyncStore.ts index 3519050078..1977e808dc 100644 --- a/src/stores/AsyncStore.ts +++ b/src/stores/AsyncStore.ts @@ -42,18 +42,20 @@ export const UPDATE_EVENT = "update"; * help prevent lock conflicts. */ export abstract class AsyncStore extends EventEmitter { - private storeState: T = {}; + private storeState: T; private lock = new AwaitLock(); private readonly dispatcherRef: string; /** * Creates a new AsyncStore using the given dispatcher. * @param {Dispatcher} dispatcher The dispatcher to rely upon. + * @param {T} initialState The initial state for the store. */ - protected constructor(private dispatcher: Dispatcher) { + protected constructor(private dispatcher: Dispatcher, initialState: T = {}) { super(); this.dispatcherRef = dispatcher.register(this.onDispatch.bind(this)); + this.storeState = initialState; } /** diff --git a/src/stores/MessagePreviewStore.ts b/src/stores/MessagePreviewStore.ts new file mode 100644 index 0000000000..d0d4664951 --- /dev/null +++ b/src/stores/MessagePreviewStore.ts @@ -0,0 +1,132 @@ +/* +Copyright 2020 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 { Room } from "matrix-js-sdk/src/models/room"; +import { ActionPayload } from "../dispatcher/payloads"; +import { AsyncStoreWithClient } from "./AsyncStoreWithClient"; +import defaultDispatcher from "../dispatcher/dispatcher"; +import { RoomListStoreTempProxy } from "./room-list/RoomListStoreTempProxy"; +import { textForEvent } from "../TextForEvent"; +import { MatrixEvent } from "matrix-js-sdk/src/models/Event"; +import { _t } from "../languageHandler"; + +const PREVIEWABLE_EVENTS = [ + // This is the same list from RiotX + {type: "m.room.message", isState: false}, + {type: "m.room.name", isState: true}, + {type: "m.room.topic", isState: true}, + {type: "m.room.member", isState: true}, + {type: "m.room.history_visibility", isState: true}, + {type: "m.call.invite", isState: false}, + {type: "m.call.hangup", isState: false}, + {type: "m.call.answer", isState: false}, + {type: "m.room.encrypted", isState: false}, + {type: "m.room.encryption", isState: true}, + {type: "m.room.third_party_invite", isState: true}, + {type: "m.sticker", isState: false}, + {type: "m.room.create", isState: true}, +]; + +interface IState { + [roomId: string]: string; +} + +export class MessagePreviewStore extends AsyncStoreWithClient { + private static internalInstance = new MessagePreviewStore(); + + private constructor() { + super(defaultDispatcher, {}); + } + + public static get instance(): MessagePreviewStore { + return MessagePreviewStore.internalInstance; + } + + /** + * Gets the pre-translated preview for a given room + * @param room The room to get the preview for. + * @returns The preview, or null if none present. + */ + public getPreviewForRoom(room: Room): string { + if (!room) return null; // invalid room, just return nothing + + // It's faster to do a lookup this way than it is to use Object.keys().includes() + const val = this.state[room.roomId]; + if (val !== null && typeof(val) !== "string") { + this.generatePreview(room); + } + + return this.state[room.roomId]; + } + + private generatePreview(room: Room) { + const maxEventsBackwards = 50; // any further and we just assume there's nothing important + + const timeline = room.getLiveTimeline(); + if (!timeline) return; // usually only happens in tests + const events = timeline.getEvents(); + + for (let i = events.length - 1; i >= 0; i--) { + if (i === events.length - maxEventsBackwards) return; // limit reached + + const event = events[i]; + const preview = this.generatePreviewForEvent(event); + if (preview.isPreviewable) { + // noinspection JSIgnoredPromiseFromCall - the AsyncStore handles concurrent calls + this.updateState({[room.roomId]: preview.preview}); + return; // break - we found some text + } + } + + // if we didn't find anything, subscribe ourselves to an update + // noinspection JSIgnoredPromiseFromCall - the AsyncStore handles concurrent calls + this.updateState({[room.roomId]: null}); + } + + protected async onAction(payload: ActionPayload) { + if (!this.matrixClient) return; + + // TODO: Remove when new room list is made the default + if (!RoomListStoreTempProxy.isUsingNewStore()) return; + + if (payload.action === 'MatrixActions.Room.timeline' || payload.action === 'MatrixActions.Event.decrypted') { + const event = payload.event; // TODO: Type out the dispatcher + if (!Object.keys(this.state).includes(event.getRoomId())) return; // not important + + const preview = this.generatePreviewForEvent(event); + if (preview.isPreviewable) { + await this.updateState({[event.getRoomId()]: preview.preview}); + return; // break - we found some text + } + } + } + + private generatePreviewForEvent(event: MatrixEvent): { isPreviewable: boolean, preview: string } { + if (PREVIEWABLE_EVENTS.some(p => p.type === event.getType() && p.isState === event.isState())) { + const isSelf = event.getSender() === this.matrixClient.getUserId(); + let text = textForEvent(event, /*skipUserPrefix=*/isSelf); + if (!text || text.trim().length === 0) text = null; // force null if useless to us + if (text && isSelf) { + // XXX: i18n doesn't really work here if the language doesn't support prefixing. + // We'd ideally somehow route the `You:` bit to the textForEvent call, however + // threading that through is non-trivial. + text = _t("You: %(message)s", {message: text}); + } + return {isPreviewable: true, preview: text}; + } + return {isPreviewable: false, preview: null}; + } +}