Merge branch 'streamfield' into 'develop'

Allow editing custom profile fields

See merge request soapbox-pub/soapbox-fe!1300
This commit is contained in:
Alex Gleason 2022-05-03 15:02:32 +00:00
commit 4fcece9b72
6 changed files with 200 additions and 86 deletions

View file

@ -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));

View file

@ -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[]
}

View file

@ -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' />

View file

@ -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;

View file

@ -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)}

View file

@ -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.