mirror of
				https://github.com/cinnyapp/cinny.git
				synced 2025-11-04 14:30:29 +03:00 
			
		
		
		
	Rework profile settings to show a preview and support more fields
This commit is contained in:
		
							parent
							
								
									7f40605bfe
								
							
						
					
					
						commit
						3c1aa0e699
					
				
					 9 changed files with 362 additions and 159 deletions
				
			
		
							
								
								
									
										12
									
								
								package-lock.json
									
										
									
										generated
									
									
									
								
							
							
						
						
									
										12
									
								
								package-lock.json
									
										
									
										generated
									
									
									
								
							| 
						 | 
				
			
			@ -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"
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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",
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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 (
 | 
			
		||||
    <Box grow="Yes" direction="Column" gap="0">
 | 
			
		||||
| 
						 | 
				
			
			@ -110,9 +113,10 @@ export function UserHeroName({ displayName, userId }: UserHeroNameProps) {
 | 
			
		|||
          {displayName ?? username ?? userId}
 | 
			
		||||
        </Text>
 | 
			
		||||
      </Box>
 | 
			
		||||
      <Box alignItems="Center" gap="100" wrap="Wrap">
 | 
			
		||||
      <Box alignItems="Start" gap="100" wrap="Wrap" direction='Column'>
 | 
			
		||||
        <Text size="T200" className={classNames(BreakWord, LineClamp3)} title={username}>
 | 
			
		||||
          @{username}
 | 
			
		||||
          {pronouns && <span> · {pronouns.map(({ summary }) => summary).join(", ")}</span>}
 | 
			
		||||
        </Text>
 | 
			
		||||
      </Box>
 | 
			
		||||
    </Box>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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) {
 | 
			
		|||
      <Box direction="Column" gap="500" style={{ padding: config.space.S400 }}>
 | 
			
		||||
        <Box direction="Column" gap="400">
 | 
			
		||||
          <Box gap="400" alignItems="Start">
 | 
			
		||||
            <UserHeroName displayName={displayName} userId={userId} />
 | 
			
		||||
            <UserHeroName displayName={displayName} userId={userId} extendedProfile={extendedProfile} />
 | 
			
		||||
            {userId !== myUserId && (
 | 
			
		||||
              <Box shrink="No">
 | 
			
		||||
                <Button
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,11 +1,4 @@
 | 
			
		|||
import React, {
 | 
			
		||||
  ChangeEventHandler,
 | 
			
		||||
  FormEventHandler,
 | 
			
		||||
  useCallback,
 | 
			
		||||
  useEffect,
 | 
			
		||||
  useMemo,
 | 
			
		||||
  useState,
 | 
			
		||||
} from 'react';
 | 
			
		||||
import React, { ChangeEventHandler, ReactNode, useCallback, useMemo, useState } from 'react';
 | 
			
		||||
import {
 | 
			
		||||
  Box,
 | 
			
		||||
  Text,
 | 
			
		||||
| 
						 | 
				
			
			@ -13,28 +6,21 @@ import {
 | 
			
		|||
  Icon,
 | 
			
		||||
  Icons,
 | 
			
		||||
  Input,
 | 
			
		||||
  Avatar,
 | 
			
		||||
  Button,
 | 
			
		||||
  Overlay,
 | 
			
		||||
  OverlayBackdrop,
 | 
			
		||||
  OverlayCenter,
 | 
			
		||||
  Modal,
 | 
			
		||||
  Dialog,
 | 
			
		||||
  Header,
 | 
			
		||||
  config,
 | 
			
		||||
  Spinner,
 | 
			
		||||
} from 'folds';
 | 
			
		||||
import FocusTrap from 'focus-trap-react';
 | 
			
		||||
import { UserEvent } from 'matrix-js-sdk';
 | 
			
		||||
import { SequenceCard } from '../../../components/sequence-card';
 | 
			
		||||
import { SequenceCardStyle } from '../styles.css';
 | 
			
		||||
import { SettingTile } from '../../../components/setting-tile';
 | 
			
		||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
 | 
			
		||||
import { UserProfile, useUserProfile } from '../../../hooks/useUserProfile';
 | 
			
		||||
import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
 | 
			
		||||
import { UserAvatar } from '../../../components/user-avatar';
 | 
			
		||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
 | 
			
		||||
import { nameInitials } from '../../../utils/common';
 | 
			
		||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
 | 
			
		||||
import { useFilePicker } from '../../../hooks/useFilePicker';
 | 
			
		||||
import { useObjectURL } from '../../../hooks/useObjectURL';
 | 
			
		||||
import { stopPropagation } from '../../../utils/keyboard';
 | 
			
		||||
| 
						 | 
				
			
			@ -42,23 +28,24 @@ import { ImageEditor } from '../../../components/image-editor';
 | 
			
		|||
import { ModalWide } from '../../../styles/Modal.css';
 | 
			
		||||
import { createUploadAtom, UploadSuccess } from '../../../state/upload';
 | 
			
		||||
import { CompactUploadCardRenderer } from '../../../components/upload-card';
 | 
			
		||||
import { useCapabilities } from '../../../hooks/useCapabilities';
 | 
			
		||||
import { UserHero, UserHeroName } from '../../../components/user-profile/UserHero';
 | 
			
		||||
import {
 | 
			
		||||
  ExtendedProfile,
 | 
			
		||||
  useExtendedProfile,
 | 
			
		||||
  useProfileFieldAllowed,
 | 
			
		||||
} from '../../../hooks/useExtendedProfile';
 | 
			
		||||
import { ProfileFieldContextProvider, useProfileField } from './ProfileFieldContext';
 | 
			
		||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
 | 
			
		||||
import { FilterByValues } from '../../../../types/utils';
 | 
			
		||||
 | 
			
		||||
type ProfileProps = {
 | 
			
		||||
  profile: UserProfile;
 | 
			
		||||
  userId: string;
 | 
			
		||||
};
 | 
			
		||||
function ProfileAvatar({ profile, userId }: ProfileProps) {
 | 
			
		||||
function ProfileAvatar() {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const useAuthentication = useMediaAuthentication();
 | 
			
		||||
  const capabilities = useCapabilities();
 | 
			
		||||
  const [alertRemove, setAlertRemove] = useState(false);
 | 
			
		||||
  const disableSetAvatar = capabilities['m.set_avatar_url']?.enabled === false;
 | 
			
		||||
 | 
			
		||||
  const defaultDisplayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId;
 | 
			
		||||
  const avatarUrl = profile.avatarUrl
 | 
			
		||||
    ? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined
 | 
			
		||||
  const { busy, value, setValue } = useProfileField('avatar_url');
 | 
			
		||||
  const avatarUrl = value
 | 
			
		||||
    ? mxcUrlToHttp(mx, value, useAuthentication, 96, 96, 'crop') ?? undefined
 | 
			
		||||
    : undefined;
 | 
			
		||||
  const disabled = !useProfileFieldAllowed('avatar_url') || busy;
 | 
			
		||||
 | 
			
		||||
  const [imageFile, setImageFile] = useState<File>();
 | 
			
		||||
  const imageFileURL = useObjectURL(imageFile);
 | 
			
		||||
| 
						 | 
				
			
			@ -76,34 +63,18 @@ function ProfileAvatar({ profile, userId }: ProfileProps) {
 | 
			
		|||
  const handleUploaded = useCallback(
 | 
			
		||||
    (upload: UploadSuccess) => {
 | 
			
		||||
      const { mxc } = upload;
 | 
			
		||||
      mx.setAvatarUrl(mxc);
 | 
			
		||||
      setValue(mxc);
 | 
			
		||||
      handleRemoveUpload();
 | 
			
		||||
    },
 | 
			
		||||
    [mx, handleRemoveUpload]
 | 
			
		||||
    [setValue, handleRemoveUpload]
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const handleRemoveAvatar = () => {
 | 
			
		||||
    mx.setAvatarUrl('');
 | 
			
		||||
    setAlertRemove(false);
 | 
			
		||||
    setValue('');
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <SettingTile
 | 
			
		||||
      title={
 | 
			
		||||
        <Text as="span" size="L400">
 | 
			
		||||
          Avatar
 | 
			
		||||
        </Text>
 | 
			
		||||
      }
 | 
			
		||||
      after={
 | 
			
		||||
        <Avatar size="500" radii="300">
 | 
			
		||||
          <UserAvatar
 | 
			
		||||
            userId={userId}
 | 
			
		||||
            src={avatarUrl}
 | 
			
		||||
            renderFallback={() => <Text size="H4">{nameInitials(defaultDisplayName)}</Text>}
 | 
			
		||||
          />
 | 
			
		||||
        </Avatar>
 | 
			
		||||
      }
 | 
			
		||||
    >
 | 
			
		||||
    <SettingTile>
 | 
			
		||||
      {uploadAtom ? (
 | 
			
		||||
        <Box gap="200" direction="Column">
 | 
			
		||||
          <CompactUploadCardRenderer
 | 
			
		||||
| 
						 | 
				
			
			@ -121,9 +92,9 @@ function ProfileAvatar({ profile, userId }: ProfileProps) {
 | 
			
		|||
            fill="Soft"
 | 
			
		||||
            outlined
 | 
			
		||||
            radii="300"
 | 
			
		||||
            disabled={disableSetAvatar}
 | 
			
		||||
            disabled={disabled}
 | 
			
		||||
          >
 | 
			
		||||
            <Text size="B300">Upload</Text>
 | 
			
		||||
            <Text size="B300">Upload Avatar</Text>
 | 
			
		||||
          </Button>
 | 
			
		||||
          {avatarUrl && (
 | 
			
		||||
            <Button
 | 
			
		||||
| 
						 | 
				
			
			@ -131,10 +102,10 @@ function ProfileAvatar({ profile, userId }: ProfileProps) {
 | 
			
		|||
              variant="Critical"
 | 
			
		||||
              fill="None"
 | 
			
		||||
              radii="300"
 | 
			
		||||
              disabled={disableSetAvatar}
 | 
			
		||||
              onClick={() => setAlertRemove(true)}
 | 
			
		||||
              disabled={disabled}
 | 
			
		||||
              onClick={handleRemoveAvatar}
 | 
			
		||||
            >
 | 
			
		||||
              <Text size="B300">Remove</Text>
 | 
			
		||||
              <Text size="B300">Remove Avatar</Text>
 | 
			
		||||
            </Button>
 | 
			
		||||
          )}
 | 
			
		||||
        </Box>
 | 
			
		||||
| 
						 | 
				
			
			@ -162,116 +133,54 @@ function ProfileAvatar({ profile, userId }: ProfileProps) {
 | 
			
		|||
          </OverlayCenter>
 | 
			
		||||
        </Overlay>
 | 
			
		||||
      )}
 | 
			
		||||
 | 
			
		||||
      <Overlay open={alertRemove} backdrop={<OverlayBackdrop />}>
 | 
			
		||||
        <OverlayCenter>
 | 
			
		||||
          <FocusTrap
 | 
			
		||||
            focusTrapOptions={{
 | 
			
		||||
              initialFocus: false,
 | 
			
		||||
              onDeactivate: () => setAlertRemove(false),
 | 
			
		||||
              clickOutsideDeactivates: true,
 | 
			
		||||
              escapeDeactivates: stopPropagation,
 | 
			
		||||
            }}
 | 
			
		||||
          >
 | 
			
		||||
            <Dialog variant="Surface">
 | 
			
		||||
              <Header
 | 
			
		||||
                style={{
 | 
			
		||||
                  padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
 | 
			
		||||
                  borderBottomWidth: config.borderWidth.B300,
 | 
			
		||||
                }}
 | 
			
		||||
                variant="Surface"
 | 
			
		||||
                size="500"
 | 
			
		||||
              >
 | 
			
		||||
                <Box grow="Yes">
 | 
			
		||||
                  <Text size="H4">Remove Avatar</Text>
 | 
			
		||||
                </Box>
 | 
			
		||||
                <IconButton size="300" onClick={() => setAlertRemove(false)} radii="300">
 | 
			
		||||
                  <Icon src={Icons.Cross} />
 | 
			
		||||
                </IconButton>
 | 
			
		||||
              </Header>
 | 
			
		||||
              <Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
 | 
			
		||||
                <Box direction="Column" gap="200">
 | 
			
		||||
                  <Text priority="400">Are you sure you want to remove profile avatar?</Text>
 | 
			
		||||
                </Box>
 | 
			
		||||
                <Button variant="Critical" onClick={handleRemoveAvatar}>
 | 
			
		||||
                  <Text size="B400">Remove</Text>
 | 
			
		||||
                </Button>
 | 
			
		||||
              </Box>
 | 
			
		||||
            </Dialog>
 | 
			
		||||
          </FocusTrap>
 | 
			
		||||
        </OverlayCenter>
 | 
			
		||||
      </Overlay>
 | 
			
		||||
    </SettingTile>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function ProfileDisplayName({ profile, userId }: ProfileProps) {
 | 
			
		||||
  const mx = useMatrixClient();
 | 
			
		||||
  const capabilities = useCapabilities();
 | 
			
		||||
  const disableSetDisplayname = capabilities['m.set_displayname']?.enabled === false;
 | 
			
		||||
type ProfileTextFieldProps<K> = {
 | 
			
		||||
  field: K;
 | 
			
		||||
  label: ReactNode;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
  const defaultDisplayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId;
 | 
			
		||||
  const [displayName, setDisplayName] = useState<string>(defaultDisplayName);
 | 
			
		||||
 | 
			
		||||
  const [changeState, changeDisplayName] = useAsyncCallback(
 | 
			
		||||
    useCallback((name: string) => mx.setDisplayName(name), [mx])
 | 
			
		||||
  );
 | 
			
		||||
  const changingDisplayName = changeState.status === AsyncStatus.Loading;
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    setDisplayName(defaultDisplayName);
 | 
			
		||||
  }, [defaultDisplayName]);
 | 
			
		||||
function ProfileTextField<K extends keyof FilterByValues<ExtendedProfile, string | undefined>>({
 | 
			
		||||
  field,
 | 
			
		||||
  label,
 | 
			
		||||
}: ProfileTextFieldProps<K>) {
 | 
			
		||||
  const { busy, defaultValue, value, setValue } = useProfileField<K>(field);
 | 
			
		||||
  const disabled = !useProfileFieldAllowed(field) || busy;
 | 
			
		||||
  const hasChanges = defaultValue !== value;
 | 
			
		||||
 | 
			
		||||
  const handleChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
 | 
			
		||||
    const name = evt.currentTarget.value;
 | 
			
		||||
    setDisplayName(name);
 | 
			
		||||
    setValue(evt.currentTarget.value);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleReset = () => {
 | 
			
		||||
    setDisplayName(defaultDisplayName);
 | 
			
		||||
    setValue(defaultValue);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleSubmit: FormEventHandler<HTMLFormElement> = (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 (
 | 
			
		||||
    <SettingTile
 | 
			
		||||
      title={
 | 
			
		||||
        <Text as="span" size="L400">
 | 
			
		||||
          Display Name
 | 
			
		||||
          {label}
 | 
			
		||||
        </Text>
 | 
			
		||||
      }
 | 
			
		||||
    >
 | 
			
		||||
      <Box direction="Column" grow="Yes" gap="100">
 | 
			
		||||
        <Box
 | 
			
		||||
          as="form"
 | 
			
		||||
          onSubmit={handleSubmit}
 | 
			
		||||
          gap="200"
 | 
			
		||||
          aria-disabled={changingDisplayName || disableSetDisplayname}
 | 
			
		||||
        >
 | 
			
		||||
        <Box gap="200" aria-disabled={disabled}>
 | 
			
		||||
          <Box grow="Yes" direction="Column">
 | 
			
		||||
            <Input
 | 
			
		||||
              required
 | 
			
		||||
              name="displayNameInput"
 | 
			
		||||
              value={displayName}
 | 
			
		||||
              value={value ?? ''}
 | 
			
		||||
              onChange={handleChange}
 | 
			
		||||
              variant="Secondary"
 | 
			
		||||
              radii="300"
 | 
			
		||||
              style={{ paddingRight: config.space.S200 }}
 | 
			
		||||
              readOnly={changingDisplayName || disableSetDisplayname}
 | 
			
		||||
              readOnly={disabled}
 | 
			
		||||
              after={
 | 
			
		||||
                hasChanges &&
 | 
			
		||||
                !changingDisplayName && (
 | 
			
		||||
                !busy && (
 | 
			
		||||
                  <IconButton
 | 
			
		||||
                    type="reset"
 | 
			
		||||
                    onClick={handleReset}
 | 
			
		||||
| 
						 | 
				
			
			@ -285,18 +194,6 @@ function ProfileDisplayName({ profile, userId }: ProfileProps) {
 | 
			
		|||
              }
 | 
			
		||||
            />
 | 
			
		||||
          </Box>
 | 
			
		||||
          <Button
 | 
			
		||||
            size="400"
 | 
			
		||||
            variant={hasChanges ? 'Success' : 'Secondary'}
 | 
			
		||||
            fill={hasChanges ? 'Solid' : 'Soft'}
 | 
			
		||||
            outlined
 | 
			
		||||
            radii="300"
 | 
			
		||||
            disabled={!hasChanges || changingDisplayName}
 | 
			
		||||
            type="submit"
 | 
			
		||||
          >
 | 
			
		||||
            {changingDisplayName && <Spinner variant="Success" fill="Solid" size="300" />}
 | 
			
		||||
            <Text size="B400">Save</Text>
 | 
			
		||||
          </Button>
 | 
			
		||||
        </Box>
 | 
			
		||||
      </Box>
 | 
			
		||||
    </SettingTile>
 | 
			
		||||
| 
						 | 
				
			
			@ -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>(
 | 
			
		||||
    () =>
 | 
			
		||||
      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 (
 | 
			
		||||
    <Box direction="Column" gap="100">
 | 
			
		||||
      <Text size="L400">Profile</Text>
 | 
			
		||||
      <SequenceCard
 | 
			
		||||
        className={SequenceCardStyle}
 | 
			
		||||
        variant="SurfaceVariant"
 | 
			
		||||
        direction="Column"
 | 
			
		||||
        gap="400"
 | 
			
		||||
        style={{
 | 
			
		||||
          overflow: 'hidden',
 | 
			
		||||
        }}
 | 
			
		||||
      >
 | 
			
		||||
        <ProfileAvatar userId={userId} profile={profile} />
 | 
			
		||||
        <ProfileDisplayName userId={userId} profile={profile} />
 | 
			
		||||
        <ProfileFieldContextProvider fieldDefaults={fieldDefaults} save={handleSave} busy={busy}>
 | 
			
		||||
          {(save, reset, hasChanges, fields) => {
 | 
			
		||||
            const heroAvatarUrl =
 | 
			
		||||
              (fields.avatar_url &&
 | 
			
		||||
                mxcUrlToHttp(mx, fields.avatar_url, useAuthentication)) ??
 | 
			
		||||
              undefined;
 | 
			
		||||
            return (
 | 
			
		||||
              <>
 | 
			
		||||
                <UserHero userId={userId} avatarUrl={heroAvatarUrl} />
 | 
			
		||||
                <Box direction="Column" gap="400" style={{ padding: config.space.S400 }}>
 | 
			
		||||
                  <Box gap="400" alignItems="Start">
 | 
			
		||||
                    <UserHeroName
 | 
			
		||||
                      userId={userId}
 | 
			
		||||
                      displayName={fields.displayname as string}
 | 
			
		||||
                      extendedProfile={fields}
 | 
			
		||||
                    />
 | 
			
		||||
                  </Box>
 | 
			
		||||
                  <ProfileAvatar />
 | 
			
		||||
                  <ProfileTextField field="displayname" label="Display Name" />
 | 
			
		||||
                  <Box gap="300">
 | 
			
		||||
                    <Button
 | 
			
		||||
                      type="submit"
 | 
			
		||||
                      size="300"
 | 
			
		||||
                      variant={!busy && hasChanges ? 'Success' : 'Secondary'}
 | 
			
		||||
                      fill={!busy && hasChanges ? 'Solid' : 'Soft'}
 | 
			
		||||
                      outlined
 | 
			
		||||
                      radii="300"
 | 
			
		||||
                      disabled={!hasChanges || busy}
 | 
			
		||||
                      onClick={save}
 | 
			
		||||
                    >
 | 
			
		||||
                      {saving && <Spinner variant="Success" fill="Solid" size="300" />}
 | 
			
		||||
                      <Text size="B300">Save</Text>
 | 
			
		||||
                    </Button>
 | 
			
		||||
                    <Button
 | 
			
		||||
                      type="reset"
 | 
			
		||||
                      size="300"
 | 
			
		||||
                      variant="Secondary"
 | 
			
		||||
                      fill="Soft"
 | 
			
		||||
                      outlined
 | 
			
		||||
                      radii="300"
 | 
			
		||||
                      onClick={reset}
 | 
			
		||||
                      disabled={!hasChanges || busy}
 | 
			
		||||
                    >
 | 
			
		||||
                      <Text size="B300">Cancel</Text>
 | 
			
		||||
                    </Button>
 | 
			
		||||
                  </Box>
 | 
			
		||||
                </Box>
 | 
			
		||||
              </>
 | 
			
		||||
            );
 | 
			
		||||
          }}
 | 
			
		||||
        </ProfileFieldContextProvider>
 | 
			
		||||
      </SequenceCard>
 | 
			
		||||
    </Box>
 | 
			
		||||
  );
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										75
									
								
								src/app/features/settings/account/ProfileFieldContext.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										75
									
								
								src/app/features/settings/account/ProfileFieldContext.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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<ExtendedProfile>(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 (
 | 
			
		||||
    <ProfileFieldContext.Provider value={providerValue}>
 | 
			
		||||
      {children(() => save(fields), reset, hasChanges, fields)}
 | 
			
		||||
    </ProfileFieldContext.Provider>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function useProfileField<K extends keyof ExtendedProfile>(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);
 | 
			
		||||
    },
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										103
									
								
								src/app/hooks/useExtendedProfile.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										103
									
								
								src/app/hooks/useExtendedProfile.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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<typeof extendedProfile>;
 | 
			
		||||
 | 
			
		||||
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<ExtendedProfile | undefined, unknown>,
 | 
			
		||||
  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;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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[];
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,3 +1,8 @@
 | 
			
		|||
export type WithRequiredProp<Type extends object, Key extends keyof Type> = Type & {
 | 
			
		||||
  [Property in Key]-?: Type[Property];
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// Represents a subset of T containing only the keys whose values extend V
 | 
			
		||||
export type FilterByValues<T extends object, V> = {
 | 
			
		||||
  [Property in keyof T as T[Property] extends V ? Property : never]: T[Property];
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue