From 24f2e3722d214d304ea6aef26860b69c95f92d2b Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Tue, 12 Nov 2024 17:58:18 +0200 Subject: [PATCH] web/rightpanel: add basic user view --- pkg/hicli/json-commands.go | 8 +++ web/src/api/media.ts | 4 +- web/src/api/rpc.ts | 5 ++ web/src/api/types/mxtypes.ts | 8 ++- web/src/ui/MainScreen.tsx | 15 ++++- web/src/ui/modal/Lightbox.tsx | 4 +- web/src/ui/rightpanel/RightPanel.css | 39 ++++++++++++ web/src/ui/rightpanel/RightPanel.tsx | 3 +- web/src/ui/rightpanel/UserInfo.tsx | 88 ++++++++++++++++++++++++++++ 9 files changed, 166 insertions(+), 8 deletions(-) create mode 100644 web/src/ui/rightpanel/UserInfo.tsx diff --git a/pkg/hicli/json-commands.go b/pkg/hicli/json-commands.go index b20108d..ad6cbda 100644 --- a/pkg/hicli/json-commands.go +++ b/pkg/hicli/json-commands.go @@ -77,6 +77,10 @@ func (h *HiClient) handleJSONCommand(ctx context.Context, req *JSONCommand) (any return unmarshalAndCall(req.Data, func(params *setTypingParams) (bool, error) { return true, h.SetTyping(ctx, params.RoomID, time.Duration(params.Timeout)*time.Millisecond) }) + case "get_profile": + return unmarshalAndCall(req.Data, func(params *getProfileParams) (*mautrix.RespUserProfile, error) { + return h.Client.GetProfile(ctx, params.UserID) + }) case "get_event": return unmarshalAndCall(req.Data, func(params *getEventParams) (*database.Event, error) { return h.GetEvent(ctx, params.RoomID, params.EventID) @@ -194,6 +198,10 @@ type setTypingParams struct { Timeout int `json:"timeout"` } +type getProfileParams struct { + UserID id.UserID `json:"user_id"` +} + type getEventParams struct { RoomID id.RoomID `json:"room_id"` EventID id.EventID `json:"event_id"` diff --git a/web/src/api/media.ts b/web/src/api/media.ts index b825327..3f920af 100644 --- a/web/src/api/media.ts +++ b/web/src/api/media.ts @@ -14,7 +14,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . import { parseMXC } from "@/util/validation.ts" -import { MemberEventContent, UserID } from "./types" +import { UserID, UserProfile } from "./types" export const getMediaURL = (mxc?: string, encrypted: boolean = false): string | undefined => { const [server, mediaID] = parseMXC(mxc) @@ -78,7 +78,7 @@ function getFallbackCharacter(from: unknown, idx: number): string { return Array.from(from.slice(0, (idx + 1) * 2))[idx]?.toUpperCase().toWellFormed() ?? "" } -export const getAvatarURL = (userID: UserID, content?: Partial): string | undefined => { +export const getAvatarURL = (userID: UserID, content?: UserProfile | null): string | undefined => { const fallbackCharacter = getFallbackCharacter(content?.displayname, 0) || getFallbackCharacter(userID, 1) const backgroundColor = getUserColor(userID) const [server, mediaID] = parseMXC(content?.avatar_url) diff --git a/web/src/api/rpc.ts b/web/src/api/rpc.ts index 62ac75a..96db534 100644 --- a/web/src/api/rpc.ts +++ b/web/src/api/rpc.ts @@ -34,6 +34,7 @@ import type { RoomStateGUID, TimelineRowID, UserID, + UserProfile, } from "./types" export interface ConnectionEvent { @@ -159,6 +160,10 @@ export default abstract class RPCClient { return this.request("set_typing", { room_id, timeout }) } + getProfile(user_id: UserID): Promise { + return this.request("get_profile", { user_id }) + } + ensureGroupSessionShared(room_id: RoomID): Promise { return this.request("ensure_group_session_shared", { room_id }) } diff --git a/web/src/api/types/mxtypes.ts b/web/src/api/types/mxtypes.ts index 639c4c1..0e29f29 100644 --- a/web/src/api/types/mxtypes.ts +++ b/web/src/api/types/mxtypes.ts @@ -62,10 +62,14 @@ export interface EncryptedEventContent { device_id?: DeviceID } -export interface MemberEventContent { - membership: "join" | "leave" | "ban" | "invite" | "knock" +export interface UserProfile { displayname?: string avatar_url?: ContentURI + [custom: string]: unknown +} + +export interface MemberEventContent extends UserProfile { + membership: "join" | "leave" | "ban" | "invite" | "knock" reason?: string } diff --git a/web/src/ui/MainScreen.tsx b/web/src/ui/MainScreen.tsx index 19f7abd..05758fc 100644 --- a/web/src/ui/MainScreen.tsx +++ b/web/src/ui/MainScreen.tsx @@ -26,8 +26,21 @@ import RoomView from "./roomview/RoomView.tsx" import { useResizeHandle } from "./util/useResizeHandle.tsx" import "./MainScreen.css" +function objectIsEqual(a: RightPanelProps | null, b: RightPanelProps | null): boolean { + if (a === null || b === null) { + return a === null && b === null + } + for (const key of Object.keys(a)) { + // @ts-expect-error 3:< + if (a[key] !== b[key]) { + return false + } + } + return true +} + const rpReducer = (prevState: RightPanelProps | null, newState: RightPanelProps | null) => { - if (prevState?.type === newState?.type) { + if (objectIsEqual(prevState, newState)) { return null } return newState diff --git a/web/src/ui/modal/Lightbox.tsx b/web/src/ui/modal/Lightbox.tsx index fc32815..ac825f3 100644 --- a/web/src/ui/modal/Lightbox.tsx +++ b/web/src/ui/modal/Lightbox.tsx @@ -29,9 +29,9 @@ export interface LightboxParams { alt: string } -type openLightbox = (params: LightboxParams | React.MouseEvent) => void +export type OpenLightboxType = (params: LightboxParams | React.MouseEvent) => void -export const LightboxContext = createContext(() => +export const LightboxContext = createContext(() => console.error("Tried to open lightbox without being inside context")) export const LightboxWrapper = ({ children }: { children: React.ReactNode }) => { diff --git a/web/src/ui/rightpanel/RightPanel.css b/web/src/ui/rightpanel/RightPanel.css index 74f4fcd..25f7237 100644 --- a/web/src/ui/rightpanel/RightPanel.css +++ b/web/src/ui/rightpanel/RightPanel.css @@ -50,3 +50,42 @@ div.right-panel-content.pinned-messages { margin: auto; } } + +div.right-panel-content.user { + display: flex; + flex-direction: column; + padding: 1rem; + + div.avatar-container { + width: calc((var(--right-panel-width) - 4rem)); + height: calc((var(--right-panel-width) - 4rem)); + display: flex; + justify-content: center; + align-items: center; + padding: 1rem; + + > img { + width: 100%; + height: 100%; + } + } + + div.displayname { + font-size: 1.5rem; + font-weight: bold; + text-align: center; + } + + div.userid { + text-align: center; + font-family: var(--monospace-font-stack); + } + + div.userid, div.displayname { + /* Ensure names aren't too long */ + display: -webkit-box; + -webkit-line-clamp: 4; + -webkit-box-orient: vertical; + word-break: break-word; + } +} diff --git a/web/src/ui/rightpanel/RightPanel.tsx b/web/src/ui/rightpanel/RightPanel.tsx index 3c32219..2a34549 100644 --- a/web/src/ui/rightpanel/RightPanel.tsx +++ b/web/src/ui/rightpanel/RightPanel.tsx @@ -17,6 +17,7 @@ import { JSX, use } from "react" import type { UserID } from "@/api/types" import MainScreenContext from "../MainScreenContext.ts" import PinnedMessages from "./PinnedMessages.tsx" +import UserInfo from "./UserInfo.tsx" import BackIcon from "@/icons/back.svg?react" import CloseIcon from "@/icons/close.svg?react" import "./RightPanel.css" @@ -52,7 +53,7 @@ function renderRightPanelContent(props: RightPanelProps): JSX.Element | null { case "members": return <>Member list is not yet implemented case "user": - return <>{props.userID} + return } } diff --git a/web/src/ui/rightpanel/UserInfo.tsx b/web/src/ui/rightpanel/UserInfo.tsx new file mode 100644 index 0000000..941838b --- /dev/null +++ b/web/src/ui/rightpanel/UserInfo.tsx @@ -0,0 +1,88 @@ +// 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, useEffect, useState } from "react" +import { PuffLoader } from "react-spinners" +import { getAvatarURL } from "@/api/media.ts" +import { useRoomState } from "@/api/statestore" +import { MemberEventContent, UserID, UserProfile } from "@/api/types" +import ClientContext from "../ClientContext.ts" +import { LightboxContext, OpenLightboxType } from "../modal/Lightbox.tsx" +import { RoomContext } from "../roomview/roomcontext.ts" + +interface UserInfoProps { + userID: UserID +} + +const UserInfo = ({ userID }: UserInfoProps) => { + const roomCtx = use(RoomContext) + const openLightbox = use(LightboxContext)! + const memberEvt = useRoomState(roomCtx?.store, "m.room.member", userID) + const memberEvtContent = memberEvt?.content as MemberEventContent + if (!memberEvtContent) { + return + } + return renderUserInfo({ userID, profile: memberEvtContent, error: null, openLightbox }) +} + +const NonMemberInfo = ({ userID }: UserInfoProps) => { + const openLightbox = use(LightboxContext)! + const client = use(ClientContext)! + const [profile, setProfile] = useState(null) + const [error, setError] = useState(null) + useEffect(() => { + client.rpc.getProfile(userID).then(setProfile, setError) + }, [userID, client]) + return renderUserInfo({ userID, profile, error, openLightbox }) +} + +interface RenderUserInfoParams { + userID: UserID + profile: UserProfile | null + error: unknown + openLightbox: OpenLightboxType +} + +function renderUserInfo({ userID, profile, error, openLightbox }: RenderUserInfoParams) { + const displayname = profile?.displayname ?? userID + if (profile === null) { + return <> +
+ +
+
 
+
{userID}
+ + } + return <> +
+ +
+
{displayname}
+ {displayname !== userID ?
{userID}
: null} + {error &&
{`${error}`}
} + +} + +export default UserInfo