web/rightpanel: implement member list

This commit is contained in:
Tulir Asokan 2024-11-12 21:08:37 +02:00
parent 24f2e3722d
commit 04117b5211
12 changed files with 182 additions and 29 deletions

View file

@ -36,6 +36,13 @@ export function useRoomState(
) )
} }
export function useRoomMembers(room?: RoomStateStore): MemDBEvent[] {
return useSyncExternalStore(
room ? room.stateSubs.getSubscriber("m.room.member") : noopSubscribe,
room ? room.getMembers : () => [],
)
}
const noopSubscribe = () => () => {} const noopSubscribe = () => () => {}
const returnNull = () => null const returnNull = () => null

View file

@ -16,6 +16,7 @@
import { CustomEmojiPack, parseCustomEmojiPack } from "@/util/emoji" import { CustomEmojiPack, parseCustomEmojiPack } from "@/util/emoji"
import { NonNullCachedEventDispatcher } from "@/util/eventdispatcher.ts" import { NonNullCachedEventDispatcher } from "@/util/eventdispatcher.ts"
import Subscribable, { MultiSubscribable } from "@/util/subscribable.ts" import Subscribable, { MultiSubscribable } from "@/util/subscribable.ts"
import { getDisplayname } from "@/util/validation.ts"
import { import {
DBRoom, DBRoom,
EncryptedEventContent, EncryptedEventContent,
@ -26,10 +27,13 @@ import {
ImagePack, ImagePack,
LazyLoadSummary, LazyLoadSummary,
MemDBEvent, MemDBEvent,
MemberEventContent,
PowerLevelEventContent,
RawDBEvent, RawDBEvent,
RoomID, RoomID,
SyncRoom, SyncRoom,
TimelineRowTuple, TimelineRowTuple,
UserID,
roomStateGUIDToString, roomStateGUIDToString,
} from "../types" } from "../types"
import type { StateStore } from "./main.ts" import type { StateStore } from "./main.ts"
@ -80,6 +84,7 @@ export class RoomStateStore {
readonly requestedEvents: Set<EventID> = new Set() readonly requestedEvents: Set<EventID> = new Set()
readonly openNotifications: Map<EventRowID, Notification> = new Map() readonly openNotifications: Map<EventRowID, Notification> = new Map()
readonly emojiPacks: Map<string, CustomEmojiPack | null> = new Map() readonly emojiPacks: Map<string, CustomEmojiPack | null> = new Map()
#membersCache: MemDBEvent[] | null = null
#allPacksCache: Record<string, CustomEmojiPack> | null = null #allPacksCache: Record<string, CustomEmojiPack> | null = null
readonly pendingEvents: EventRowID[] = [] readonly pendingEvents: EventRowID[] = []
paginating = false paginating = false
@ -150,6 +155,36 @@ export class RoomStateStore {
return this.#allPacksCache return this.#allPacksCache
} }
getMembers = (): MemDBEvent[] => {
if (this.#membersCache === null) {
const memberEvtIDs = this.state.get("m.room.member")
if (!memberEvtIDs) {
return []
}
const powerLevels: PowerLevelEventContent = this.getStateEvent("m.room.power_levels", "")?.content ?? {}
this.#membersCache = memberEvtIDs.values()
.map(rowID => this.eventsByRowID.get(rowID))
.filter(evt => !!evt)
.toArray()
this.#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)
})
}
return this.#membersCache
}
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)) {
@ -229,6 +264,8 @@ export class RoomStateStore {
this.emojiPacks.delete(key) this.emojiPacks.delete(key)
this.#allPacksCache = null this.#allPacksCache = null
this.parent.invalidateEmojiPacksCache() this.parent.invalidateEmojiPacksCache()
} else if (evtType === "m.room.member" || evtType === "m.room.power_levels") {
this.#membersCache = null
} }
this.stateSubs.notify(this.stateSubKey(evtType, key)) this.stateSubs.notify(this.stateSubKey(evtType, key))
} }

View file

