diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 30d1c0e..3cf1d47 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -37,7 +37,7 @@ export default class Client { readonly initComplete = new NonNullCachedEventDispatcher(false) readonly store = new StateStore() #stateRequests: RoomStateGUID[] = [] - #stateRequestQueued = false + #stateRequestPromise: Promise | 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 = () => { - const reqs = this.#stateRequests - this.#stateRequestQueued = false - this.#stateRequests = [] - this.loadSpecificRoomState(reqs).catch(err => console.error("Failed to load room state", reqs, err)) + #doStateRequestsPromise = (resolve: () => void) => { + window.queueMicrotask(() => { + const reqs = this.#stateRequests + this.#stateRequestPromise = null + this.#stateRequests = [] + this.loadSpecificRoomState(reqs) + .catch(err => console.error("Failed to load room state", reqs, err)) + .finally(resolve) + }) } requestEvent(room: RoomStateStore | RoomID | undefined, eventID: EventID) { diff --git a/web/src/api/statestore/hooks.ts b/web/src/api/statestore/hooks.ts index 2e6eec0..a31ecff 100644 --- a/web/src/api/statestore/hooks.ts +++ b/web/src/api/statestore/hooks.ts @@ -13,9 +13,18 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -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( diff --git a/web/src/api/statestore/room.ts b/web/src/api/statestore/room.ts index 6fc62fb..e2b888f 100644 --- a/web/src/api/statestore/room.ts +++ b/web/src/api/statestore/room.ts @@ -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) } diff --git a/web/src/ui/composer/TypingNotifications.tsx b/web/src/ui/composer/TypingNotifications.tsx index a8e9a08..10812ce 100644 --- a/web/src/ui/composer/TypingNotifications.tsx +++ b/web/src/ui/composer/TypingNotifications.tsx @@ -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 = } 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( { src={getAvatarURL(sender, member)} alt="" />) - memberNames.push(member?.displayname ?? sender) + memberNames.push(getDisplayname(sender, member)) } let description: JSX.Element | null = null diff --git a/web/src/ui/rightpanel/UserInfo.tsx b/web/src/ui/rightpanel/UserInfo.tsx index e605278..37e1313 100644 --- a/web/src/ui/rightpanel/UserInfo.tsx +++ b/web/src/ui/rightpanel/UserInfo.tsx @@ -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(null) const [errors, setErrors] = useState(null) useEffect(() => { diff --git a/web/src/ui/timeline/ReadReceipts.tsx b/web/src/ui/timeline/ReadReceipts.tsx index 7983c45..c4d3436 100644 --- a/web/src/ui/timeline/ReadReceipts.tsx +++ b/web/src/ui/timeline/ReadReceipts.tsx @@ -15,28 +15,22 @@ // along with this program. If not, see . 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 . 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"] diff --git a/web/src/ui/timeline/TimelineEvent.css b/web/src/ui/timeline/TimelineEvent.css index caa2c9c..4d7f239 100644 --- a/web/src/ui/timeline/TimelineEvent.css +++ b/web/src/ui/timeline/TimelineEvent.css @@ -96,6 +96,7 @@ div.timeline-event { > svg { height: 1rem; + width: 1rem; } &.error { diff --git a/web/src/ui/timeline/TimelineEvent.tsx b/web/src/ui/timeline/TimelineEvent.tsx index 3a96fd8..4410350 100644 --- a/web/src/ui/timeline/TimelineEvent.tsx +++ b/web/src/ui/timeline/TimelineEvent.tsx @@ -15,7 +15,7 @@ // along with this program. If not, see . 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)