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 (
);
+}
+
+function ProfileFields({ field: formField }) {
+ return (
+
+
+ {formField.value.map((data, i) => (
+
+ ))}
+
+
+ );
+}
+
+function Field({ index, data }) {
+ const form = useWithFormContext(index, {
+ name: useTextInput("name", { defaultValue: data.name }),
+ value: useTextInput("value", { defaultValue: data.value })
+ });
+
+ return (
+
+
+
+
+ );
}
\ No newline at end of file
diff --git a/web/source/yarn.lock b/web/source/yarn.lock
index 7a06633eb..f792f115a 100644
--- 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"