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