From 04117b52112d59b368bc7e7a5b46d070243b6b08 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 12 Nov 2024 21:08:37 +0200 Subject: [PATCH] web/rightpanel: implement member list --- web/src/api/statestore/hooks.ts | 7 ++ web/src/api/statestore/room.ts | 37 ++++++++++ web/src/ui/rightpanel/MemberList.tsx | 71 +++++++++++++++++++ web/src/ui/rightpanel/RightPanel.css | 33 +++++++++ web/src/ui/rightpanel/RightPanel.tsx | 3 +- web/src/ui/rightpanel/UserInfo.tsx | 26 +++---- web/src/ui/roomlist/Entry.tsx | 10 ++- web/src/ui/timeline/ReplyBody.tsx | 3 +- web/src/ui/timeline/TimelineEvent.tsx | 4 +- .../ui/timeline/content/TextMessageBody.tsx | 3 +- web/src/util/polyfill.js | 3 + web/src/util/validation.ts | 11 ++- 12 files changed, 182 insertions(+), 29 deletions(-) create mode 100644 web/src/ui/rightpanel/MemberList.tsx diff --git a/web/src/api/statestore/hooks.ts b/web/src/api/statestore/hooks.ts index 00db164..76f5e1e 100644 --- a/web/src/api/statestore/hooks.ts +++ b/web/src/api/statestore/hooks.ts @@ -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 diff --git a/web/src/api/statestore/room.ts b/web/src/api/statestore/room.ts index 33f2fc8..0b60c84 100644 --- a/web/src/api/statestore/room.ts +++ b/web/src/api/statestore/room.ts @@ -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 = new Set() readonly openNotifications: Map = new Map() readonly emojiPacks: Map = new Map() + #membersCache: MemDBEvent[] | null = null #allPacksCache: Record | 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)) } diff --git a/web/src/ui/rightpanel/MemberList.tsx b/web/src/ui/rightpanel/MemberList.tsx new file mode 100644 index 0000000..dd0fc72 --- /dev/null +++ b/web/src/ui/rightpanel/MemberList.tsx @@ -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 . +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) => void +} + +const MemberRow = ({ evt, onClick }: MemberRowProps) => { + const userID = evt.state_key! + const content = evt.content as MemberEventContent + return
+ + {getDisplayname(userID, content)} +
+} + +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() + if (members.length >= limit) { + break + } + } + return <> + {members} + {memberEvents.length > limit ? : null} + +} + +export default MemberList diff --git a/web/src/ui/rightpanel/RightPanel.css b/web/src/ui/rightpanel/RightPanel.css index 25f7237..f4adb95 100644 --- a/web/src/ui/rightpanel/RightPanel.css +++ b/web/src/ui/rightpanel/RightPanel.css @@ -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; + } +} diff --git a/web/src/ui/rightpanel/RightPanel.tsx b/web/src/ui/rightpanel/RightPanel.tsx index 2a34549..f264181 100644 --- a/web/src/ui/rightpanel/RightPanel.tsx +++ b/web/src/ui/rightpanel/RightPanel.tsx @@ -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 case "members": - return <>Member list is not yet implemented + return case "user": return } diff --git a/web/src/ui/rightpanel/UserInfo.tsx b/web/src/ui/rightpanel/UserInfo.tsx index 941838b..c81b971 100644 --- a/web/src/ui/rightpanel/UserInfo.tsx +++ b/web/src/ui/rightpanel/UserInfo.tsx @@ -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) { - return <> -
- -
-
 
-
{userID}
- - } + const displayname = getDisplayname(userID, profile) return <>
- : + />}
{displayname}
- {displayname !== userID ?
{userID}
: null} +
{userID}
{error &&
{`${error}`}
} } diff --git a/web/src/ui/roomlist/Entry.tsx b/web/src/ui/roomlist/Entry.tsx index dd83049..5b6346d 100644 --- a/web/src/ui/roomlist/Entry.tsx +++ b/web/src/ui/roomlist/Entry.tsx @@ -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 = "" diff --git a/web/src/ui/timeline/ReplyBody.tsx b/web/src/ui/timeline/ReplyBody.tsx index 8eac960..e9eede4 100644 --- a/web/src/ui/timeline/ReplyBody.tsx +++ b/web/src/ui/timeline/ReplyBody.tsx @@ -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 /> - {memberEvtContent?.displayname || event.sender} + {getDisplayname(event.sender, memberEvtContent)} {onClose && } diff --git a/web/src/ui/timeline/TimelineEvent.tsx b/web/src/ui/timeline/TimelineEvent.tsx index 810c8bd..86ed029 100644 --- a/web/src/ui/timeline/TimelineEvent.tsx +++ b/web/src/ui/timeline/TimelineEvent.tsx @@ -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)} {shortTime} {(editEventTS && editTime) ? diff --git a/web/src/ui/timeline/content/TextMessageBody.tsx b/web/src/ui/timeline/content/TextMessageBody.tsx index 53c6090..90655bc 100644 --- a/web/src/ui/timeline/content/TextMessageBody.tsx +++ b/web/src/ui/timeline/content/TextMessageBody.tsx @@ -14,6 +14,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . 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") diff --git a/web/src/util/polyfill.js b/web/src/util/polyfill.js index 7651c30..830f731 100644 --- a/web/src/util/polyfill.js +++ b/web/src/util/polyfill.js @@ -24,4 +24,7 @@ if (!window.Iterator?.prototype.map) { } return output } + Array.prototype.toArray = function() { + return this + } } diff --git a/web/src/util/validation.ts b/web/src/util/validation.ts index 5917575..b5a88b5 100644 --- a/web/src/util/validation.ts +++ b/web/src/util/validation.ts @@ -13,7 +13,7 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -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, "!", t export const isRoomAlias = (roomAlias: unknown) => isIdentifier(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 []