@ -0,0 +1,71 @@
// gomuks - A Matrix client written in Go.
// Copyright (C) 2024 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// 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 { getAvatarURL } from "@/api/media.ts"
import { useRoomMembers } from "@/api/statestore"
import { MemDBEvent, MemberEventContent } from "@/api/types"
import { getDisplayname } from "@/util/validation.ts"
import MainScreenContext from "../MainScreenContext.ts"
import { RoomContext } from "../roomview/roomcontext.ts"
interface MemberRowProps {
evt: MemDBEvent
onClick: (evt: React.MouseEvent<HTMLDivElement>) => void
}
const MemberRow = ({ evt, onClick }: MemberRowProps) => {
const userID = evt.state_key!
const content = evt.content as MemberEventContent
return <div className="member" data-target-panel="user" data-target-user={userID} onClick={onClick}>
<img
className="avatar"
src={getAvatarURL(userID, content)}
alt=""
loading="lazy"
/>
<span className="displayname">{getDisplayname(userID, content)}</span>
</div>
}
const MemberList = () => {
const [limit, setLimit] = useState(50)
const increaseLimit = useCallback(() => setLimit(limit => limit + 50), [])
const roomCtx = use(RoomContext)
const memberEvents = useRoomMembers(roomCtx?.store)
if (!roomCtx) {
return null
}
const mainScreen = use(MainScreenContext)
const members = []
for (const evt of memberEvents) {
members.push(<MemberRow
key={evt.state_key}
evt={evt}
onClick={mainScreen.clickRightPanelOpener}
/>)
if (members.length >= limit) {
break
}
}
return <>
{members}
{memberEvents.length > limit ? <button onClick={increaseLimit}>
and {memberEvents.length - limit} others
</button> : null}
</>
}
export default MemberList

View file

@ -89,3 +89,36 @@ div.right-panel-content.user {
word-break: break-word; word-break: break-word;
} }
} }
div.right-panel-content.members {
display: flex;
flex-direction: column;
> 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;
}
}

View file

@ -16,6 +16,7 @@
import { JSX, use } from "react" import { JSX, use } from "react"
import type { UserID } from "@/api/types" import type { UserID } from "@/api/types"
import MainScreenContext from "../MainScreenContext.ts" import MainScreenContext from "../MainScreenContext.ts"
import MemberList from "./MemberList.tsx"
import PinnedMessages from "./PinnedMessages.tsx" import PinnedMessages from "./PinnedMessages.tsx"
import UserInfo from "./UserInfo.tsx" import UserInfo from "./UserInfo.tsx"
import BackIcon from "@/icons/back.svg?react" import BackIcon from "@/icons/back.svg?react"
@ -51,7 +52,7 @@ function renderRightPanelContent(props: RightPanelProps): JSX.Element | null {
case "pinned-messages": case "pinned-messages":
return <PinnedMessages /> return <PinnedMessages />
case "members": case "members":
return <>Member list is not yet implemented</> return <MemberList />
case "user": case "user":
return <UserInfo userID={props.userID} /> return <UserInfo userID={props.userID} />
} }

View file

@ -18,6 +18,7 @@ import { PuffLoader } from "react-spinners"
import { getAvatarURL } from "@/api/media.ts" import { getAvatarURL } from "@/api/media.ts"
import { useRoomState } from "@/api/statestore" import { useRoomState } from "@/api/statestore"
import { MemberEventContent, UserID, UserProfile } from "@/api/types" import { MemberEventContent, UserID, UserProfile } from "@/api/types"
import { getDisplayname } from "@/util/validation.ts"
import ClientContext from "../ClientContext.ts" import ClientContext from "../ClientContext.ts"
import { LightboxContext, OpenLightboxType } from "../modal/Lightbox.tsx" import { LightboxContext, OpenLightboxType } from "../modal/Lightbox.tsx"
import { RoomContext } from "../roomview/roomcontext.ts" import { RoomContext } from "../roomview/roomcontext.ts"
@ -56,31 +57,22 @@ interface RenderUserInfoParams {
} }
function renderUserInfo({ userID, profile, error, openLightbox }: RenderUserInfoParams) { function renderUserInfo({ userID, profile, error, openLightbox }: RenderUserInfoParams) {
const displayname = profile?.displayname ?? userID const displayname = getDisplayname(userID, profile)
if (profile === null) {
return <> return <>
<div className="avatar-container"> <div className="avatar-container">
<PuffLoader {profile === null && error === null ? <PuffLoader
color="var(--primary-color)" color="var(--primary-color)"
size="100%" size="100%"
className="avatar-loader" className="avatar-loader"
/> /> : <img
</div>
<div className="displayname">&nbsp;</div>
<div className="userid">{userID}</div>
</>
}
return <>
<div className="avatar-container">
<img
className="avatar" className="avatar"
src={getAvatarURL(userID, profile)} src={getAvatarURL(userID, profile)}
onClick={openLightbox} onClick={openLightbox}
alt="" alt=""
/> />}
</div> </div>
<div className="displayname" title={displayname}>{displayname}</div> <div className="displayname" title={displayname}>{displayname}</div>
{displayname !== userID ? <div className="userid" title={userID}>{userID}</div> : null} <div className="userid" title={userID}>{userID}</div>
{error && <div className="error">{`${error}`}</div>} {error && <div className="error">{`${error}`}</div>}
</> </>
} }

