gtsocial-umbx

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README | LICENSE

commit 8fb5a7e7f8d4201590e709989e8f0627e800c147
parent 4990099fdeee5ac362295de3879d4b291e629c76
Author: f0x52 <f0x@cthu.lu>
Date:   Tue, 13 Jun 2023 12:21:26 +0200

[Frontend] Settings for profile fields (#1885)

* get max emoji size from instance settings

* expose (hardcoded) max amount of profile fields in instance api

* basic profile field setting

* fix profile field hook structure for updates

* *twirls mustache* fix ze tests

---------

Co-authored-by: tsmethurst <tobi.smethurst@protonmail.com>
Diffstat:
Minternal/api/client/instance/instancepatch_test.go | 18++++++++++++------
Minternal/api/model/instance.go | 3+++
Minternal/typeutils/internaltofrontend.go | 3+++
Minternal/typeutils/internaltofrontend_test.go | 6++++--
Mweb/source/package.json | 1+
Mweb/source/settings/admin/emoji/local/new-emoji.js | 7++++++-
Aweb/source/settings/lib/form/context.jsx | 34++++++++++++++++++++++++++++++++++
Aweb/source/settings/lib/form/field-array.jsx | 66++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aweb/source/settings/lib/form/get-form-mutations.js | 48++++++++++++++++++++++++++++++++++++++++++++++++
Mweb/source/settings/lib/form/index.js | 1+
Mweb/source/settings/lib/form/submit.js | 27+++++++--------------------
Mweb/source/settings/lib/query/base.js | 22++++------------------
Mweb/source/settings/style.css | 11+++++++++++
Mweb/source/settings/user/profile.js | 64+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mweb/source/yarn.lock | 5+++++
15 files changed, 264 insertions(+), 52 deletions(-)

diff --git a/internal/api/client/instance/instancepatch_test.go b/internal/api/client/instance/instancepatch_test.go @@ -118,7 +118,8 @@ func (suite *InstancePatchTestSuite) TestInstancePatch1() { }, "accounts": { "allow_custom_css": true, - "max_featured_tags": 10 + "max_featured_tags": 10, + "max_profile_fields": 6 }, "emojis": { "emoji_size_limit": 51200 @@ -221,7 +222,8 @@ func (suite *InstancePatchTestSuite) TestInstancePatch2() { }, "accounts": { "allow_custom_css": true, - "max_featured_tags": 10 + "max_featured_tags": 10, + "max_profile_fields": 6 }, "emojis": { "emoji_size_limit": 51200 @@ -324,7 +326,8 @@ func (suite *InstancePatchTestSuite) TestInstancePatch3() { }, "accounts": { "allow_custom_css": true, - "max_featured_tags": 10 + "max_featured_tags": 10, + "max_profile_fields": 6 }, "emojis": { "emoji_size_limit": 51200 @@ -478,7 +481,8 @@ func (suite *InstancePatchTestSuite) TestInstancePatch6() { }, "accounts": { "allow_custom_css": true, - "max_featured_tags": 10 + "max_featured_tags": 10, + "max_profile_fields": 6 }, "emojis": { "emoji_size_limit": 51200 @@ -603,7 +607,8 @@ func (suite *InstancePatchTestSuite) TestInstancePatch8() { }, "accounts": { "allow_custom_css": true, - "max_featured_tags": 10 + "max_featured_tags": 10, + "max_profile_fields": 6 }, "emojis": { "emoji_size_limit": 51200 @@ -743,7 +748,8 @@ func (suite *InstancePatchTestSuite) TestInstancePatch9() { }, "accounts": { "allow_custom_css": true, - "max_featured_tags": 10 + "max_featured_tags": 10, + "max_profile_fields": 6 }, "emojis": { "emoji_size_limit": 51200 diff --git a/internal/api/model/instance.go b/internal/api/model/instance.go @@ -54,6 +54,9 @@ type InstanceConfigurationAccounts struct { // The maximum number of featured tags allowed for each account. // Currently not implemented, so this is hardcoded to 10. MaxFeaturedTags int `json:"max_featured_tags"` + // The maximum number of profile fields allowed for each account. + // Currently not configurable, so this is hardcoded to 6. (https://github.com/superseriousbusiness/gotosocial/issues/1876) + MaxProfileFields int `json:"max_profile_fields"` } // InstanceConfigurationStatuses models instance status config parameters. diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go @@ -43,6 +43,7 @@ const ( instancePollsMinExpiration = 300 // seconds instancePollsMaxExpiration = 2629746 // seconds instanceAccountsMaxFeaturedTags = 10 + instanceAccountsMaxProfileFields = 6 // FIXME: https://github.com/superseriousbusiness/gotosocial/issues/1876 instanceSourceURL = "https://github.com/superseriousbusiness/gotosocial" ) @@ -756,6 +757,7 @@ func (c *converter) InstanceToAPIV1Instance(ctx context.Context, i *gtsmodel.Ins instance.Configuration.Polls.MaxExpiration = instancePollsMaxExpiration instance.Configuration.Accounts.AllowCustomCSS = config.GetAccountsAllowCustomCSS() instance.Configuration.Accounts.MaxFeaturedTags = instanceAccountsMaxFeaturedTags + instance.Configuration.Accounts.MaxProfileFields = instanceAccountsMaxProfileFields instance.Configuration.Emojis.EmojiSizeLimit = int(config.GetMediaEmojiLocalMaxSize()) // URLs @@ -882,6 +884,7 @@ func (c *converter) InstanceToAPIV2Instance(ctx context.Context, i *gtsmodel.Ins instance.Configuration.Polls.MaxExpiration = instancePollsMaxExpiration instance.Configuration.Accounts.AllowCustomCSS = config.GetAccountsAllowCustomCSS() instance.Configuration.Accounts.MaxFeaturedTags = instanceAccountsMaxFeaturedTags + instance.Configuration.Accounts.MaxProfileFields = instanceAccountsMaxProfileFields instance.Configuration.Emojis.EmojiSizeLimit = int(config.GetMediaEmojiLocalMaxSize()) // registrations diff --git a/internal/typeutils/internaltofrontend_test.go b/internal/typeutils/internaltofrontend_test.go @@ -647,7 +647,8 @@ func (suite *InternalToFrontendTestSuite) TestInstanceV1ToFrontend() { }, "accounts": { "allow_custom_css": true, - "max_featured_tags": 10 + "max_featured_tags": 10, + "max_profile_fields": 6 }, "emojis": { "emoji_size_limit": 51200 @@ -730,7 +731,8 @@ func (suite *InternalToFrontendTestSuite) TestInstanceV2ToFrontend() { }, "accounts": { "allow_custom_css": true, - "max_featured_tags": 10 + "max_featured_tags": 10, + "max_profile_fields": 6 }, "statuses": { "max_characters": 5000, diff --git a/web/source/package.json b/web/source/package.json @@ -21,6 +21,7 @@ "match-sorter": "^6.3.1", "modern-normalize": "^1.1.0", "nanoid": "^4.0.0", + "object-to-formdata": "^4.4.2", "papaparse": "^5.3.2", "photoswipe": "^5.3.3", "photoswipe-dynamic-caption-plugin": "^1.2.7", diff --git a/web/source/settings/admin/emoji/local/new-emoji.js b/web/source/settings/admin/emoji/local/new-emoji.js @@ -42,9 +42,14 @@ const MutationButton = require("../../../components/form/mutation-button"); module.exports = function NewEmojiForm() { const shortcode = useShortcode(); + const { data: instance } = query.useInstanceQuery(); + const emojiMaxSize = React.useMemo(() => { + return instance?.configuration?.emojis?.emoji_size_limit ?? 50 * 1024; + }, [instance]); + const image = useFileInput("image", { withPreview: true, - maxSize: 50 * 1024 // TODO: get from instance api? + maxSize: emojiMaxSize }); const category = useComboBoxInput("category"); diff --git a/web/source/settings/lib/form/context.jsx b/web/source/settings/lib/form/context.jsx @@ -0,0 +1,33 @@ +/* + GoToSocial + Copyright (C) GoToSocial Authors admin@gotosocial.org + SPDX-License-Identifier: AGPL-3.0-or-later + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +"use strict"; + +const React = require("react"); + +const FormContext = React.createContext({}); + +module.exports = { + FormContext, + useWithFormContext(index, form) { + const formContainer = React.useContext(FormContext); + formContainer[index] = form; + return form; + } +}; +\ No newline at end of file diff --git a/web/source/settings/lib/form/field-array.jsx b/web/source/settings/lib/form/field-array.jsx @@ -0,0 +1,65 @@ +/* + GoToSocial + Copyright (C) GoToSocial Authors admin@gotosocial.org + SPDX-License-Identifier: AGPL-3.0-or-later + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +"use strict"; + +const React = require("react"); + +const getFormMutations = require("./get-form-mutations"); + +function parseFields(entries, length) { + const fields = []; + + for (let i = 0; i < length; i++) { + if (entries[i] != undefined) { + fields[i] = Object.assign({}, entries[i]); + } else { + fields[i] = {}; + } + } + + return fields; +} + +module.exports = function useArrayInput({ name, _Name }, { initialValue, length = 0 }) { + const fields = React.useRef({}); + + const value = React.useMemo(() => parseFields(initialValue, length), [initialValue, length]); + + return { + name, + value, + ctx: fields.current, + maxLength: length, + selectedValues() { + // if any form field changed, we need to re-send everything + const hasUpdate = Object.values(fields.current).some((fieldSet) => { + const { updatedFields } = getFormMutations(fieldSet, { changedOnly: true }); + return updatedFields.length > 0; + }); + if (hasUpdate) { + return Object.values(fields.current).map((fieldSet) => { + return getFormMutations(fieldSet, { changedOnly: false }).mutationData; + }); + } else { + return []; + } + } + }; +}; +\ No newline at end of file diff --git a/web/source/settings/lib/form/get-form-mutations.js b/web/source/settings/lib/form/get-form-mutations.js @@ -0,0 +1,47 @@ +/* + GoToSocial + Copyright (C) GoToSocial Authors admin@gotosocial.org + SPDX-License-Identifier: AGPL-3.0-or-later + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +*/ + +"use strict"; + +const syncpipe = require("syncpipe"); + +module.exports = function getFormMutations(form, { changedOnly }) { + let updatedFields = []; + return { + updatedFields, + mutationData: syncpipe(form, [ + (_) => Object.values(_), + (_) => _.map((field) => { + if (field.selectedValues != undefined) { + let selected = field.selectedValues(); + if (!changedOnly || selected.length > 0) { + updatedFields.push(field); + return [field.name, selected]; + } + } else if (!changedOnly || field.hasChanged()) { + updatedFields.push(field); + return [field.name, field.value]; + } + return null; + }), + (_) => _.filter((value) => value != null), + (_) => Object.fromEntries(_) + ]) + }; +}; +\ No newline at end of file diff --git a/web/source/settings/lib/form/index.js b/web/source/settings/lib/form/index.js @@ -74,6 +74,7 @@ module.exports = { useRadioInput: makeHook(require("./radio")), useComboBoxInput: makeHook(require("./combo-box")), useCheckListInput: makeHook(require("./check-list")), + useFieldArrayInput: makeHook(require("./field-array")), useValue: function (name, value) { return { name, diff --git a/web/source/settings/lib/form/submit.js b/web/source/settings/lib/form/submit.js @@ -21,7 +21,7 @@ const Promise = require("bluebird"); const React = require("react"); -const syncpipe = require("syncpipe"); +const getFormMutations = require("./get-form-mutations"); module.exports = function useFormSubmit(form, mutationQuery, { changedOnly = true, onFinish } = {}) { if (!Array.isArray(mutationQuery)) { @@ -44,25 +44,12 @@ module.exports = function useFormSubmit(form, mutationQuery, { changedOnly = tru } usedAction.current = action; // transform the field definitions into an object with just their values - let updatedFields = []; - const mutationData = syncpipe(form, [ - (_) => Object.values(_), - (_) => _.map((field) => { - if (field.selectedValues != undefined) { - let selected = field.selectedValues(); - if (!changedOnly || selected.length > 0) { - updatedFields.push(field); - return [field.name, selected]; - } - } else if (!changedOnly || field.hasChanged()) { - updatedFields.push(field); - return [field.name, field.value]; - } - return null; - }), - (_) => _.filter((value) => value != null), - (_) => Object.fromEntries(_) - ]); + + const { mutationData, updatedFields } = getFormMutations(form, { changedOnly }); + + if (updatedFields.length == 0) { + return; + } mutationData.action = action; diff --git a/web/source/settings/lib/query/base.js b/web/source/settings/lib/query/base.js @@ -20,23 +20,7 @@ "use strict"; const { createApi, fetchBaseQuery } = require("@reduxjs/toolkit/query/react"); -const { isPlainObject } = require("is-plain-object"); - -function convertToForm(obj) { - const formData = new FormData(); - Object.entries(obj).forEach(([key, val]) => { - if (isPlainObject(val)) { - Object.entries(val).forEach(([key2, val2]) => { - if (val2 != undefined) { - formData.set(`${key}[${key2}]`, val2); - } - }); - } else if (val != undefined) { - formData.set(key, val); - } - }); - return formData; -} +const { serialize: serializeForm } = require("object-to-formdata"); function instanceBasedQuery(args, api, extraOptions) { const state = api.getState(); @@ -55,7 +39,9 @@ function instanceBasedQuery(args, api, extraOptions) { if (args.asForm) { delete args.asForm; - args.body = convertToForm(args.body); + args.body = serializeForm(args.body, { + indices: true, // Array indices, for profile fields + }); } return fetchBaseQuery({ diff --git a/web/source/settings/style.css b/web/source/settings/style.css @@ -439,6 +439,17 @@ section.with-sidebar > div, section.with-sidebar > form { } } } + + .fields { + display: flex; + flex-direction: column; + gap: 0.5rem; + + .entry { + display: flex; + gap: 0.5rem; + } + } } form { diff --git a/web/source/settings/user/profile.js b/web/source/settings/user/profile.js @@ -26,10 +26,12 @@ const query = require("../lib/query"); const { useTextInput, useFileInput, - useBoolInput + useBoolInput, + useFieldArrayInput } = require("../lib/form"); const useFormSubmit = require("../lib/form/submit"); +const { useWithFormContext, FormContext } = require("../lib/form/context"); const { TextInput, @@ -65,8 +67,11 @@ function UserProfileForm({ data: profile }) { */ const { data: instance } = query.useInstanceQuery(); - const allowCustomCSS = React.useMemo(() => { - return instance?.configuration?.accounts?.allow_custom_css === true; + const instanceConfig = React.useMemo(() => { + return { + allowCustomCSS: instance?.configuration?.accounts?.allow_custom_css === true, + maxPinnedFields: instance?.configuration?.accounts?.max_profile_fields ?? 6 + }; }, [instance]); const form = { @@ -78,9 +83,18 @@ function UserProfileForm({ data: profile }) { bot: useBoolInput("bot", { source: profile }), locked: useBoolInput("locked", { source: profile }), enableRSS: useBoolInput("enable_rss", { source: profile }), + fields: useFieldArrayInput("fields_attributes", { + defaultValue: profile?.source?.fields, + length: instanceConfig.maxPinnedFields + }), }; - const [submitForm, result] = useFormSubmit(form, query.useUpdateCredentialsMutation()); + const [submitForm, result] = useFormSubmit(form, query.useUpdateCredentialsMutation(), { + onFinish: () => { + form.avatar.reset(); + form.header.reset(); + } + }); return ( <form className="user-profile" onSubmit={submitForm}> @@ -129,7 +143,11 @@ function UserProfileForm({ data: profile }) { field={form.enableRSS} label="Enable RSS feed of Public posts" /> - {!allowCustomCSS ? null : + <b>Profile fields</b> + <ProfileFields + field={form.fields} + /> + {!instanceConfig.allowCustomCSS ? null : <TextArea field={form.customCSS} label="Custom CSS" @@ -142,4 +160,40 @@ function UserProfileForm({ data: profile }) { <MutationButton label="Save profile info" result={result} /> </form> ); +} + +function ProfileFields({ field: formField }) { + return ( + <div className="fields"> + <FormContext.Provider value={formField.ctx}> + {formField.value.map((data, i) => ( + <Field + key={i} + index={i} + data={data} + /> + ))} + </FormContext.Provider> + </div> + ); +} + +function Field({ index, data }) { + const form = useWithFormContext(index, { + name: useTextInput("name", { defaultValue: data.name }), + value: useTextInput("value", { defaultValue: data.value }) + }); + + return ( + <div className="entry"> + <TextInput + field={form.name} + placeholder="Name" + /> + <TextInput + field={form.value} + placeholder="Value" + /> + </div> + ); } \ No newline at end of file diff --git a/web/source/yarn.lock b/web/source/yarn.lock @@ -4137,6 +4137,11 @@ object-keys@^1.1.1: resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e" integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA== +object-to-formdata@^4.4.2: + version "4.4.2" + resolved "https://registry.yarnpkg.com/object-to-formdata/-/object-to-formdata-4.4.2.tgz#f89013f90493c58cb5f6ab9f50b7aeec30745ea6" + integrity sha512-fu6UDjsqIfFUu/B3GXJ2IFnNAL/YbsC1PPzqDIFXcfkhdYjTD3K4zqhyD/lZ6+KdP9O/64YIPckIOiS5ouXwLA== + object.assign@^4.1.3, object.assign@^4.1.4: version "4.1.4" resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.4.tgz#9673c7c7c351ab8c4d0b516f4343ebf4dfb7799f"