diff --git a/web/src/api/statestore/hooks.ts b/web/src/api/statestore/hooks.ts index 97fc16f..d2763b3 100644 --- a/web/src/api/statestore/hooks.ts +++ b/web/src/api/statestore/hooks.ts @@ -14,11 +14,11 @@ // 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 { CustomEmojiPack } from "@/util/emoji" +import type { CustomEmojiPack } from "@/util/emoji" import type { EventID, EventType, MemDBEvent, UnknownEventContent } from "../types" import { Preferences, preferences } from "../types/preferences" -import { StateStore } from "./main.ts" -import { RoomStateStore } from "./room.ts" +import type { StateStore } from "./main.ts" +import type { AutocompleteMemberEntry, RoomStateStore } from "./room.ts" export function useRoomTimeline(room: RoomStateStore): (MemDBEvent | null)[] { return useSyncExternalStore( @@ -38,7 +38,7 @@ export function useRoomState( } -export function useRoomMembers(room?: RoomStateStore): MemDBEvent[] { +export function useRoomMembers(room?: RoomStateStore): AutocompleteMemberEntry[] { return useSyncExternalStore( room ? room.stateSubs.getSubscriber("m.room.member") : noopSubscribe, room ? room.getMembers : returnEmptyArray, diff --git a/web/src/api/statestore/room.ts b/web/src/api/statestore/room.ts index daf9955..70d1f59 100644 --- a/web/src/api/statestore/room.ts +++ b/web/src/api/statestore/room.ts @@ -78,8 +78,11 @@ export interface AutocompleteMemberEntry { displayName: string avatarURL?: ContentURI searchString: string + event: MemDBEvent } +const collator = new Intl.Collator() + export class RoomStateStore { readonly roomID: RoomID readonly meta: NonNullCachedEventDispatcher @@ -103,8 +106,7 @@ export class RoomStateStore { readonly localPreferenceCache: Preferences readonly preferenceSub = new NoDataSubscribable() serverPreferenceCache: Preferences = {} - #membersCache: MemDBEvent[] | null = null - #autocompleteMembersCache: AutocompleteMemberEntry[] | null = null + #membersCache: AutocompleteMemberEntry[] | null = null membersRequested: boolean = false #allPacksCache: Record | null = null lastOpened: number = 0 @@ -190,46 +192,36 @@ export class RoomStateStore { const membersCache = memberEvtIDs.values() .map(rowID => this.eventsByRowID.get(rowID)) .filter((evt): evt is MemDBEvent => !!evt && evt.content.membership === "join") + .map((evt): AutocompleteMemberEntry => ({ + userID: evt.state_key!, + displayName: getDisplayname(evt.state_key!, evt.content as MemberEventContent), + avatarURL: evt.content?.avatar_url, + searchString: toSearchableString(`${evt.content?.displayname ?? ""}${evt.state_key!.slice(1)}`), + event: evt, + })) .toArray() membersCache.sort((a, b) => { - const aUserID = a.state_key as UserID - const bUserID = b.state_key as UserID - const aPower = powerLevels.users?.[aUserID] ?? powerLevels.users_default ?? 0 - const bPower = powerLevels.users?.[bUserID] ?? powerLevels.users_default ?? 0 + const aPower = powerLevels.users?.[a.userID] ?? powerLevels.users_default ?? 0 + const bPower = powerLevels.users?.[b.userID] ?? powerLevels.users_default ?? 0 if (aPower !== bPower) { return bPower - aPower + } else if (a.displayName === b.displayName) { + return a.userID.localeCompare(b.userID) + } else { + return collator.compare(a.displayName, b.displayName) } - const aName = getDisplayname(aUserID, a.content as MemberEventContent).toLowerCase() - const bName = getDisplayname(bUserID, b.content as MemberEventContent).toLowerCase() - if (aName === bName) { - return aUserID.localeCompare(bUserID) - } - return aName.localeCompare(bName) }) - this.#autocompleteMembersCache = membersCache.map(evt => ({ - userID: evt.state_key!, - displayName: getDisplayname(evt.state_key!, evt.content as MemberEventContent), - avatarURL: evt.content?.avatar_url, - searchString: toSearchableString(`${evt.content?.displayname ?? ""}${evt.state_key!.slice(1)}`), - })) this.#membersCache = membersCache return membersCache } - getMembers = (): MemDBEvent[] => { + getMembers = (): AutocompleteMemberEntry[] => { if (this.#membersCache === null) { this.#fillMembersCache() } return this.#membersCache ?? [] } - getAutocompleteMembers = (): AutocompleteMemberEntry[] => { - if (this.#autocompleteMembersCache === null) { - this.#fillMembersCache() - } - return this.#autocompleteMembersCache ?? [] - } - getPinnedEvents(): EventID[] { const pinnedList = this.getStateEvent("m.room.pinned_events", "")?.content?.pinned if (Array.isArray(pinnedList)) { @@ -311,11 +303,9 @@ export class RoomStateStore { this.parent.invalidateEmojiPacksCache() } else if (evtType === "m.room.member") { this.#membersCache = null - this.#autocompleteMembersCache = null this.requestedMembers.delete(key as UserID) } else if (evtType === "m.room.power_levels") { this.#membersCache = null - this.#autocompleteMembersCache = null } this.stateSubs.notify(this.stateSubKey(evtType, key)) } @@ -399,7 +389,6 @@ export class RoomStateStore { newStateMap.set("m.room.member", this.state.get("m.room.member") ?? new Map()) } else { this.#membersCache = null - this.#autocompleteMembersCache = null } this.state = newStateMap this.stateLoaded = true @@ -467,7 +456,6 @@ export class RoomStateStore { this.fullMembersLoaded = false this.membersRequested = false this.#membersCache = null - this.#autocompleteMembersCache = null this.paginationRequestedForRow = -1 this.hasMoreHistory = true this.timeline = [] diff --git a/web/src/ui/composer/Autocompleter.tsx b/web/src/ui/composer/Autocompleter.tsx index 1c510bd..78e1a0d 100644 --- a/web/src/ui/composer/Autocompleter.tsx +++ b/web/src/ui/composer/Autocompleter.tsx @@ -15,13 +15,13 @@ // along with this program. If not, see . import { JSX, RefObject, use, useEffect } from "react" import { getAvatarURL, getMediaURL } from "@/api/media.ts" -import { RoomStateStore, useCustomEmojis } from "@/api/statestore" +import { AutocompleteMemberEntry, RoomStateStore, useCustomEmojis } from "@/api/statestore" import { Emoji, emojiToMarkdown, useSortedAndFilteredEmojis } from "@/util/emoji" import { escapeMarkdown } from "@/util/markdown.ts" import useEvent from "@/util/useEvent.ts" import ClientContext from "../ClientContext.ts" import type { ComposerState } from "./MessageComposer.tsx" -import { AutocompleteUser, useFilteredMembers } from "./userautocomplete.ts" +import { useFilteredMembers } from "./userautocomplete.ts" import "./Autocompleter.css" export interface AutocompleteQuery { @@ -131,10 +131,10 @@ export const EmojiAutocompleter = ({ params, room, ...rest }: AutocompleterProps const escapeDisplayname = (input: string) => escapeMarkdown(input).replace("\n", " ") const userFuncs = { - getText: (user: AutocompleteUser) => + getText: (user: AutocompleteMemberEntry) => `[${escapeDisplayname(user.displayName)}](https://matrix.to/#/${encodeURIComponent(user.userID)}) `, - getKey: (user: AutocompleteUser) => user.userID, - render: (user: AutocompleteUser) => <> + getKey: (user: AutocompleteMemberEntry) => user.userID, + render: (user: AutocompleteMemberEntry) => <> . -import { useMemo, useRef } from "react" -import { RoomStateStore } from "@/api/statestore" -import type { ContentURI, UserID } from "@/api/types" +import { useRef } from "react" +import { AutocompleteMemberEntry, RoomStateStore, useRoomMembers } from "@/api/statestore" import toSearchableString from "@/util/searchablestring.ts" -export interface AutocompleteUser { - userID: UserID - displayName: string - avatarURL?: ContentURI - searchString: string -} - -export function filterAndSort(users: AutocompleteUser[], query: string): AutocompleteUser[] { +export function filterAndSort(users: AutocompleteMemberEntry[], query: string): AutocompleteMemberEntry[] { query = toSearchableString(query) return users .map(user => ({ user, matchIndex: user.searchString.indexOf(query) })) @@ -34,28 +26,30 @@ export function filterAndSort(users: AutocompleteUser[], query: string): Autocom .map(({ user }) => user) } -interface filteredUserCache { - query: string - result: AutocompleteUser[] +export function filter(users: AutocompleteMemberEntry[], query: string): AutocompleteMemberEntry[] { + query = toSearchableString(query) + return users.filter(user => user.searchString.includes(query)) } -export function useFilteredMembers(room: RoomStateStore, query: string): AutocompleteUser[] { - const allMembers = useMemo( - () => room.getAutocompleteMembers(), - // fullMembersLoaded needs to be monitored for when the member list loads - // eslint-disable-next-line react-hooks/exhaustive-deps - [room, room.fullMembersLoaded], - ) +interface filteredUserCache { + query: string + result: AutocompleteMemberEntry[] +} + +export function useFilteredMembers( + room: RoomStateStore | undefined, query: string, sort = true, slice = true, +): AutocompleteMemberEntry[] { + const allMembers = useRoomMembers(room) const prev = useRef({ query: "", result: allMembers }) if (!query) { prev.current.query = "" prev.current.result = allMembers } else if (prev.current.query !== query) { - prev.current.result = filterAndSort( + prev.current.result = (sort ? filterAndSort : filter)( query.startsWith(prev.current.query) ? prev.current.result : allMembers, query, ) - if (prev.current.result.length > 100) { + if (prev.current.result.length > 100 && slice) { prev.current.result = prev.current.result.slice(0, 100) } prev.current.query = query diff --git a/web/src/ui/rightpanel/MemberList.tsx b/web/src/ui/rightpanel/MemberList.tsx index 3718483..3da42b1 100644 --- a/web/src/ui/rightpanel/MemberList.tsx +++ b/web/src/ui/rightpanel/MemberList.tsx @@ -13,13 +13,13 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import { use, useCallback, useState } from "react" +import React, { use, useCallback, useState } from "react" import { getAvatarURL } from "@/api/media.ts" -import { useRoomMembers } from "@/api/statestore" import { MemDBEvent, MemberEventContent } from "@/api/types" import { getDisplayname } from "@/util/validation.ts" import ClientContext from "../ClientContext.ts" import MainScreenContext from "../MainScreenContext.ts" +import { useFilteredMembers } from "../composer/userautocomplete.ts" import { RoomContext } from "../roomview/roomcontext.ts" interface MemberRowProps { @@ -42,23 +42,25 @@ const MemberRow = ({ evt, onClick }: MemberRowProps) => { } const MemberList = () => { - const [limit, setLimit] = useState(50) + const [filter, setFilter] = useState("") + const [limit, setLimit] = useState(30) const increaseLimit = useCallback(() => setLimit(limit => limit + 50), []) + const onChangeFilter = useCallback((e: React.ChangeEvent) => setFilter(e.target.value), []) const roomCtx = use(RoomContext) if (roomCtx?.store && !roomCtx?.store.membersRequested && !roomCtx?.store.fullMembersLoaded) { roomCtx.store.membersRequested = true use(ClientContext)?.loadRoomState(roomCtx.store.roomID, { omitMembers: false, refetch: false }) } - const memberEvents = useRoomMembers(roomCtx?.store) + const memberEvents = useFilteredMembers(roomCtx?.store, filter) if (!roomCtx) { return null } const mainScreen = use(MainScreenContext) const members = [] - for (const evt of memberEvents) { + for (const member of memberEvents) { members.push() if (members.length >= limit) { @@ -66,10 +68,13 @@ const MemberList = () => { } } return <> - {members} - {memberEvents.length > limit ? : null} + +
+ {members} + {memberEvents.length > limit ? : null} +
} diff --git a/web/src/ui/rightpanel/RightPanel.css b/web/src/ui/rightpanel/RightPanel.css index cd9e7de..2dd0cbe 100644 --- a/web/src/ui/rightpanel/RightPanel.css +++ b/web/src/ui/rightpanel/RightPanel.css @@ -193,34 +193,48 @@ div.right-panel-content.user { } div.right-panel-content.members { - display: flex; - flex-direction: column; + overflow: hidden; - > div.member { - display: flex; - align-items: center; - gap: .5rem; - cursor: var(--clickable-cursor); - - content-visibility: auto; - contain-intrinsic-height: 3rem; - height: 3rem; - padding: .25rem; - - > span.displayname { - overflow: hidden; - text-wrap: nowrap; - text-overflow: ellipsis; - user-select: none; - } - - &:hover, &:focus { - background-color: var(--light-hover-color); - } + > input.member-filter { + border: none; + outline: none; + padding: 1rem; + border-bottom: 1px solid var(--border-color); + width: 100%; + box-sizing: border-box; } - > button { - border-radius: 0; - padding: .5rem; + > div.member-list { + display: flex; + flex-direction: column; + overflow: auto; + + > div.member { + display: flex; + align-items: center; + gap: .5rem; + cursor: var(--clickable-cursor); + + content-visibility: auto; + contain-intrinsic-height: 3rem; + height: 3rem; + padding: .25rem; + + > span.displayname { + overflow: hidden; + text-wrap: nowrap; + text-overflow: ellipsis; + user-select: none; + } + + &:hover, &:focus { + background-color: var(--light-hover-color); + } + } + + > button { + border-radius: 0; + padding: .5rem; + } } }