mirror of
https://github.com/tulir/gomuks.git
synced 2025-04-20 10:33:41 -05:00
web/rightpanel: add basic user view
This commit is contained in:
parent
fe6156302d
commit
24f2e3722d
9 changed files with 166 additions and 8 deletions
|
@ -77,6 +77,10 @@ func (h *HiClient) handleJSONCommand(ctx context.Context, req *JSONCommand) (any
|
||||||
return unmarshalAndCall(req.Data, func(params *setTypingParams) (bool, error) {
|
return unmarshalAndCall(req.Data, func(params *setTypingParams) (bool, error) {
|
||||||
return true, h.SetTyping(ctx, params.RoomID, time.Duration(params.Timeout)*time.Millisecond)
|
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":
|
case "get_event":
|
||||||
return unmarshalAndCall(req.Data, func(params *getEventParams) (*database.Event, error) {
|
return unmarshalAndCall(req.Data, func(params *getEventParams) (*database.Event, error) {
|
||||||
return h.GetEvent(ctx, params.RoomID, params.EventID)
|
return h.GetEvent(ctx, params.RoomID, params.EventID)
|
||||||
|
@ -194,6 +198,10 @@ type setTypingParams struct {
|
||||||
Timeout int `json:"timeout"`
|
Timeout int `json:"timeout"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type getProfileParams struct {
|
||||||
|
UserID id.UserID `json:"user_id"`
|
||||||
|
}
|
||||||
|
|
||||||
type getEventParams struct {
|
type getEventParams struct {
|
||||||
RoomID id.RoomID `json:"room_id"`
|
RoomID id.RoomID `json:"room_id"`
|
||||||
EventID id.EventID `json:"event_id"`
|
EventID id.EventID `json:"event_id"`
|
||||||
|
|
|
@ -14,7 +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 { parseMXC } from "@/util/validation.ts"
|
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 => {
|
export const getMediaURL = (mxc?: string, encrypted: boolean = false): string | undefined => {
|
||||||
const [server, mediaID] = parseMXC(mxc)
|
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() ?? ""
|
return Array.from(from.slice(0, (idx + 1) * 2))[idx]?.toUpperCase().toWellFormed() ?? ""
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getAvatarURL = (userID: UserID, content?: Partial<MemberEventContent>): string | undefined => {
|
export const getAvatarURL = (userID: UserID, content?: UserProfile | null): string | undefined => {
|
||||||
const fallbackCharacter = getFallbackCharacter(content?.displayname, 0) || getFallbackCharacter(userID, 1)
|
const fallbackCharacter = getFallbackCharacter(content?.displayname, 0) || getFallbackCharacter(userID, 1)
|
||||||
const backgroundColor = getUserColor(userID)
|
const backgroundColor = getUserColor(userID)
|
||||||
const [server, mediaID] = parseMXC(content?.avatar_url)
|
const [server, mediaID] = parseMXC(content?.avatar_url)
|
||||||
|
|
|
@ -34,6 +34,7 @@ import type {
|
||||||
RoomStateGUID,
|
RoomStateGUID,
|
||||||
TimelineRowID,
|
TimelineRowID,
|
||||||
UserID,
|
UserID,
|
||||||
|
UserProfile,
|
||||||
} from "./types"
|
} from "./types"
|
||||||
|
|
||||||
export interface ConnectionEvent {
|
export interface ConnectionEvent {
|
||||||
|
@ -159,6 +160,10 @@ export default abstract class RPCClient {
|
||||||
return this.request("set_typing", { room_id, timeout })
|
return this.request("set_typing", { room_id, timeout })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getProfile(user_id: UserID): Promise<UserProfile> {
|
||||||
|
return this.request("get_profile", { user_id })
|
||||||
|
}
|
||||||
|
|
||||||
ensureGroupSessionShared(room_id: RoomID): Promise<boolean> {
|
ensureGroupSessionShared(room_id: RoomID): Promise<boolean> {
|
||||||
return this.request("ensure_group_session_shared", { room_id })
|
return this.request("ensure_group_session_shared", { room_id })
|
||||||
}
|
}
|
||||||
|
|
|
@ -62,10 +62,14 @@ export interface EncryptedEventContent {
|
||||||
device_id?: DeviceID
|
device_id?: DeviceID
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MemberEventContent {
|
export interface UserProfile {
|
||||||
membership: "join" | "leave" | "ban" | "invite" | "knock"
|
|
||||||
displayname?: string
|
displayname?: string
|
||||||
avatar_url?: ContentURI
|
avatar_url?: ContentURI
|
||||||
|
[custom: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MemberEventContent extends UserProfile {
|
||||||
|
membership: "join" | "leave" | "ban" | "invite" | "knock"
|
||||||
reason?: string
|
reason?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -26,8 +26,21 @@ import RoomView from "./roomview/RoomView.tsx"
|
||||||
import { useResizeHandle } from "./util/useResizeHandle.tsx"
|
import { useResizeHandle } from "./util/useResizeHandle.tsx"
|
||||||
import "./MainScreen.css"
|
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) => {
|
const rpReducer = (prevState: RightPanelProps | null, newState: RightPanelProps | null) => {
|
||||||
if (prevState?.type === newState?.type) {
|
if (objectIsEqual(prevState, newState)) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
return newState
|
return newState
|
||||||
|
|
|
@ -29,9 +29,9 @@ export interface LightboxParams {
|
||||||
alt: string
|
alt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type openLightbox = (params: LightboxParams | React.MouseEvent<HTMLImageElement>) => void
|
export type OpenLightboxType = (params: LightboxParams | React.MouseEvent<HTMLImageElement>) => void
|
||||||
|
|
||||||
export const LightboxContext = createContext<openLightbox>(() =>
|
export const LightboxContext = createContext<OpenLightboxType>(() =>
|
||||||
console.error("Tried to open lightbox without being inside context"))
|
console.error("Tried to open lightbox without being inside context"))
|
||||||
|
|
||||||
export const LightboxWrapper = ({ children }: { children: React.ReactNode }) => {
|
export const LightboxWrapper = ({ children }: { children: React.ReactNode }) => {
|
||||||
|
|
|
@ -50,3 +50,42 @@ div.right-panel-content.pinned-messages {
|
||||||
margin: auto;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -17,6 +17,7 @@ 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 PinnedMessages from "./PinnedMessages.tsx"
|
import PinnedMessages from "./PinnedMessages.tsx"
|
||||||
|
import UserInfo from "./UserInfo.tsx"
|
||||||
import BackIcon from "@/icons/back.svg?react"
|
import BackIcon from "@/icons/back.svg?react"
|
||||||
import CloseIcon from "@/icons/close.svg?react"
|
import CloseIcon from "@/icons/close.svg?react"
|
||||||
import "./RightPanel.css"
|
import "./RightPanel.css"
|
||||||
|
@ -52,7 +53,7 @@ function renderRightPanelContent(props: RightPanelProps): JSX.Element | null {
|
||||||
case "members":
|
case "members":
|
||||||
return <>Member list is not yet implemented</>
|
return <>Member list is not yet implemented</>
|
||||||
case "user":
|
case "user":
|
||||||
return <>{props.userID}</>
|
return <UserInfo userID={props.userID} />
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
88
web/src/ui/rightpanel/UserInfo.tsx
Normal file
88
web/src/ui/rightpanel/UserInfo.tsx
Normal file
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
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 <NonMemberInfo userID={userID}/>
|
||||||
|
}
|
||||||
|
return renderUserInfo({ userID, profile: memberEvtContent, error: null, openLightbox })
|
||||||
|
}
|
||||||
|
|
||||||
|
const NonMemberInfo = ({ userID }: UserInfoProps) => {
|
||||||
|
const openLightbox = use(LightboxContext)!
|
||||||
|
const client = use(ClientContext)!
|
||||||
|
const [profile, setProfile] = useState<UserProfile | null>(null)
|
||||||
|
const [error, setError] = useState<unknown>(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 <>
|
||||||
|
<div className="avatar-container">
|
||||||
|
<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
|
||||||
|
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}
|
||||||
|
{error && <div className="error">{`${error}`}</div>}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UserInfo
|
Loading…
Add table
Reference in a new issue