web: request replied-to event if it's not cached

This commit is contained in:
Tulir Asokan 2024-10-14 01:26:33 +03:00
parent bbc59a2f89
commit e9834fd987
6 changed files with 93 additions and 37 deletions

View file

@ -15,15 +15,8 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
import { CachedEventDispatcher } from "../util/eventdispatcher.ts" import { CachedEventDispatcher } from "../util/eventdispatcher.ts"
import RPCClient, { SendMessageParams } from "./rpc.ts" import RPCClient, { SendMessageParams } from "./rpc.ts"
import { StateStore } from "./statestore" import { RoomStateStore, StateStore } from "./statestore"
import type { import type { ClientState, EventID, EventRowID, EventType, RPCEvent, RoomID, UserID } from "./types"
ClientState,
EventRowID,
EventType,
RPCEvent,
RoomID,
UserID,
} from "./types"
export default class Client { export default class Client {
readonly state = new CachedEventDispatcher<ClientState>() readonly state = new CachedEventDispatcher<ClientState>()
@ -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<void> { async sendMessage(params: SendMessageParams): Promise<void> {
const room = this.store.rooms.get(params.room_id) const room = this.store.rooms.get(params.room_id)
if (!room) { if (!room) {

View file

@ -14,12 +14,19 @@
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
import { useSyncExternalStore } from "react" import { useSyncExternalStore } from "react"
import type { MemDBEvent } from "../types" import type { EventID, MemDBEvent } from "../types"
import { RoomStateStore } from "./room.ts" import { RoomStateStore } from "./room.ts"
export function useRoomTimeline(room: RoomStateStore): (MemDBEvent | null)[] { export function useRoomTimeline(room: RoomStateStore): (MemDBEvent | null)[] {
return useSyncExternalStore( return useSyncExternalStore(
room.subscribeTimeline, room.timelineSub.subscribe,
() => room.timelineCache, () => room.timelineCache,
) )
} }
export function useRoomEvent(room: RoomStateStore, eventID: EventID): MemDBEvent | null {
return useSyncExternalStore(
room.getEventSubscriber(eventID).subscribe,
() => room.eventsByID.get(eventID) ?? null,
)
}

View file

@ -60,7 +60,35 @@ function visibleMetaIsEqual(meta1: DBRoom, meta2: DBRoom): boolean {
meta1.has_member_list === meta2.has_member_list 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<Subscriber> = 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 { export class RoomStateStore {
readonly roomID: RoomID readonly roomID: RoomID
@ -71,7 +99,8 @@ export class RoomStateStore {
stateLoaded = false stateLoaded = false
readonly eventsByRowID: Map<EventRowID, MemDBEvent> = new Map() readonly eventsByRowID: Map<EventRowID, MemDBEvent> = new Map()
readonly eventsByID: Map<EventID, MemDBEvent> = new Map() readonly eventsByID: Map<EventID, MemDBEvent> = new Map()
readonly timelineSubscribers: Set<() => void> = new Set() readonly timelineSub = new Subscribable()
readonly eventSubs: Map<EventID, EventSubscribable> = new Map()
readonly pendingEvents: EventRowID[] = [] readonly pendingEvents: EventRowID[] = []
paginating = false paginating = false
@ -80,11 +109,6 @@ export class RoomStateStore {
this.meta = new NonNullCachedEventDispatcher(meta) this.meta = new NonNullCachedEventDispatcher(meta)
} }
subscribeTimeline: SubscribeFunc = callback => {
this.timelineSubscribers.add(callback)
return () => this.timelineSubscribers.delete(callback)
}
notifyTimelineSubscribers() { notifyTimelineSubscribers() {
this.timelineCache = this.timeline.map(rt => { this.timelineCache = this.timeline.map(rt => {
const evt = this.eventsByRowID.get(rt.event_rowid) const evt = this.eventsByRowID.get(rt.event_rowid)
@ -96,9 +120,16 @@ export class RoomStateStore {
}).concat(this.pendingEvents }).concat(this.pendingEvents
.map(rowID => this.eventsByRowID.get(rowID)) .map(rowID => this.eventsByRowID.get(rowID))
.filter(evt => !!evt)) .filter(evt => !!evt))
for (const sub of this.timelineSubscribers) { this.timelineSub.notify()
sub()
} }
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 { getStateEvent(type: EventType, stateKey: string): MemDBEvent | undefined {
@ -149,6 +180,7 @@ export class RoomStateStore {
this.pendingEvents.splice(pendingIdx, 1) this.pendingEvents.splice(pendingIdx, 1)
} }
} }
this.eventSubs.get(evt.event_id)?.notify()
} }
applySendComplete(evt: RawDBEvent) { applySendComplete(evt: RawDBEvent) {

View file

@ -17,7 +17,7 @@ import React, { use, useCallback, useRef, useState } from "react"
import { RoomStateStore } from "@/api/statestore" import { RoomStateStore } from "@/api/statestore"
import { MemDBEvent, Mentions } from "@/api/types" import { MemDBEvent, Mentions } from "@/api/types"
import { ClientContext } from "./ClientContext.ts" import { ClientContext } from "./ClientContext.ts"
import ReplyBody from "./timeline/ReplyBody.tsx" import { ReplyBody } from "./timeline/ReplyBody.tsx"
import "./MessageComposer.css" import "./MessageComposer.css"
interface MessageComposerProps { interface MessageComposerProps {

View file

@ -13,31 +13,39 @@
// //
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
import { use } from "react"
import { getAvatarURL } from "@/api/media.ts" 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 type { EventID, MemDBEvent, MemberEventContent } from "@/api/types"
import { ClientContext } from "../ClientContext.ts"
import { TextMessageBody } from "./content/MessageBody.tsx" import { TextMessageBody } from "./content/MessageBody.tsx"
import CloseButton from "@/icons/close.svg?react" import CloseButton from "@/icons/close.svg?react"
import "./ReplyBody.css" import "./ReplyBody.css"
interface BaseReplyBodyProps { interface ReplyBodyProps {
room: RoomStateStore room: RoomStateStore
eventID?: EventID event: MemDBEvent
event?: MemDBEvent
onClose?: () => void 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) => {
if (!event) { const event = useRoomEvent(room, eventID)
event = room.eventsByID.get(eventID!)
if (!event) { if (!event) {
// 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 <blockquote className="reply-body"> return <blockquote className="reply-body">
Reply to {eventID} Reply to {eventID}
</blockquote> </blockquote>
} }
return <ReplyBody room={room} event={event}/>
} }
export const ReplyBody = ({ room, event, onClose }: ReplyBodyProps) => {
const memberEvt = room.getStateEvent("m.room.member", event.sender) const memberEvt = room.getStateEvent("m.room.member", event.sender)
const memberEvtContent = memberEvt?.content as MemberEventContent | undefined const memberEvtContent = memberEvt?.content as MemberEventContent | undefined
return <blockquote className={`reply-body ${onClose ? "composer" : ""}`}> return <blockquote className={`reply-body ${onClose ? "composer" : ""}`}>
@ -56,5 +64,3 @@ const ReplyBody = ({ room, eventID, event, onClose }: ReplyBodyProps) => {
<TextMessageBody room={room} event={event}/> <TextMessageBody room={room} event={event}/>
</blockquote> </blockquote>
} }
export default ReplyBody

View file

@ -19,7 +19,7 @@ import { RoomStateStore } from "@/api/statestore"
import { MemDBEvent, MemberEventContent } from "@/api/types" import { MemDBEvent, MemberEventContent } from "@/api/types"
import { ClientContext } from "../ClientContext.ts" import { ClientContext } from "../ClientContext.ts"
import { LightboxContext } from "../Lightbox.tsx" import { LightboxContext } from "../Lightbox.tsx"
import ReplyBody from "./ReplyBody.tsx" import { ReplyIDBody } from "./ReplyBody.tsx"
import EncryptedBody from "./content/EncryptedBody.tsx" import EncryptedBody from "./content/EncryptedBody.tsx"
import HiddenEvent from "./content/HiddenEvent.tsx" import HiddenEvent from "./content/HiddenEvent.tsx"
import MemberBody from "./content/MemberBody.tsx" import MemberBody from "./content/MemberBody.tsx"
@ -153,7 +153,7 @@ const TimelineEvent = ({ room, evt, prevEvt, setReplyTo }: TimelineEventProps) =
</div> </div>
<div className="event-content"> <div className="event-content">
{typeof replyTo === "string" && BodyType !== HiddenEvent {typeof replyTo === "string" && BodyType !== HiddenEvent
? <ReplyBody room={room} eventID={replyTo}/> : null} ? <ReplyIDBody room={room} eventID={replyTo}/> : null}
<BodyType room={room} sender={memberEvt} event={evt}/> <BodyType room={room} sender={memberEvt} event={evt}/>
{evt.reactions ? <EventReactions reactions={evt.reactions}/> : null} {evt.reactions ? <EventReactions reactions={evt.reactions}/> : null}
</div> </div>