diff --git a/package-lock.json b/package-lock.json
index 34a390f7..3a446325 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -62,7 +62,8 @@
"slate-dom": "0.112.2",
"slate-history": "0.110.3",
"slate-react": "0.112.1",
- "ua-parser-js": "1.0.35"
+ "ua-parser-js": "1.0.35",
+ "zod": "4.1.8"
},
"devDependencies": {
"@esbuild-plugins/node-globals-polyfill": "0.2.3",
@@ -12119,6 +12120,15 @@
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
+ },
+ "node_modules/zod": {
+ "version": "4.1.8",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.8.tgz",
+ "integrity": "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
}
}
}
diff --git a/package.json b/package.json
index 0c06993e..57986714 100644
--- a/package.json
+++ b/package.json
@@ -73,7 +73,8 @@
"slate-dom": "0.112.2",
"slate-history": "0.110.3",
"slate-react": "0.112.1",
- "ua-parser-js": "1.0.35"
+ "ua-parser-js": "1.0.35",
+ "zod": "4.1.8"
},
"devDependencies": {
"@esbuild-plugins/node-globals-polyfill": "0.2.3",
diff --git a/src/app/components/user-profile/UserHero.tsx b/src/app/components/user-profile/UserHero.tsx
index 0e7fb748..c042b3ec 100644
--- a/src/app/components/user-profile/UserHero.tsx
+++ b/src/app/components/user-profile/UserHero.tsx
@@ -21,6 +21,7 @@ import { UserPresence } from '../../hooks/useUserPresence';
import { AvatarPresence, PresenceBadge } from '../presence';
import { ImageViewer } from '../image-viewer';
import { stopPropagation } from '../../utils/keyboard';
+import { extendedProfileFields } from '../../hooks/useExtendedProfile';
type UserHeroProps = {
userId: string;
@@ -95,9 +96,11 @@ export function UserHero({ userId, avatarUrl, presence }: UserHeroProps) {
type UserHeroNameProps = {
displayName?: string;
userId: string;
+ extendedProfile?: extendedProfileFields;
};
-export function UserHeroName({ displayName, userId }: UserHeroNameProps) {
+export function UserHeroName({ displayName, userId, extendedProfile }: UserHeroNameProps) {
const username = getMxIdLocalPart(userId);
+ const pronouns = extendedProfile?.["io.fsky.nyx.pronouns"];
return (
@@ -110,9 +113,10 @@ export function UserHeroName({ displayName, userId }: UserHeroNameProps) {
{displayName ?? username ?? userId}
-
+
@{username}
+ {pronouns && ยท {pronouns.map(({ summary }) => summary).join(", ")}}
diff --git a/src/app/components/user-profile/UserRoomProfile.tsx b/src/app/components/user-profile/UserRoomProfile.tsx
index 78d201ec..26b687eb 100644
--- a/src/app/components/user-profile/UserRoomProfile.tsx
+++ b/src/app/components/user-profile/UserRoomProfile.tsx
@@ -1,5 +1,5 @@
import { Box, Button, config, Icon, Icons, Text } from 'folds';
-import React from 'react';
+import React, { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { UserHero, UserHeroName } from './UserHero';
import { getMxIdServer, mxcUrlToHttp } from '../../utils/matrix';
@@ -22,6 +22,8 @@ import { useMemberPowerCompare } from '../../hooks/useMemberPowerCompare';
import { CreatorChip } from './CreatorChip';
import { getDirectCreatePath, withSearchParam } from '../../pages/pathUtils';
import { DirectCreateSearchParams } from '../../pages/paths';
+import { useExtendedProfile } from '../../hooks/useExtendedProfile';
+import { AsyncStatus } from '../../hooks/useAsyncCallback';
type UserRoomProfileProps = {
userId: string;
@@ -56,9 +58,15 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
const displayName = getMemberDisplayName(room, userId);
const avatarMxc = getMemberAvatarMxc(room, userId);
const avatarUrl = (avatarMxc && mxcUrlToHttp(mx, avatarMxc, useAuthentication)) ?? undefined;
+ const [extendedProfileState, refreshExtendedProfile] = useExtendedProfile(userId);
+ const extendedProfile = extendedProfileState.status === AsyncStatus.Success ? extendedProfileState.data : undefined;
const presence = useUserPresence(userId);
+ useEffect(() => {
+ refreshExtendedProfile();
+ }, [refreshExtendedProfile]);
+
const handleMessage = () => {
closeUserRoomProfile();
const directSearchParam: DirectCreateSearchParams = {
@@ -77,7 +85,7 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
-
+
{userId !== myUserId && (
{avatarUrl && (
)}
@@ -162,116 +133,54 @@ function ProfileAvatar({ profile, userId }: ProfileProps) {
)}
-
- }>
-
- setAlertRemove(false),
- clickOutsideDeactivates: true,
- escapeDeactivates: stopPropagation,
- }}
- >
-
-
-
-
);
}
-function ProfileDisplayName({ profile, userId }: ProfileProps) {
- const mx = useMatrixClient();
- const capabilities = useCapabilities();
- const disableSetDisplayname = capabilities['m.set_displayname']?.enabled === false;
+type ProfileTextFieldProps = {
+ field: K;
+ label: ReactNode;
+};
- const defaultDisplayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId;
- const [displayName, setDisplayName] = useState(defaultDisplayName);
-
- const [changeState, changeDisplayName] = useAsyncCallback(
- useCallback((name: string) => mx.setDisplayName(name), [mx])
- );
- const changingDisplayName = changeState.status === AsyncStatus.Loading;
-
- useEffect(() => {
- setDisplayName(defaultDisplayName);
- }, [defaultDisplayName]);
+function ProfileTextField>({
+ field,
+ label,
+}: ProfileTextFieldProps) {
+ const { busy, defaultValue, value, setValue } = useProfileField(field);
+ const disabled = !useProfileFieldAllowed(field) || busy;
+ const hasChanges = defaultValue !== value;
const handleChange: ChangeEventHandler = (evt) => {
- const name = evt.currentTarget.value;
- setDisplayName(name);
+ setValue(evt.currentTarget.value);
};
const handleReset = () => {
- setDisplayName(defaultDisplayName);
+ setValue(defaultValue);
};
- const handleSubmit: FormEventHandler = (evt) => {
- evt.preventDefault();
- if (changingDisplayName) return;
-
- const target = evt.target as HTMLFormElement | undefined;
- const displayNameInput = target?.displayNameInput as HTMLInputElement | undefined;
- const name = displayNameInput?.value;
- if (!name) return;
-
- changeDisplayName(name);
- };
-
- const hasChanges = displayName !== defaultDisplayName;
return (
- Display Name
+ {label}
}
>
-
+
-
@@ -305,20 +202,113 @@ function ProfileDisplayName({ profile, userId }: ProfileProps) {
export function Profile() {
const mx = useMatrixClient();
- const userId = mx.getUserId()!;
- const profile = useUserProfile(userId);
+ const userId = mx.getUserId() as string;
+
+ const [extendedProfileState, refreshExtendedProfile] = useExtendedProfile(userId);
+ const extendedProfile =
+ extendedProfileState.status === AsyncStatus.Success ? extendedProfileState.data : undefined;
+ const fieldDefaults = useMemo(
+ () =>
+ extendedProfile !== undefined
+ ? {
+ ...extendedProfile,
+ displayname: extendedProfile.displayname ?? getMxIdLocalPart(userId) ?? userId,
+ }
+ : {},
+ [userId, extendedProfile]
+ );
+
+ const useAuthentication = useMediaAuthentication();
+
+ const [saveState, handleSave] = useAsyncCallback(
+ useCallback(
+ async (fields: ExtendedProfile) => {
+ await Promise.all(
+ Object.entries(fields).map(async ([key, value]) => {
+ if (value !== undefined) {
+ await mx.setExtendedProfileProperty(key, value);
+ }
+ })
+ );
+ await refreshExtendedProfile();
+ // XXX: synthesise a profile update for ourselves because Synapse is broken and won't
+ const user = mx.getUser(userId);
+ if (user) {
+ user.displayName = fields.displayname;
+ user.avatarUrl = fields.avatar_url;
+ user.emit(UserEvent.DisplayName, user.events.presence, user);
+ user.emit(UserEvent.AvatarUrl, user.events.presence, user);
+ }
+ },
+ [mx, userId, refreshExtendedProfile]
+ )
+ );
+
+ const saving = saveState.status === AsyncStatus.Loading;
+ const loadingExtendedProfile = extendedProfileState.status === AsyncStatus.Loading;
+ const busy = saving || loadingExtendedProfile;
return (
Profile
-
-
+
+ {(save, reset, hasChanges, fields) => {
+ const heroAvatarUrl =
+ (fields.avatar_url &&
+ mxcUrlToHttp(mx, fields.avatar_url, useAuthentication)) ??
+ undefined;
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+ }}
+
);
diff --git a/src/app/features/settings/account/ProfileFieldContext.tsx b/src/app/features/settings/account/ProfileFieldContext.tsx
new file mode 100644
index 00000000..8c5f4e5f
--- /dev/null
+++ b/src/app/features/settings/account/ProfileFieldContext.tsx
@@ -0,0 +1,75 @@
+import React, { createContext, ReactNode, useCallback, useContext, useEffect, useMemo, useState } from 'react';
+import { ExtendedProfile } from '../../../hooks/useExtendedProfile';
+
+const ProfileFieldContext = createContext<{
+ busy: boolean;
+ fieldDefaults: ExtendedProfile;
+ fields: ExtendedProfile;
+ setField: (key: string, value: unknown) => void;
+} | null>(null);
+
+export type ProfileFieldContextProviderProps = {
+ fieldDefaults: ExtendedProfile;
+ save: (fields: ExtendedProfile) => void;
+ busy: boolean;
+ children: (save: () => void, reset: () => void, hasChanges: boolean, fields: ExtendedProfile) => ReactNode;
+};
+
+export function ProfileFieldContextProvider({
+ fieldDefaults,
+ save,
+ busy,
+ children,
+}: ProfileFieldContextProviderProps) {
+ const [fields, setFields] = useState(fieldDefaults);
+
+ const reset = useCallback(() => {
+ setFields(fieldDefaults);
+ }, [fieldDefaults]);
+
+ useEffect(() => {
+ reset()
+ }, [reset]);
+
+ const setField = useCallback(
+ (key: string, value: unknown) => {
+ setFields({
+ ...fields,
+ [key]: value,
+ });
+ },
+ [fields]
+ );
+
+ const providerValue = useMemo(
+ () => ({ busy, fieldDefaults, fields, setField }),
+ [busy, fieldDefaults, fields, setField]
+ );
+
+ const hasChanges = useMemo(
+ () => Object.entries(fields).find(([key, value]) => fieldDefaults[key as keyof ExtendedProfile] !== value) !== undefined,
+ [fields, fieldDefaults]
+ );
+
+ return (
+
+ {children(() => save(fields), reset, hasChanges, fields)}
+
+ );
+}
+
+export function useProfileField(field: K): { busy: boolean, defaultValue: ExtendedProfile[K], value: ExtendedProfile[K], setValue: (value: ExtendedProfile[K]) => void } {
+ const context = useContext(ProfileFieldContext);
+ if (context === null) {
+ throw new Error("useProfileField() called without context");
+ }
+
+ return {
+ busy: context.busy,
+ defaultValue: context.fieldDefaults[field],
+ value: context.fields[field],
+ setValue(value) {
+ context.setField(field, value);
+ },
+ };
+}
\ No newline at end of file
diff --git a/src/app/hooks/useExtendedProfile.ts b/src/app/hooks/useExtendedProfile.ts
new file mode 100644
index 00000000..22e11acb
--- /dev/null
+++ b/src/app/hooks/useExtendedProfile.ts
@@ -0,0 +1,103 @@
+import { useCallback, useEffect } from 'react';
+import z from 'zod';
+import { AsyncCallback, AsyncState, useAsyncCallback } from './useAsyncCallback';
+import { useMatrixClient } from './useMatrixClient';
+import { useSpecVersions } from './useSpecVersions';
+import { useCapabilities } from './useCapabilities';
+import { IProfileFieldsCapability } from '../../types/matrix/common';
+
+const extendedProfile = z.looseObject({
+ displayname: z.string().optional(),
+ avatar_url: z.string().optional(),
+ 'io.fsky.nyx.pronouns': z
+ .object({
+ language: z.string(),
+ summary: z.string(),
+ })
+ .array()
+ .optional()
+ .catch(undefined),
+ 'us.cloke.msc4175.tz': z.string().optional().catch(undefined),
+});
+
+export type ExtendedProfile = z.infer;
+
+export function useExtendedProfileSupported(): boolean {
+ const { versions, unstable_features: unstableFeatures } = useSpecVersions();
+
+ return unstableFeatures?.['uk.tcpip.msc4133'] || versions.includes('v1.15');
+}
+
+export function useExtendedProfile(
+ userId: string
+): [
+ AsyncState,
+ AsyncCallback<[], ExtendedProfile | undefined>
+] {
+ const mx = useMatrixClient();
+ const extendedProfileSupported = useExtendedProfileSupported();
+ const [extendedProfileData, refresh] = useAsyncCallback(
+ useCallback(async () => {
+ if (extendedProfileSupported) {
+ return extendedProfile.parse(await mx.getExtendedProfile(userId));
+ }
+ return undefined;
+ }, [mx, userId, extendedProfileSupported])
+ );
+
+ useEffect(() => {
+ refresh();
+ }, [refresh]);
+
+ return [extendedProfileData, refresh];
+}
+
+const LEGACY_FIELDS = ['displayname', 'avatar_url'];
+
+export function useProfileFieldAllowed(field: string): boolean {
+ const capabilities = useCapabilities();
+ const extendedProfileSupported = useExtendedProfileSupported();
+
+ if (LEGACY_FIELDS.includes(field)) {
+ // this field might have a pre-msc4133 capability. check that first
+ if (capabilities[`m.set_${field}`]?.enabled === false) {
+ return false;
+ }
+ }
+
+ if (extendedProfileSupported) {
+ // the homeserver has msc4133 support
+ const extendedProfileCapability = capabilities[
+ 'uk.tcpip.msc4133.profile_fields'
+ ] as IProfileFieldsCapability;
+
+ if (extendedProfileCapability === undefined) {
+ // the capability is missing, assume modification is allowed
+ return true;
+ }
+
+ if (!extendedProfileCapability.enabled) {
+ // the capability is set to disable profile modifications
+ return false;
+ }
+
+ if (
+ extendedProfileCapability.allowed !== undefined &&
+ !extendedProfileCapability.allowed.includes(field)
+ ) {
+ // the capability includes an allowlist and `field` isn't in it
+ return false;
+ }
+
+ if (extendedProfileCapability.disallowed?.includes(field)) {
+ // the capability includes an blocklist and `field` is in it
+ return false;
+ }
+
+ // the capability is enabled and `field` isn't blocked
+ return true;
+ }
+
+ // `field` is an extended profile key and the homeserver lacks msc4133 support
+ return false;
+}
diff --git a/src/types/matrix/common.ts b/src/types/matrix/common.ts
index 210c711f..2764d6c3 100644
--- a/src/types/matrix/common.ts
+++ b/src/types/matrix/common.ts
@@ -3,7 +3,8 @@ import { MsgType } from 'matrix-js-sdk';
export const MATRIX_BLUR_HASH_PROPERTY_NAME = 'xyz.amorgan.blurhash';
export const MATRIX_SPOILER_PROPERTY_NAME = 'page.codeberg.everypizza.msc4193.spoiler';
-export const MATRIX_SPOILER_REASON_PROPERTY_NAME = 'page.codeberg.everypizza.msc4193.spoiler.reason';
+export const MATRIX_SPOILER_REASON_PROPERTY_NAME =
+ 'page.codeberg.everypizza.msc4193.spoiler.reason';
export type IImageInfo = {
w?: number;
@@ -88,3 +89,9 @@ export type ILocationContent = {
geo_uri?: string;
info?: IThumbnailContent;
};
+
+export type IProfileFieldsCapability = {
+ enabled?: boolean;
+ allowed?: string[];
+ disallowed?: string[];
+};
diff --git a/src/types/utils.ts b/src/types/utils.ts
index 353ace6e..84632c99 100644
--- a/src/types/utils.ts
+++ b/src/types/utils.ts
@@ -1,3 +1,8 @@
export type WithRequiredProp = Type & {
[Property in Key]-?: Type[Property];
};
+
+// Represents a subset of T containing only the keys whose values extend V
+export type FilterByValues = {
+ [Property in keyof T as T[Property] extends V ? Property : never]: T[Property];
+};