Merge branch 'streamfield' into 'develop'
Allow editing custom profile fields See merge request soapbox-pub/soapbox-fe!1300
This commit is contained in:
commit
4fcece9b72
6 changed files with 200 additions and 86 deletions
|
@ -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' />
|
||||
|
|
94
app/soapbox/components/ui/streamfield/streamfield.tsx
Normal file
94
app/soapbox/components/ui/streamfield/streamfield.tsx
Normal 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;
|
|
@ -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.
|
||||
|
|
Loading…
Reference in a new issue