web/rightpanel: add support for filtering member list

This commit is contained in:
Tulir Asokan 2024-12-09 00:13:21 +02:00
parent 1056a319bd
commit e9f146ebc7
6 changed files with 100 additions and 99 deletions

View file

@ -14,11 +14,11 @@
// 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 { useEffect, useMemo, useState, useSyncExternalStore } from "react" 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 type { EventID, EventType, MemDBEvent, UnknownEventContent } from "../types"
import { Preferences, preferences } from "../types/preferences" import { Preferences, preferences } from "../types/preferences"
import { StateStore } from "./main.ts" import type { StateStore } from "./main.ts"
import { RoomStateStore } from "./room.ts" import type { AutocompleteMemberEntry, RoomStateStore } from "./room.ts"
export function useRoomTimeline(room: RoomStateStore): (MemDBEvent | null)[] { export function useRoomTimeline(room: RoomStateStore): (MemDBEvent | null)[] {
return useSyncExternalStore( return useSyncExternalStore(
@ -38,7 +38,7 @@ export function useRoomState(
} }
export function useRoomMembers(room?: RoomStateStore): MemDBEvent[] { export function useRoomMembers(room?: RoomStateStore): AutocompleteMemberEntry[] {
return useSyncExternalStore( return useSyncExternalStore(
room ? room.stateSubs.getSubscriber("m.room.member") : noopSubscribe, room ? room.stateSubs.getSubscriber("m.room.member") : noopSubscribe,
room ? room.getMembers : returnEmptyArray, room ? room.getMembers : returnEmptyArray,

View file

@ -78,8 +78,11 @@ export interface AutocompleteMemberEntry {
displayName: string displayName: string
avatarURL?: ContentURI avatarURL?: ContentURI
searchString: string searchString: string
event: MemDBEvent
} }
const collator = new Intl.Collator()
export class RoomStateStore { export class RoomStateStore {
readonly roomID: RoomID readonly roomID: RoomID
readonly meta: NonNullCachedEventDispatcher<DBRoom> readonly meta: NonNullCachedEventDispatcher<DBRoom>
@ -103,8 +106,7 @@ export class RoomStateStore {
readonly localPreferenceCache: Preferences readonly localPreferenceCache: Preferences
readonly preferenceSub = new NoDataSubscribable() readonly preferenceSub = new NoDataSubscribable()
serverPreferenceCache: Preferences = {} serverPreferenceCache: Preferences = {}
#membersCache: MemDBEvent[] | null = null #membersCache: AutocompleteMemberEntry[] | null = null
#autocompleteMembersCache: AutocompleteMemberEntry[] | null = null
membersRequested: boolean = false membersRequested: boolean = false
#allPacksCache: Record<string, CustomEmojiPack> | null = null #allPacksCache: Record<string, CustomEmojiPack> | null = null
lastOpened: number = 0 lastOpened: number = 0
@ -190,46 +192,36 @@ export class RoomStateStore {
const membersCache = memberEvtIDs.values() const membersCache = memberEvtIDs.values()
.map(rowID => this.eventsByRowID.get(rowID)) .map(rowID => this.eventsByRowID.get(rowID))
.filter((evt): evt is MemDBEvent => !!evt && evt.content.membership === "join") .filter((evt): evt is MemDBEvent => !!evt && evt.content.membership === "join")
.toArray() .map((evt): AutocompleteMemberEntry => ({
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
if (aPower !== bPower) {
return bPower - aPower
}
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!, userID: evt.state_key!,
displayName: getDisplayname(evt.state_key!, evt.content as MemberEventContent), displayName: getDisplayname(evt.state_key!, evt.content as MemberEventContent),
avatarURL: evt.content?.avatar_url, avatarURL: evt.content?.avatar_url,
searchString: toSearchableString(`${evt.content?.displayname ?? ""}${evt.state_key!.slice(1)}`), searchString: toSearchableString(`${evt.content?.displayname ?? ""}${evt.state_key!.slice(1)}`),
event: evt,
})) }))
.toArray()
membersCache.sort((a, b) => {
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)
}
})
this.#membersCache = membersCache this.#membersCache = membersCache
return membersCache return membersCache
} }
getMembers = (): MemDBEvent[] => { getMembers = (): AutocompleteMemberEntry[] => {
if (this.#membersCache === null) { if (this.#membersCache === null) {
this.#fillMembersCache() this.#fillMembersCache()
} }
return this.#membersCache ?? [] return this.#membersCache ?? []
} }
getAutocompleteMembers = (): AutocompleteMemberEntry[] => {
if (this.#autocompleteMembersCache === null) {
this.#fillMembersCache()
}
return this.#autocompleteMembersCache ?? []
}
getPinnedEvents(): EventID[] { getPinnedEvents(): EventID[] {
const pinnedList = this.getStateEvent("m.room.pinned_events", "")?.content?.pinned const pinnedList = this.getStateEvent("m.room.pinned_events", "")?.content?.pinned
if (Array.isArray(pinnedList)) { if (Array.isArray(pinnedList)) {
@ -311,11 +303,9 @@ export class RoomStateStore {
this.parent.invalidateEmojiPacksCache() this.parent.invalidateEmojiPacksCache()
} else if (evtType === "m.room.member") { } else if (evtType === "m.room.member") {
this.#membersCache = null this.#membersCache = null
this.#autocompleteMembersCache = null
this.requestedMembers.delete(key as UserID) this.requestedMembers.delete(key as UserID)
} else if (evtType === "m.room.power_levels") { } else if (evtType === "m.room.power_levels") {
this.#membersCache = null this.#membersCache = null
this.#autocompleteMembersCache = null
} }
this.stateSubs.notify(this.stateSubKey(evtType, key)) 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()) newStateMap.set("m.room.member", this.state.get("m.room.member") ?? new Map())
} else { } else {
this.#membersCache = null this.#membersCache = null
this.#autocompleteMembersCache = null
} }
this.state = newStateMap this.state = newStateMap
this.stateLoaded = true this.stateLoaded = true
@ -467,7 +456,6 @@ export class RoomStateStore {
this.fullMembersLoaded = false this.fullMembersLoaded = false
this.membersRequested = false this.membersRequested = false
this.#membersCache = null this.#membersCache = null
this.#autocompleteMembersCache = null
this.paginationRequestedForRow = -1 this.paginationRequestedForRow = -1
this.hasMoreHistory = true this.hasMoreHistory = true
this.timeline = [] this.timeline = []

View file

@ -15,13 +15,13 @@
// 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 { JSX, RefObject, use, useEffect } from "react" import { JSX, RefObject, use, useEffect } from "react"
import { getAvatarURL, getMediaURL } from "@/api/media.ts" 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 { Emoji, emojiToMarkdown, useSortedAndFilteredEmojis } from "@/util/emoji"
import { escapeMarkdown } from "@/util/markdown.ts" import { escapeMarkdown } from "@/util/markdown.ts"
import useEvent from "@/util/useEvent.ts" import useEvent from "@/util/useEvent.ts"
import ClientContext from "../ClientContext.ts" import ClientContext from "../ClientContext.ts"
import type { ComposerState } from "./MessageComposer.tsx" import type { ComposerState } from "./MessageComposer.tsx"
import { AutocompleteUser, useFilteredMembers } from "./userautocomplete.ts" import { useFilteredMembers } from "./userautocomplete.ts"
import "./Autocompleter.css" import "./Autocompleter.css"
export interface AutocompleteQuery { export interface AutocompleteQuery {
@ -131,10 +131,10 @@ export const EmojiAutocompleter = ({ params, room, ...rest }: AutocompleterProps
const escapeDisplayname = (input: string) => escapeMarkdown(input).replace("\n", " ") const escapeDisplayname = (input: string) => escapeMarkdown(input).replace("\n", " ")
const userFuncs = { const userFuncs = {
getText: (user: AutocompleteUser) => getText: (user: AutocompleteMemberEntry) =>
`[${escapeDisplayname(user.displayName)}](https://matrix.to/#/${encodeURIComponent(user.userID)}) `, `[${escapeDisplayname(user.displayName)}](https://matrix.to/#/${encodeURIComponent(user.userID)}) `,
getKey: (user: AutocompleteUser) => user.userID, getKey: (user: AutocompleteMemberEntry) => user.userID,
render: (user: AutocompleteUser) => <> render: (user: AutocompleteMemberEntry) => <>
<img <img
className="small avatar" className="small avatar"
loading="lazy" loading="lazy"

View file

@ -13,19 +13,11 @@
// //
// 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 { useMemo, useRef } from "react" import { useRef } from "react"
import { RoomStateStore } from "@/api/statestore" import { AutocompleteMemberEntry, RoomStateStore, useRoomMembers } from "@/api/statestore"
import type { ContentURI, UserID } from "@/api/types"
import toSearchableString from "@/util/searchablestring.ts" import toSearchableString from "@/util/searchablestring.ts"
export interface AutocompleteUser { export function filterAndSort(users: AutocompleteMemberEntry[], query: string): AutocompleteMemberEntry[] {
userID: UserID
displayName: string
avatarURL?: ContentURI
searchString: string
}
export function filterAndSort(users: AutocompleteUser[], query: string): AutocompleteUser[] {
query = toSearchableString(query) query = toSearchableString(query)
return users return users
.map(user => ({ user, matchIndex: user.searchString.indexOf(query) })) .map(user => ({ user, matchIndex: user.searchString.indexOf(query) }))
@ -34,28 +26,30 @@ export function filterAndSort(users: AutocompleteUser[], query: string): Autocom
.map(({ user }) => user) .map(({ user }) => user)
} }
interface filteredUserCache { export function filter(users: AutocompleteMemberEntry[], query: string): AutocompleteMemberEntry[] {
query: string query = toSearchableString(query)
result: AutocompleteUser[] return users.filter(user => user.searchString.includes(query))
} }
export function useFilteredMembers(room: RoomStateStore, query: string): AutocompleteUser[] { interface filteredUserCache {
const allMembers = useMemo( query: string
() => room.getAutocompleteMembers(), result: AutocompleteMemberEntry[]
// fullMembersLoaded needs to be monitored for when the member list loads }
// eslint-disable-next-line react-hooks/exhaustive-deps
[room, room.fullMembersLoaded], export function useFilteredMembers(
) room: RoomStateStore | undefined, query: string, sort = true, slice = true,
): AutocompleteMemberEntry[] {
const allMembers = useRoomMembers(room)
const prev = useRef<filteredUserCache>({ query: "", result: allMembers }) const prev = useRef<filteredUserCache>({ query: "", result: allMembers })
if (!query) { if (!query) {
prev.current.query = "" prev.current.query = ""
prev.current.result = allMembers prev.current.result = allMembers
} else if (prev.current.query !== query) { } 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.startsWith(prev.current.query) ? prev.current.result : allMembers,
query, 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.result = prev.current.result.slice(0, 100)
} }
prev.current.query = query prev.current.query = query

View file

@ -13,13 +13,13 @@
// //
// 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, useCallback, useState } from "react" import React, { use, useCallback, useState } from "react"
import { getAvatarURL } from "@/api/media.ts" import { getAvatarURL } from "@/api/media.ts"
import { useRoomMembers } from "@/api/statestore"
import { MemDBEvent, MemberEventContent } from "@/api/types" import { MemDBEvent, MemberEventContent } from "@/api/types"
import { getDisplayname } from "@/util/validation.ts" import { getDisplayname } from "@/util/validation.ts"
import ClientContext from "../ClientContext.ts" import ClientContext from "../ClientContext.ts"
import MainScreenContext from "../MainScreenContext.ts" import MainScreenContext from "../MainScreenContext.ts"
import { useFilteredMembers } from "../composer/userautocomplete.ts"
import { RoomContext } from "../roomview/roomcontext.ts" import { RoomContext } from "../roomview/roomcontext.ts"
interface MemberRowProps { interface MemberRowProps {
@ -42,23 +42,25 @@ const MemberRow = ({ evt, onClick }: MemberRowProps) => {
} }
const MemberList = () => { const MemberList = () => {
const [limit, setLimit] = useState(50) const [filter, setFilter] = useState("")
const [limit, setLimit] = useState(30)
const increaseLimit = useCallback(() => setLimit(limit => limit + 50), []) const increaseLimit = useCallback(() => setLimit(limit => limit + 50), [])
const onChangeFilter = useCallback((e: React.ChangeEvent<HTMLInputElement>) => setFilter(e.target.value), [])
const roomCtx = use(RoomContext) const roomCtx = use(RoomContext)
if (roomCtx?.store && !roomCtx?.store.membersRequested && !roomCtx?.store.fullMembersLoaded) { if (roomCtx?.store && !roomCtx?.store.membersRequested && !roomCtx?.store.fullMembersLoaded) {
roomCtx.store.membersRequested = true roomCtx.store.membersRequested = true
use(ClientContext)?.loadRoomState(roomCtx.store.roomID, { omitMembers: false, refetch: false }) use(ClientContext)?.loadRoomState(roomCtx.store.roomID, { omitMembers: false, refetch: false })
} }
const memberEvents = useRoomMembers(roomCtx?.store) const memberEvents = useFilteredMembers(roomCtx?.store, filter)
if (!roomCtx) { if (!roomCtx) {
return null return null
} }
const mainScreen = use(MainScreenContext) const mainScreen = use(MainScreenContext)
const members = [] const members = []
for (const evt of memberEvents) { for (const member of memberEvents) {
members.push(<MemberRow members.push(<MemberRow
key={evt.state_key} key={member.userID}
evt={evt} evt={member.event}
onClick={mainScreen.clickRightPanelOpener} onClick={mainScreen.clickRightPanelOpener}
/>) />)
if (members.length >= limit) { if (members.length >= limit) {
@ -66,10 +68,13 @@ const MemberList = () => {
} }
} }
return <> return <>
<input className="member-filter" value={filter} onChange={onChangeFilter} placeholder="Filter members" />
<div className="member-list">
{members} {members}
{memberEvents.length > limit ? <button onClick={increaseLimit}> {memberEvents.length > limit ? <button onClick={increaseLimit}>
and {memberEvents.length - limit} others and {memberEvents.length - limit} others
</button> : null} </button> : null}
</div>
</> </>
} }

View file

@ -193,8 +193,21 @@ div.right-panel-content.user {
} }
div.right-panel-content.members { div.right-panel-content.members {
overflow: hidden;
> input.member-filter {
border: none;
outline: none;
padding: 1rem;
border-bottom: 1px solid var(--border-color);
width: 100%;
box-sizing: border-box;
}
> div.member-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: auto;
> div.member { > div.member {
display: flex; display: flex;
@ -224,3 +237,4 @@ div.right-panel-content.members {
padding: .5rem; padding: .5rem;
} }
} }
}