Merge branch 'next-edit-profile' into 'next'
Next: EditProfile improvements See merge request soapbox-pub/soapbox-fe!1278
This commit is contained in:
commit
fa80911fae
|
@ -46,11 +46,18 @@ export function fetchMe() {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function patchMe(params) {
|
export function patchMe(params, formData = false) {
|
||||||
return (dispatch, getState) => {
|
return (dispatch, getState) => {
|
||||||
dispatch(patchMeRequest());
|
dispatch(patchMeRequest());
|
||||||
|
|
||||||
|
const options = formData ? {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
},
|
||||||
|
} : {};
|
||||||
|
|
||||||
return api(getState)
|
return api(getState)
|
||||||
.patch('/api/v1/accounts/update_credentials', params)
|
.patch('/api/v1/accounts/update_credentials', params, options)
|
||||||
.then(response => {
|
.then(response => {
|
||||||
dispatch(patchMeSuccess(response.data));
|
dispatch(patchMeSuccess(response.data));
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
|
|
|
@ -2,8 +2,8 @@ import React, { useMemo } from 'react';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
interface IFormGroup {
|
interface IFormGroup {
|
||||||
hintText?: string | React.ReactNode,
|
hintText?: React.ReactNode,
|
||||||
labelText: string,
|
labelText: React.ReactNode,
|
||||||
errors?: string[]
|
errors?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -92,7 +92,7 @@ const AnimatedTab: React.FC<IAnimatedTab> = ({ index, ...props }) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
type Item = {
|
type Item = {
|
||||||
text: string,
|
text: React.ReactNode,
|
||||||
title?: string,
|
title?: string,
|
||||||
href?: string,
|
href?: string,
|
||||||
to?: string,
|
to?: string,
|
||||||
|
|
|
@ -8,6 +8,7 @@ interface ITextarea extends Pick<React.TextareaHTMLAttributes<HTMLTextAreaElemen
|
||||||
isCodeEditor?: boolean,
|
isCodeEditor?: boolean,
|
||||||
placeholder?: string,
|
placeholder?: string,
|
||||||
value?: string,
|
value?: string,
|
||||||
|
autoComplete?: string,
|
||||||
}
|
}
|
||||||
|
|
||||||
const Textarea = React.forwardRef(
|
const Textarea = React.forwardRef(
|
||||||
|
|
|
@ -1,46 +0,0 @@
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import React from 'react';
|
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
|
|
||||||
import StillImage from 'soapbox/components/still_image';
|
|
||||||
import VerificationBadge from 'soapbox/components/verification_badge';
|
|
||||||
import { getAcct } from 'soapbox/utils/accounts';
|
|
||||||
import { displayFqn } from 'soapbox/utils/state';
|
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
|
||||||
displayFqn: displayFqn(state),
|
|
||||||
});
|
|
||||||
|
|
||||||
const ProfilePreview = ({ account, displayFqn }) => (
|
|
||||||
<div className='card h-card'>
|
|
||||||
<Link to={`/@${account.get('acct')}`}>
|
|
||||||
<div className='card__img'>
|
|
||||||
<StillImage alt='' src={account.get('header')} />
|
|
||||||
</div>
|
|
||||||
<div className='card__bar'>
|
|
||||||
<div className='avatar'>
|
|
||||||
<StillImage alt='' className='u-photo' src={account.get('avatar')} width='48' height='48' />
|
|
||||||
</div>
|
|
||||||
<div className='display-name'>
|
|
||||||
<span style={{ display: 'none' }}>{account.get('username')}</span>
|
|
||||||
<bdi>
|
|
||||||
<strong className='emojify p-name'>
|
|
||||||
{account.get('display_name')}
|
|
||||||
{account.get('verified') && <VerificationBadge />}
|
|
||||||
</strong>
|
|
||||||
</bdi>
|
|
||||||
<span>@{getAcct(account, displayFqn)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
ProfilePreview.propTypes = {
|
|
||||||
account: ImmutablePropTypes.record,
|
|
||||||
displayFqn: PropTypes.bool,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default connect(mapStateToProps)(ProfilePreview);
|
|
|
@ -0,0 +1,44 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
import StillImage from 'soapbox/components/still_image';
|
||||||
|
import VerificationBadge from 'soapbox/components/verification_badge';
|
||||||
|
import { useSoapboxConfig } from 'soapbox/hooks';
|
||||||
|
|
||||||
|
import type { Account } from 'soapbox/types/entities';
|
||||||
|
|
||||||
|
interface IProfilePreview {
|
||||||
|
account: Account,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Displays a preview of the user's account, including avatar, banner, etc. */
|
||||||
|
const ProfilePreview: React.FC<IProfilePreview> = ({ account }) => {
|
||||||
|
const { displayFqn } = useSoapboxConfig();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='card h-card'>
|
||||||
|
<Link to={`/@${account.acct}`}>
|
||||||
|
<div className='card__img'>
|
||||||
|
<StillImage alt='' src={account.header} />
|
||||||
|
</div>
|
||||||
|
<div className='card__bar'>
|
||||||
|
<div className='avatar'>
|
||||||
|
<StillImage alt='' className='u-photo' src={account.avatar} width='48' height='48' />
|
||||||
|
</div>
|
||||||
|
<div className='display-name'>
|
||||||
|
<span style={{ display: 'none' }}>{account.username}</span>
|
||||||
|
<bdi>
|
||||||
|
<strong className='emojify p-name'>
|
||||||
|
{account.display_name}
|
||||||
|
{account.verified && <VerificationBadge />}
|
||||||
|
</strong>
|
||||||
|
</bdi>
|
||||||
|
<span>@{displayFqn ? account.fqn : account.acct}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProfilePreview;
|
|
@ -1,436 +0,0 @@
|
||||||
import {
|
|
||||||
Map as ImmutableMap,
|
|
||||||
List as ImmutableList,
|
|
||||||
} from 'immutable';
|
|
||||||
import { unescape } from 'lodash';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import React from 'react';
|
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
|
||||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
|
|
||||||
// import { updateNotificationSettings } from 'soapbox/actions/accounts';
|
|
||||||
import { patchMe } from 'soapbox/actions/me';
|
|
||||||
import snackbar from 'soapbox/actions/snackbar';
|
|
||||||
import { getSoapboxConfig } from 'soapbox/actions/soapbox';
|
|
||||||
// import Icon from 'soapbox/components/icon';
|
|
||||||
import {
|
|
||||||
Checkbox,
|
|
||||||
} from 'soapbox/features/forms';
|
|
||||||
import { makeGetAccount } from 'soapbox/selectors';
|
|
||||||
import { getFeatures } from 'soapbox/utils/features';
|
|
||||||
import resizeImage from 'soapbox/utils/resize_image';
|
|
||||||
|
|
||||||
import { Button, Column, Form, FormActions, FormGroup, Input, Textarea } from '../../components/ui';
|
|
||||||
|
|
||||||
import ProfilePreview from './components/profile_preview';
|
|
||||||
|
|
||||||
const hidesNetwork = account => {
|
|
||||||
const pleroma = account.get('pleroma');
|
|
||||||
if (!pleroma) return false;
|
|
||||||
|
|
||||||
const { hide_followers, hide_follows, hide_followers_count, hide_follows_count } = pleroma.toJS();
|
|
||||||
return hide_followers && hide_follows && hide_followers_count && hide_follows_count;
|
|
||||||
};
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
heading: { id: 'column.edit_profile', defaultMessage: 'Edit profile' },
|
|
||||||
header: { id: 'edit_profile.header', defaultMessage: 'Edit Profile' },
|
|
||||||
metaFieldLabel: { id: 'edit_profile.fields.meta_fields.label_placeholder', defaultMessage: 'Label' },
|
|
||||||
metaFieldContent: { id: 'edit_profile.fields.meta_fields.content_placeholder', defaultMessage: 'Content' },
|
|
||||||
verified: { id: 'edit_profile.fields.verified_display_name', defaultMessage: 'Verified users may not update their display name' },
|
|
||||||
success: { id: 'edit_profile.success', defaultMessage: 'Profile saved!' },
|
|
||||||
error: { id: 'edit_profile.error', defaultMessage: 'Profile update failed' },
|
|
||||||
bioPlaceholder: { id: 'edit_profile.fields.bio_placeholder', defaultMessage: 'Tell us about yourself.' },
|
|
||||||
displayNamePlaceholder: { id: 'edit_profile.fields.display_name_placeholder', defaultMessage: 'Name' },
|
|
||||||
websitePlaceholder: { id: 'edit_profile.fields.website_placeholder', defaultMessage: 'Display a Link' },
|
|
||||||
locationPlaceholder: { id: 'edit_profile.fields.location_placeholder', defaultMessage: 'Location' },
|
|
||||||
cancel: { id: 'common.cancel', defaultMessage: 'Cancel' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const makeMapStateToProps = () => {
|
|
||||||
const getAccount = makeGetAccount();
|
|
||||||
|
|
||||||
const mapStateToProps = state => {
|
|
||||||
const me = state.get('me');
|
|
||||||
const account = getAccount(state, me);
|
|
||||||
const soapbox = getSoapboxConfig(state);
|
|
||||||
const features = getFeatures(state.instance);
|
|
||||||
|
|
||||||
return {
|
|
||||||
account,
|
|
||||||
features,
|
|
||||||
maxFields: state.getIn(['instance', 'pleroma', 'metadata', 'fields_limits', 'max_fields'], 4),
|
|
||||||
verifiedCanEditName: soapbox.get('verifiedCanEditName'),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
return mapStateToProps;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Forces fields to be maxFields size, filling empty values
|
|
||||||
const normalizeFields = (fields, maxFields) => (
|
|
||||||
ImmutableList(fields).setSize(Math.max(fields.size, maxFields)).map(field =>
|
|
||||||
field ? field : ImmutableMap({ name: '', value: '' }),
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
// HTML unescape for special chars, eg <br>
|
|
||||||
const unescapeParams = (map, params) => (
|
|
||||||
params.reduce((map, param) => (
|
|
||||||
map.set(param, unescape(map.get(param)))
|
|
||||||
), map)
|
|
||||||
);
|
|
||||||
|
|
||||||
export default @connect(makeMapStateToProps)
|
|
||||||
@injectIntl
|
|
||||||
class EditProfile extends ImmutablePureComponent {
|
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
dispatch: PropTypes.func.isRequired,
|
|
||||||
intl: PropTypes.object.isRequired,
|
|
||||||
account: ImmutablePropTypes.record,
|
|
||||||
maxFields: PropTypes.number,
|
|
||||||
verifiedCanEditName: PropTypes.bool,
|
|
||||||
};
|
|
||||||
|
|
||||||
state = {
|
|
||||||
isLoading: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
const { account, maxFields } = this.props;
|
|
||||||
|
|
||||||
const strangerNotifications = account.getIn(['pleroma', 'notification_settings', 'block_from_strangers']);
|
|
||||||
const acceptsEmailList = account.getIn(['pleroma', 'accepts_email_list']);
|
|
||||||
const discoverable = account.getIn(['source', 'pleroma', 'discoverable']);
|
|
||||||
|
|
||||||
const initialState = ImmutableMap(account).withMutations(map => {
|
|
||||||
map.merge(map.get('source'));
|
|
||||||
map.delete('source');
|
|
||||||
map.set('fields', normalizeFields(map.get('fields'), Math.min(maxFields, 4)));
|
|
||||||
map.set('stranger_notifications', strangerNotifications);
|
|
||||||
map.set('accepts_email_list', acceptsEmailList);
|
|
||||||
map.set('hide_network', hidesNetwork(account));
|
|
||||||
map.set('discoverable', discoverable);
|
|
||||||
unescapeParams(map, ['display_name', 'bio']);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.state = initialState.toObject();
|
|
||||||
}
|
|
||||||
|
|
||||||
makePreviewAccount = () => {
|
|
||||||
const { account } = this.props;
|
|
||||||
return account.merge(ImmutableMap({
|
|
||||||
header: this.state.header,
|
|
||||||
avatar: this.state.avatar,
|
|
||||||
display_name: this.state.display_name || account.get('username'),
|
|
||||||
website: this.state.website || account.get('website'),
|
|
||||||
location: this.state.location || account.get('location'),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
getFieldParams = () => {
|
|
||||||
let params = ImmutableMap();
|
|
||||||
this.state.fields.forEach((f, i) =>
|
|
||||||
params = params
|
|
||||||
.set(`fields_attributes[${i}][name]`, f.get('name'))
|
|
||||||
.set(`fields_attributes[${i}][value]`, f.get('value')),
|
|
||||||
);
|
|
||||||
return params;
|
|
||||||
}
|
|
||||||
|
|
||||||
getParams = () => {
|
|
||||||
const { state } = this;
|
|
||||||
return Object.assign({
|
|
||||||
discoverable: state.discoverable,
|
|
||||||
bot: state.bot,
|
|
||||||
display_name: state.display_name,
|
|
||||||
website: state.website,
|
|
||||||
location: state.location,
|
|
||||||
birthday: state.birthday,
|
|
||||||
note: state.note,
|
|
||||||
avatar: state.avatar_file,
|
|
||||||
header: state.header_file,
|
|
||||||
locked: state.locked,
|
|
||||||
accepts_email_list: state.accepts_email_list,
|
|
||||||
hide_followers: state.hide_network,
|
|
||||||
hide_follows: state.hide_network,
|
|
||||||
hide_followers_count: state.hide_network,
|
|
||||||
hide_follows_count: state.hide_network,
|
|
||||||
}, this.getFieldParams().toJS());
|
|
||||||
}
|
|
||||||
|
|
||||||
getFormdata = () => {
|
|
||||||
const data = this.getParams();
|
|
||||||
const formData = new FormData();
|
|
||||||
for (const key in data) {
|
|
||||||
const hasValue = data[key] !== null && data[key] !== undefined;
|
|
||||||
// Compact the submission. This should probably be done better.
|
|
||||||
const shouldAppend = Boolean(hasValue || key.startsWith('fields_attributes'));
|
|
||||||
if (shouldAppend) formData.append(key, hasValue ? data[key] : '');
|
|
||||||
}
|
|
||||||
return formData;
|
|
||||||
}
|
|
||||||
|
|
||||||
handleSubmit = (event) => {
|
|
||||||
const { dispatch, intl } = this.props;
|
|
||||||
|
|
||||||
const credentials = dispatch(patchMe(this.getFormdata()));
|
|
||||||
/* Bad API url, was causing errors in the promise call below blocking the success message after making edits. */
|
|
||||||
/* const notifications = dispatch(updateNotificationSettings({
|
|
||||||
block_from_strangers: this.state.stranger_notifications || false,
|
|
||||||
})); */
|
|
||||||
|
|
||||||
this.setState({ isLoading: true });
|
|
||||||
|
|
||||||
Promise.all([credentials /*notifications*/]).then(() => {
|
|
||||||
this.setState({ isLoading: false });
|
|
||||||
dispatch(snackbar.success(intl.formatMessage(messages.success)));
|
|
||||||
}).catch((error) => {
|
|
||||||
this.setState({ isLoading: false });
|
|
||||||
dispatch(snackbar.error(intl.formatMessage(messages.error)));
|
|
||||||
});
|
|
||||||
|
|
||||||
event.preventDefault();
|
|
||||||
}
|
|
||||||
|
|
||||||
handleCheckboxChange = e => {
|
|
||||||
this.setState({ [e.target.name]: e.target.checked });
|
|
||||||
}
|
|
||||||
|
|
||||||
handleTextChange = e => {
|
|
||||||
this.setState({ [e.target.name]: e.target.value });
|
|
||||||
}
|
|
||||||
|
|
||||||
handleFieldChange = (i, key) => {
|
|
||||||
return (e) => {
|
|
||||||
this.setState({
|
|
||||||
fields: this.state.fields.setIn([i, key], e.target.value),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
handleFileChange = maxPixels => {
|
|
||||||
return e => {
|
|
||||||
const { name } = e.target;
|
|
||||||
const [f] = e.target.files || [];
|
|
||||||
|
|
||||||
resizeImage(f, maxPixels).then(file => {
|
|
||||||
const url = file ? URL.createObjectURL(file) : this.state[name];
|
|
||||||
|
|
||||||
this.setState({
|
|
||||||
[name]: url,
|
|
||||||
[`${name}_file`]: file,
|
|
||||||
});
|
|
||||||
}).catch(console.error);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
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)),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { intl, account, verifiedCanEditName, features /* maxFields */ } = this.props;
|
|
||||||
const verified = account.get('verified');
|
|
||||||
const canEditName = verifiedCanEditName || !verified;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Column label={intl.formatMessage(messages.header)}>
|
|
||||||
<Form onSubmit={this.handleSubmit}>
|
|
||||||
<FormGroup
|
|
||||||
labelText={<FormattedMessage id='edit_profile.fields.display_name_label' defaultMessage='Display name' />}
|
|
||||||
hintText={!canEditName && intl.formatMessage(messages.verified)}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
name='display_name'
|
|
||||||
value={this.state.display_name}
|
|
||||||
onChange={this.handleTextChange}
|
|
||||||
placeholder={intl.formatMessage(messages.displayNamePlaceholder)}
|
|
||||||
disabled={!canEditName}
|
|
||||||
/>
|
|
||||||
</FormGroup>
|
|
||||||
|
|
||||||
{features.birthdays && (
|
|
||||||
<FormGroup
|
|
||||||
labelText={<FormattedMessage id='edit_profile.fields.birthday_label' defaultMessage='Birthday' />}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
name='birthday'
|
|
||||||
value={this.state.birthday}
|
|
||||||
onChange={this.handleTextChange}
|
|
||||||
/>
|
|
||||||
</FormGroup>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{features.accountLocation && (
|
|
||||||
<FormGroup
|
|
||||||
labelText={<FormattedMessage id='edit_profile.fields.location_label' defaultMessage='Location' />}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
name='location'
|
|
||||||
value={this.state.location}
|
|
||||||
onChange={this.handleTextChange}
|
|
||||||
placeholder={intl.formatMessage(messages.locationPlaceholder)}
|
|
||||||
/>
|
|
||||||
</FormGroup>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{features.accountWebsite && (
|
|
||||||
<FormGroup
|
|
||||||
labelText={<FormattedMessage id='edit_profile.fields.website_label' defaultMessage='Website' />}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
name='website'
|
|
||||||
value={this.state.website}
|
|
||||||
onChange={this.handleTextChange}
|
|
||||||
placeholder={intl.formatMessage(messages.websitePlaceholder)}
|
|
||||||
/>
|
|
||||||
</FormGroup>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<FormGroup
|
|
||||||
labelText={<FormattedMessage id='edit_profile.fields.bio_label' defaultMessage='Bio' />}
|
|
||||||
>
|
|
||||||
<Textarea
|
|
||||||
name='note'
|
|
||||||
value={this.state.note}
|
|
||||||
onChange={this.handleTextChange}
|
|
||||||
autoComplete='off'
|
|
||||||
placeholder={intl.formatMessage(messages.bioPlaceholder)}
|
|
||||||
/>
|
|
||||||
</FormGroup>
|
|
||||||
|
|
||||||
<div className='grid grid-cols-2 gap-4'>
|
|
||||||
<ProfilePreview account={this.makePreviewAccount()} />
|
|
||||||
|
|
||||||
<div className='space-y-4'>
|
|
||||||
<FormGroup
|
|
||||||
labelText={<FormattedMessage id='edit_profile.fields.header_label' defaultMessage='Choose Background Picture' />}
|
|
||||||
hintText={<FormattedMessage id='edit_profile.hints.header' defaultMessage='PNG, GIF or JPG. Will be downscaled to {size}' values={{ size: '1920x1080px' }} />}
|
|
||||||
>
|
|
||||||
<input type='file' name='header' onChange={this.handleFileChange(1920 * 1080)} className='text-sm' />
|
|
||||||
</FormGroup>
|
|
||||||
|
|
||||||
<FormGroup
|
|
||||||
labelText={<FormattedMessage id='edit_profile.fields.avatar_label' defaultMessage='Choose Profile Picture' />}
|
|
||||||
hintText={<FormattedMessage id='edit_profile.hints.avatar' defaultMessage='PNG, GIF or JPG. Will be downscaled to {size}' values={{ size: '400x400px' }} />}
|
|
||||||
>
|
|
||||||
<input type='file' name='avatar' onChange={this.handleFileChange(400 * 400)} className='text-sm' />
|
|
||||||
</FormGroup>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/*<Checkbox
|
|
||||||
label={<FormattedMessage id='edit_profile.fields.locked_label' defaultMessage='Lock account' />}
|
|
||||||
hint={<FormattedMessage id='edit_profile.hints.locked' defaultMessage='Requires you to manually approve followers' />}
|
|
||||||
name='locked'
|
|
||||||
checked={this.state.locked}
|
|
||||||
onChange={this.handleCheckboxChange}
|
|
||||||
/>
|
|
||||||
<Checkbox
|
|
||||||
label={<FormattedMessage id='edit_profile.fields.hide_network_label' defaultMessage='Hide network' />}
|
|
||||||
hint={<FormattedMessage id='edit_profile.hints.hide_network' defaultMessage='Who you follow and who follows you will not be shown on your profile' />}
|
|
||||||
name='hide_network'
|
|
||||||
checked={this.state.hide_network}
|
|
||||||
onChange={this.handleCheckboxChange}
|
|
||||||
/>
|
|
||||||
<Checkbox
|
|
||||||
label={<FormattedMessage id='edit_profile.fields.bot_label' defaultMessage='This is a bot account' />}
|
|
||||||
hint={<FormattedMessage id='edit_profile.hints.bot' defaultMessage='This account mainly performs automated actions and might not be monitored' />}
|
|
||||||
name='bot'
|
|
||||||
checked={this.state.bot}
|
|
||||||
onChange={this.handleCheckboxChange}
|
|
||||||
/>
|
|
||||||
<Checkbox
|
|
||||||
label={<FormattedMessage id='edit_profile.fields.stranger_notifications_label' defaultMessage='Block notifications from strangers' />}
|
|
||||||
hint={<FormattedMessage id='edit_profile.hints.stranger_notifications' defaultMessage='Only show notifications from people you follow' />}
|
|
||||||
name='stranger_notifications'
|
|
||||||
checked={this.state.stranger_notifications}
|
|
||||||
onChange={this.handleCheckboxChange}
|
|
||||||
/>
|
|
||||||
<Checkbox
|
|
||||||
label={<FormattedMessage id='edit_profile.fields.discoverable_label' defaultMessage='Allow account discovery' />}
|
|
||||||
hint={<FormattedMessage id='edit_profile.hints.discoverable' defaultMessage='Display account in profile directory and allow indexing by external services' />}
|
|
||||||
name='discoverable'
|
|
||||||
checked={this.state.discoverable}
|
|
||||||
onChange={this.handleCheckboxChange}
|
|
||||||
/>*/}
|
|
||||||
{features.emailList && (
|
|
||||||
<Checkbox
|
|
||||||
label={<FormattedMessage id='edit_profile.fields.accepts_email_list_label' defaultMessage='Subscribe to newsletter' />}
|
|
||||||
hint={<FormattedMessage id='edit_profile.hints.accepts_email_list' defaultMessage='Opt-in to news and marketing updates.' />}
|
|
||||||
name='accepts_email_list'
|
|
||||||
checked={this.state.accepts_email_list}
|
|
||||||
onChange={this.handleCheckboxChange}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{/* </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> */}
|
|
||||||
<FormActions>
|
|
||||||
<Button to='/settings' theme='ghost'>
|
|
||||||
{intl.formatMessage(messages.cancel)}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button theme='primary' type='submit' disabled={this.state.isLoading}>
|
|
||||||
<FormattedMessage id='edit_profile.save' defaultMessage='Save' />
|
|
||||||
</Button>
|
|
||||||
</FormActions>
|
|
||||||
</Form>
|
|
||||||
</Column>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -0,0 +1,471 @@
|
||||||
|
import { unescape } from 'lodash';
|
||||||
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import { updateNotificationSettings } from 'soapbox/actions/accounts';
|
||||||
|
import { patchMe } from 'soapbox/actions/me';
|
||||||
|
import snackbar from 'soapbox/actions/snackbar';
|
||||||
|
import {
|
||||||
|
Checkbox,
|
||||||
|
} from 'soapbox/features/forms';
|
||||||
|
import { 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 ProfilePreview from './components/profile_preview';
|
||||||
|
|
||||||
|
import type { Account } from 'soapbox/types/entities';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the user is hiding their follows and/or followers.
|
||||||
|
* Pleroma's config is granular, but we simplify it into one setting.
|
||||||
|
*/
|
||||||
|
const hidesNetwork = (account: Account): boolean => {
|
||||||
|
const { hide_followers, hide_follows, hide_followers_count, hide_follows_count } = account.pleroma.toJS();
|
||||||
|
return Boolean(hide_followers && hide_follows && hide_followers_count && hide_follows_count);
|
||||||
|
};
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
heading: { id: 'column.edit_profile', defaultMessage: 'Edit profile' },
|
||||||
|
header: { id: 'edit_profile.header', defaultMessage: 'Edit Profile' },
|
||||||
|
metaFieldLabel: { id: 'edit_profile.fields.meta_fields.label_placeholder', defaultMessage: 'Label' },
|
||||||
|
metaFieldContent: { id: 'edit_profile.fields.meta_fields.content_placeholder', defaultMessage: 'Content' },
|
||||||
|
success: { id: 'edit_profile.success', defaultMessage: 'Profile saved!' },
|
||||||
|
error: { id: 'edit_profile.error', defaultMessage: 'Profile update failed' },
|
||||||
|
bioPlaceholder: { id: 'edit_profile.fields.bio_placeholder', defaultMessage: 'Tell us about yourself.' },
|
||||||
|
displayNamePlaceholder: { id: 'edit_profile.fields.display_name_placeholder', defaultMessage: 'Name' },
|
||||||
|
websitePlaceholder: { id: 'edit_profile.fields.website_placeholder', defaultMessage: 'Display a Link' },
|
||||||
|
locationPlaceholder: { id: 'edit_profile.fields.location_placeholder', defaultMessage: 'Location' },
|
||||||
|
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)
|
||||||
|
*/
|
||||||
|
interface AccountCredentialsField {
|
||||||
|
name: string,
|
||||||
|
value: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Private information (settings) for the account. */
|
||||||
|
interface AccountCredentialsSource {
|
||||||
|
/** Default post privacy for authored statuses. */
|
||||||
|
privacy?: string,
|
||||||
|
/** Whether to mark authored statuses as sensitive by default. */
|
||||||
|
sensitive?: boolean,
|
||||||
|
/** Default language to use for authored statuses. (ISO 6391) */
|
||||||
|
language?: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Params to submit when updating an account.
|
||||||
|
* @see PATCH /api/v1/accounts/update_credentials
|
||||||
|
*/
|
||||||
|
interface AccountCredentials {
|
||||||
|
/** Whether the account should be shown in the profile directory. */
|
||||||
|
discoverable?: boolean,
|
||||||
|
/** Whether the account has a bot flag. */
|
||||||
|
bot?: boolean,
|
||||||
|
/** The display name to use for the profile. */
|
||||||
|
display_name?: string,
|
||||||
|
/** The account bio. */
|
||||||
|
note?: string,
|
||||||
|
/** Avatar image encoded using multipart/form-data */
|
||||||
|
avatar?: File,
|
||||||
|
/** Header image encoded using multipart/form-data */
|
||||||
|
header?: File,
|
||||||
|
/** Whether manual approval of follow requests is required. */
|
||||||
|
locked?: boolean,
|
||||||
|
/** Private information (settings) about the account. */
|
||||||
|
source?: AccountCredentialsSource,
|
||||||
|
/** Custom profile fields. */
|
||||||
|
fields_attributes?: AccountCredentialsField[],
|
||||||
|
|
||||||
|
// Non-Mastodon fields
|
||||||
|
/** Pleroma: whether to accept notifications from people you don't follow. */
|
||||||
|
stranger_notifications?: boolean,
|
||||||
|
/** Soapbox BE: whether the user opts-in to email communications. */
|
||||||
|
accepts_email_list?: boolean,
|
||||||
|
/** Pleroma: whether to publicly display followers. */
|
||||||
|
hide_followers?: boolean,
|
||||||
|
/** Pleroma: whether to publicly display follows. */
|
||||||
|
hide_follows?: boolean,
|
||||||
|
/** Pleroma: whether to publicly display follower count. */
|
||||||
|
hide_followers_count?: boolean,
|
||||||
|
/** Pleroma: whether to publicly display follows count. */
|
||||||
|
hide_follows_count?: boolean,
|
||||||
|
/** User's website URL. */
|
||||||
|
website?: string,
|
||||||
|
/** User's location. */
|
||||||
|
location?: string,
|
||||||
|
/** User's birthday. */
|
||||||
|
birthday?: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert an account into an update_credentials request object. */
|
||||||
|
const accountToCredentials = (account: Account): AccountCredentials => {
|
||||||
|
const hideNetwork = hidesNetwork(account);
|
||||||
|
|
||||||
|
return {
|
||||||
|
discoverable: account.discoverable,
|
||||||
|
bot: account.bot,
|
||||||
|
display_name: unescape(account.display_name),
|
||||||
|
note: account.source.get('note') || unescape(account.note),
|
||||||
|
locked: account.locked,
|
||||||
|
fields_attributes: [...account.source.get<Iterable<AccountCredentialsField>>('fields', [])],
|
||||||
|
stranger_notifications: account.getIn(['pleroma', 'notification_settings', 'block_from_strangers']) === true,
|
||||||
|
accepts_email_list: account.getIn(['pleroma', 'accepts_email_list']) === true,
|
||||||
|
hide_followers: hideNetwork,
|
||||||
|
hide_follows: hideNetwork,
|
||||||
|
hide_followers_count: hideNetwork,
|
||||||
|
hide_follows_count: hideNetwork,
|
||||||
|
website: account.website,
|
||||||
|
location: account.location,
|
||||||
|
birthday: account.birthday,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Edit profile page. */
|
||||||
|
const EditProfile: React.FC = () => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const account = useOwnAccount();
|
||||||
|
const features = useFeatures();
|
||||||
|
// 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>({});
|
||||||
|
const [muteStrangers, setMuteStrangers] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (account) {
|
||||||
|
const credentials = accountToCredentials(account);
|
||||||
|
const strangerNotifications = account.getIn(['pleroma', 'notification_settings', 'block_from_strangers']) === true;
|
||||||
|
setData(credentials);
|
||||||
|
setMuteStrangers(strangerNotifications);
|
||||||
|
}
|
||||||
|
}, [account?.id]);
|
||||||
|
|
||||||
|
/** Set a single key in the request data. */
|
||||||
|
const updateData = (key: string, value: any) => {
|
||||||
|
setData(prevData => {
|
||||||
|
return { ...prevData, [key]: value };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit: React.FormEventHandler = (event) => {
|
||||||
|
const promises = [];
|
||||||
|
|
||||||
|
promises.push(dispatch(patchMe(data, true)));
|
||||||
|
|
||||||
|
if (features.muteStrangers) {
|
||||||
|
promises.push(
|
||||||
|
dispatch(updateNotificationSettings({
|
||||||
|
block_from_strangers: muteStrangers,
|
||||||
|
})).catch(console.error),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
Promise.all(promises).then(() => {
|
||||||
|
setLoading(false);
|
||||||
|
dispatch(snackbar.success(intl.formatMessage(messages.success)));
|
||||||
|
}).catch(() => {
|
||||||
|
setLoading(false);
|
||||||
|
dispatch(snackbar.error(intl.formatMessage(messages.error)));
|
||||||
|
});
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCheckboxChange = (key: keyof AccountCredentials): React.ChangeEventHandler<HTMLInputElement> => {
|
||||||
|
return e => {
|
||||||
|
updateData(key, e.target.checked);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTextChange = (key: keyof AccountCredentials): React.ChangeEventHandler<HTMLInputElement | HTMLTextAreaElement> => {
|
||||||
|
return e => {
|
||||||
|
updateData(key, e.target.value);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleHideNetworkChange: React.ChangeEventHandler<HTMLInputElement> = e => {
|
||||||
|
const hide = e.target.checked;
|
||||||
|
|
||||||
|
setData(prevData => {
|
||||||
|
return {
|
||||||
|
...prevData,
|
||||||
|
hide_followers: hide,
|
||||||
|
hide_follows: hide,
|
||||||
|
hide_followers_count: hide,
|
||||||
|
hide_follows_count: hide,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileChange = (
|
||||||
|
name: keyof AccountCredentials,
|
||||||
|
maxPixels: number,
|
||||||
|
): React.ChangeEventHandler<HTMLInputElement> => {
|
||||||
|
return e => {
|
||||||
|
const f = e.target.files?.item(0);
|
||||||
|
if (!f) return;
|
||||||
|
|
||||||
|
resizeImage(f, maxPixels).then(file => {
|
||||||
|
updateData(name, file);
|
||||||
|
}).catch(console.error);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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)),
|
||||||
|
// });
|
||||||
|
// };
|
||||||
|
// };
|
||||||
|
|
||||||
|
/** Memoized avatar preview URL. */
|
||||||
|
const avatarUrl = useMemo(() => {
|
||||||
|
return data.avatar ? URL.createObjectURL(data.avatar) : account?.avatar;
|
||||||
|
}, [data.avatar, account?.avatar]);
|
||||||
|
|
||||||
|
/** Memoized header preview URL. */
|
||||||
|
const headerUrl = useMemo(() => {
|
||||||
|
return data.header ? URL.createObjectURL(data.header) : account?.header;
|
||||||
|
}, [data.header, account?.header]);
|
||||||
|
|
||||||
|
/** Preview account data. */
|
||||||
|
const previewAccount = useMemo(() => {
|
||||||
|
return normalizeAccount({
|
||||||
|
...account?.toJS(),
|
||||||
|
...data,
|
||||||
|
avatar: avatarUrl,
|
||||||
|
header: headerUrl,
|
||||||
|
}) as Account;
|
||||||
|
}, [account?.id, data.display_name, avatarUrl, headerUrl]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Column label={intl.formatMessage(messages.header)}>
|
||||||
|
<Form onSubmit={handleSubmit}>
|
||||||
|
<FormGroup
|
||||||
|
labelText={<FormattedMessage id='edit_profile.fields.display_name_label' defaultMessage='Display name' />}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type='text'
|
||||||
|
value={data.display_name}
|
||||||
|
onChange={handleTextChange('display_name')}
|
||||||
|
placeholder={intl.formatMessage(messages.displayNamePlaceholder)}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
{features.birthdays && (
|
||||||
|
<FormGroup
|
||||||
|
labelText={<FormattedMessage id='edit_profile.fields.birthday_label' defaultMessage='Birthday' />}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type='text'
|
||||||
|
value={data.birthday}
|
||||||
|
onChange={handleTextChange('birthday')}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{features.accountLocation && (
|
||||||
|
<FormGroup
|
||||||
|
labelText={<FormattedMessage id='edit_profile.fields.location_label' defaultMessage='Location' />}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type='text'
|
||||||
|
value={data.location}
|
||||||
|
onChange={handleTextChange('location')}
|
||||||
|
placeholder={intl.formatMessage(messages.locationPlaceholder)}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{features.accountWebsite && (
|
||||||
|
<FormGroup
|
||||||
|
labelText={<FormattedMessage id='edit_profile.fields.website_label' defaultMessage='Website' />}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type='text'
|
||||||
|
value={data.website}
|
||||||
|
onChange={handleTextChange('website')}
|
||||||
|
placeholder={intl.formatMessage(messages.websitePlaceholder)}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormGroup
|
||||||
|
labelText={<FormattedMessage id='edit_profile.fields.bio_label' defaultMessage='Bio' />}
|
||||||
|
>
|
||||||
|
<Textarea
|
||||||
|
value={data.note}
|
||||||
|
onChange={handleTextChange('note')}
|
||||||
|
autoComplete='off'
|
||||||
|
placeholder={intl.formatMessage(messages.bioPlaceholder)}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
<div className='grid grid-cols-2 gap-4'>
|
||||||
|
<ProfilePreview account={previewAccount} />
|
||||||
|
|
||||||
|
<div className='space-y-4'>
|
||||||
|
<FormGroup
|
||||||
|
labelText={<FormattedMessage id='edit_profile.fields.header_label' defaultMessage='Choose Background Picture' />}
|
||||||
|
hintText={<FormattedMessage id='edit_profile.hints.header' defaultMessage='PNG, GIF or JPG. Will be downscaled to {size}' values={{ size: '1920x1080px' }} />}
|
||||||
|
>
|
||||||
|
<input type='file' onChange={handleFileChange('header', 1920 * 1080)} className='text-sm' />
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
<FormGroup
|
||||||
|
labelText={<FormattedMessage id='edit_profile.fields.avatar_label' defaultMessage='Choose Profile Picture' />}
|
||||||
|
hintText={<FormattedMessage id='edit_profile.hints.avatar' defaultMessage='PNG, GIF or JPG. Will be downscaled to {size}' values={{ size: '400x400px' }} />}
|
||||||
|
>
|
||||||
|
<input type='file' onChange={handleFileChange('avatar', 400 * 400)} className='text-sm' />
|
||||||
|
</FormGroup>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* HACK: wrap these checkboxes in a .simple_form container so they get styled (for now) */}
|
||||||
|
{/* Need a either move, replace, or refactor these checkboxes. */}
|
||||||
|
<div className='simple_form'>
|
||||||
|
{features.followRequests && (
|
||||||
|
<Checkbox
|
||||||
|
label={<FormattedMessage id='edit_profile.fields.locked_label' defaultMessage='Lock account' />}
|
||||||
|
hint={<FormattedMessage id='edit_profile.hints.locked' defaultMessage='Requires you to manually approve followers' />}
|
||||||
|
checked={data.locked}
|
||||||
|
onChange={handleCheckboxChange('locked')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{features.hideNetwork && (
|
||||||
|
<Checkbox
|
||||||
|
label={<FormattedMessage id='edit_profile.fields.hide_network_label' defaultMessage='Hide network' />}
|
||||||
|
hint={<FormattedMessage id='edit_profile.hints.hide_network' defaultMessage='Who you follow and who follows you will not be shown on your profile' />}
|
||||||
|
checked={account ? hidesNetwork(account): false}
|
||||||
|
onChange={handleHideNetworkChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{features.bots && (
|
||||||
|
<Checkbox
|
||||||
|
label={<FormattedMessage id='edit_profile.fields.bot_label' defaultMessage='This is a bot account' />}
|
||||||
|
hint={<FormattedMessage id='edit_profile.hints.bot' defaultMessage='This account mainly performs automated actions and might not be monitored' />}
|
||||||
|
checked={data.bot}
|
||||||
|
onChange={handleCheckboxChange('bot')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{features.muteStrangers && (
|
||||||
|
<Checkbox
|
||||||
|
label={<FormattedMessage id='edit_profile.fields.stranger_notifications_label' defaultMessage='Block notifications from strangers' />}
|
||||||
|
hint={<FormattedMessage id='edit_profile.hints.stranger_notifications' defaultMessage='Only show notifications from people you follow' />}
|
||||||
|
checked={muteStrangers}
|
||||||
|
onChange={(e) => setMuteStrangers(e.target.checked)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{features.profileDirectory && (
|
||||||
|
<Checkbox
|
||||||
|
label={<FormattedMessage id='edit_profile.fields.discoverable_label' defaultMessage='Allow account discovery' />}
|
||||||
|
hint={<FormattedMessage id='edit_profile.hints.discoverable' defaultMessage='Display account in profile directory and allow indexing by external services' />}
|
||||||
|
checked={data.discoverable}
|
||||||
|
onChange={handleCheckboxChange('discoverable')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{features.emailList && (
|
||||||
|
<Checkbox
|
||||||
|
label={<FormattedMessage id='edit_profile.fields.accepts_email_list_label' defaultMessage='Subscribe to newsletter' />}
|
||||||
|
hint={<FormattedMessage id='edit_profile.hints.accepts_email_list' defaultMessage='Opt-in to news and marketing updates.' />}
|
||||||
|
checked={data.accepts_email_list}
|
||||||
|
onChange={handleCheckboxChange('accepts_email_list')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</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> */}
|
||||||
|
<FormActions>
|
||||||
|
<Button to='/settings' theme='ghost'>
|
||||||
|
{intl.formatMessage(messages.cancel)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button theme='primary' type='submit' disabled={isLoading}>
|
||||||
|
<FormattedMessage id='edit_profile.save' defaultMessage='Save' />
|
||||||
|
</Button>
|
||||||
|
</FormActions>
|
||||||
|
</Form>
|
||||||
|
</Column>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EditProfile;
|
|
@ -82,8 +82,8 @@ interface ISimpleInput {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SimpleInput: React.FC<ISimpleInput> = (props) => {
|
export const SimpleInput: React.FC<ISimpleInput> = (props) => {
|
||||||
const { hint, label, error, ...rest } = props;
|
const { hint, error, ...rest } = props;
|
||||||
const Input = label ? LabelInput : 'input';
|
const Input = props.label ? LabelInput : 'input';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<InputContainer {...props}>
|
<InputContainer {...props}>
|
||||||
|
@ -146,7 +146,14 @@ export const FieldsGroup: React.FC = ({ children }) => (
|
||||||
<div className='fields-group'>{children}</div>
|
<div className='fields-group'>{children}</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const Checkbox: React.FC = (props) => (
|
interface ICheckbox {
|
||||||
|
label?: React.ReactNode,
|
||||||
|
hint?: React.ReactNode,
|
||||||
|
checked?: boolean,
|
||||||
|
onChange?: React.ChangeEventHandler<HTMLInputElement>,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Checkbox: React.FC<ICheckbox> = (props) => (
|
||||||
<SimpleInput type='checkbox' {...props} />
|
<SimpleInput type='checkbox' {...props} />
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -61,7 +61,7 @@ const AvatarSelectionStep = ({ onNext }: { onNext: () => void }) => {
|
||||||
setSelectedFile(null);
|
setSelectedFile(null);
|
||||||
|
|
||||||
if (error.response?.status === 422) {
|
if (error.response?.status === 422) {
|
||||||
dispatch(snackbar.error(error.response.data.error.replace('Validation failed: ', '')));
|
dispatch(snackbar.error((error.response.data as any).error.replace('Validation failed: ', '')));
|
||||||
} else {
|
} else {
|
||||||
dispatch(snackbar.error('An unexpected error occurred. Please try again or skip this step.'));
|
dispatch(snackbar.error('An unexpected error occurred. Please try again or skip this step.'));
|
||||||
}
|
}
|
||||||
|
|
|
@ -33,7 +33,7 @@ const BioStep = ({ onNext }: { onNext: () => void }) => {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
|
|
||||||
if (error.response?.status === 422) {
|
if (error.response?.status === 422) {
|
||||||
setErrors([error.response.data.error.replace('Validation failed: ', '')]);
|
setErrors([(error.response.data as any).error.replace('Validation failed: ', '')]);
|
||||||
} else {
|
} else {
|
||||||
dispatch(snackbar.error('An unexpected error occurred. Please try again or skip this step.'));
|
dispatch(snackbar.error('An unexpected error occurred. Please try again or skip this step.'));
|
||||||
}
|
}
|
||||||
|
|
|
@ -62,7 +62,7 @@ const CoverPhotoSelectionStep = ({ onNext }: { onNext: () => void }) => {
|
||||||
setSelectedFile(null);
|
setSelectedFile(null);
|
||||||
|
|
||||||
if (error.response?.status === 422) {
|
if (error.response?.status === 422) {
|
||||||
dispatch(snackbar.error(error.response.data.error.replace('Validation failed: ', '')));
|
dispatch(snackbar.error((error.response.data as any).error.replace('Validation failed: ', '')));
|
||||||
} else {
|
} else {
|
||||||
dispatch(snackbar.error('An unexpected error occurred. Please try again or skip this step.'));
|
dispatch(snackbar.error('An unexpected error occurred. Please try again or skip this step.'));
|
||||||
}
|
}
|
||||||
|
|
|
@ -40,7 +40,7 @@ const DisplayNameStep = ({ onNext }: { onNext: () => void }) => {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
|
|
||||||
if (error.response?.status === 422) {
|
if (error.response?.status === 422) {
|
||||||
setErrors([error.response.data.error.replace('Validation failed: ', '')]);
|
setErrors([(error.response.data as any).error.replace('Validation failed: ', '')]);
|
||||||
} else {
|
} else {
|
||||||
dispatch(snackbar.error('An unexpected error occurred. Please try again or skip this step.'));
|
dispatch(snackbar.error('An unexpected error occurred. Please try again or skip this step.'));
|
||||||
}
|
}
|
||||||
|
|
|
@ -57,7 +57,7 @@ const Header = () => {
|
||||||
.catch((error: AxiosError) => {
|
.catch((error: AxiosError) => {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
||||||
const data = error.response?.data;
|
const data: any = error.response?.data;
|
||||||
if (data?.error === 'mfa_required') {
|
if (data?.error === 'mfa_required') {
|
||||||
setMfaToken(data.mfa_token);
|
setMfaToken(data.mfa_token);
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,92 @@
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import React from 'react';
|
||||||
|
import { defineMessages, useIntl, FormattedMessage, FormatDateOptions } from 'react-intl';
|
||||||
|
|
||||||
|
import { Widget, Stack, HStack, Icon, Text } from 'soapbox/components/ui';
|
||||||
|
import BundleContainer from 'soapbox/features/ui/containers/bundle_container';
|
||||||
|
import { CryptoAddress } from 'soapbox/features/ui/util/async-components';
|
||||||
|
|
||||||
|
import type { Account, Field } from 'soapbox/types/entities';
|
||||||
|
|
||||||
|
const getTicker = (value: string): string => (value.match(/\$([a-zA-Z]*)/i) || [])[1];
|
||||||
|
const isTicker = (value: string): boolean => Boolean(getTicker(value));
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
linkVerifiedOn: { id: 'account.link_verified_on', defaultMessage: 'Ownership of this link was checked on {date}' },
|
||||||
|
account_locked: { id: 'account.locked_info', defaultMessage: 'This account privacy status is set to locked. The owner manually reviews who can follow them.' },
|
||||||
|
deactivated: { id: 'account.deactivated', defaultMessage: 'Deactivated' },
|
||||||
|
bot: { id: 'account.badges.bot', defaultMessage: 'Bot' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const dateFormatOptions: FormatDateOptions = {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
hour12: false,
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface IProfileField {
|
||||||
|
field: Field,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Renders a single profile field. */
|
||||||
|
const ProfileField: React.FC<IProfileField> = ({ field }) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
if (isTicker(field.name)) {
|
||||||
|
return (
|
||||||
|
<BundleContainer fetchComponent={CryptoAddress}>
|
||||||
|
{Component => (
|
||||||
|
<Component
|
||||||
|
ticker={getTicker(field.name).toLowerCase()}
|
||||||
|
address={field.value_plain}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</BundleContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<dl>
|
||||||
|
<dt title={field.name}>
|
||||||
|
<Text weight='bold' tag='span' dangerouslySetInnerHTML={{ __html: field.name_emojified }} />
|
||||||
|
</dt>
|
||||||
|
|
||||||
|
<dd
|
||||||
|
className={classNames({ 'text-success-500': field.verified_at })}
|
||||||
|
title={field.value_plain}
|
||||||
|
>
|
||||||
|
<HStack space={2} alignItems='center'>
|
||||||
|
{field.verified_at && (
|
||||||
|
<span className='flex-none' title={intl.formatMessage(messages.linkVerifiedOn, { date: intl.formatDate(field.verified_at, dateFormatOptions) })}>
|
||||||
|
<Icon src={require('@tabler/icons/icons/check.svg')} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Text tag='span' dangerouslySetInnerHTML={{ __html: field.value_emojified }} />
|
||||||
|
</HStack>
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface IProfileFieldsPanel {
|
||||||
|
account: Account,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Custom profile fields for sidebar. */
|
||||||
|
const ProfileFieldsPanel: React.FC<IProfileFieldsPanel> = ({ account }) => {
|
||||||
|
return (
|
||||||
|
<Widget title={<FormattedMessage id='profile_fields_panel.title' defaultMessage='Profile fields' />}>
|
||||||
|
<Stack space={4}>
|
||||||
|
{account.fields.map((field, i) => (
|
||||||
|
<ProfileField field={field} key={i} />
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Widget>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProfileFieldsPanel;
|
|
@ -1,271 +0,0 @@
|
||||||
'use strict';
|
|
||||||
|
|
||||||
import { List as ImmutableList } from 'immutable';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import React from 'react';
|
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
|
||||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
|
|
||||||
import { initAccountNoteModal } from 'soapbox/actions/account_notes';
|
|
||||||
import Badge from 'soapbox/components/badge';
|
|
||||||
import { Icon, HStack, Stack, Text } from 'soapbox/components/ui';
|
|
||||||
import VerificationBadge from 'soapbox/components/verification_badge';
|
|
||||||
import { isLocal } from 'soapbox/utils/accounts';
|
|
||||||
import { displayFqn } from 'soapbox/utils/state';
|
|
||||||
|
|
||||||
import ProfileStats from './profile_stats';
|
|
||||||
|
|
||||||
// Basically ensure the URL isn't `javascript:alert('hi')` or something like that
|
|
||||||
const isSafeUrl = text => {
|
|
||||||
try {
|
|
||||||
const url = new URL(text);
|
|
||||||
return ['http:', 'https:'].includes(url.protocol);
|
|
||||||
} catch (e) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
linkVerifiedOn: { id: 'account.link_verified_on', defaultMessage: 'Ownership of this link was checked on {date}' },
|
|
||||||
account_locked: { id: 'account.locked_info', defaultMessage: 'This account privacy status is set to locked. The owner manually reviews who can follow them.' },
|
|
||||||
deactivated: { id: 'account.deactivated', defaultMessage: 'Deactivated' },
|
|
||||||
bot: { id: 'account.badges.bot', defaultMessage: 'Bot' },
|
|
||||||
});
|
|
||||||
|
|
||||||
class ProfileInfoPanel extends ImmutablePureComponent {
|
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
account: ImmutablePropTypes.record,
|
|
||||||
identity_proofs: ImmutablePropTypes.list,
|
|
||||||
intl: PropTypes.object.isRequired,
|
|
||||||
username: PropTypes.string,
|
|
||||||
displayFqn: PropTypes.bool,
|
|
||||||
onShowNote: PropTypes.func,
|
|
||||||
};
|
|
||||||
|
|
||||||
getStaffBadge = () => {
|
|
||||||
const { account } = this.props;
|
|
||||||
|
|
||||||
if (account?.admin) {
|
|
||||||
return <Badge slug='admin' title='Admin' key='staff' />;
|
|
||||||
} else if (account?.moderator) {
|
|
||||||
return <Badge slug='moderator' title='Moderator' key='staff' />;
|
|
||||||
} else {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getBadges = () => {
|
|
||||||
const { account } = this.props;
|
|
||||||
const staffBadge = this.getStaffBadge();
|
|
||||||
const isPatron = account.getIn(['patron', 'is_patron']);
|
|
||||||
|
|
||||||
const badges = [];
|
|
||||||
|
|
||||||
if (staffBadge) {
|
|
||||||
badges.push(staffBadge);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isPatron) {
|
|
||||||
badges.push(<Badge slug='patron' title='Patron' key='patron' />);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (account.donor) {
|
|
||||||
badges.push(<Badge slug='donor' title='Donor' key='donor' />);
|
|
||||||
}
|
|
||||||
|
|
||||||
return badges;
|
|
||||||
}
|
|
||||||
|
|
||||||
renderBirthday = () => {
|
|
||||||
const { account, intl } = this.props;
|
|
||||||
|
|
||||||
const birthday = account.get('birthday');
|
|
||||||
if (!birthday) return null;
|
|
||||||
|
|
||||||
const formattedBirthday = intl.formatDate(birthday, { timeZone: 'UTC', day: 'numeric', month: 'long', year: 'numeric' });
|
|
||||||
|
|
||||||
const date = new Date(birthday);
|
|
||||||
const today = new Date();
|
|
||||||
|
|
||||||
const hasBirthday = date.getDate() === today.getDate() && date.getMonth() === today.getMonth();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<HStack alignItems='center' space={0.5}>
|
|
||||||
<Icon
|
|
||||||
src={require('@tabler/icons/icons/ballon.svg')}
|
|
||||||
className='w-4 h-4 text-gray-800 dark:text-gray-200'
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Text size='sm'>
|
|
||||||
{hasBirthday ? (
|
|
||||||
<FormattedMessage id='account.birthday_today' defaultMessage='Birthday is today!' />
|
|
||||||
) : (
|
|
||||||
<FormattedMessage id='account.birthday' defaultMessage='Born {date}' values={{ date: formattedBirthday }} />
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleShowNote = e => {
|
|
||||||
const { account, onShowNote } = this.props;
|
|
||||||
|
|
||||||
e.preventDefault();
|
|
||||||
onShowNote(account);
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { account, displayFqn, intl, username } = this.props;
|
|
||||||
|
|
||||||
if (!account) {
|
|
||||||
return (
|
|
||||||
<div className='mt-6 min-w-0 flex-1 sm:px-2'>
|
|
||||||
<Stack space={2}>
|
|
||||||
<Stack>
|
|
||||||
<HStack space={1} alignItems='center'>
|
|
||||||
<Text size='sm' theme='muted'>
|
|
||||||
@{username}
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const content = { __html: account.get('note_emojified') };
|
|
||||||
const deactivated = !account.getIn(['pleroma', 'is_active'], true);
|
|
||||||
const displayNameHtml = deactivated ? { __html: intl.formatMessage(messages.deactivated) } : { __html: account.get('display_name_html') };
|
|
||||||
const memberSinceDate = intl.formatDate(account.get('created_at'), { month: 'long', year: 'numeric' });
|
|
||||||
const verified = account.get('verified');
|
|
||||||
const badges = this.getBadges();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='mt-6 min-w-0 flex-1 sm:px-2'>
|
|
||||||
<Stack space={2}>
|
|
||||||
{/* Not sure if this is actual used. */}
|
|
||||||
{/* <div className='profile-info-panel-content__deactivated'>
|
|
||||||
<FormattedMessage
|
|
||||||
id='account.deactivated_description' defaultMessage='This account has been deactivated.'
|
|
||||||
/>
|
|
||||||
</div> */}
|
|
||||||
|
|
||||||
<Stack>
|
|
||||||
<HStack space={1} alignItems='center'>
|
|
||||||
<Text size='lg' weight='bold' dangerouslySetInnerHTML={displayNameHtml} />
|
|
||||||
|
|
||||||
{verified && <VerificationBadge />}
|
|
||||||
|
|
||||||
{account.bot && <Badge slug='bot' title={intl.formatMessage(messages.bot)} />}
|
|
||||||
|
|
||||||
{badges.length > 0 && (
|
|
||||||
<HStack space={1} alignItems='center'>
|
|
||||||
{badges}
|
|
||||||
</HStack>
|
|
||||||
)}
|
|
||||||
</HStack>
|
|
||||||
|
|
||||||
<HStack alignItems='center' space={0.5}>
|
|
||||||
<Text size='sm' theme='muted'>
|
|
||||||
@{displayFqn ? account.fqn : account.acct}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{account.get('locked') && (
|
|
||||||
<Icon
|
|
||||||
src={require('@tabler/icons/icons/lock.svg')}
|
|
||||||
title={intl.formatMessage(messages.account_locked)}
|
|
||||||
className='w-4 h-4 text-gray-600'
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</HStack>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<ProfileStats account={account} />
|
|
||||||
|
|
||||||
{
|
|
||||||
(account.get('note').length > 0 && account.get('note') !== '<p></p>') &&
|
|
||||||
<Text size='sm' dangerouslySetInnerHTML={content} />
|
|
||||||
}
|
|
||||||
|
|
||||||
<div className='flex flex-col md:flex-row items-start md:flex-wrap md:items-center gap-2'>
|
|
||||||
{isLocal(account) ? (
|
|
||||||
<HStack alignItems='center' space={0.5}>
|
|
||||||
<Icon
|
|
||||||
src={require('@tabler/icons/icons/calendar.svg')}
|
|
||||||
className='w-4 h-4 text-gray-800 dark:text-gray-200'
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Text size='sm'>
|
|
||||||
<FormattedMessage
|
|
||||||
id='account.member_since' defaultMessage='Joined {date}' values={{
|
|
||||||
date: memberSinceDate,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{account.get('location') ? (
|
|
||||||
<HStack alignItems='center' space={0.5}>
|
|
||||||
<Icon
|
|
||||||
src={require('@tabler/icons/icons/map-pin.svg')}
|
|
||||||
className='w-4 h-4 text-gray-800 dark:text-gray-200'
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Text size='sm'>
|
|
||||||
{account.get('location')}
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{account.get('website') ? (
|
|
||||||
<HStack alignItems='center' space={0.5}>
|
|
||||||
<Icon
|
|
||||||
src={require('@tabler/icons/icons/link.svg')}
|
|
||||||
className='w-4 h-4 text-gray-800 dark:text-gray-200'
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className='max-w-[300px]'>
|
|
||||||
<Text size='sm' truncate>
|
|
||||||
{isSafeUrl(account.get('website')) ? (
|
|
||||||
<a className='text-primary-600 dark:text-primary-400 hover:underline' href={account.get('website')} target='_blank'>{account.get('website')}</a>
|
|
||||||
) : (
|
|
||||||
account.get('website')
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</HStack>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{this.renderBirthday()}
|
|
||||||
</div>
|
|
||||||
</Stack>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
const mapStateToProps = (state, { account }) => {
|
|
||||||
const identity_proofs = account ? state.getIn(['identity_proofs', account.get('id')], ImmutableList()) : ImmutableList();
|
|
||||||
return {
|
|
||||||
identity_proofs,
|
|
||||||
domain: state.getIn(['meta', 'domain']),
|
|
||||||
displayFqn: displayFqn(state),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const mapDispatchToProps = (dispatch) => ({
|
|
||||||
onShowNote(account) {
|
|
||||||
dispatch(initAccountNoteModal(account));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default injectIntl(
|
|
||||||
connect(mapStateToProps, mapDispatchToProps, null, {
|
|
||||||
forwardRef: true,
|
|
||||||
},
|
|
||||||
)(ProfileInfoPanel));
|
|
|
@ -0,0 +1,230 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import Badge from 'soapbox/components/badge';
|
||||||
|
import { Icon, HStack, Stack, Text } from 'soapbox/components/ui';
|
||||||
|
import VerificationBadge from 'soapbox/components/verification_badge';
|
||||||
|
import { useSoapboxConfig } from 'soapbox/hooks';
|
||||||
|
import { isLocal } from 'soapbox/utils/accounts';
|
||||||
|
|
||||||
|
import ProfileStats from './profile_stats';
|
||||||
|
|
||||||
|
import type { Account } from 'soapbox/types/entities';
|
||||||
|
|
||||||
|
/** Basically ensure the URL isn't `javascript:alert('hi')` or something like that */
|
||||||
|
const isSafeUrl = (text: string): boolean => {
|
||||||
|
try {
|
||||||
|
const url = new URL(text);
|
||||||
|
return ['http:', 'https:'].includes(url.protocol);
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
linkVerifiedOn: { id: 'account.link_verified_on', defaultMessage: 'Ownership of this link was checked on {date}' },
|
||||||
|
account_locked: { id: 'account.locked_info', defaultMessage: 'This account privacy status is set to locked. The owner manually reviews who can follow them.' },
|
||||||
|
deactivated: { id: 'account.deactivated', defaultMessage: 'Deactivated' },
|
||||||
|
bot: { id: 'account.badges.bot', defaultMessage: 'Bot' },
|
||||||
|
});
|
||||||
|
|
||||||
|
interface IProfileInfoPanel {
|
||||||
|
account: Account,
|
||||||
|
/** Username from URL params, in case the account isn't found. */
|
||||||
|
username: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** User profile metadata, such as location, birthday, etc. */
|
||||||
|
const ProfileInfoPanel: React.FC<IProfileInfoPanel> = ({ account, username }) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const { displayFqn } = useSoapboxConfig();
|
||||||
|
|
||||||
|
const getStaffBadge = (): React.ReactNode => {
|
||||||
|
if (account?.admin) {
|
||||||
|
return <Badge slug='admin' title='Admin' key='staff' />;
|
||||||
|
} else if (account?.moderator) {
|
||||||
|
return <Badge slug='moderator' title='Moderator' key='staff' />;
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getBadges = (): React.ReactNode[] => {
|
||||||
|
const staffBadge = getStaffBadge();
|
||||||
|
const isPatron = account.getIn(['patron', 'is_patron']) === true;
|
||||||
|
|
||||||
|
const badges = [];
|
||||||
|
|
||||||
|
if (staffBadge) {
|
||||||
|
badges.push(staffBadge);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPatron) {
|
||||||
|
badges.push(<Badge slug='patron' title='Patron' key='patron' />);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (account.donor) {
|
||||||
|
badges.push(<Badge slug='donor' title='Donor' key='donor' />);
|
||||||
|
}
|
||||||
|
|
||||||
|
return badges;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderBirthday = (): React.ReactNode => {
|
||||||
|
const birthday = account.birthday;
|
||||||
|
if (!birthday) return null;
|
||||||
|
|
||||||
|
const formattedBirthday = intl.formatDate(birthday, { timeZone: 'UTC', day: 'numeric', month: 'long', year: 'numeric' });
|
||||||
|
|
||||||
|
const date = new Date(birthday);
|
||||||
|
const today = new Date();
|
||||||
|
|
||||||
|
const hasBirthday = date.getDate() === today.getDate() && date.getMonth() === today.getMonth();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HStack alignItems='center' space={0.5}>
|
||||||
|
<Icon
|
||||||
|
src={require('@tabler/icons/icons/ballon.svg')}
|
||||||
|
className='w-4 h-4 text-gray-800 dark:text-gray-200'
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text size='sm'>
|
||||||
|
{hasBirthday ? (
|
||||||
|
<FormattedMessage id='account.birthday_today' defaultMessage='Birthday is today!' />
|
||||||
|
) : (
|
||||||
|
<FormattedMessage id='account.birthday' defaultMessage='Born {date}' values={{ date: formattedBirthday }} />
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!account) {
|
||||||
|
return (
|
||||||
|
<div className='mt-6 min-w-0 flex-1 sm:px-2'>
|
||||||
|
<Stack space={2}>
|
||||||
|
<Stack>
|
||||||
|
<HStack space={1} alignItems='center'>
|
||||||
|
<Text size='sm' theme='muted'>
|
||||||
|
@{username}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = { __html: account.note_emojified };
|
||||||
|
const deactivated = !account.pleroma.get('is_active', true) === true;
|
||||||
|
const displayNameHtml = deactivated ? { __html: intl.formatMessage(messages.deactivated) } : { __html: account.display_name_html };
|
||||||
|
const memberSinceDate = intl.formatDate(account.created_at, { month: 'long', year: 'numeric' });
|
||||||
|
const verified = account.verified;
|
||||||
|
const badges = getBadges();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='mt-6 min-w-0 flex-1 sm:px-2'>
|
||||||
|
<Stack space={2}>
|
||||||
|
{/* Not sure if this is actual used. */}
|
||||||
|
{/* <div className='profile-info-panel-content__deactivated'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='account.deactivated_description' defaultMessage='This account has been deactivated.'
|
||||||
|
/>
|
||||||
|
</div> */}
|
||||||
|
|
||||||
|
<Stack>
|
||||||
|
<HStack space={1} alignItems='center'>
|
||||||
|
<Text size='lg' weight='bold' dangerouslySetInnerHTML={displayNameHtml} />
|
||||||
|
|
||||||
|
{verified && <VerificationBadge />}
|
||||||
|
|
||||||
|
{account.bot && <Badge slug='bot' title={intl.formatMessage(messages.bot)} />}
|
||||||
|
|
||||||
|
{badges.length > 0 && (
|
||||||
|
<HStack space={1} alignItems='center'>
|
||||||
|
{badges}
|
||||||
|
</HStack>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<HStack alignItems='center' space={0.5}>
|
||||||
|
<Text size='sm' theme='muted'>
|
||||||
|
@{displayFqn ? account.fqn : account.acct}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{account.locked && (
|
||||||
|
<Icon
|
||||||
|
src={require('@tabler/icons/icons/lock.svg')}
|
||||||
|
alt={intl.formatMessage(messages.account_locked)}
|
||||||
|
className='w-4 h-4 text-gray-600'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<ProfileStats account={account} />
|
||||||
|
|
||||||
|
{account.note.length > 0 && account.note !== '<p></p>' && (
|
||||||
|
<Text size='sm' dangerouslySetInnerHTML={content} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className='flex flex-col md:flex-row items-start md:flex-wrap md:items-center gap-2'>
|
||||||
|
{isLocal(account as any) ? (
|
||||||
|
<HStack alignItems='center' space={0.5}>
|
||||||
|
<Icon
|
||||||
|
src={require('@tabler/icons/icons/calendar.svg')}
|
||||||
|
className='w-4 h-4 text-gray-800 dark:text-gray-200'
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text size='sm'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='account.member_since' defaultMessage='Joined {date}' values={{
|
||||||
|
date: memberSinceDate,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{account.location ? (
|
||||||
|
<HStack alignItems='center' space={0.5}>
|
||||||
|
<Icon
|
||||||
|
src={require('@tabler/icons/icons/map-pin.svg')}
|
||||||
|
className='w-4 h-4 text-gray-800 dark:text-gray-200'
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text size='sm'>
|
||||||
|
{account.location}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{account.website ? (
|
||||||
|
<HStack alignItems='center' space={0.5}>
|
||||||
|
<Icon
|
||||||
|
src={require('@tabler/icons/icons/link.svg')}
|
||||||
|
className='w-4 h-4 text-gray-800 dark:text-gray-200'
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className='max-w-[300px]'>
|
||||||
|
<Text size='sm' truncate>
|
||||||
|
{isSafeUrl(account.website) ? (
|
||||||
|
<a className='text-primary-600 dark:text-primary-400 hover:underline' href={account.website} target='_blank'>{account.website}</a>
|
||||||
|
) : (
|
||||||
|
account.website
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</HStack>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{renderBirthday()}
|
||||||
|
</div>
|
||||||
|
</Stack>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProfileInfoPanel;
|
|
@ -1,63 +0,0 @@
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import React from 'react';
|
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
|
||||||
import { injectIntl, defineMessages } from 'react-intl';
|
|
||||||
import { NavLink } from 'react-router-dom';
|
|
||||||
|
|
||||||
import { shortNumberFormat } from 'soapbox/utils/numbers';
|
|
||||||
|
|
||||||
import { HStack, Text } from '../../../components/ui';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
followers: { id: 'account.followers', defaultMessage: 'Followers' },
|
|
||||||
follows: { id: 'account.follows', defaultMessage: 'Follows' },
|
|
||||||
});
|
|
||||||
|
|
||||||
export default @injectIntl
|
|
||||||
class ProfileStats extends React.PureComponent {
|
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
intl: PropTypes.object.isRequired,
|
|
||||||
account: ImmutablePropTypes.record.isRequired,
|
|
||||||
className: PropTypes.string,
|
|
||||||
onClickHandler: PropTypes.func,
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { intl } = this.props;
|
|
||||||
const { account, onClickHandler } = this.props;
|
|
||||||
|
|
||||||
if (!account) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const acct = account.get('acct');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<HStack alignItems='center' space={3}>
|
|
||||||
<NavLink to={`/@${acct}/followers`} onClick={onClickHandler} title={intl.formatNumber(account.get('followers_count'))}>
|
|
||||||
<HStack alignItems='center' space={1}>
|
|
||||||
<Text theme='primary' weight='bold' size='sm'>
|
|
||||||
{shortNumberFormat(account.get('followers_count'))}
|
|
||||||
</Text>
|
|
||||||
<Text weight='bold' size='sm'>
|
|
||||||
{intl.formatMessage(messages.followers)}
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
|
||||||
</NavLink>
|
|
||||||
|
|
||||||
<NavLink to={`/@${acct}/following`} onClick={onClickHandler} title={intl.formatNumber(account.get('following_count'))}>
|
|
||||||
<HStack alignItems='center' space={1}>
|
|
||||||
<Text theme='primary' weight='bold' size='sm'>
|
|
||||||
{shortNumberFormat(account.get('following_count'))}
|
|
||||||
</Text>
|
|
||||||
<Text weight='bold' size='sm'>
|
|
||||||
{intl.formatMessage(messages.follows)}
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
|
||||||
</NavLink>
|
|
||||||
</HStack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -0,0 +1,56 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { useIntl, defineMessages } from 'react-intl';
|
||||||
|
import { NavLink } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { shortNumberFormat } from 'soapbox/utils/numbers';
|
||||||
|
|
||||||
|
import { HStack, Text } from '../../../components/ui';
|
||||||
|
|
||||||
|
import type { Account } from 'soapbox/types/entities';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
followers: { id: 'account.followers', defaultMessage: 'Followers' },
|
||||||
|
follows: { id: 'account.follows', defaultMessage: 'Follows' },
|
||||||
|
});
|
||||||
|
|
||||||
|
interface IProfileStats {
|
||||||
|
account: Account | undefined,
|
||||||
|
onClickHandler?: React.MouseEventHandler,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Display follower and following counts for an account. */
|
||||||
|
const ProfileStats: React.FC<IProfileStats> = ({ account, onClickHandler }) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
if (!account) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HStack alignItems='center' space={3}>
|
||||||
|
<NavLink to={`/@${account.acct}/followers`} onClick={onClickHandler} title={intl.formatNumber(account.followers_count)}>
|
||||||
|
<HStack alignItems='center' space={1}>
|
||||||
|
<Text theme='primary' weight='bold' size='sm'>
|
||||||
|
{shortNumberFormat(account.followers_count)}
|
||||||
|
</Text>
|
||||||
|
<Text weight='bold' size='sm'>
|
||||||
|
{intl.formatMessage(messages.followers)}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
</NavLink>
|
||||||
|
|
||||||
|
<NavLink to={`/@${account.acct}/following`} onClick={onClickHandler} title={intl.formatNumber(account.following_count)}>
|
||||||
|
<HStack alignItems='center' space={1}>
|
||||||
|
<Text theme='primary' weight='bold' size='sm'>
|
||||||
|
{shortNumberFormat(account.following_count)}
|
||||||
|
</Text>
|
||||||
|
<Text weight='bold' size='sm'>
|
||||||
|
{intl.formatMessage(messages.follows)}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
</NavLink>
|
||||||
|
</HStack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProfileStats;
|
|
@ -362,6 +362,10 @@ export function ProfileMediaPanel() {
|
||||||
return import(/* webpackChunkName: "features/account_gallery" */'../components/profile_media_panel');
|
return import(/* webpackChunkName: "features/account_gallery" */'../components/profile_media_panel');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ProfileFieldsPanel() {
|
||||||
|
return import(/* webpackChunkName: "features/account_timeline" */'../components/profile_fields_panel');
|
||||||
|
}
|
||||||
|
|
||||||
export function PinnedAccountsPanel() {
|
export function PinnedAccountsPanel() {
|
||||||
return import(/* webpackChunkName: "features/pinned_accounts" */'../components/pinned_accounts_panel');
|
return import(/* webpackChunkName: "features/pinned_accounts" */'../components/pinned_accounts_panel');
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,9 +24,10 @@ export const AccountRecord = ImmutableRecord({
|
||||||
acct: '',
|
acct: '',
|
||||||
avatar: '',
|
avatar: '',
|
||||||
avatar_static: '',
|
avatar_static: '',
|
||||||
birthday: undefined as Date | undefined,
|
birthday: undefined as string | undefined,
|
||||||
bot: false,
|
bot: false,
|
||||||
created_at: new Date(),
|
created_at: new Date(),
|
||||||
|
discoverable: false,
|
||||||
display_name: '',
|
display_name: '',
|
||||||
emojis: ImmutableList<Emoji>(),
|
emojis: ImmutableList<Emoji>(),
|
||||||
favicon: '',
|
favicon: '',
|
||||||
|
@ -255,6 +256,11 @@ const addStaffFields = (account: ImmutableMap<string, any>) => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizeDiscoverable = (account: ImmutableMap<string, any>) => {
|
||||||
|
const discoverable = Boolean(account.get('discoverable') || account.getIn(['source', 'pleroma', 'discoverable']));
|
||||||
|
return account.set('discoverable', discoverable);
|
||||||
|
};
|
||||||
|
|
||||||
export const normalizeAccount = (account: Record<string, any>) => {
|
export const normalizeAccount = (account: Record<string, any>) => {
|
||||||
return AccountRecord(
|
return AccountRecord(
|
||||||
ImmutableMap(fromJS(account)).withMutations(account => {
|
ImmutableMap(fromJS(account)).withMutations(account => {
|
||||||
|
@ -269,6 +275,7 @@ export const normalizeAccount = (account: Record<string, any>) => {
|
||||||
normalizeLocation(account);
|
normalizeLocation(account);
|
||||||
normalizeFqn(account);
|
normalizeFqn(account);
|
||||||
normalizeFavicon(account);
|
normalizeFavicon(account);
|
||||||
|
normalizeDiscoverable(account);
|
||||||
addDomain(account);
|
addDomain(account);
|
||||||
addStaffFields(account);
|
addStaffFields(account);
|
||||||
fixUsername(account);
|
fixUsername(account);
|
||||||
|
|
|
@ -1,173 +0,0 @@
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import React from 'react';
|
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
|
||||||
import { FormattedMessage } from 'react-intl';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import { Redirect, withRouter } from 'react-router-dom';
|
|
||||||
|
|
||||||
import LinkFooter from 'soapbox/features/ui/components/link_footer';
|
|
||||||
import BundleContainer from 'soapbox/features/ui/containers/bundle_container';
|
|
||||||
import {
|
|
||||||
WhoToFollowPanel,
|
|
||||||
TrendsPanel,
|
|
||||||
ProfileInfoPanel,
|
|
||||||
ProfileMediaPanel,
|
|
||||||
SignUpPanel,
|
|
||||||
} from 'soapbox/features/ui/util/async-components';
|
|
||||||
import { findAccountByUsername } from 'soapbox/selectors';
|
|
||||||
import { getAcct } from 'soapbox/utils/accounts';
|
|
||||||
import { getFeatures } from 'soapbox/utils/features';
|
|
||||||
import { displayFqn } from 'soapbox/utils/state';
|
|
||||||
|
|
||||||
import { Column, Layout, Tabs } from '../components/ui';
|
|
||||||
import HeaderContainer from '../features/account_timeline/containers/header_container';
|
|
||||||
import { makeGetAccount } from '../selectors';
|
|
||||||
|
|
||||||
const mapStateToProps = (state, { params, withReplies = false }) => {
|
|
||||||
const username = params.username || '';
|
|
||||||
const accounts = state.getIn(['accounts']);
|
|
||||||
const accountFetchError = ((state.getIn(['accounts', -1, 'username']) || '').toLowerCase() === username.toLowerCase());
|
|
||||||
const getAccount = makeGetAccount();
|
|
||||||
const me = state.get('me');
|
|
||||||
|
|
||||||
let accountId = -1;
|
|
||||||
let account = null;
|
|
||||||
let accountUsername = username;
|
|
||||||
if (accountFetchError) {
|
|
||||||
accountId = null;
|
|
||||||
} else {
|
|
||||||
account = findAccountByUsername(state, username);
|
|
||||||
accountId = account ? account.getIn(['id'], null) : -1;
|
|
||||||
accountUsername = account ? account.getIn(['acct'], '') : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
//Children components fetch information
|
|
||||||
|
|
||||||
let realAccount;
|
|
||||||
if (!account) {
|
|
||||||
const maybeAccount = accounts.get(username);
|
|
||||||
if (maybeAccount) {
|
|
||||||
realAccount = maybeAccount;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
me,
|
|
||||||
account: accountId ? getAccount(state, accountId) : account,
|
|
||||||
accountId,
|
|
||||||
accountUsername,
|
|
||||||
features: getFeatures(state.get('instance')),
|
|
||||||
realAccount,
|
|
||||||
displayFqn: displayFqn(state),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default @connect(mapStateToProps)
|
|
||||||
@withRouter
|
|
||||||
class ProfilePage extends ImmutablePureComponent {
|
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
account: ImmutablePropTypes.record,
|
|
||||||
accountUsername: PropTypes.string.isRequired,
|
|
||||||
displayFqn: PropTypes.bool,
|
|
||||||
features: PropTypes.object,
|
|
||||||
};
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { children, accountId, account, displayFqn, accountUsername, me, features, realAccount } = this.props;
|
|
||||||
|
|
||||||
if (realAccount) {
|
|
||||||
return <Redirect to={`/@${realAccount.get('acct')}`} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tabItems = [
|
|
||||||
{
|
|
||||||
text: <FormattedMessage id='account.posts' defaultMessage='Posts' />,
|
|
||||||
to: `/@${accountUsername}`,
|
|
||||||
name: 'profile',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: <FormattedMessage id='account.posts_with_replies' defaultMessage='Posts and replies' />,
|
|
||||||
to: `/@${accountUsername}/with_replies`,
|
|
||||||
name: 'replies',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: <FormattedMessage id='account.media' defaultMessage='Media' />,
|
|
||||||
to: `/@${accountUsername}/media`,
|
|
||||||
name: 'media',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
if (account) {
|
|
||||||
const ownAccount = account.get('id') === me;
|
|
||||||
if (ownAccount || !account.getIn(['pleroma', 'hide_favorites'], true)) {
|
|
||||||
tabItems.push({
|
|
||||||
text: <FormattedMessage id='navigation_bar.favourites' defaultMessage='Likes' />,
|
|
||||||
to: `/@${account.get('acct')}/favorites`,
|
|
||||||
name: 'likes',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const showTrendsPanel = features.trends;
|
|
||||||
const showWhoToFollowPanel = features.suggestions;
|
|
||||||
|
|
||||||
let activeItem;
|
|
||||||
const pathname = this.props.history.location.pathname.replace(`@${accountUsername}/`);
|
|
||||||
if (pathname.includes('with_replies')) {
|
|
||||||
activeItem = 'replies';
|
|
||||||
} else if (pathname.includes('media')) {
|
|
||||||
activeItem = 'media';
|
|
||||||
} else if (pathname.includes('favorites')) {
|
|
||||||
activeItem = 'likes';
|
|
||||||
} else if (pathname === `/@${accountUsername}`) {
|
|
||||||
activeItem = 'profile';
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Layout.Main>
|
|
||||||
<Column label={account ? `@${getAcct(account, displayFqn)}` : null} withHeader={false}>
|
|
||||||
<div className='space-y-4'>
|
|
||||||
<HeaderContainer accountId={accountId} username={accountUsername} />
|
|
||||||
|
|
||||||
<BundleContainer fetchComponent={ProfileInfoPanel}>
|
|
||||||
{Component => <Component username={accountUsername} account={account} />}
|
|
||||||
</BundleContainer>
|
|
||||||
|
|
||||||
{account && (
|
|
||||||
<Tabs items={tabItems} activeItem={activeItem} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</Column>
|
|
||||||
</Layout.Main>
|
|
||||||
|
|
||||||
<Layout.Aside>
|
|
||||||
{!me && (
|
|
||||||
<BundleContainer fetchComponent={SignUpPanel}>
|
|
||||||
{Component => <Component key='sign-up-panel' />}
|
|
||||||
</BundleContainer>
|
|
||||||
)}
|
|
||||||
<BundleContainer fetchComponent={ProfileMediaPanel}>
|
|
||||||
{Component => <Component account={account} />}
|
|
||||||
</BundleContainer>
|
|
||||||
{showTrendsPanel && (
|
|
||||||
<BundleContainer fetchComponent={TrendsPanel}>
|
|
||||||
{Component => <Component limit={3} key='trends-panel' />}
|
|
||||||
</BundleContainer>
|
|
||||||
)}
|
|
||||||
{showWhoToFollowPanel && (
|
|
||||||
<BundleContainer fetchComponent={WhoToFollowPanel}>
|
|
||||||
{Component => <Component limit={5} key='wtf-panel' />}
|
|
||||||
</BundleContainer>
|
|
||||||
)}
|
|
||||||
<LinkFooter key='link-footer' />
|
|
||||||
</Layout.Aside>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -0,0 +1,160 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
import { Redirect, useHistory } from 'react-router-dom';
|
||||||
|
|
||||||
|
import LinkFooter from 'soapbox/features/ui/components/link_footer';
|
||||||
|
import BundleContainer from 'soapbox/features/ui/containers/bundle_container';
|
||||||
|
import {
|
||||||
|
WhoToFollowPanel,
|
||||||
|
ProfileInfoPanel,
|
||||||
|
ProfileMediaPanel,
|
||||||
|
ProfileFieldsPanel,
|
||||||
|
SignUpPanel,
|
||||||
|
} from 'soapbox/features/ui/util/async-components';
|
||||||
|
import { useAppSelector, useFeatures, useSoapboxConfig } from 'soapbox/hooks';
|
||||||
|
import { findAccountByUsername } from 'soapbox/selectors';
|
||||||
|
import { getAcct } from 'soapbox/utils/accounts';
|
||||||
|
|
||||||
|
import { Column, Layout, Tabs } from '../components/ui';
|
||||||
|
import HeaderContainer from '../features/account_timeline/containers/header_container';
|
||||||
|
import { makeGetAccount } from '../selectors';
|
||||||
|
|
||||||
|
const getAccount = makeGetAccount();
|
||||||
|
|
||||||
|
interface IProfilePage {
|
||||||
|
params?: {
|
||||||
|
username?: string,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProfilePage: React.FC<IProfilePage> = ({ params, children }) => {
|
||||||
|
const history = useHistory();
|
||||||
|
|
||||||
|
const { accountId, account, accountUsername, realAccount } = useAppSelector(state => {
|
||||||
|
const username = params?.username || '';
|
||||||
|
const { accounts } = state;
|
||||||
|
const accountFetchError = (((state.accounts.getIn([-1, 'username']) || '') as string).toLowerCase() === username.toLowerCase());
|
||||||
|
|
||||||
|
let accountId: string | -1 | null = -1;
|
||||||
|
let account = null;
|
||||||
|
let accountUsername = username;
|
||||||
|
if (accountFetchError) {
|
||||||
|
accountId = null;
|
||||||
|
} else {
|
||||||
|
account = findAccountByUsername(state, username);
|
||||||
|
accountId = account ? account.id : -1;
|
||||||
|
accountUsername = account ? account.acct : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
let realAccount;
|
||||||
|
if (!account) {
|
||||||
|
const maybeAccount = accounts.get(username);
|
||||||
|
if (maybeAccount) {
|
||||||
|
realAccount = maybeAccount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
account: typeof accountId === 'string' ? getAccount(state, accountId) : account,
|
||||||
|
accountId,
|
||||||
|
accountUsername,
|
||||||
|
realAccount,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const me = useAppSelector(state => state.me);
|
||||||
|
const features = useFeatures();
|
||||||
|
const { displayFqn } = useSoapboxConfig();
|
||||||
|
|
||||||
|
if (realAccount) {
|
||||||
|
return <Redirect to={`/@${realAccount.acct}`} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabItems = [
|
||||||
|
{
|
||||||
|
text: <FormattedMessage id='account.posts' defaultMessage='Posts' />,
|
||||||
|
to: `/@${accountUsername}`,
|
||||||
|
name: 'profile',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: <FormattedMessage id='account.posts_with_replies' defaultMessage='Posts and replies' />,
|
||||||
|
to: `/@${accountUsername}/with_replies`,
|
||||||
|
name: 'replies',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: <FormattedMessage id='account.media' defaultMessage='Media' />,
|
||||||
|
to: `/@${accountUsername}/media`,
|
||||||
|
name: 'media',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (account) {
|
||||||
|
const ownAccount = account.id === me;
|
||||||
|
if (ownAccount || !account.pleroma.get('hide_favorites', true)) {
|
||||||
|
tabItems.push({
|
||||||
|
text: <FormattedMessage id='navigation_bar.favourites' defaultMessage='Likes' />,
|
||||||
|
to: `/@${account.acct}/favorites`,
|
||||||
|
name: 'likes',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let activeItem;
|
||||||
|
const pathname = history.location.pathname.replace(`@${accountUsername}/`, '');
|
||||||
|
if (pathname.includes('with_replies')) {
|
||||||
|
activeItem = 'replies';
|
||||||
|
} else if (pathname.includes('media')) {
|
||||||
|
activeItem = 'media';
|
||||||
|
} else if (pathname.includes('favorites')) {
|
||||||
|
activeItem = 'likes';
|
||||||
|
} else if (pathname === `/@${accountUsername}`) {
|
||||||
|
activeItem = 'profile';
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Layout.Main>
|
||||||
|
<Column label={account ? `@${getAcct(account, displayFqn)}` : ''} withHeader={false}>
|
||||||
|
<div className='space-y-4'>
|
||||||
|
{/* @ts-ignore */}
|
||||||
|
<HeaderContainer accountId={accountId} username={accountUsername} />
|
||||||
|
|
||||||
|
<BundleContainer fetchComponent={ProfileInfoPanel}>
|
||||||
|
{Component => <Component username={accountUsername} account={account} />}
|
||||||
|
</BundleContainer>
|
||||||
|
|
||||||
|
{account && activeItem && (
|
||||||
|
<Tabs items={tabItems} activeItem={activeItem} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</Column>
|
||||||
|
</Layout.Main>
|
||||||
|
|
||||||
|
<Layout.Aside>
|
||||||
|
{!me && (
|
||||||
|
<BundleContainer fetchComponent={SignUpPanel}>
|
||||||
|
{Component => <Component key='sign-up-panel' />}
|
||||||
|
</BundleContainer>
|
||||||
|
)}
|
||||||
|
<BundleContainer fetchComponent={ProfileMediaPanel}>
|
||||||
|
{Component => <Component account={account} />}
|
||||||
|
</BundleContainer>
|
||||||
|
{account && !account.fields.isEmpty() && (
|
||||||
|
<BundleContainer fetchComponent={ProfileFieldsPanel}>
|
||||||
|
{Component => <Component account={account} />}
|
||||||
|
</BundleContainer>
|
||||||
|
)}
|
||||||
|
{features.suggestions && (
|
||||||
|
<BundleContainer fetchComponent={WhoToFollowPanel}>
|
||||||
|
{Component => <Component limit={5} key='wtf-panel' />}
|
||||||
|
</BundleContainer>
|
||||||
|
)}
|
||||||
|
<LinkFooter key='link-footer' />
|
||||||
|
</Layout.Aside>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProfilePage;
|
|
@ -148,6 +148,15 @@ const getInstanceFeatures = (instance: Instance) => {
|
||||||
v.software === PIXELFED,
|
v.software === PIXELFED,
|
||||||
]),
|
]),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accounts can be marked as bots.
|
||||||
|
* @see PATCH /api/v1/accounts/update_credentials
|
||||||
|
*/
|
||||||
|
bots: any([
|
||||||
|
v.software === MASTODON,
|
||||||
|
v.software === PLEROMA,
|
||||||
|
]),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pleroma chats API.
|
* Pleroma chats API.
|
||||||
* @see {@link https://docs.pleroma.social/backend/development/API/chats/}
|
* @see {@link https://docs.pleroma.social/backend/development/API/chats/}
|
||||||
|
@ -240,12 +249,27 @@ const getInstanceFeatures = (instance: Instance) => {
|
||||||
*/
|
*/
|
||||||
focalPoint: v.software === MASTODON && gte(v.compatVersion, '2.3.0'),
|
focalPoint: v.software === MASTODON && gte(v.compatVersion, '2.3.0'),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ability to lock accounts and manually approve followers.
|
||||||
|
* @see PATCH /api/v1/accounts/update_credentials
|
||||||
|
*/
|
||||||
|
followRequests: any([
|
||||||
|
v.software === MASTODON,
|
||||||
|
v.software === PLEROMA,
|
||||||
|
]),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether client settings can be retrieved from the API.
|
* Whether client settings can be retrieved from the API.
|
||||||
* @see GET /api/pleroma/frontend_configurations
|
* @see GET /api/pleroma/frontend_configurations
|
||||||
*/
|
*/
|
||||||
frontendConfigurations: v.software === PLEROMA,
|
frontendConfigurations: v.software === PLEROMA,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Can hide follows/followers lists and counts.
|
||||||
|
* @see PATCH /api/v1/accounts/update_credentials
|
||||||
|
*/
|
||||||
|
hideNetwork: v.software === PLEROMA,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pleroma import API.
|
* Pleroma import API.
|
||||||
* @see POST /api/pleroma/follow_import
|
* @see POST /api/pleroma/follow_import
|
||||||
|
@ -287,6 +311,12 @@ const getInstanceFeatures = (instance: Instance) => {
|
||||||
// v.software === PLEROMA && gte(v.version, '2.1.0'),
|
// v.software === PLEROMA && gte(v.version, '2.1.0'),
|
||||||
]),
|
]),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ability to hide notifications from people you don't follow.
|
||||||
|
* @see PUT /api/pleroma/notification_settings
|
||||||
|
*/
|
||||||
|
muteStrangers: v.software === PLEROMA,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add private notes to accounts.
|
* Add private notes to accounts.
|
||||||
* @see POST /api/v1/accounts/:id/note
|
* @see POST /api/v1/accounts/:id/note
|
||||||
|
|
|
@ -89,7 +89,7 @@
|
||||||
"@types/uuid": "^8.3.4",
|
"@types/uuid": "^8.3.4",
|
||||||
"array-includes": "^3.0.3",
|
"array-includes": "^3.0.3",
|
||||||
"autoprefixer": "^10.4.2",
|
"autoprefixer": "^10.4.2",
|
||||||
"axios": "^0.21.4",
|
"axios": "^0.27.2",
|
||||||
"axios-mock-adapter": "^1.18.1",
|
"axios-mock-adapter": "^1.18.1",
|
||||||
"babel-loader": "^8.2.2",
|
"babel-loader": "^8.2.2",
|
||||||
"babel-plugin-lodash": "^3.3.4",
|
"babel-plugin-lodash": "^3.3.4",
|
||||||
|
|
27
yarn.lock
27
yarn.lock
|
@ -2906,12 +2906,13 @@ axios-mock-adapter@^1.18.1:
|
||||||
is-blob "^2.1.0"
|
is-blob "^2.1.0"
|
||||||
is-buffer "^2.0.5"
|
is-buffer "^2.0.5"
|
||||||
|
|
||||||
axios@^0.21.4:
|
axios@^0.27.2:
|
||||||
version "0.21.4"
|
version "0.27.2"
|
||||||
resolved "https://registry.yarnpkg.com/axios/-/axios-0.21.4.tgz#c67b90dc0568e5c1cf2b0b858c43ba28e2eda575"
|
resolved "https://registry.yarnpkg.com/axios/-/axios-0.27.2.tgz#207658cc8621606e586c85db4b41a750e756d972"
|
||||||
integrity sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==
|
integrity sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==
|
||||||
dependencies:
|
dependencies:
|
||||||
follow-redirects "^1.14.0"
|
follow-redirects "^1.14.9"
|
||||||
|
form-data "^4.0.0"
|
||||||
|
|
||||||
axobject-query@^2.2.0:
|
axobject-query@^2.2.0:
|
||||||
version "2.2.0"
|
version "2.2.0"
|
||||||
|
@ -5106,11 +5107,16 @@ flatted@^3.1.0:
|
||||||
resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.2.2.tgz#64bfed5cb68fe3ca78b3eb214ad97b63bedce561"
|
resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.2.2.tgz#64bfed5cb68fe3ca78b3eb214ad97b63bedce561"
|
||||||
integrity sha512-JaTY/wtrcSyvXJl4IMFHPKyFur1sE9AUqc0QnhOaJ0CxHtAoIV8pYDzeEfAaNEtGkOfq4gr3LBFmdXW5mOQFnA==
|
integrity sha512-JaTY/wtrcSyvXJl4IMFHPKyFur1sE9AUqc0QnhOaJ0CxHtAoIV8pYDzeEfAaNEtGkOfq4gr3LBFmdXW5mOQFnA==
|
||||||
|
|
||||||
follow-redirects@^1.0.0, follow-redirects@^1.14.0:
|
follow-redirects@^1.0.0:
|
||||||
version "1.14.4"
|
version "1.14.4"
|
||||||
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.4.tgz#838fdf48a8bbdd79e52ee51fb1c94e3ed98b9379"
|
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.4.tgz#838fdf48a8bbdd79e52ee51fb1c94e3ed98b9379"
|
||||||
integrity sha512-zwGkiSXC1MUJG/qmeIFH2HBJx9u0V46QGUe3YR1fXG8bXQxq7fLj0RjLZQ5nubr9qNJUZrH+xUcwXEoXNpfS+g==
|
integrity sha512-zwGkiSXC1MUJG/qmeIFH2HBJx9u0V46QGUe3YR1fXG8bXQxq7fLj0RjLZQ5nubr9qNJUZrH+xUcwXEoXNpfS+g==
|
||||||
|
|
||||||
|
follow-redirects@^1.14.9:
|
||||||
|
version "1.14.9"
|
||||||
|
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.9.tgz#dd4ea157de7bfaf9ea9b3fbd85aa16951f78d8d7"
|
||||||
|
integrity sha512-MQDfihBQYMcyy5dhRDJUHcw7lb2Pv/TuE6xP1vyraLukNDHKbDxDNaOE3NbCAdKQApno+GPRyo1YAp89yCjK4w==
|
||||||
|
|
||||||
foreach@^2.0.5:
|
foreach@^2.0.5:
|
||||||
version "2.0.5"
|
version "2.0.5"
|
||||||
resolved "https://registry.yarnpkg.com/foreach/-/foreach-2.0.5.tgz#0bee005018aeb260d0a3af3ae658dd0136ec1b99"
|
resolved "https://registry.yarnpkg.com/foreach/-/foreach-2.0.5.tgz#0bee005018aeb260d0a3af3ae658dd0136ec1b99"
|
||||||
|
@ -5144,6 +5150,15 @@ form-data@^3.0.0:
|
||||||
combined-stream "^1.0.8"
|
combined-stream "^1.0.8"
|
||||||
mime-types "^2.1.12"
|
mime-types "^2.1.12"
|
||||||
|
|
||||||
|
form-data@^4.0.0:
|
||||||
|
version "4.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452"
|
||||||
|
integrity sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==
|
||||||
|
dependencies:
|
||||||
|
asynckit "^0.4.0"
|
||||||
|
combined-stream "^1.0.8"
|
||||||
|
mime-types "^2.1.12"
|
||||||
|
|
||||||
forwarded@0.2.0:
|
forwarded@0.2.0:
|
||||||
version "0.2.0"
|
version "0.2.0"
|
||||||
resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811"
|
resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811"
|
||||||
|
|
Loading…
Reference in New Issue