View file

@ -18,6 +18,7 @@ import { getAvatarURL } from "@/api/media.ts"
import type { RoomListEntry } from "@/api/statestore" import type { RoomListEntry } from "@/api/statestore"
import type { MemDBEvent, MemberEventContent } from "@/api/types" import type { MemDBEvent, MemberEventContent } from "@/api/types"
import useContentVisibility from "@/util/contentvisibility.ts" import useContentVisibility from "@/util/contentvisibility.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"
@ -33,12 +34,9 @@ function usePreviewText(evt?: MemDBEvent, senderMemberEvt?: MemDBEvent | null):
} }
if ((evt.type === "m.room.message" || evt.type === "m.sticker") && typeof evt.content.body === "string") { if ((evt.type === "m.room.message" || evt.type === "m.sticker") && typeof evt.content.body === "string") {
const client = use(ClientContext)! const client = use(ClientContext)!
let displayname = (senderMemberEvt?.content as MemberEventContent)?.displayname const displayname = evt.sender === client.userID
if (evt.sender === client.userID) { ? "You"
displayname = "You" : getDisplayname(evt.sender, senderMemberEvt?.content as MemberEventContent)
} else if (!displayname) {
displayname = evt.sender.slice(1).split(":")[0]
}
let previewText = evt.content.body let previewText = evt.content.body
if (evt.content.formatted_body?.includes?.("data-mx-spoiler")) { if (evt.content.formatted_body?.includes?.("data-mx-spoiler")) {
previewText = "<message contains spoilers>" previewText = "<message contains spoilers>"

View file

@ -17,6 +17,7 @@ import { use } from "react"
import { getAvatarURL, getUserColorIndex } from "@/api/media.ts" import { getAvatarURL, getUserColorIndex } from "@/api/media.ts"
import { RoomStateStore, useRoomEvent, useRoomState } from "@/api/statestore" import { RoomStateStore, useRoomEvent, useRoomState } from "@/api/statestore"
import type { EventID, MemDBEvent, MemberEventContent } from "@/api/types" import type { EventID, MemDBEvent, MemberEventContent } from "@/api/types"
import { getDisplayname } from "@/util/validation.ts"
import ClientContext from "../ClientContext.ts" import ClientContext from "../ClientContext.ts"
import { ContentErrorBoundary, getBodyType } from "./content" import { ContentErrorBoundary, getBodyType } from "./content"
import CloseButton from "@/icons/close.svg?react" import CloseButton from "@/icons/close.svg?react"
@ -94,7 +95,7 @@ export const ReplyBody = ({ room, event, onClose, isThread, isEditing }: ReplyBo
/> />
</div> </div>
<span className={`event-sender sender-color-${userColorIndex}`}> <span className={`event-sender sender-color-${userColorIndex}`}>
{memberEvtContent?.displayname || event.sender} {getDisplayname(event.sender, memberEvtContent)}
</span> </span>
{onClose && <button className="close-reply" onClick={onClose}><CloseButton/></button>} {onClose && <button className="close-reply" onClick={onClose}><CloseButton/></button>}
</div> </div>

View file

@ -17,7 +17,7 @@ import React, { use, useState } from "react"
import { getAvatarURL, getMediaURL, getUserColorIndex } from "@/api/media.ts" import { getAvatarURL, getMediaURL, getUserColorIndex } from "@/api/media.ts"
import { useRoomState } from "@/api/statestore" import { useRoomState } from "@/api/statestore"
import { MemDBEvent, MemberEventContent, UnreadType } from "@/api/types" import { MemDBEvent, MemberEventContent, UnreadType } from "@/api/types"
import { isEventID } from "@/util/validation.ts" import { getDisplayname, isEventID } 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 { useRoomContext } from "../roomview/roomcontext.ts" import { useRoomContext } from "../roomview/roomcontext.ts"
@ -125,7 +125,7 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu }: TimelineEventProps) => {
data-target-user={evt.sender} data-target-user={evt.sender}
onClick={roomCtx.appendMentionToComposer} onClick={roomCtx.appendMentionToComposer}
> >
{memberEvtContent?.displayname || evt.sender} {getDisplayname(evt.sender, memberEvtContent)}
</span> </span>
<span className="event-time" title={fullTime}>{shortTime}</span> <span className="event-time" title={fullTime}>{shortTime}</span>
{(editEventTS && editTime) ? <span className="event-edited" title={editTime}> {(editEventTS && editTime) ? <span className="event-edited" title={editTime}>

View file

@ -14,6 +14,7 @@
// 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 { MessageEventContent } from "@/api/types" import { MessageEventContent } from "@/api/types"
import { getDisplayname } from "@/util/validation.ts"
import EventContentProps from "./props.ts" import EventContentProps from "./props.ts"
function isImageElement(elem: EventTarget): elem is HTMLImageElement { function isImageElement(elem: EventTarget): elem is HTMLImageElement {
@ -81,7 +82,7 @@ const TextMessageBody = ({ event, sender }: EventContentProps) => {
classNames.push("notice-message") classNames.push("notice-message")
} else if (content.msgtype === "m.emote") { } else if (content.msgtype === "m.emote") {
classNames.push("emote-message") classNames.push("emote-message")
eventSenderName = sender?.content?.displayname || event.sender eventSenderName = getDisplayname(event.sender, sender?.content)
} }
if (event.local_content?.big_emoji) { if (event.local_content?.big_emoji) {
classNames.push("big-emoji-body") classNames.push("big-emoji-body")

View file

@ -24,4 +24,7 @@ if (!window.Iterator?.prototype.map) {
} }
return output return output
} }
Array.prototype.toArray = function() {
return this
}
} }

View file

@ -13,7 +13,7 @@
// //
// 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 { ContentURI, EventID, RoomAlias, RoomID, UserID } from "@/api/types" import { ContentURI, EventID, RoomAlias, RoomID, UserID, UserProfile } from "@/api/types"
const simpleHomeserverRegex = /^[a-zA-Z0-9.:-]+$/ const simpleHomeserverRegex = /^[a-zA-Z0-9.:-]+$/
const mediaRegex = /^mxc:\/\/([a-zA-Z0-9.:-]+)\/([a-zA-Z0-9_-]+)$/ const mediaRegex = /^mxc:\/\/([a-zA-Z0-9.:-]+)\/([a-zA-Z0-9_-]+)$/
@ -39,6 +39,15 @@ export const isRoomID = (roomID: unknown) => isIdentifier<RoomID>(roomID, "!", t
export const isRoomAlias = (roomAlias: unknown) => isIdentifier<RoomAlias>(roomAlias, "#", true) export const isRoomAlias = (roomAlias: unknown) => isIdentifier<RoomAlias>(roomAlias, "#", true)
export const isMXC = (mxc: unknown): mxc is ContentURI => typeof mxc === "string" && mediaRegex.test(mxc) export const isMXC = (mxc: unknown): mxc is ContentURI => typeof mxc === "string" && mediaRegex.test(mxc)
export function getLocalpart(userID: UserID): string {
const idx = userID.indexOf(":")
return idx > 0 ? userID.slice(1, idx) : userID.slice(1)
}
export function getDisplayname(userID: UserID, profile?: UserProfile | null): string {
return profile?.displayname || getLocalpart(userID)
}
export function parseMXC(mxc: unknown): [string, string] | [] { export function parseMXC(mxc: unknown): [string, string] | [] {
if (typeof mxc !== "string") { if (typeof mxc !== "string") {
return [] return []