sforkowany z mirror/soapbox
				
			Merge branch 'streamfield' into 'develop'
Allow editing custom profile fields See merge request soapbox-pub/soapbox-fe!1300api-accept
						commit
						4fcece9b72
					
				| 
						 | 
				
			
			@ -59,18 +59,12 @@ const persistAuthAccount = (account, params) => {
 | 
			
		|||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export function patchMe(params, formData = false) {
 | 
			
		||||
export function patchMe(params) {
 | 
			
		||||
  return (dispatch, getState) => {
 | 
			
		||||
    dispatch(patchMeRequest());
 | 
			
		||||
 | 
			
		||||
    const options = formData ? {
 | 
			
		||||
      headers: {
 | 
			
		||||
        'Content-Type': 'multipart/form-data',
 | 
			
		||||
      },
 | 
			
		||||
    } : {};
 | 
			
		||||
 | 
			
		||||
    return api(getState)
 | 
			
		||||
      .patch('/api/v1/accounts/update_credentials', params, options)
 | 
			
		||||
      .patch('/api/v1/accounts/update_credentials', params)
 | 
			
		||||
      .then(response => {
 | 
			
		||||
        persistAuthAccount(response.data, params);
 | 
			
		||||
        dispatch(patchMeSuccess(response.data));
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -3,9 +3,9 @@ import { v4 as uuidv4 } from 'uuid';
 | 
			
		|||
 | 
			
		||||
interface IFormGroup {
 | 
			
		||||
  /** Input label message. */
 | 
			
		||||
  hintText?: string | React.ReactNode,
 | 
			
		||||
  labelText: React.ReactNode,
 | 
			
		||||
  /** Input hint message. */
 | 
			
		||||
  labelText: string | React.ReactNode,
 | 
			
		||||
  hintText?: React.ReactNode,
 | 
			
		||||
  /** Input errors. */
 | 
			
		||||
  errors?: string[]
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -18,6 +18,8 @@ interface IInput extends Pick<React.InputHTMLAttributes<HTMLInputElement>, 'maxL
 | 
			
		|||
  defaultValue?: string,
 | 
			
		||||
  /** Extra class names for the <input> element. */
 | 
			
		||||
  className?: string,
 | 
			
		||||
  /** Extra class names for the outer <div> element. */
 | 
			
		||||
  outerClassName?: string,
 | 
			
		||||
  /** URL to the svg icon. */
 | 
			
		||||
  icon?: string,
 | 
			
		||||
  /** Internal input name. */
 | 
			
		||||
| 
						 | 
				
			
			@ -37,7 +39,7 @@ const Input = React.forwardRef<HTMLInputElement, IInput>(
 | 
			
		|||
  (props, ref) => {
 | 
			
		||||
    const intl = useIntl();
 | 
			
		||||
 | 
			
		||||
    const { type = 'text', icon, className, ...filteredProps } = props;
 | 
			
		||||
    const { type = 'text', icon, className, outerClassName, ...filteredProps } = props;
 | 
			
		||||
 | 
			
		||||
    const [revealed, setRevealed] = React.useState(false);
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -48,7 +50,7 @@ const Input = React.forwardRef<HTMLInputElement, IInput>(
 | 
			
		|||
    }, []);
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <div className='mt-1 relative rounded-md shadow-sm'>
 | 
			
		||||
      <div className={classNames('mt-1 relative rounded-md shadow-sm', outerClassName)}>
 | 
			
		||||
        {icon ? (
 | 
			
		||||
          <div className='absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none'>
 | 
			
		||||
            <Icon src={icon} className='h-4 w-4 text-gray-400' aria-hidden='true' />
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,94 @@
 | 
			
		|||
import React from 'react';
 | 
			
		||||
import { useIntl, defineMessages } from 'react-intl';
 | 
			
		||||
 | 
			
		||||
import Button from '../button/button';
 | 
			
		||||
import HStack from '../hstack/hstack';
 | 
			
		||||
import IconButton from '../icon-button/icon-button';
 | 
			
		||||
import Stack from '../stack/stack';
 | 
			
		||||
import Text from '../text/text';
 | 
			
		||||
 | 
			
		||||
const messages = defineMessages({
 | 
			
		||||
  add: { id: 'streamfield.add', defaultMessage: 'Add' },
 | 
			
		||||
  remove: { id: 'streamfield.remove', defaultMessage: 'Remove' },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
interface IStreamfield {
 | 
			
		||||
  /** Array of values for the streamfield. */
 | 
			
		||||
  values: any[],
 | 
			
		||||
  /** Input label message. */
 | 
			
		||||
  labelText?: React.ReactNode,
 | 
			
		||||
  /** Input hint message. */
 | 
			
		||||
  hintText?: React.ReactNode,
 | 
			
		||||
  /** Callback to add an item. */
 | 
			
		||||
  onAddItem?: () => void,
 | 
			
		||||
  /** Callback to remove an item by index. */
 | 
			
		||||
  onRemoveItem?: (i: number) => void,
 | 
			
		||||
  /** Callback when values are changed. */
 | 
			
		||||
  onChange: (values: any[]) => void,
 | 
			
		||||
  /** Input to render for each value. */
 | 
			
		||||
  component: React.ComponentType<{ onChange: (value: any) => void, value: any }>,
 | 
			
		||||
  /** Maximum number of allowed inputs. */
 | 
			
		||||
  maxItems?: number,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/** List of inputs that can be added or removed. */
 | 
			
		||||
const Streamfield: React.FC<IStreamfield> = ({
 | 
			
		||||
  values,
 | 
			
		||||
  labelText,
 | 
			
		||||
  hintText,
 | 
			
		||||
  onAddItem,
 | 
			
		||||
  onRemoveItem,
 | 
			
		||||
  onChange,
 | 
			
		||||
  component: Component,
 | 
			
		||||
  maxItems = Infinity,
 | 
			
		||||
}) => {
 | 
			
		||||
  const intl = useIntl();
 | 
			
		||||
 | 
			
		||||
  const handleChange = (i: number) => {
 | 
			
		||||
    return (value: any) => {
 | 
			
		||||
      const newData = [...values];
 | 
			
		||||
      newData[i] = value;
 | 
			
		||||
      onChange(newData);
 | 
			
		||||
    };
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Stack space={4}>
 | 
			
		||||
      <Stack>
 | 
			
		||||
        {labelText && <Text size='sm' weight='medium'>{labelText}</Text>}
 | 
			
		||||
        {hintText && <Text size='xs' theme='muted'>{hintText}</Text>}
 | 
			
		||||
      </Stack>
 | 
			
		||||
 | 
			
		||||
      <Stack>
 | 
			
		||||
        {values.map((value, i) => (
 | 
			
		||||
          <HStack space={2} alignItems='center'>
 | 
			
		||||
            <Component key={i} onChange={handleChange(i)} value={value} />
 | 
			
		||||
            {onRemoveItem && (
 | 
			
		||||
              <IconButton
 | 
			
		||||
                iconClassName='w-4 h-4'
 | 
			
		||||
                className='bg-transparent text-gray-400 hover:text-gray-600'
 | 
			
		||||
                src={require('@tabler/icons/icons/x.svg')}
 | 
			
		||||
                onClick={() => onRemoveItem(i)}
 | 
			
		||||
                title={intl.formatMessage(messages.remove)}
 | 
			
		||||
              />
 | 
			
		||||
            )}
 | 
			
		||||
          </HStack>
 | 
			
		||||
        ))}
 | 
			
		||||
      </Stack>
 | 
			
		||||
 | 
			
		||||
      {onAddItem && (
 | 
			
		||||
        <Button
 | 
			
		||||
          icon={require('@tabler/icons/icons/plus.svg')}
 | 
			
		||||
          onClick={onAddItem}
 | 
			
		||||
          disabled={values.length >= maxItems}
 | 
			
		||||
          theme='ghost'
 | 
			
		||||
          block
 | 
			
		||||
        >
 | 
			
		||||
          {intl.formatMessage(messages.add)}
 | 
			
		||||
        </Button>
 | 
			
		||||
      )}
 | 
			
		||||
    </Stack>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default Streamfield;
 | 
			
		||||
| 
						 | 
				
			
			@ -7,11 +7,12 @@ import snackbar from 'soapbox/actions/snackbar';
 | 
			
		|||
import {
 | 
			
		||||
  Checkbox,
 | 
			
		||||
} from 'soapbox/features/forms';
 | 
			
		||||
import { useAppDispatch, useOwnAccount, useFeatures } from 'soapbox/hooks';
 | 
			
		||||
import { useAppSelector, useAppDispatch, useOwnAccount, useFeatures } from 'soapbox/hooks';
 | 
			
		||||
import { normalizeAccount } from 'soapbox/normalizers';
 | 
			
		||||
import resizeImage from 'soapbox/utils/resize_image';
 | 
			
		||||
 | 
			
		||||
import { Button, Column, Form, FormActions, FormGroup, Input, Textarea } from '../../components/ui';
 | 
			
		||||
import { Button, Column, Form, FormActions, FormGroup, Input, Textarea, HStack } from '../../components/ui';
 | 
			
		||||
import Streamfield from '../../components/ui/streamfield/streamfield';
 | 
			
		||||
 | 
			
		||||
import ProfilePreview from './components/profile_preview';
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -26,6 +27,25 @@ const hidesNetwork = (account: Account): boolean => {
 | 
			
		|||
  return Boolean(hide_followers && hide_follows && hide_followers_count && hide_follows_count);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
/** Converts JSON objects to FormData. */
 | 
			
		||||
// https://stackoverflow.com/a/60286175/8811886
 | 
			
		||||
// @ts-ignore
 | 
			
		||||
const toFormData = (f => f(f))(h => f => f(x => h(h)(f)(x)))(f => fd => pk => d => {
 | 
			
		||||
  if (d instanceof Object) {
 | 
			
		||||
    // eslint-disable-next-line consistent-return
 | 
			
		||||
    Object.keys(d).forEach(k => {
 | 
			
		||||
      const v = d[k];
 | 
			
		||||
      if (pk) k = `${pk}[${k}]`;
 | 
			
		||||
      if (v instanceof Object && !(v instanceof Date) && !(v instanceof File)) {
 | 
			
		||||
        return f(fd)(k)(v);
 | 
			
		||||
      } else {
 | 
			
		||||
        fd.append(k, v);
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
  return fd;
 | 
			
		||||
})(new FormData())();
 | 
			
		||||
 | 
			
		||||
const messages = defineMessages({
 | 
			
		||||
  heading: { id: 'column.edit_profile', defaultMessage: 'Edit profile' },
 | 
			
		||||
  header: { id: 'edit_profile.header', defaultMessage: 'Edit Profile' },
 | 
			
		||||
| 
						 | 
				
			
			@ -40,13 +60,6 @@ const messages = defineMessages({
 | 
			
		|||
  cancel: { id: 'common.cancel', defaultMessage: 'Cancel' },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
// /** Forces fields to be maxFields size, filling empty values. */
 | 
			
		||||
// const normalizeFields = (fields, maxFields: number) => (
 | 
			
		||||
//   ImmutableList(fields).setSize(Math.max(fields.size, maxFields)).map(field =>
 | 
			
		||||
//     field ? field : ImmutableMap({ name: '', value: '' }),
 | 
			
		||||
//   )
 | 
			
		||||
// );
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Profile metadata `name` and `value`.
 | 
			
		||||
 * (By default, max 4 fields and 255 characters per property/value)
 | 
			
		||||
| 
						 | 
				
			
			@ -121,7 +134,7 @@ const accountToCredentials = (account: Account): AccountCredentials => {
 | 
			
		|||
    display_name: account.display_name,
 | 
			
		||||
    note: account.source.get('note'),
 | 
			
		||||
    locked: account.locked,
 | 
			
		||||
    fields_attributes: [...account.source.get<Iterable<AccountCredentialsField>>('fields', [])],
 | 
			
		||||
    fields_attributes: [...account.source.get<Iterable<AccountCredentialsField>>('fields', []).toJS()],
 | 
			
		||||
    stranger_notifications: account.getIn(['pleroma', 'notification_settings', 'block_from_strangers']) === true,
 | 
			
		||||
    accepts_email_list: account.getIn(['pleroma', 'accepts_email_list']) === true,
 | 
			
		||||
    hide_followers: hideNetwork,
 | 
			
		||||
| 
						 | 
				
			
			@ -134,6 +147,40 @@ const accountToCredentials = (account: Account): AccountCredentials => {
 | 
			
		|||
  };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
interface IProfileField {
 | 
			
		||||
  value: AccountCredentialsField,
 | 
			
		||||
  onChange: (field: AccountCredentialsField) => void,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const ProfileField: React.FC<IProfileField> = ({ value, onChange }) => {
 | 
			
		||||
  const intl = useIntl();
 | 
			
		||||
 | 
			
		||||
  const handleChange = (key: string): React.ChangeEventHandler<HTMLInputElement> => {
 | 
			
		||||
    return e => {
 | 
			
		||||
      onChange({ ...value, [key]: e.currentTarget.value });
 | 
			
		||||
    };
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <HStack space={2} grow>
 | 
			
		||||
      <Input
 | 
			
		||||
        type='text'
 | 
			
		||||
        outerClassName='w-2/5 flex-grow'
 | 
			
		||||
        value={value.name}
 | 
			
		||||
        onChange={handleChange('name')}
 | 
			
		||||
        placeholder={intl.formatMessage(messages.metaFieldLabel)}
 | 
			
		||||
      />
 | 
			
		||||
      <Input
 | 
			
		||||
        type='text'
 | 
			
		||||
        outerClassName='w-3/5 flex-grow'
 | 
			
		||||
        value={value.value}
 | 
			
		||||
        onChange={handleChange('value')}
 | 
			
		||||
        placeholder={intl.formatMessage(messages.metaFieldContent)}
 | 
			
		||||
      />
 | 
			
		||||
    </HStack>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
/** Edit profile page. */
 | 
			
		||||
const EditProfile: React.FC = () => {
 | 
			
		||||
  const intl = useIntl();
 | 
			
		||||
| 
						 | 
				
			
			@ -141,7 +188,7 @@ const EditProfile: React.FC = () => {
 | 
			
		|||
 | 
			
		||||
  const account   = useOwnAccount();
 | 
			
		||||
  const features  = useFeatures();
 | 
			
		||||
  // const maxFields = useAppSelector(state => state.instance.pleroma.getIn(['metadata', 'fields_limits', 'max_fields'], 4) as number);
 | 
			
		||||
  const maxFields = useAppSelector(state => state.instance.pleroma.getIn(['metadata', 'fields_limits', 'max_fields'], 4) as number);
 | 
			
		||||
 | 
			
		||||
  const [isLoading, setLoading] = useState(false);
 | 
			
		||||
  const [data, setData] = useState<AccountCredentials>({});
 | 
			
		||||
| 
						 | 
				
			
			@ -165,8 +212,9 @@ const EditProfile: React.FC = () => {
 | 
			
		|||
 | 
			
		||||
  const handleSubmit: React.FormEventHandler = (event) => {
 | 
			
		||||
    const promises = [];
 | 
			
		||||
    const formData = toFormData(data);
 | 
			
		||||
 | 
			
		||||
    promises.push(dispatch(patchMe(data, true)));
 | 
			
		||||
    promises.push(dispatch(patchMe(formData)));
 | 
			
		||||
 | 
			
		||||
    if (features.muteStrangers) {
 | 
			
		||||
      promises.push(
 | 
			
		||||
| 
						 | 
				
			
			@ -229,27 +277,22 @@ const EditProfile: React.FC = () => {
 | 
			
		|||
    };
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  // handleFieldChange = (i, key) => {
 | 
			
		||||
  //   return (e) => {
 | 
			
		||||
  //     this.setState({
 | 
			
		||||
  //       fields: this.state.fields.setIn([i, key], e.target.value),
 | 
			
		||||
  //     });
 | 
			
		||||
  //   };
 | 
			
		||||
  // };
 | 
			
		||||
  //
 | 
			
		||||
  // handleAddField = () => {
 | 
			
		||||
  //   this.setState({
 | 
			
		||||
  //     fields: this.state.fields.push(ImmutableMap({ name: '', value: '' })),
 | 
			
		||||
  //   });
 | 
			
		||||
  // };
 | 
			
		||||
  //
 | 
			
		||||
  // handleDeleteField = i => {
 | 
			
		||||
  //   return () => {
 | 
			
		||||
  //     this.setState({
 | 
			
		||||
  //       fields: normalizeFields(this.state.fields.delete(i), Math.min(this.props.maxFields, 4)),
 | 
			
		||||
  //     });
 | 
			
		||||
  //   };
 | 
			
		||||
  // };
 | 
			
		||||
  const handleFieldsChange = (fields: AccountCredentialsField[]) => {
 | 
			
		||||
    updateData('fields_attributes', fields);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleAddField = () => {
 | 
			
		||||
    const oldFields = data.fields_attributes || [];
 | 
			
		||||
    const fields = [...oldFields, { name: '', value: '' }];
 | 
			
		||||
    updateData('fields_attributes', fields);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleRemoveField = (i: number) => {
 | 
			
		||||
    const oldFields = data.fields_attributes || [];
 | 
			
		||||
    const fields = [...oldFields];
 | 
			
		||||
    fields.splice(i, 1);
 | 
			
		||||
    updateData('fields_attributes', fields);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  /** Memoized avatar preview URL. */
 | 
			
		||||
  const avatarUrl = useMemo(() => {
 | 
			
		||||
| 
						 | 
				
			
			@ -412,47 +455,19 @@ const EditProfile: React.FC = () => {
 | 
			
		|||
          )}
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        {/* </FieldsGroup> */}
 | 
			
		||||
        {/*<FieldsGroup>
 | 
			
		||||
            <div className='fields-row__column fields-group'>
 | 
			
		||||
              <div className='input with_block_label'>
 | 
			
		||||
                <label><FormattedMessage id='edit_profile.fields.meta_fields_label' defaultMessage='Profile metadata' /></label>
 | 
			
		||||
                <span className='hint'>
 | 
			
		||||
                  <FormattedMessage id='edit_profile.hints.meta_fields' defaultMessage='You can have up to {count, plural, one {# item} other {# items}} displayed as a table on your profile' values={{ count: maxFields }} />
 | 
			
		||||
                </span>
 | 
			
		||||
                {
 | 
			
		||||
                  this.state.fields.map((field, i) => (
 | 
			
		||||
                    <div className='row' key={i}>
 | 
			
		||||
                      <TextInput
 | 
			
		||||
                        placeholder={intl.formatMessage(messages.metaFieldLabel)}
 | 
			
		||||
                        value={field.get('name')}
 | 
			
		||||
                        onChange={this.handleFieldChange(i, 'name')}
 | 
			
		||||
                      />
 | 
			
		||||
                      <TextInput
 | 
			
		||||
                        placeholder={intl.formatMessage(messages.metaFieldContent)}
 | 
			
		||||
                        value={field.get('value')}
 | 
			
		||||
                        onChange={this.handleFieldChange(i, 'value')}
 | 
			
		||||
                      />
 | 
			
		||||
                      {
 | 
			
		||||
                        this.state.fields.size > 4 && <Icon className='delete-field' src={require('@tabler/icons/icons/circle-x.svg')} onClick={this.handleDeleteField(i)} />
 | 
			
		||||
                      }
 | 
			
		||||
                    </div>
 | 
			
		||||
                  ))
 | 
			
		||||
                }
 | 
			
		||||
                {
 | 
			
		||||
                  this.state.fields.size < maxFields && (
 | 
			
		||||
                    <div className='actions add-row'>
 | 
			
		||||
                      <div name='button' type='button' role='presentation' className='btn button button-secondary' onClick={this.handleAddField}>
 | 
			
		||||
                        <Icon src={require('@tabler/icons/icons/circle-plus.svg')} />
 | 
			
		||||
                        <FormattedMessage id='edit_profile.meta_fields.add' defaultMessage='Add new item' />
 | 
			
		||||
                      </div>
 | 
			
		||||
                    </div>
 | 
			
		||||
                  )
 | 
			
		||||
                }
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
          </FieldsGroup>*/}
 | 
			
		||||
        {/* </fieldset> */}
 | 
			
		||||
        {features.profileFields && (
 | 
			
		||||
          <Streamfield
 | 
			
		||||
            labelText={<FormattedMessage id='edit_profile.fields.meta_fields_label' defaultMessage='Profile fields' />}
 | 
			
		||||
            hintText={<FormattedMessage id='edit_profile.hints.meta_fields' defaultMessage='You can have up to {count, plural, one {# custom field} other {# custom fields}} displayed on your profile.' values={{ count: maxFields }} />}
 | 
			
		||||
            values={data.fields_attributes || []}
 | 
			
		||||
            onChange={handleFieldsChange}
 | 
			
		||||
            onAddItem={handleAddField}
 | 
			
		||||
            onRemoveItem={handleRemoveField}
 | 
			
		||||
            component={ProfileField}
 | 
			
		||||
            maxItems={maxFields}
 | 
			
		||||
          />
 | 
			
		||||
        )}
 | 
			
		||||
 | 
			
		||||
        <FormActions>
 | 
			
		||||
          <Button to='/settings' theme='ghost'>
 | 
			
		||||
            {intl.formatMessage(messages.cancel)}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -370,6 +370,15 @@ const getInstanceFeatures = (instance: Instance) => {
 | 
			
		|||
      features.includes('profile_directory'),
 | 
			
		||||
    ]),
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Ability to set custom profile fields.
 | 
			
		||||
     * @see PATCH /api/v1/accounts/update_credentials
 | 
			
		||||
     */
 | 
			
		||||
    profileFields: any([
 | 
			
		||||
      v.software === MASTODON,
 | 
			
		||||
      v.software === PLEROMA,
 | 
			
		||||
    ]),
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * Can display a timeline of all known public statuses.
 | 
			
		||||
     * Local and Fediverse timelines both use this feature.
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Ładowanie…
	
		Reference in New Issue