From 5cc962593e1aaa93f0d5f182740b697aee9fc1b8 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 29 Apr 2022 12:58:57 -0500 Subject: [PATCH 01/16] ProfilePreview: convert to TSX --- .../components/profile_preview.js | Bin 1520 -> 0 bytes .../components/profile_preview.tsx | 44 ++++++++++++++++++ 2 files changed, 44 insertions(+) delete mode 100644 app/soapbox/features/edit_profile/components/profile_preview.js create mode 100644 app/soapbox/features/edit_profile/components/profile_preview.tsx diff --git a/app/soapbox/features/edit_profile/components/profile_preview.js b/app/soapbox/features/edit_profile/components/profile_preview.js deleted file mode 100644 index 7fe1e8e4c9570fa739ed9df4fdf59838fe234576..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1520 zcmaKs!H(M?5Qgu0iaAt^EyeMrhboPuZ4Z@Js;a8H>NQIYiD_-)0?sC~{O%nHNw6Jl zE-~=)&Cd*js2ZsZ{8X}eIW=6vPRR;LgX6^53G#U+&YEAG=Ekop`+Zw_s;bt|ZOO+R zr%S|TIkDUkwu4;OHAi$G!V|?=d%T6{;Jc{*jy1A1TqR6aD7^T5Hli$_D!S*MrzLH+ z@|fll4^s2m=u}(HMp)4;#BZ*|PUO@GS%0PMz5Rzky4hOo4b#D%n=g5Apo@VPqw19A zxopuyxPuAZl=SrNUwu8{aFaIF*cdZRmNrn)=Go4?NSkpD88`uCYlylTz>T*Iz9T1! z=d(V(l5qb{*uwFH;BN?uI`9tX!B*&F2S@gbi38*%)%pjmct&!n7!-;9BWqkog)5bj zS@)Vge&{@IfoX{d4h)I=*ZwTMkK15r_8#VdK}~6CGD1MBJR6b=P8nCohV;r#(d%_^ z$M*({r@}xZ4LwjZ5TM_wmmnE!(iGCjd;UBN{k;ihii~{xM4;eeU*Li4_tiEA35`#4 zuuAPkxAFX>ZL48JE$oYTepxfHo#_KEVftn+6wjq%b5w=> kYdhQ#7F#JxY+ek)kM7xKd`H{TTwc&<^en{D& = ({ account }) => { + const { displayFqn } = useSoapboxConfig(); + + return ( +
+ +
+ +
+
+
+ +
+
+ {account.username} + + + {account.display_name} + {account.verified && } + + + @{displayFqn ? account.fqn : account.acct} +
+
+ +
+ ); +}; + +export default ProfilePreview; From e6a797d712657a40da22d03d12260ac43eb6076e Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 29 Apr 2022 12:59:13 -0500 Subject: [PATCH 02/16] normalizeAccount(): normalize `discoverable` field --- app/soapbox/normalizers/account.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/soapbox/normalizers/account.ts b/app/soapbox/normalizers/account.ts index cdb80dfd40..5db1100216 100644 --- a/app/soapbox/normalizers/account.ts +++ b/app/soapbox/normalizers/account.ts @@ -27,6 +27,7 @@ export const AccountRecord = ImmutableRecord({ birthday: undefined as Date | undefined, bot: false, created_at: new Date(), + discoverable: false, display_name: '', emojis: ImmutableList(), favicon: '', @@ -255,6 +256,11 @@ const addStaffFields = (account: ImmutableMap) => { }); }; +const normalizeDiscoverable = (account: ImmutableMap) => { + const discoverable = Boolean(account.get('discoverable') || account.getIn(['source', 'pleroma', 'discoverable'])); + return account.set('discoverable', discoverable); +}; + export const normalizeAccount = (account: Record) => { return AccountRecord( ImmutableMap(fromJS(account)).withMutations(account => { @@ -269,6 +275,7 @@ export const normalizeAccount = (account: Record) => { normalizeLocation(account); normalizeFqn(account); normalizeFavicon(account); + normalizeDiscoverable(account); addDomain(account); addStaffFields(account); fixUsername(account); From 858740ad4717e0b0135551092cd9dc0094e2946b Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 29 Apr 2022 14:12:52 -0500 Subject: [PATCH 03/16] EditProfile: convert to React.FC+TSX (mostly) --- .../components/ui/form-group/form-group.tsx | 4 +- .../components/ui/textarea/textarea.tsx | 1 + app/soapbox/features/edit_profile/index.js | Bin 17694 -> 0 bytes app/soapbox/features/edit_profile/index.tsx | 421 ++++++++++++++++++ app/soapbox/features/forms/index.tsx | 9 +- app/soapbox/normalizers/account.ts | 2 +- 6 files changed, 433 insertions(+), 4 deletions(-) delete mode 100644 app/soapbox/features/edit_profile/index.js create mode 100644 app/soapbox/features/edit_profile/index.tsx diff --git a/app/soapbox/components/ui/form-group/form-group.tsx b/app/soapbox/components/ui/form-group/form-group.tsx index bd8a078a1f..c0b587526f 100644 --- a/app/soapbox/components/ui/form-group/form-group.tsx +++ b/app/soapbox/components/ui/form-group/form-group.tsx @@ -2,8 +2,8 @@ import React, { useMemo } from 'react'; import { v4 as uuidv4 } from 'uuid'; interface IFormGroup { - hintText?: string | React.ReactNode, - labelText: string, + hintText?: React.ReactNode, + labelText: React.ReactNode, errors?: string[] } diff --git a/app/soapbox/components/ui/textarea/textarea.tsx b/app/soapbox/components/ui/textarea/textarea.tsx index 81a8488b8b..b0b72573af 100644 --- a/app/soapbox/components/ui/textarea/textarea.tsx +++ b/app/soapbox/components/ui/textarea/textarea.tsx @@ -8,6 +8,7 @@ interface ITextarea extends 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 Rj^)CPJoB9_oTlF&{U7Z%ga-fs diff --git a/app/soapbox/features/edit_profile/index.tsx b/app/soapbox/features/edit_profile/index.tsx new file mode 100644 index 0000000000..a4bb5dd049 --- /dev/null +++ b/app/soapbox/features/edit_profile/index.tsx @@ -0,0 +1,421 @@ +import { + Map as ImmutableMap, + List as ImmutableList, +} from 'immutable'; +import { unescape } from 'lodash'; +import React, { useState, useEffect } 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 Icon from 'soapbox/components/icon'; +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 } 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: '' }), +// ) +// ); +// +// /** HTML unescape for special chars, eg
*/ +// const unescapeParams = (map, params) => ( +// params.reduce((map, param) => ( +// map.set(param, unescape(map.get(param))) +// ), map) +// ); + +/** + * 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?: string, + /** Header image encoded using multipart/form-data */ + header?: string, + /** 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>('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({}); + + useEffect(() => { + if (account) { + const credentials = accountToCredentials(account); + setData(credentials); + } + }, [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 credentials = dispatch(patchMe(data)); + /* 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, + })); */ + + setLoading(true); + + Promise.all([credentials /*notifications*/]).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 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 => { + // const url = file ? URL.createObjectURL(file) : data[name]; + 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)), + // }); + // }; + // }; + + return ( + +
+ } + > + + + + {features.birthdays && ( + } + > + + + )} + + {features.accountLocation && ( + } + > + + + )} + + {features.accountWebsite && ( + } + > + + + )} + + } + > +