1
0
Fork 0
forked from Mirrors/gomuks

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
// along with this program. If not, see <https://www.gnu.org/licenses/>.
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,

View file

@ -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<DBRoom>
@ -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<string, CustomEmojiPack> | 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 = []

View file

@ -15,13 +15,13 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
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) => <>
<img
className="small avatar"
loading="lazy"

View file

@ -13,19 +13,11 @@
//
// 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 { 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<filteredUserCache>({ 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

View file

@ -13,13 +13,13 @@
//
// 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 { 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<HTMLInputElement>) => 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(<MemberRow
key={evt.state_key}
evt={evt}
key={member.userID}
evt={member.event}
onClick={mainScreen.clickRightPanelOpener}
/>)
if (members.length >= limit) {
@ -66,10 +68,13 @@ const MemberList = () => {
}
}
return <>
{members}
{memberEvents.length > limit ? <button onClick={increaseLimit}>
and {memberEvents.length - limit} others
</button> : null}
<input className="member-filter" value={filter} onChange={onChangeFilter} placeholder="Filter members" />
<div className="member-list">
{members}
{memberEvents.length > limit ? <button onClick={increaseLimit}>
and {memberEvents.length - limit} others
</button> : null}
</div>
</>
}

View file

@ -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;
}
}
}