mirror of
https://github.com/tulir/gomuks.git
synced 2025-04-20 10:33:41 -05:00
web/rightpanel: add support for filtering member list
This commit is contained in:
parent
1056a319bd
commit
e9f146ebc7
6 changed files with 100 additions and 99 deletions
|
@ -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,
|
||||||
|
|
|
@ -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 = []
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
|
@ -223,4 +236,5 @@ div.right-panel-content.members {
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
padding: .5rem;
|
padding: .5rem;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Reference in a new issue