From e9834fd987ebf089c906644156dcc665561e40b0 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Mon, 14 Oct 2024 01:26:33 +0300 Subject: [PATCH] web: request replied-to event if it's not cached --- web/src/api/client.ts | 29 +++++++++++----- web/src/api/statestore/hooks.ts | 11 ++++-- web/src/api/statestore/room.ts | 50 ++++++++++++++++++++++----- web/src/ui/MessageComposer.tsx | 2 +- web/src/ui/timeline/ReplyBody.tsx | 34 ++++++++++-------- web/src/ui/timeline/TimelineEvent.tsx | 4 +-- 6 files changed, 93 insertions(+), 37 deletions(-) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index a20b545..6173d27 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -15,15 +15,8 @@ // along with this program. If not, see . import { CachedEventDispatcher } from "../util/eventdispatcher.ts" import RPCClient, { SendMessageParams } from "./rpc.ts" -import { StateStore } from "./statestore" -import type { - ClientState, - EventRowID, - EventType, - RPCEvent, - RoomID, - UserID, -} from "./types" +import { RoomStateStore, StateStore } from "./statestore" +import type { ClientState, EventID, EventRowID, EventType, RPCEvent, RoomID, UserID } from "./types" export default class Client { readonly state = new CachedEventDispatcher() @@ -49,6 +42,24 @@ export default class Client { } } + requestEvent(room: RoomStateStore | RoomID | undefined, eventID: EventID) { + if (typeof room === "string") { + room = this.store.rooms.get(room) + } + if (!room || room.eventsByID.has(eventID)) { + return + } + const sub = room.getEventSubscriber(eventID) + if (sub.requested) { + return + } + sub.requested = true + this.rpc.getEvent(room.roomID, eventID).then( + evt => room.applyEvent(evt), + err => console.error(`Failed to fetch event ${eventID}`, err), + ) + } + async sendMessage(params: SendMessageParams): Promise { const room = this.store.rooms.get(params.room_id) if (!room) { diff --git a/web/src/api/statestore/hooks.ts b/web/src/api/statestore/hooks.ts index 81f4012..e214eb7 100644 --- a/web/src/api/statestore/hooks.ts +++ b/web/src/api/statestore/hooks.ts @@ -14,12 +14,19 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . import { useSyncExternalStore } from "react" -import type { MemDBEvent } from "../types" +import type { EventID, MemDBEvent } from "../types" import { RoomStateStore } from "./room.ts" export function useRoomTimeline(room: RoomStateStore): (MemDBEvent | null)[] { return useSyncExternalStore( - room.subscribeTimeline, + room.timelineSub.subscribe, () => room.timelineCache, ) } + +export function useRoomEvent(room: RoomStateStore, eventID: EventID): MemDBEvent | null { + return useSyncExternalStore( + room.getEventSubscriber(eventID).subscribe, + () => room.eventsByID.get(eventID) ?? null, + ) +} diff --git a/web/src/api/statestore/room.ts b/web/src/api/statestore/room.ts index 2d7d607..6244ae4 100644 --- a/web/src/api/statestore/room.ts +++ b/web/src/api/statestore/room.ts @@ -60,7 +60,35 @@ function visibleMetaIsEqual(meta1: DBRoom, meta2: DBRoom): boolean { meta1.has_member_list === meta2.has_member_list } -type SubscribeFunc = (callback: () => void) => () => void +type Subscriber = () => void +type SubscribeFunc = (callback: Subscriber) => () => void + +class Subscribable { + readonly subscribers: Set = new Set() + + constructor(private onEmpty?: () => void) { + } + + subscribe: SubscribeFunc = callback => { + this.subscribers.add(callback) + return () => { + this.subscribers.delete(callback) + if (this.subscribers.size === 0) { + this.onEmpty?.() + } + } + } + + notify() { + for (const sub of this.subscribers) { + sub() + } + } +} + +class EventSubscribable extends Subscribable { + requested: boolean = false +} export class RoomStateStore { readonly roomID: RoomID @@ -71,7 +99,8 @@ export class RoomStateStore { stateLoaded = false readonly eventsByRowID: Map = new Map() readonly eventsByID: Map = new Map() - readonly timelineSubscribers: Set<() => void> = new Set() + readonly timelineSub = new Subscribable() + readonly eventSubs: Map = new Map() readonly pendingEvents: EventRowID[] = [] paginating = false @@ -80,11 +109,6 @@ export class RoomStateStore { this.meta = new NonNullCachedEventDispatcher(meta) } - subscribeTimeline: SubscribeFunc = callback => { - this.timelineSubscribers.add(callback) - return () => this.timelineSubscribers.delete(callback) - } - notifyTimelineSubscribers() { this.timelineCache = this.timeline.map(rt => { const evt = this.eventsByRowID.get(rt.event_rowid) @@ -96,9 +120,16 @@ export class RoomStateStore { }).concat(this.pendingEvents .map(rowID => this.eventsByRowID.get(rowID)) .filter(evt => !!evt)) - for (const sub of this.timelineSubscribers) { - sub() + this.timelineSub.notify() + } + + getEventSubscriber(eventID: EventID): EventSubscribable { + let sub = this.eventSubs.get(eventID) + if (!sub) { + sub = new EventSubscribable(() => this.eventsByID.has(eventID) && this.eventSubs.delete(eventID)) + this.eventSubs.set(eventID, sub) } + return sub } getStateEvent(type: EventType, stateKey: string): MemDBEvent | undefined { @@ -149,6 +180,7 @@ export class RoomStateStore { this.pendingEvents.splice(pendingIdx, 1) } } + this.eventSubs.get(evt.event_id)?.notify() } applySendComplete(evt: RawDBEvent) { diff --git a/web/src/ui/MessageComposer.tsx b/web/src/ui/MessageComposer.tsx index f2f22ab..a889c29 100644 --- a/web/src/ui/MessageComposer.tsx +++ b/web/src/ui/MessageComposer.tsx @@ -17,7 +17,7 @@ import React, { use, useCallback, useRef, useState } from "react" import { RoomStateStore } from "@/api/statestore" import { MemDBEvent, Mentions } from "@/api/types" import { ClientContext } from "./ClientContext.ts" -import ReplyBody from "./timeline/ReplyBody.tsx" +import { ReplyBody } from "./timeline/ReplyBody.tsx" import "./MessageComposer.css" interface MessageComposerProps { diff --git a/web/src/ui/timeline/ReplyBody.tsx b/web/src/ui/timeline/ReplyBody.tsx index a65f63e..636c389 100644 --- a/web/src/ui/timeline/ReplyBody.tsx +++ b/web/src/ui/timeline/ReplyBody.tsx @@ -13,31 +13,39 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +import { use } from "react" import { getAvatarURL } from "@/api/media.ts" -import type { RoomStateStore } from "@/api/statestore" +import { RoomStateStore, useRoomEvent } from "@/api/statestore" import type { EventID, MemDBEvent, MemberEventContent } from "@/api/types" +import { ClientContext } from "../ClientContext.ts" import { TextMessageBody } from "./content/MessageBody.tsx" import CloseButton from "@/icons/close.svg?react" import "./ReplyBody.css" -interface BaseReplyBodyProps { +interface ReplyBodyProps { room: RoomStateStore - eventID?: EventID - event?: MemDBEvent + event: MemDBEvent onClose?: () => void } -type ReplyBodyProps = BaseReplyBodyProps & ({eventID: EventID } | {event: MemDBEvent }) +interface ReplyIDBodyProps { + room: RoomStateStore + eventID: EventID +} -const ReplyBody = ({ room, eventID, event, onClose }: ReplyBodyProps) => { +export const ReplyIDBody = ({ room, eventID }: ReplyIDBodyProps) => { + const event = useRoomEvent(room, eventID) if (!event) { - event = room.eventsByID.get(eventID!) - if (!event) { - return
- Reply to {eventID} -
- } + // This caches whether the event is requested or not, so it doesn't need to be wrapped in an effect. + use(ClientContext)!.requestEvent(room, eventID) + return
+ Reply to {eventID} +
} + return +} + +export const ReplyBody = ({ room, event, onClose }: ReplyBodyProps) => { const memberEvt = room.getStateEvent("m.room.member", event.sender) const memberEvtContent = memberEvt?.content as MemberEventContent | undefined return
@@ -56,5 +64,3 @@ const ReplyBody = ({ room, eventID, event, onClose }: ReplyBodyProps) => {
} - -export default ReplyBody diff --git a/web/src/ui/timeline/TimelineEvent.tsx b/web/src/ui/timeline/TimelineEvent.tsx index 988221d..33ba74f 100644 --- a/web/src/ui/timeline/TimelineEvent.tsx +++ b/web/src/ui/timeline/TimelineEvent.tsx @@ -19,7 +19,7 @@ import { RoomStateStore } from "@/api/statestore" import { MemDBEvent, MemberEventContent } from "@/api/types" import { ClientContext } from "../ClientContext.ts" import { LightboxContext } from "../Lightbox.tsx" -import ReplyBody from "./ReplyBody.tsx" +import { ReplyIDBody } from "./ReplyBody.tsx" import EncryptedBody from "./content/EncryptedBody.tsx" import HiddenEvent from "./content/HiddenEvent.tsx" import MemberBody from "./content/MemberBody.tsx" @@ -153,7 +153,7 @@ const TimelineEvent = ({ room, evt, prevEvt, setReplyTo }: TimelineEventProps) =
{typeof replyTo === "string" && BodyType !== HiddenEvent - ? : null} + ? : null} {evt.reactions ? : null}