mirror of
https://github.com/tulir/gomuks.git
synced 2025-04-20 10:33:41 -05:00
web/rightpanel: show extended profile info for users (#574)
Co-authored-by: Tulir Asokan <tulir@maunium.net>
This commit is contained in:
parent
f766b786ee
commit
a1a006bf6b
6 changed files with 172 additions and 5 deletions
|
@ -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"`
|
||||||
|
|
|
@ -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 })
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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;
|
||||||
|
|
114
web/src/ui/rightpanel/UserExtendedProfile.tsx
Normal file
114
web/src/ui/rightpanel/UserExtendedProfile.tsx
Normal 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
|
|
@ -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}/>
|
||||||
|
|
Loading…
Add table
Reference in a new issue