web: make receipts and typing update after fetching member events

This commit is contained in:
Tulir Asokan 2024-12-18 20:18:32 +02:00
parent 0fbf76af98
commit 10d3da6e7a
9 changed files with 75 additions and 51 deletions

View file

@ -37,7 +37,7 @@ export default class Client {
readonly initComplete = new NonNullCachedEventDispatcher<boolean>(false)
readonly store = new StateStore()
#stateRequests: RoomStateGUID[] = []
#stateRequestQueued = false
#stateRequestPromise: Promise<void> | null = null
#gcInterval: number | undefined
constructor(readonly rpc: RPCClient) {
@ -126,21 +126,25 @@ export default class Client {
room = this.store.rooms.get(room)
}
if (!room || room.state.get("m.room.member")?.has(userID) || room.requestedMembers.has(userID)) {
return
return null
}
room.requestedMembers.add(userID)
this.#stateRequests.push({ room_id: room.roomID, type: "m.room.member", state_key: userID })
if (!this.#stateRequestQueued) {
this.#stateRequestQueued = true
window.queueMicrotask(this.doStateRequests)
if (this.#stateRequestPromise === null) {
this.#stateRequestPromise = new Promise(this.#doStateRequestsPromise)
}
return this.#stateRequestPromise
}
doStateRequests = () => {
#doStateRequestsPromise = (resolve: () => void) => {
window.queueMicrotask(() => {
const reqs = this.#stateRequests
this.#stateRequestQueued = false
this.#stateRequestPromise = null
this.#stateRequests = []
this.loadSpecificRoomState(reqs).catch(err => console.error("Failed to load room state", reqs, err))
this.loadSpecificRoomState(reqs)
.catch(err => console.error("Failed to load room state", reqs, err))
.finally(resolve)
})
}
requestEvent(room: RoomStateStore | RoomID | undefined, eventID: EventID) {

View file

@ -13,9 +13,18 @@
//
// 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/>.
import { useEffect, useMemo, useState, useSyncExternalStore } from "react"
import { useEffect, useMemo, useReducer, useState, useSyncExternalStore } from "react"
import Client from "@/api/client.ts"
import type { CustomEmojiPack } from "@/util/emoji"
import type { EventID, EventType, MemDBEvent, MemReceipt, UnknownEventContent } from "../types"
import type {
EventID,
EventType,
MemDBEvent,
MemReceipt,
MemberEventContent,
UnknownEventContent,
UserID,
} from "../types"
import { Preferences, preferences } from "../types/preferences"
import type { StateStore } from "./main.ts"
import type { AutocompleteMemberEntry, RoomStateStore } from "./room.ts"
@ -48,6 +57,34 @@ export function useRoomState(
)
}
export function useRoomMember(
client: Client | undefined | null, room: RoomStateStore | undefined, userID: UserID,
): MemDBEvent | null {
const evt = useRoomState(room, "m.room.member", userID)
if (!evt && client && room) {
client.requestMemberEvent(room, userID)
}
return evt
}
export function useMultipleRoomMembers(
client: Client, room: RoomStateStore, userIDs: UserID[],
): [UserID, MemberEventContent | null][] {
const [, forceUpdate] = useReducer(x => x + 1, 0)
let promiseAwaited = false
return userIDs.map(userID => {
const evt = room.getStateEvent("m.room.member", userID)
if (!evt) {
const promise = client.requestMemberEvent(room, userID)
if (promise && !promiseAwaited) {
promiseAwaited = true
promise.then(forceUpdate)
}
}
const member = (evt?.content ?? null) as MemberEventContent | null
return [userID, member]
})
}
export function useRoomMembers(room?: RoomStateStore): AutocompleteMemberEntry[] {
return useSyncExternalStore(

View file

@ -281,11 +281,12 @@ export class RoomStateStore {
}
const oldArr = this.receiptsByEventID.get(existingReceipt.event_id)
if (oldArr) {
const idx = oldArr.indexOf(existingReceipt)
if (idx >= 0) {
oldArr.splice(idx, 1)
if (oldArr.length === 0) {
const updated = oldArr.filter(r => r !== existingReceipt)
if (updated.length !== oldArr.length) {
if (updated.length === 0) {
this.receiptsByEventID.delete(existingReceipt.event_id)
} else {
this.receiptsByEventID.set(existingReceipt.event_id, updated)
}
this.receiptSubs.notify(existingReceipt.event_id)
}

View file

@ -16,9 +16,9 @@
import { JSX, use } from "react"
import { PulseLoader } from "react-spinners"
import { getAvatarURL } from "@/api/media.ts"
import { useRoomTyping } from "@/api/statestore"
import { MemberEventContent } from "@/api/types/mxtypes.ts"
import { useMultipleRoomMembers, useRoomTyping } from "@/api/statestore"
import { humanJoin } from "@/util/join.ts"
import { getDisplayname } from "@/util/validation.ts"
import ClientContext from "../ClientContext.ts"
import { useRoomContext } from "../roomview/roomcontext.ts"
import "./TypingNotifications.css"
@ -28,19 +28,14 @@ const TypingNotifications = () => {
const client = use(ClientContext)!
const room = roomCtx.store
const typing = useRoomTyping(room).filter(u => u !== client.userID)
const memberEvts = useMultipleRoomMembers(client, room, typing.slice(0, 5))
let loader: JSX.Element | null = null
if (typing.length > 0) {
loader = <PulseLoader speedMultiplier={0.5} size={5} color="var(--primary-color)" />
}
const avatars: JSX.Element[] = []
const memberNames: string[] = []
for (let i = 0; i < 5 && i < typing.length; i++) {
const sender = typing[i]
const memberEvt = room.getStateEvent("m.room.member", sender)
const member = (memberEvt?.content ?? null) as MemberEventContent | null
if (!memberEvt) {
use(ClientContext)?.requestMemberEvent(room, sender)
}
for (const [sender, member] of memberEvts) {
avatars.push(<img
key={sender}
className="small avatar"
@ -48,7 +43,7 @@ const TypingNotifications = () => {
src={getAvatarURL(sender, member)}
alt=""
/>)
memberNames.push(member?.displayname ?? sender)
memberNames.push(getDisplayname(sender, member))
}
let description: JSX.Element | null = null

View file

@ -16,7 +16,7 @@
import { use, useEffect, useState } from "react"
import { PuffLoader } from "react-spinners"
import { getAvatarURL } from "@/api/media.ts"
import { useRoomState } from "@/api/statestore"
import { useRoomMember } from "@/api/statestore"
import { MemberEventContent, UserID, UserProfile } from "@/api/types"
import { getLocalpart } from "@/util/validation.ts"
import ClientContext from "../ClientContext.ts"
@ -34,11 +34,8 @@ const UserInfo = ({ userID }: UserInfoProps) => {
const client = use(ClientContext)!
const roomCtx = use(RoomContext)
const openLightbox = use(LightboxContext)!
const memberEvt = useRoomState(roomCtx?.store, "m.room.member", userID)
const memberEvt = useRoomMember(client, roomCtx?.store, userID)
const member = (memberEvt?.content ?? null) as MemberEventContent | null
if (!memberEvt) {
use(ClientContext)?.requestMemberEvent(roomCtx?.store, userID)
}
const [globalProfile, setGlobalProfile] = useState<UserProfile | null>(null)
const [errors, setErrors] = useState<string[] | null>(null)
useEffect(() => {

View file

@ -15,28 +15,22 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import { use } from "react"
import { getAvatarURL } from "@/api/media.ts"
import { RoomStateStore, useReadReceipts } from "@/api/statestore"
import { EventID, MemberEventContent } from "@/api/types"
import { RoomStateStore, useMultipleRoomMembers, useReadReceipts } from "@/api/statestore"
import { EventID } from "@/api/types"
import { humanJoin } from "@/util/join.ts"
import { getDisplayname } from "@/util/validation.ts"
import ClientContext from "../ClientContext.ts"
import "./ReadReceipts.css"
const ReadReceipts = ({ room, eventID }: { room: RoomStateStore, eventID: EventID }) => {
const client = use(ClientContext)!
const receipts = useReadReceipts(room, eventID)
const memberEvts = useMultipleRoomMembers(client, room, receipts.map(receipt => receipt.user_id))
if (receipts.length === 0) {
return null
}
// Hacky hack for mobile clients. Would be nicer to get the number based on the CSS variable defining the size
const maxAvatarCount = window.innerWidth > 720 ? 4 : 2
const memberEvts = receipts.map(receipt => {
const memberEvt = room.getStateEvent("m.room.member", receipt.user_id)
const member = (memberEvt?.content ?? null) as MemberEventContent | null
if (!memberEvt) {
use(ClientContext)?.requestMemberEvent(room, receipt.user_id)
}
return [receipt.user_id, member] as const
})
const avatarMembers = receipts.length > maxAvatarCount ? memberEvts.slice(-maxAvatarCount+1) : memberEvts
const avatars = avatarMembers.map(([userID, member]) => {
return <img

View file

@ -15,7 +15,7 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import { use } from "react"
import { getAvatarURL, getUserColorIndex } from "@/api/media.ts"
import { RoomStateStore, useRoomEvent, useRoomState } from "@/api/statestore"
import { RoomStateStore, useRoomEvent, useRoomMember } from "@/api/statestore"
import type { EventID, MemDBEvent, MemberEventContent } from "@/api/types"
import { getDisplayname } from "@/util/validation.ts"
import ClientContext from "../ClientContext.ts"
@ -80,10 +80,8 @@ const onClickReply = (evt: React.MouseEvent) => {
export const ReplyBody = ({
room, event, onClose, isThread, isEditing, isSilent, onSetSilent, isExplicitInThread, onSetExplicitInThread,
}: ReplyBodyProps) => {
const memberEvt = useRoomState(room, "m.room.member", event.sender)
if (!memberEvt) {
use(ClientContext)?.requestMemberEvent(room, event.sender)
}
const client = use(ClientContext)
const memberEvt = useRoomMember(client, room, event.sender)
const memberEvtContent = memberEvt?.content as MemberEventContent | undefined
const BodyType = getBodyType(event, true)
const classNames = ["reply-body"]

View file

@ -96,6 +96,7 @@ div.timeline-event {
> svg {
height: 1rem;
width: 1rem;
}
&.error {

View file

@ -15,7 +15,7 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import React, { use, useCallback, useState } from "react"
import { getAvatarURL, getMediaURL, getUserColorIndex } from "@/api/media.ts"
import { useRoomState } from "@/api/statestore"
import { useRoomMember } from "@/api/statestore"
import { MemDBEvent, MemberEventContent, UnreadType } from "@/api/types"
import { isMobileDevice } from "@/util/ismobile.ts"
import { getDisplayname, isEventID } from "@/util/validation.ts"
@ -96,10 +96,7 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu }: TimelineEventProps) => {
/>,
})
}, [openModal, evt, roomCtx])
const memberEvt = useRoomState(roomCtx.store, "m.room.member", evt.sender)
if (!memberEvt) {
client.requestMemberEvent(roomCtx.store, evt.sender)
}
const memberEvt = useRoomMember(client, roomCtx.store, evt.sender)
const memberEvtContent = memberEvt?.content as MemberEventContent | undefined
const BodyType = getBodyType(evt)
const eventTS = new Date(evt.timestamp)