mirror of
https://github.com/tulir/gomuks.git
synced 2025-04-19 18:13:41 -05:00
web/rightpanel: implement member list
This commit is contained in:
parent
24f2e3722d
commit
04117b5211
12 changed files with 182 additions and 29 deletions
|
@ -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 returnNull = () => null
|
||||
|
||||
|
|
|
@ -16,6 +16,7 @@
|
|||
import { CustomEmojiPack, parseCustomEmojiPack } from "@/util/emoji"
|
||||
import { NonNullCachedEventDispatcher } from "@/util/eventdispatcher.ts"
|
||||
import Subscribable, { MultiSubscribable } from "@/util/subscribable.ts"
|
||||
import { getDisplayname } from "@/util/validation.ts"
|
||||
import {
|
||||
DBRoom,
|
||||
EncryptedEventContent,
|
||||
|
@ -26,10 +27,13 @@ import {
|
|||
ImagePack,
|
||||
LazyLoadSummary,
|
||||
MemDBEvent,
|
||||
MemberEventContent,
|
||||
PowerLevelEventContent,
|
||||
RawDBEvent,
|
||||
RoomID,
|
||||
SyncRoom,
|
||||
TimelineRowTuple,
|
||||
UserID,
|
||||
roomStateGUIDToString,
|
||||
} from "../types"
|
||||
import type { StateStore } from "./main.ts"
|
||||
|
@ -80,6 +84,7 @@ export class RoomStateStore {
|
|||
readonly requestedEvents: Set<EventID> = new Set()
|
||||
readonly openNotifications: Map<EventRowID, Notification> = new Map()
|
||||
readonly emojiPacks: Map<string, CustomEmojiPack | null> = new Map()
|
||||
#membersCache: MemDBEvent[] | null = null
|
||||
#allPacksCache: Record<string, CustomEmojiPack> | null = null
|
||||
readonly pendingEvents: EventRowID[] = []
|
||||
paginating = false
|
||||
|
@ -150,6 +155,36 @@ export class RoomStateStore {
|
|||
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[] {
|
||||
const pinnedList = this.getStateEvent("m.room.pinned_events", "")?.content?.pinned
|
||||
if (Array.isArray(pinnedList)) {
|
||||
|
@ -229,6 +264,8 @@ export class RoomStateStore {
|
|||
this.emojiPacks.delete(key)
|
||||
this.#allPacksCache = null
|
||||
this.parent.invalidateEmojiPacksCache()
|
||||
} else if (evtType === "m.room.member" || evtType === "m.room.power_levels") {
|
||||
this.#membersCache = null
|
||||
}
|
||||
this.stateSubs.notify(this.stateSubKey(evtType, key))
|
||||
}
|
||||
|
|
71
web/src/ui/rightpanel/MemberList.tsx
Normal file
71
web/src/ui/rightpanel/MemberList.tsx
Normal 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
|
|
@ -89,3 +89,36 @@ div.right-panel-content.user {
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -16,6 +16,7 @@
|
|||
import { JSX, use } from "react"
|
||||
import type { UserID } from "@/api/types"
|
||||
import MainScreenContext from "../MainScreenContext.ts"
|
||||
import MemberList from "./MemberList.tsx"
|
||||
import PinnedMessages from "./PinnedMessages.tsx"
|
||||
import UserInfo from "./UserInfo.tsx"
|
||||
import BackIcon from "@/icons/back.svg?react"
|
||||
|
@ -51,7 +52,7 @@ function renderRightPanelContent(props: RightPanelProps): JSX.Element | null {
|
|||
case "pinned-messages":
|
||||
return <PinnedMessages />
|
||||
case "members":
|
||||
return <>Member list is not yet implemented</>
|
||||
return <MemberList />
|
||||
case "user":
|
||||
return <UserInfo userID={props.userID} />
|
||||
}
|
||||
|
|
|
@ -18,6 +18,7 @@ import { PuffLoader } from "react-spinners"
|
|||
import { getAvatarURL } from "@/api/media.ts"
|
||||
import { useRoomState } from "@/api/statestore"
|
||||
import { MemberEventContent, UserID, UserProfile } from "@/api/types"
|
||||
import { getDisplayname } from "@/util/validation.ts"
|
||||
import ClientContext from "../ClientContext.ts"
|
||||
import { LightboxContext, OpenLightboxType } from "../modal/Lightbox.tsx"
|
||||
import { RoomContext } from "../roomview/roomcontext.ts"
|
||||
|
@ -56,31 +57,22 @@ interface RenderUserInfoParams {
|
|||
}
|
||||
|
||||
function renderUserInfo({ userID, profile, error, openLightbox }: RenderUserInfoParams) {
|
||||
const displayname = profile?.displayname ?? userID
|
||||
if (profile === null) {
|
||||
const displayname = getDisplayname(userID, profile)
|
||||
return <>
|
||||
<div className="avatar-container">
|
||||
<PuffLoader
|
||||
{profile === null && error === null ? <PuffLoader
|
||||
color="var(--primary-color)"
|
||||
size="100%"
|
||||
className="avatar-loader"
|
||||
/>
|
||||
</div>
|
||||
<div className="displayname"> </div>
|
||||
<div className="userid">{userID}</div>
|
||||
</>
|
||||
}
|
||||
return <>
|
||||
<div className="avatar-container">
|
||||
<img
|
||||
/> : <img
|
||||
className="avatar"
|
||||
src={getAvatarURL(userID, profile)}
|
||||
onClick={openLightbox}
|
||||
alt=""
|
||||
/>
|
||||
/>}
|
||||
</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>}
|
||||
</>
|
||||
}
|
||||
|
|
|
@ -18,6 +18,7 @@ import { getAvatarURL } from "@/api/media.ts"
|
|||
import type { RoomListEntry } from "@/api/statestore"
|
||||
import type { MemDBEvent, MemberEventContent } from "@/api/types"
|
||||
import useContentVisibility from "@/util/contentvisibility.ts"
|
||||
import { getDisplayname } from "@/util/validation.ts"
|
||||
import ClientContext from "../ClientContext.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") {
|
||||
const client = use(ClientContext)!
|
||||
let displayname = (senderMemberEvt?.content as MemberEventContent)?.displayname
|
||||
if (evt.sender === client.userID) {
|
||||
displayname = "You"
|
||||
} else if (!displayname) {
|
||||
displayname = evt.sender.slice(1).split(":")[0]
|
||||
}
|
||||
const displayname = evt.sender === client.userID
|
||||
? "You"
|
||||
: getDisplayname(evt.sender, senderMemberEvt?.content as MemberEventContent)
|
||||
let previewText = evt.content.body
|
||||
if (evt.content.formatted_body?.includes?.("data-mx-spoiler")) {
|
||||
previewText = "<message contains spoilers>"
|
||||
|
|
|
@ -17,6 +17,7 @@ import { use } from "react"
|
|||
import { getAvatarURL, getUserColorIndex } from "@/api/media.ts"
|
||||
import { RoomStateStore, useRoomEvent, useRoomState } from "@/api/statestore"
|
||||
import type { EventID, MemDBEvent, MemberEventContent } from "@/api/types"
|
||||
import { getDisplayname } from "@/util/validation.ts"
|
||||
import ClientContext from "../ClientContext.ts"
|
||||
import { ContentErrorBoundary, getBodyType } from "./content"
|
||||
import CloseButton from "@/icons/close.svg?react"
|
||||
|
@ -94,7 +95,7 @@ export const ReplyBody = ({ room, event, onClose, isThread, isEditing }: ReplyBo
|
|||
/>
|
||||
</div>
|
||||
<span className={`event-sender sender-color-${userColorIndex}`}>
|
||||
{memberEvtContent?.displayname || event.sender}
|
||||
{getDisplayname(event.sender, memberEvtContent)}
|
||||
</span>
|
||||
{onClose && <button className="close-reply" onClick={onClose}><CloseButton/></button>}
|
||||
</div>
|
||||
|
|
|
@ -17,7 +17,7 @@ import React, { use, useState } from "react"
|
|||
import { getAvatarURL, getMediaURL, getUserColorIndex } from "@/api/media.ts"
|
||||
import { useRoomState } from "@/api/statestore"
|
||||
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 MainScreenContext from "../MainScreenContext.ts"
|
||||
import { useRoomContext } from "../roomview/roomcontext.ts"
|
||||
|
@ -125,7 +125,7 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu }: TimelineEventProps) => {
|
|||
data-target-user={evt.sender}
|
||||
onClick={roomCtx.appendMentionToComposer}
|
||||
>
|
||||
{memberEvtContent?.displayname || evt.sender}
|
||||
{getDisplayname(evt.sender, memberEvtContent)}
|
||||
</span>
|
||||
<span className="event-time" title={fullTime}>{shortTime}</span>
|
||||
{(editEventTS && editTime) ? <span className="event-edited" title={editTime}>
|
||||
|
|
|
@ -14,6 +14,7 @@
|
|||
// 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 { MessageEventContent } from "@/api/types"
|
||||
import { getDisplayname } from "@/util/validation.ts"
|
||||
import EventContentProps from "./props.ts"
|
||||
|
||||
function isImageElement(elem: EventTarget): elem is HTMLImageElement {
|
||||
|
@ -81,7 +82,7 @@ const TextMessageBody = ({ event, sender }: EventContentProps) => {
|
|||
classNames.push("notice-message")
|
||||
} else if (content.msgtype === "m.emote") {
|
||||
classNames.push("emote-message")
|
||||
eventSenderName = sender?.content?.displayname || event.sender
|
||||
eventSenderName = getDisplayname(event.sender, sender?.content)
|
||||
}
|
||||
if (event.local_content?.big_emoji) {
|
||||
classNames.push("big-emoji-body")
|
||||
|
|
|
@ -24,4 +24,7 @@ if (!window.Iterator?.prototype.map) {
|
|||
}
|
||||
return output
|
||||
}
|
||||
Array.prototype.toArray = function() {
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,7 +13,7 @@
|
|||
//
|
||||
// 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 { 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 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 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] | [] {
|
||||
if (typeof mxc !== "string") {
|
||||
return []
|
||||
|
|
Loading…
Add table
Reference in a new issue