diff --git a/internal/api/client/instance/instancepatch_test.go b/internal/api/client/instance/instancepatch_test.go index 60c29c0f2..11382f83a 100644 --- 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 index edd9fe7bb..5232e8d66 100644 --- 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 index 7d2056a4c..d6da9493f 100644 --- a/internal/typeutils/internaltofrontend.go +++ b/internal/typeutils/internaltofrontend.go @@ -43,6 +43,7 @@ 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 index 5a98303e8..d99a31e25 100644 --- 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 index bd082b6e7..abdb46159 100644 --- 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 index 76f90b462..de796fe30 100644 --- 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 new file mode 100644 index 000000000..b25bb11b7 --- /dev/null +++ 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 . +*/ + +"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 new file mode 100644 index 000000000..beea0bc9b --- /dev/null +++ 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 . +*/ + +"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 new file mode 100644 index 000000000..6bdc3e4cd --- /dev/null +++ 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 . +*/ + +"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 index 1bdb2a6d4..3d5f5238b 100644 --- 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 index ee30df8a2..2d1d42b3a 100644 --- 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 index f880853d2..653fc449b 100644 --- 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 index 9392b76a5..84878728d 100644 --- 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 index 4b39d1822..9bae41f63 100644 --- 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 (
@@ -129,7 +143,11 @@ function UserProfileForm({ data: profile }) { field={form.enableRSS} label="Enable RSS feed of Public posts" /> - {!allowCustomCSS ? null : + Profile fields + + {!instanceConfig.allowCustomCSS ? null :