From a1a006bf6b9a63a4fc7410686dbc79f74cea4c29 Mon Sep 17 00:00:00 2001 From: nexy7574 Date: Fri, 3 Jan 2025 12:27:02 +0000 Subject: [PATCH] web/rightpanel: show extended profile info for users (#574) Co-authored-by: Tulir Asokan --- pkg/hicli/json-commands.go | 9 ++ web/src/api/rpc.ts | 5 + web/src/api/types/mxtypes.ts | 18 +++ web/src/ui/rightpanel/RightPanel.css | 14 +++ web/src/ui/rightpanel/UserExtendedProfile.tsx | 114 ++++++++++++++++++ web/src/ui/rightpanel/UserInfo.tsx | 17 ++- 6 files changed, 172 insertions(+), 5 deletions(-) create mode 100644 web/src/ui/rightpanel/UserExtendedProfile.tsx diff --git a/pkg/hicli/json-commands.go b/pkg/hicli/json-commands.go index 26b8e896..2ef0ab4a 100644 --- a/pkg/hicli/json-commands.go +++ b/pkg/hicli/json-commands.go @@ -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 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": return unmarshalAndCall(req.Data, func(params *getProfileParams) ([]id.RoomID, error) { return h.GetMutualRooms(ctx, params.UserID) @@ -275,6 +279,11 @@ type getProfileParams struct { UserID id.UserID `json:"user_id"` } +type setProfileFieldParams struct { + Field string `json:"field"` + Value any `json:"value"` +} + type getEventParams struct { RoomID id.RoomID `json:"room_id"` EventID id.EventID `json:"event_id"` diff --git a/web/src/api/rpc.ts b/web/src/api/rpc.ts index a52a3c3f..083ca757 100644 --- a/web/src/api/rpc.ts +++ b/web/src/api/rpc.ts @@ -20,6 +20,7 @@ import type { EventID, EventRowID, EventType, + JSONValue, LoginFlowsResponse, LoginRequest, Mentions, @@ -181,6 +182,10 @@ export default abstract class RPCClient { return this.request("get_profile", { user_id }) } + setProfileField(field: string, value: JSONValue): Promise { + return this.request("set_profile_field", { field, value }) + } + getMutualRooms(user_id: UserID): Promise { return this.request("get_mutual_rooms", { user_id }) } diff --git a/web/src/api/types/mxtypes.ts b/web/src/api/types/mxtypes.ts index 81261ea4..86052398 100644 --- a/web/src/api/types/mxtypes.ts +++ b/web/src/api/types/mxtypes.ts @@ -25,6 +25,14 @@ export type RoomVersion = "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | export type RoomType = "" | "m.space" 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 { room_id: RoomID event_id: EventID @@ -68,6 +76,16 @@ export interface UserProfile { [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 interface MemberEventContent extends UserProfile { diff --git a/web/src/ui/rightpanel/RightPanel.css b/web/src/ui/rightpanel/RightPanel.css index 76be03fc..50a3c449 100644 --- a/web/src/ui/rightpanel/RightPanel.css +++ b/web/src/ui/rightpanel/RightPanel.css @@ -91,6 +91,20 @@ div.right-panel-content.user { 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 { width: 100%; opacity: .2; diff --git a/web/src/ui/rightpanel/UserExtendedProfile.tsx b/web/src/ui/rightpanel/UserExtendedProfile.tsx new file mode 100644 index 00000000..236e1ef3 --- /dev/null +++ b/web/src/ui/rightpanel/UserExtendedProfile.tsx @@ -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 <> +
Time:
+
{time}
+ +} + +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 <> + + evt.key === "Enter" && saveTz(evt.currentTarget.value)} + onBlur={evt => evt.currentTarget.value !== defaultValue && saveTz(evt.currentTarget.value)} + /> + + {zones.map((zone) => + +} + + +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
for no real reason. + + const pronouns = ensureArray(profile["io.fsky.nyx.pronouns"]) as PronounSet[] + const userTimeZone = ensureString(profile["us.cloke.msc4175.tz"]) + return <> +
+
+ {userTimeZone && } + {userID === client.userID && + } + {pronouns.length > 0 && <> +
Pronouns:
+
{pronouns.map(pronounSet => ensureString(pronounSet.summary)).join(" / ")}
+ } +
+ +} + +export default UserExtendedProfile diff --git a/web/src/ui/rightpanel/UserInfo.tsx b/web/src/ui/rightpanel/UserInfo.tsx index 4e93ec70..0181904e 100644 --- a/web/src/ui/rightpanel/UserInfo.tsx +++ b/web/src/ui/rightpanel/UserInfo.tsx @@ -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 { use, useEffect, useState } from "react" +import { use, useCallback, useEffect, useState } from "react" import { PuffLoader } from "react-spinners" import { getAvatarURL } from "@/api/media.ts" import { useRoomMember } from "@/api/statestore" @@ -22,6 +22,7 @@ import { getLocalpart } from "@/util/validation.ts" import ClientContext from "../ClientContext.ts" import { LightboxContext } from "../modal" import { RoomContext } from "../roomview/roomcontext.ts" +import UserExtendedProfile from "./UserExtendedProfile.tsx" import DeviceList from "./UserInfoDeviceList.tsx" import UserInfoError from "./UserInfoError.tsx" import MutualRooms from "./UserInfoMutualRooms.tsx" @@ -38,14 +39,17 @@ const UserInfo = ({ userID }: UserInfoProps) => { const member = (memberEvt?.content ?? null) as MemberEventContent | null const [globalProfile, setGlobalProfile] = useState(null) const [errors, setErrors] = useState(null) - useEffect(() => { - setErrors(null) - setGlobalProfile(null) + const refreshProfile = useCallback((clearState = false) => { + if (clearState) { + setErrors(null) + setGlobalProfile(null) + } client.rpc.getProfile(userID).then( setGlobalProfile, err => setErrors([`${err}`]), ) - }, [roomCtx, userID, client]) + }, [userID, client]) + useEffect(() => refreshProfile(true), [refreshProfile]) const displayname = member?.displayname || globalProfile?.displayname || getLocalpart(userID) return <> @@ -63,6 +67,9 @@ const UserInfo = ({ userID }: UserInfoProps) => {
{displayname}
{userID}
+ {globalProfile && }
{userID !== client.userID && <>