web/rightpanel: show extended profile info for users (#574)

Co-authored-by: Tulir Asokan <tulir@maunium.net>
This commit is contained in:
nexy7574 2025-01-03 12:27:02 +00:00 committed by GitHub
parent f766b786ee
commit a1a006bf6b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 172 additions and 5 deletions

View file

@ -86,6 +86,10 @@ func (h *HiClient) handleJSONCommand(ctx context.Context, req *JSONCommand) (any
return unmarshalAndCall(req.Data, func(params *getProfileParams) (*mautrix.RespUserProfile, error) { return unmarshalAndCall(req.Data, func(params *getProfileParams) (*mautrix.RespUserProfile, error) {
return h.Client.GetProfile(ctx, params.UserID) return h.Client.GetProfile(ctx, params.UserID)
}) })
case "set_profile_field":
return unmarshalAndCall(req.Data, func(params *setProfileFieldParams) (bool, error) {
return true, h.Client.UnstableSetProfileField(ctx, params.Field, params.Value)
})
case "get_mutual_rooms": case "get_mutual_rooms":
return unmarshalAndCall(req.Data, func(params *getProfileParams) ([]id.RoomID, error) { return unmarshalAndCall(req.Data, func(params *getProfileParams) ([]id.RoomID, error) {
return h.GetMutualRooms(ctx, params.UserID) return h.GetMutualRooms(ctx, params.UserID)
@ -275,6 +279,11 @@ type getProfileParams struct {
UserID id.UserID `json:"user_id"` UserID id.UserID `json:"user_id"`
} }
type setProfileFieldParams struct {
Field string `json:"field"`
Value any `json:"value"`
}
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"`

View file

@ -20,6 +20,7 @@ import type {
EventID, EventID,
EventRowID, EventRowID,
EventType, EventType,
JSONValue,
LoginFlowsResponse, LoginFlowsResponse,
LoginRequest, LoginRequest,
Mentions, Mentions,
@ -181,6 +182,10 @@ export default abstract class RPCClient {
return this.request("get_profile", { user_id }) return this.request("get_profile", { user_id })
} }
setProfileField(field: string, value: JSONValue): Promise<boolean> {
return this.request("set_profile_field", { field, value })
}
getMutualRooms(user_id: UserID): Promise<RoomID[]> { getMutualRooms(user_id: UserID): Promise<RoomID[]> {
return this.request("get_mutual_rooms", { user_id }) return this.request("get_mutual_rooms", { user_id })
} }

View file

@ -25,6 +25,14 @@ export type RoomVersion = "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" |
export type RoomType = "" | "m.space" export type RoomType = "" | "m.space"
export type RelationType = "m.annotation" | "m.reference" | "m.replace" | "m.thread" export type RelationType = "m.annotation" | "m.reference" | "m.replace" | "m.thread"
export type JSONValue =
| string
| number
| boolean
| null
| JSONValue[]
| {[key: string]: JSONValue}
export interface RoomPredecessor { export interface RoomPredecessor {
room_id: RoomID room_id: RoomID
event_id: EventID event_id: EventID
@ -68,6 +76,16 @@ export interface UserProfile {
[custom: string]: unknown [custom: string]: unknown
} }
export interface PronounSet {
subject?: string
object?: string
possessive_determiner?: string
possessive_pronoun?: string
reflexive?: string
summary: string
language: string
}
export type Membership = "join" | "leave" | "ban" | "invite" | "knock" export type Membership = "join" | "leave" | "ban" | "invite" | "knock"
export interface MemberEventContent extends UserProfile { export interface MemberEventContent extends UserProfile {

View file

@ -91,6 +91,20 @@ div.right-panel-content.user {
word-break: break-word; word-break: break-word;
} }
div.extended-profile {
display: grid;
gap: 0.25rem;
grid-template-columns: 1fr 1fr;
> input {
border: 0;
padding: 0; /* Necessary to prevent alignment issues with other cells */
width: 100%;
box-sizing: border-box;
border-bottom: 1px solid var(--blockquote-border-color);
}
}
hr { hr {
width: 100%; width: 100%;
opacity: .2; opacity: .2;

View file

@ -0,0 +1,114 @@
import { useEffect, useState } from "react"
import Client from "@/api/client.ts"
import { PronounSet, UserProfile } from "@/api/types"
import { ensureArray, ensureString } from "@/util/validation.ts"
interface ExtendedProfileProps {
profile: UserProfile
refreshProfile: () => void
client: Client
userID: string
}
interface SetTimezoneProps {
tz?: string
client: Client
refreshProfile: () => void
}
const getCurrentTimezone = () => new Intl.DateTimeFormat().resolvedOptions().timeZone
const currentTimeAdjusted = (tz: string) => {
try {
return new Intl.DateTimeFormat("en-GB", {
hour: "numeric",
minute: "numeric",
second: "numeric",
timeZoneName: "short",
timeZone: tz,
}).format(new Date())
} catch (e) {
return `${e}`
}
}
const ClockElement = ({ tz }: { tz: string }) => {
const [time, setTime] = useState(currentTimeAdjusted(tz))
useEffect(() => {
let interval: number | undefined
const updateTime = () => setTime(currentTimeAdjusted(tz))
const timeout = setTimeout(() => {
interval = setInterval(updateTime, 1000)
updateTime()
}, (1001 - Date.now() % 1000))
return () => interval ? clearInterval(interval) : clearTimeout(timeout)
}, [tz])
return <>
<div title={tz}>Time:</div>
<div title={tz}>{time}</div>
</>
}
const SetTimeZoneElement = ({ tz, client, refreshProfile }: SetTimezoneProps) => {
const zones = Intl.supportedValuesOf("timeZone")
const saveTz = (newTz: string) => {
if (!zones.includes(newTz)) {
return
}
client.rpc.setProfileField("us.cloke.msc4175.tz", newTz).then(
() => refreshProfile(),
err => {
console.error("Failed to set time zone:", err)
window.alert(`Failed to set time zone: ${err}`)
},
)
}
const defaultValue = tz || getCurrentTimezone()
return <>
<label htmlFor="userprofile-timezone-input">Set time zone:</label>
<input
list="timezones"
id="userprofile-timezone-input"
defaultValue={defaultValue}
onKeyDown={evt => evt.key === "Enter" && saveTz(evt.currentTarget.value)}
onBlur={evt => evt.currentTarget.value !== defaultValue && saveTz(evt.currentTarget.value)}
/>
<datalist id="timezones">
{zones.map((zone) => <option key={zone} value={zone} />)}
</datalist>
</>
}
const UserExtendedProfile = ({ profile, refreshProfile, client, userID }: ExtendedProfileProps)=> {
if (!profile) {
return null
}
const extendedProfileKeys = ["us.cloke.msc4175.tz", "io.fsky.nyx.pronouns"]
const hasExtendedProfile = extendedProfileKeys.some((key) => profile[key])
if (!hasExtendedProfile && client.userID !== userID) {
return null
}
// Explicitly only return something if the profile has the keys we're looking for.
// otherwise there's an ugly and pointless <hr/> for no real reason.
const pronouns = ensureArray(profile["io.fsky.nyx.pronouns"]) as PronounSet[]
const userTimeZone = ensureString(profile["us.cloke.msc4175.tz"])
return <>
<hr/>
<div className="extended-profile">
{userTimeZone && <ClockElement tz={userTimeZone} />}
{userID === client.userID &&
<SetTimeZoneElement tz={userTimeZone} client={client} refreshProfile={refreshProfile} />}
{pronouns.length > 0 && <>
<div>Pronouns:</div>
<div>{pronouns.map(pronounSet => ensureString(pronounSet.summary)).join(" / ")}</div>
</>}
</div>
</>
}
export default UserExtendedProfile

View file

@ -13,7 +13,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 { use, useEffect, useState } from "react" import { use, useCallback, useEffect, useState } from "react"
import { PuffLoader } from "react-spinners" import { PuffLoader } from "react-spinners"
import { getAvatarURL } from "@/api/media.ts" import { getAvatarURL } from "@/api/media.ts"
import { useRoomMember } from "@/api/statestore" import { useRoomMember } from "@/api/statestore"
@ -22,6 +22,7 @@ import { getLocalpart } from "@/util/validation.ts"
import ClientContext from "../ClientContext.ts" import ClientContext from "../ClientContext.ts"
import { LightboxContext } from "../modal" import { LightboxContext } from "../modal"
import { RoomContext } from "../roomview/roomcontext.ts" import { RoomContext } from "../roomview/roomcontext.ts"
import UserExtendedProfile from "./UserExtendedProfile.tsx"
import DeviceList from "./UserInfoDeviceList.tsx" import DeviceList from "./UserInfoDeviceList.tsx"
import UserInfoError from "./UserInfoError.tsx" import UserInfoError from "./UserInfoError.tsx"
import MutualRooms from "./UserInfoMutualRooms.tsx" import MutualRooms from "./UserInfoMutualRooms.tsx"
@ -38,14 +39,17 @@ const UserInfo = ({ userID }: UserInfoProps) => {
const member = (memberEvt?.content ?? null) as MemberEventContent | null const member = (memberEvt?.content ?? null) as MemberEventContent | null
const [globalProfile, setGlobalProfile] = useState<UserProfile | null>(null) const [globalProfile, setGlobalProfile] = useState<UserProfile | null>(null)
const [errors, setErrors] = useState<string[] | null>(null) const [errors, setErrors] = useState<string[] | null>(null)
useEffect(() => { const refreshProfile = useCallback((clearState = false) => {
if (clearState) {
setErrors(null) setErrors(null)
setGlobalProfile(null) setGlobalProfile(null)
}
client.rpc.getProfile(userID).then( client.rpc.getProfile(userID).then(
setGlobalProfile, setGlobalProfile,
err => setErrors([`${err}`]), err => setErrors([`${err}`]),
) )
}, [roomCtx, userID, client]) }, [userID, client])
useEffect(() => refreshProfile(true), [refreshProfile])
const displayname = member?.displayname || globalProfile?.displayname || getLocalpart(userID) const displayname = member?.displayname || globalProfile?.displayname || getLocalpart(userID)
return <> return <>
@ -63,6 +67,9 @@ const UserInfo = ({ userID }: UserInfoProps) => {
</div> </div>
<div className="displayname" title={displayname}>{displayname}</div> <div className="displayname" title={displayname}>{displayname}</div>
<div className="userid" title={userID}>{userID}</div> <div className="userid" title={userID}>{userID}</div>
{globalProfile && <UserExtendedProfile
profile={globalProfile} refreshProfile={refreshProfile} client={client} userID={userID}
/>}
<hr/> <hr/>
{userID !== client.userID && <> {userID !== client.userID && <>
<MutualRooms client={client} userID={userID}/> <MutualRooms client={client} userID={userID}/>