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 { useAppSelector, useAppDispatch, useOwnAccount, useFeatures } from 'soapbox/hooks'; import { normalizeAccount } from 'soapbox/normalizers'; import resizeImage from 'soapbox/utils/resize_image'; import { Button, Column, Form, FormActions, FormGroup, Input, Textarea, HStack } from '../../components/ui'; import Streamfield from '../../components/ui/streamfield/streamfield'; 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' }, }); /** * 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: account.display_name, note: account.source.get('note'), locked: account.locked, fields_attributes: [...account.source.get>('fields', []).toJS()], stranger_notifications: account.getIn(['pleroma', 'notification_settings', 'block_from_strangers']) === true, accepts_email_list: account.getIn(['pleroma', 'accepts_email_list']) === true, hide_followers: hideNetwork, hide_follows: hideNetwork, hide_followers_count: hideNetwork, hide_follows_count: hideNetwork, website: account.website, location: account.location, birthday: account.birthday, }; }; interface IProfileField { value: AccountCredentialsField, onChange: (field: AccountCredentialsField) => void, } const ProfileField: React.FC = ({ value, onChange }) => { const handleChange = (key: string): React.ChangeEventHandler => { return e => { onChange({ ...value, [key]: e.currentTarget.value }); }; }; return ( ); }; /** 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({}); 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 => { return e => { updateData(key, e.target.checked); }; }; const handleTextChange = (key: keyof AccountCredentials): React.ChangeEventHandler => { return e => { updateData(key, e.target.value); }; }; const handleHideNetworkChange: React.ChangeEventHandler = 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 => { return e => { const f = e.target.files?.item(0); if (!f) return; resizeImage(f, maxPixels).then(file => { updateData(name, file); }).catch(console.error); }; }; const handleFieldsChange = (fields: AccountCredentialsField[]) => { updateData('fields_attributes', fields); }; const handleAddField = () => { const oldFields = data.fields_attributes || []; const fields = [...oldFields, { name: '', value: '' }]; updateData('fields_attributes', fields); }; const handleRemoveField = (i: number) => { const oldFields = data.fields_attributes || []; const fields = [...oldFields]; fields.splice(i, 1); updateData('fields_attributes', fields); }; /** Memoized avatar preview URL. */ const avatarUrl = useMemo(() => { 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 (
} > {features.birthdays && ( } > )} {features.accountLocation && ( } > )} {features.accountWebsite && ( } > )} } >