mirror of
https://github.com/cinnyapp/cinny.git
synced 2025-11-04 22:40:29 +03:00
Hide profile fields which are blocked by a capability
This commit is contained in:
parent
aafd028af4
commit
317cd366c3
3 changed files with 108 additions and 91 deletions
|
|
@ -3,7 +3,6 @@ import React, {
|
||||||
FormEventHandler,
|
FormEventHandler,
|
||||||
KeyboardEventHandler,
|
KeyboardEventHandler,
|
||||||
MouseEventHandler,
|
MouseEventHandler,
|
||||||
ReactNode,
|
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
useLayoutEffect,
|
useLayoutEffect,
|
||||||
|
|
@ -53,10 +52,10 @@ import { CompactUploadCardRenderer } from '../../../components/upload-card';
|
||||||
import { UserHero, UserHeroName } from '../../../components/user-profile/UserHero';
|
import { UserHero, UserHeroName } from '../../../components/user-profile/UserHero';
|
||||||
import {
|
import {
|
||||||
ExtendedProfile,
|
ExtendedProfile,
|
||||||
|
profileEditsAllowed,
|
||||||
useExtendedProfile,
|
useExtendedProfile,
|
||||||
useProfileEditsAllowed,
|
|
||||||
} from '../../../hooks/useExtendedProfile';
|
} from '../../../hooks/useExtendedProfile';
|
||||||
import { ProfileFieldContextProvider, useProfileField } from './ProfileFieldContext';
|
import { ProfileFieldContext, ProfileFieldElementProps } from './ProfileFieldContext';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||||
import { FilterByValues } from '../../../../types/utils';
|
import { FilterByValues } from '../../../../types/utils';
|
||||||
import { CutoutCard } from '../../../components/cutout-card';
|
import { CutoutCard } from '../../../components/cutout-card';
|
||||||
|
|
@ -66,15 +65,21 @@ import { useUserProfile } from '../../../hooks/useUserProfile';
|
||||||
import { useAuthMetadata } from '../../../hooks/useAuthMetadata';
|
import { useAuthMetadata } from '../../../hooks/useAuthMetadata';
|
||||||
import { useAccountManagementActions } from '../../../hooks/useAccountManagement';
|
import { useAccountManagementActions } from '../../../hooks/useAccountManagement';
|
||||||
import { withSearchParam } from '../../../pages/pathUtils';
|
import { withSearchParam } from '../../../pages/pathUtils';
|
||||||
|
import { useCapabilities } from '../../../hooks/useCapabilities';
|
||||||
|
|
||||||
function ProfileAvatar() {
|
type FieldContext = { busy: boolean };
|
||||||
|
|
||||||
|
function ProfileAvatar({
|
||||||
|
busy,
|
||||||
|
value,
|
||||||
|
setValue,
|
||||||
|
}: ProfileFieldElementProps<'avatar_url', FieldContext>) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
const { busy, value, setValue } = useProfileField('avatar_url');
|
|
||||||
const avatarUrl = value
|
const avatarUrl = value
|
||||||
? mxcUrlToHttp(mx, value, useAuthentication, 96, 96, 'crop') ?? undefined
|
? mxcUrlToHttp(mx, value, useAuthentication, 96, 96, 'crop') ?? undefined
|
||||||
: undefined;
|
: undefined;
|
||||||
const disabled = !useProfileEditsAllowed('avatar_url') || busy;
|
const disabled = busy;
|
||||||
|
|
||||||
const [imageFile, setImageFile] = useState<File>();
|
const [imageFile, setImageFile] = useState<File>();
|
||||||
const imageFileURL = useObjectURL(imageFile);
|
const imageFileURL = useObjectURL(imageFile);
|
||||||
|
|
@ -172,17 +177,14 @@ function ProfileAvatar() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProfileTextFieldProps<K> = {
|
|
||||||
field: K;
|
|
||||||
label: ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
function ProfileTextField<K extends keyof FilterByValues<ExtendedProfile, string | undefined>>({
|
function ProfileTextField<K extends keyof FilterByValues<ExtendedProfile, string | undefined>>({
|
||||||
field,
|
|
||||||
label,
|
label,
|
||||||
}: ProfileTextFieldProps<K>) {
|
defaultValue,
|
||||||
const { busy, defaultValue, value, setValue } = useProfileField<K>(field);
|
value,
|
||||||
const disabled = !useProfileEditsAllowed(field) || busy;
|
setValue,
|
||||||
|
busy,
|
||||||
|
}: ProfileFieldElementProps<K, FieldContext> & { label: string }) {
|
||||||
|
const disabled = busy;
|
||||||
const hasChanges = defaultValue !== value;
|
const hasChanges = defaultValue !== value;
|
||||||
|
|
||||||
const handleChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
|
const handleChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
|
||||||
|
|
@ -240,9 +242,12 @@ function ProfileTextField<K extends keyof FilterByValues<ExtendedProfile, string
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProfilePronouns() {
|
function ProfilePronouns({
|
||||||
const { busy, value, setValue } = useProfileField('io.fsky.nyx.pronouns');
|
value,
|
||||||
const disabled = !useProfileEditsAllowed('io.fsky.nyx.pronouns') || busy;
|
setValue,
|
||||||
|
busy,
|
||||||
|
}: ProfileFieldElementProps<'io.fsky.nyx.pronouns', FieldContext>) {
|
||||||
|
const disabled = busy;
|
||||||
|
|
||||||
const [menuCords, setMenuCords] = useState<RectCords>();
|
const [menuCords, setMenuCords] = useState<RectCords>();
|
||||||
const [pendingPronoun, setPendingPronoun] = useState('');
|
const [pendingPronoun, setPendingPronoun] = useState('');
|
||||||
|
|
@ -364,9 +369,12 @@ function ProfilePronouns() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProfileTimezone() {
|
function ProfileTimezone({
|
||||||
const { busy, value, setValue } = useProfileField('us.cloke.msc4175.tz');
|
value,
|
||||||
const disabled = !useProfileEditsAllowed('us.cloke.msc4175.tz') || busy;
|
setValue,
|
||||||
|
busy,
|
||||||
|
}: ProfileFieldElementProps<'us.cloke.msc4175.tz', FieldContext>) {
|
||||||
|
const disabled = busy;
|
||||||
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
@ -556,12 +564,26 @@ function IdentityProviderSettings({ authMetadata }: { authMetadata: ValidatedAut
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const LEGACY_FIELD_ELEMENTS = {
|
||||||
|
avatar_url: ProfileAvatar,
|
||||||
|
displayname: (props: ProfileFieldElementProps<'displayname', FieldContext>) => (
|
||||||
|
<ProfileTextField label="Display Name" {...props} />
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
const EXTENDED_FIELD_ELEMENTS = {
|
||||||
|
'io.fsky.nyx.pronouns': ProfilePronouns,
|
||||||
|
'us.cloke.msc4175.tz': ProfileTimezone,
|
||||||
|
};
|
||||||
|
|
||||||
export function Profile() {
|
export function Profile() {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const userId = mx.getUserId() as string;
|
const userId = mx.getUserId() as string;
|
||||||
const server = getMxIdServer(userId);
|
const server = getMxIdServer(userId);
|
||||||
const authMetadata = useAuthMetadata();
|
const authMetadata = useAuthMetadata();
|
||||||
const accountManagementActions = useAccountManagementActions();
|
const accountManagementActions = useAccountManagementActions();
|
||||||
|
const useAuthentication = useMediaAuthentication();
|
||||||
|
const capabilities = useCapabilities();
|
||||||
|
|
||||||
const [extendedProfile, refreshExtendedProfile] = useExtendedProfile(userId);
|
const [extendedProfile, refreshExtendedProfile] = useExtendedProfile(userId);
|
||||||
const extendedProfileSupported = extendedProfile !== null;
|
const extendedProfileSupported = extendedProfile !== null;
|
||||||
|
|
@ -570,8 +592,15 @@ export function Profile() {
|
||||||
const profileEditableThroughIDP =
|
const profileEditableThroughIDP =
|
||||||
authMetadata !== undefined &&
|
authMetadata !== undefined &&
|
||||||
authMetadata.account_management_actions_supported?.includes(accountManagementActions.profile);
|
authMetadata.account_management_actions_supported?.includes(accountManagementActions.profile);
|
||||||
const profileEditableThroughClient = useProfileEditsAllowed(null);
|
|
||||||
|
|
||||||
|
const [fieldElementConstructors, profileEditableThroughClient] = useMemo(() => {
|
||||||
|
const entries = Object.entries({
|
||||||
|
...LEGACY_FIELD_ELEMENTS,
|
||||||
|
...(extendedProfileSupported ? EXTENDED_FIELD_ELEMENTS : {}),
|
||||||
|
}).filter(([key]) => profileEditsAllowed(key, capabilities, extendedProfileSupported));
|
||||||
|
return [Object.fromEntries(entries), entries.length > 0];
|
||||||
|
}, [capabilities, extendedProfileSupported]);
|
||||||
|
|
||||||
const [fieldDefaults, setFieldDefaults] = useState<ExtendedProfile>({
|
const [fieldDefaults, setFieldDefaults] = useState<ExtendedProfile>({
|
||||||
displayname: legacyProfile.displayName,
|
displayname: legacyProfile.displayName,
|
||||||
avatar_url: legacyProfile.avatarUrl,
|
avatar_url: legacyProfile.avatarUrl,
|
||||||
|
|
@ -582,8 +611,6 @@ export function Profile() {
|
||||||
}
|
}
|
||||||
}, [setFieldDefaults, extendedProfile]);
|
}, [setFieldDefaults, extendedProfile]);
|
||||||
|
|
||||||
const useAuthentication = useMediaAuthentication();
|
|
||||||
|
|
||||||
const [saveState, handleSave] = useAsyncCallback(
|
const [saveState, handleSave] = useAsyncCallback(
|
||||||
useCallback(
|
useCallback(
|
||||||
async (fields: ExtendedProfile) => {
|
async (fields: ExtendedProfile) => {
|
||||||
|
|
@ -631,8 +658,12 @@ export function Profile() {
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ProfileFieldContextProvider fieldDefaults={fieldDefaults} save={handleSave} busy={busy}>
|
<ProfileFieldContext
|
||||||
{(save, reset, hasChanges, fields) => {
|
fieldDefaults={fieldDefaults}
|
||||||
|
fieldElements={fieldElementConstructors}
|
||||||
|
context={{ busy }}
|
||||||
|
>
|
||||||
|
{(reset, hasChanges, fields, fieldElements) => {
|
||||||
const heroAvatarUrl =
|
const heroAvatarUrl =
|
||||||
(fields.avatar_url && mxcUrlToHttp(mx, fields.avatar_url, useAuthentication)) ??
|
(fields.avatar_url && mxcUrlToHttp(mx, fields.avatar_url, useAuthentication)) ??
|
||||||
undefined;
|
undefined;
|
||||||
|
|
@ -669,14 +700,7 @@ export function Profile() {
|
||||||
{profileEditableThroughClient && (
|
{profileEditableThroughClient && (
|
||||||
<>
|
<>
|
||||||
<Box gap="300" direction="Column">
|
<Box gap="300" direction="Column">
|
||||||
<ProfileAvatar />
|
{fieldElements}
|
||||||
<ProfileTextField field="displayname" label="Display Name" />
|
|
||||||
{extendedProfileSupported && (
|
|
||||||
<>
|
|
||||||
<ProfilePronouns />
|
|
||||||
<ProfileTimezone />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
<Box gap="300" alignItems="Center">
|
<Box gap="300" alignItems="Center">
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -687,7 +711,7 @@ export function Profile() {
|
||||||
outlined
|
outlined
|
||||||
radii="300"
|
radii="300"
|
||||||
disabled={!hasChanges || busy}
|
disabled={!hasChanges || busy}
|
||||||
onClick={save}
|
onClick={() => handleSave(fields)}
|
||||||
>
|
>
|
||||||
<Text size="B300">Save</Text>
|
<Text size="B300">Save</Text>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -727,7 +751,7 @@ export function Profile() {
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
</ProfileFieldContextProvider>
|
</ProfileFieldContext>
|
||||||
</SequenceCard>
|
</SequenceCard>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,35 @@
|
||||||
import React, { createContext, ReactNode, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
import React, { FunctionComponent, ReactNode, useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { ExtendedProfile } from '../../../hooks/useExtendedProfile';
|
import { ExtendedProfile } from '../../../hooks/useExtendedProfile';
|
||||||
|
|
||||||
const ProfileFieldContext = createContext<{
|
type ExtendedProfileKeys = keyof {
|
||||||
busy: boolean;
|
[Property in keyof ExtendedProfile as string extends Property ? never : Property]: ExtendedProfile[Property]
|
||||||
fieldDefaults: ExtendedProfile;
|
}
|
||||||
fields: ExtendedProfile;
|
|
||||||
setField: (key: string, value: unknown) => void;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
export type ProfileFieldContextProviderProps = {
|
type ProfileFieldElementRawProps<V, C> = {
|
||||||
|
defaultValue: V,
|
||||||
|
value: V,
|
||||||
|
setValue: (value: V) => void,
|
||||||
|
} & C
|
||||||
|
|
||||||
|
export type ProfileFieldElementProps<K extends ExtendedProfileKeys, C> = ProfileFieldElementRawProps<ExtendedProfile[K], C>;
|
||||||
|
|
||||||
|
type ProfileFieldElements<C> = {
|
||||||
|
[Property in ExtendedProfileKeys]?: FunctionComponent<ProfileFieldElementProps<Property, C>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProfileFieldContextProps<C> = {
|
||||||
fieldDefaults: ExtendedProfile;
|
fieldDefaults: ExtendedProfile;
|
||||||
save: (fields: ExtendedProfile) => void;
|
fieldElements: ProfileFieldElements<C>;
|
||||||
busy: boolean;
|
children: (reset: () => void, hasChanges: boolean, fields: ExtendedProfile, fieldElements: ReactNode) => ReactNode;
|
||||||
children: (save: () => void, reset: () => void, hasChanges: boolean, fields: ExtendedProfile) => ReactNode;
|
context: C;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ProfileFieldContextProvider({
|
export function ProfileFieldContext<C>({
|
||||||
fieldDefaults,
|
fieldDefaults,
|
||||||
save,
|
fieldElements: fieldElementConstructors,
|
||||||
busy,
|
|
||||||
children,
|
children,
|
||||||
}: ProfileFieldContextProviderProps) {
|
context
|
||||||
|
}: ProfileFieldContextProps<C>): ReactNode {
|
||||||
const [fields, setFields] = useState<ExtendedProfile>(fieldDefaults);
|
const [fields, setFields] = useState<ExtendedProfile>(fieldDefaults);
|
||||||
|
|
||||||
const reset = useCallback(() => {
|
const reset = useCallback(() => {
|
||||||
|
|
@ -41,35 +50,28 @@ export function ProfileFieldContextProvider({
|
||||||
[fields]
|
[fields]
|
||||||
);
|
);
|
||||||
|
|
||||||
const providerValue = useMemo(
|
|
||||||
() => ({ busy, fieldDefaults, fields, setField }),
|
|
||||||
[busy, fieldDefaults, fields, setField]
|
|
||||||
);
|
|
||||||
|
|
||||||
const hasChanges = useMemo(
|
const hasChanges = useMemo(
|
||||||
() => Object.entries(fields).find(([key, value]) => fieldDefaults[key as keyof ExtendedProfile] !== value) !== undefined,
|
() => Object.entries(fields).find(([key, value]) => fieldDefaults[key as keyof ExtendedProfile] !== value) !== undefined,
|
||||||
[fields, fieldDefaults]
|
[fields, fieldDefaults]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
const createElement = useCallback(<K extends ExtendedProfileKeys>(key: K, element: ProfileFieldElements<C>[K]) => {
|
||||||
<ProfileFieldContext.Provider value={providerValue}>
|
const props: ProfileFieldElementRawProps<ExtendedProfile[K], C> = {
|
||||||
{children(() => save(fields), reset, hasChanges, fields)}
|
...context,
|
||||||
</ProfileFieldContext.Provider>
|
defaultValue: fieldDefaults[key],
|
||||||
|
value: fields[key],
|
||||||
|
setValue: (value) => setField(key, value),
|
||||||
|
};
|
||||||
|
if (element !== undefined) {
|
||||||
|
return React.createElement(element, props);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}, [context, fieldDefaults, fields, setField]);
|
||||||
|
|
||||||
|
const fieldElements = Object.entries(fieldElementConstructors).map(([key, element]) =>
|
||||||
|
// @ts-expect-error TypeScript doesn't quite understand the magic going on here
|
||||||
|
createElement(key, element)
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
export function useProfileField<K extends keyof ExtendedProfile>(field: K): { busy: boolean, defaultValue: ExtendedProfile[K], value: ExtendedProfile[K], setValue: (value: ExtendedProfile[K]) => void } {
|
return children(reset, hasChanges, fields, fieldElements);
|
||||||
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);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import z from 'zod';
|
import z from 'zod';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { Capabilities } from 'matrix-js-sdk';
|
||||||
import { useMatrixClient } from './useMatrixClient';
|
import { useMatrixClient } from './useMatrixClient';
|
||||||
import { useSpecVersions } from './useSpecVersions';
|
import { useSpecVersions } from './useSpecVersions';
|
||||||
import { useCapabilities } from './useCapabilities';
|
|
||||||
import { IProfileFieldsCapability } from '../../types/matrix/common';
|
import { IProfileFieldsCapability } from '../../types/matrix/common';
|
||||||
|
|
||||||
const extendedProfile = z.looseObject({
|
const extendedProfile = z.looseObject({
|
||||||
|
|
@ -54,11 +54,12 @@ export function useExtendedProfile(
|
||||||
|
|
||||||
const LEGACY_FIELDS = ['displayname', 'avatar_url'];
|
const LEGACY_FIELDS = ['displayname', 'avatar_url'];
|
||||||
|
|
||||||
export function useProfileEditsAllowed(field: string | null): boolean {
|
export function profileEditsAllowed(
|
||||||
const capabilities = useCapabilities();
|
field: string,
|
||||||
const extendedProfileSupported = useExtendedProfileSupported();
|
capabilities: Capabilities,
|
||||||
|
extendedProfileSupported: boolean
|
||||||
if (field && LEGACY_FIELDS.includes(field)) {
|
): boolean {
|
||||||
|
if (LEGACY_FIELDS.includes(field)) {
|
||||||
// this field might have a pre-msc4133 capability. check that first
|
// this field might have a pre-msc4133 capability. check that first
|
||||||
if (capabilities[`m.set_${field}`]?.enabled === false) {
|
if (capabilities[`m.set_${field}`]?.enabled === false) {
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -86,11 +87,6 @@ export function useProfileEditsAllowed(field: string | null): boolean {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (field === null) {
|
|
||||||
// profile field modifications are not completely disabled
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
extendedProfileCapability.allowed !== undefined &&
|
extendedProfileCapability.allowed !== undefined &&
|
||||||
!extendedProfileCapability.allowed.includes(field)
|
!extendedProfileCapability.allowed.includes(field)
|
||||||
|
|
@ -108,11 +104,6 @@ export function useProfileEditsAllowed(field: string | null): boolean {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (field === null) {
|
|
||||||
// the homeserver only supports legacy fields. assume profile editing is generally allowed
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// `field` is an extended profile key and the homeserver lacks msc4133 support
|
// `field` is an extended profile key and the homeserver lacks msc4133 support
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue