From 0aadc2db2a42fc99538fbbb096b84b209b9ccd68 Mon Sep 17 00:00:00 2001 From: tobi <31960611+tsmethurst@users.noreply.github.com> Date: Wed, 17 Jul 2024 16:46:52 +0200 Subject: [PATCH] [feature] Allow users to set default interaction policies per status visibility (#3108) * [feature] Allow users to set default interaction policies * use vars for default policies * avoid some code repetition * unfuck form binding * avoid bonkers loop * beep boop * put policyValsToAPIPolicyVals in separate function * don't bother with slices.Grow * oops --- cmd/gotosocial/action/testrig/testrig.go | 8 +- docs/api/swagger.yaml | 233 ++++++++ .../user-settings-interaction-policy-1.png | Bin 0 -> 63414 bytes docs/user_guide/posts.md | 29 - docs/user_guide/settings.md | 50 +- internal/api/client.go | 108 ++-- internal/api/client/admin/reportsget_test.go | 66 ++- .../client/interactionpolicies/getdefaults.go | 77 +++ .../client/interactionpolicies/policies.go | 45 ++ .../interactionpolicies/updatedefaults.go | 334 +++++++++++ .../api/client/statuses/statusmute_test.go | 44 +- internal/api/model/interactionpolicy.go | 111 ++++ internal/api/model/status.go | 2 + internal/gtsmodel/interactionpolicy.go | 190 +++--- .../processing/account/interactionpolicies.go | 208 +++++++ internal/processing/status/create.go | 85 ++- .../processing/stream/statusupdate_test.go | 22 +- internal/typeutils/frontendtointernal.go | 172 ++++++ internal/typeutils/internaltofrontend.go | 123 ++++ internal/typeutils/internaltofrontend_test.go | 224 ++++++- mkdocs.yml | 2 +- .../settings/components/form/inputs.tsx | 28 +- web/source/settings/lib/query/gts-api.ts | 1 + web/source/settings/lib/query/user/index.ts | 37 +- web/source/settings/lib/types/account.ts | 11 + web/source/settings/lib/types/interaction.ts | 63 ++ web/source/settings/style.css | 84 ++- .../user/{settings.tsx => emailpassword.tsx} | 83 +-- web/source/settings/views/user/menu.tsx | 14 +- .../views/user/posts/basic-settings/index.tsx | 88 +++ .../settings/views/user/posts/index.tsx | 51 ++ .../interaction-policy-settings/basic.tsx | 180 ++++++ .../interaction-policy-settings/index.tsx | 553 ++++++++++++++++++ .../something-else.tsx | 124 ++++ .../interaction-policy-settings/types.ts | 35 ++ web/source/settings/views/user/router.tsx | 9 +- 36 files changed, 3178 insertions(+), 316 deletions(-) create mode 100644 docs/assets/user-settings-interaction-policy-1.png create mode 100644 internal/api/client/interactionpolicies/getdefaults.go create mode 100644 internal/api/client/interactionpolicies/policies.go create mode 100644 internal/api/client/interactionpolicies/updatedefaults.go create mode 100644 internal/api/model/interactionpolicy.go create mode 100644 internal/processing/account/interactionpolicies.go create mode 100644 web/source/settings/lib/types/interaction.ts rename web/source/settings/views/user/{settings.tsx => emailpassword.tsx} (73%) create mode 100644 web/source/settings/views/user/posts/basic-settings/index.tsx create mode 100644 web/source/settings/views/user/posts/index.tsx create mode 100644 web/source/settings/views/user/posts/interaction-policy-settings/basic.tsx create mode 100644 web/source/settings/views/user/posts/interaction-policy-settings/index.tsx create mode 100644 web/source/settings/views/user/posts/interaction-policy-settings/something-else.tsx create mode 100644 web/source/settings/views/user/posts/interaction-policy-settings/types.ts diff --git a/cmd/gotosocial/action/testrig/testrig.go b/cmd/gotosocial/action/testrig/testrig.go index 7b99a2a13..99f366fbe 100644 --- a/cmd/gotosocial/action/testrig/testrig.go +++ b/cmd/gotosocial/action/testrig/testrig.go @@ -155,10 +155,6 @@ } testrig.StandardStorageSetup(state.Storage, "./testrig/media") - // Initialize workers. - testrig.StartNoopWorkers(state) - defer testrig.StopWorkers(state) - // build backend handlers transportController := testrig.NewTestTransportController(state, testrig.NewMockHTTPClient(func(req *http.Request) (*http.Response, error) { r := io.NopCloser(bytes.NewReader([]byte{})) @@ -199,6 +195,10 @@ processor := testrig.NewTestProcessor(state, federator, emailSender, mediaManager) + // Initialize workers. + testrig.StartWorkers(state, processor.Workers()) + defer testrig.StopWorkers(state) + // Initialize metrics. if err := metrics.Initialize(state.DB); err != nil { return fmt.Errorf("error initializing metrics: %w", err) diff --git a/docs/api/swagger.yaml b/docs/api/swagger.yaml index b91b4f4b0..66f7e53a5 100644 --- a/docs/api/swagger.yaml +++ b/docs/api/swagger.yaml @@ -895,6 +895,20 @@ definitions: type: object x-go-name: DebugAPUrlResponse x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model + defaultPolicies: + properties: + direct: + $ref: '#/definitions/interactionPolicy' + private: + $ref: '#/definitions/interactionPolicy' + public: + $ref: '#/definitions/interactionPolicy' + unlisted: + $ref: '#/definitions/interactionPolicy' + title: Default interaction policies to use for new statuses by requesting account. + type: object + x-go-name: DefaultPolicies + x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model domain: description: Domain represents a remote domain properties: @@ -1821,6 +1835,53 @@ definitions: type: object x-go-name: InstanceV2Users x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model + interactionPolicy: + properties: + can_favourite: + $ref: '#/definitions/interactionPolicyRules' + can_reblog: + $ref: '#/definitions/interactionPolicyRules' + can_reply: + $ref: '#/definitions/interactionPolicyRules' + title: Interaction policy of a status. + type: object + x-go-name: InteractionPolicy + x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model + interactionPolicyRules: + properties: + always: + description: Policy entries for accounts that can always do this type of interaction. + items: + $ref: '#/definitions/interactionPolicyValue' + type: array + x-go-name: Always + with_approval: + description: Policy entries for accounts that require approval to do this type of interaction. + items: + $ref: '#/definitions/interactionPolicyValue' + type: array + x-go-name: WithApproval + title: Rules for one interaction type. + type: object + x-go-name: PolicyRules + x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model + interactionPolicyValue: + description: |- + It can be EITHER one of the internal keywords listed below, OR a full-fledged ActivityPub URI of an Actor, like "https://example.org/users/some_user". + + Internal keywords: + + public - Public, aka anyone who can see the status according to its visibility level. + followers - Followers of the status author. + following - People followed by the status author. + mutuals - Mutual follows of the status author (reserved, unused). + mentioned - Accounts mentioned in, or replied-to by, the status. + author - The status author themself. + me - If request was made with an authorized user, "me" represents the user who made the request and is now looking at this interaction policy. + title: One interaction policy entry for a status. + type: string + x-go-name: PolicyValue + x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model list: properties: id: @@ -2429,6 +2490,8 @@ definitions: example: 01FBVD42CQ3ZEEVMW180SBX03B type: string x-go-name: InReplyToID + interaction_policy: + $ref: '#/definitions/interactionPolicy' language: description: |- Primary language of this status (ISO 639 Part 1 two-letter language code). @@ -2620,6 +2683,8 @@ definitions: example: 01FBVD42CQ3ZEEVMW180SBX03B type: string x-go-name: InReplyToID + interaction_policy: + $ref: '#/definitions/interactionPolicy' language: description: |- Primary language of this status (ISO 639 Part 1 two-letter language code). @@ -6850,6 +6915,174 @@ paths: summary: View instance rules (public). tags: - instance + /api/v1/interaction_policies/defaults: + get: + operationId: policiesDefaultsGet + produces: + - application/json + responses: + "200": + description: A default policies object containing a policy for each status visibility. + schema: + $ref: '#/definitions/defaultPolicies' + "401": + description: unauthorized + "406": + description: not acceptable + "500": + description: internal server error + security: + - OAuth2 Bearer: + - read:accounts + summary: Get default interaction policies for new statuses created by you. + tags: + - interaction_policies + patch: + consumes: + - multipart/form-data + - application/x-www-form-urlencoded + - application/json + description: |- + If submitting using form data, use the following pattern: + + `VISIBILITY[INTERACTION_TYPE][CONDITION][INDEX]=Value` + + For example: `public[can_reply][always][0]=author` + + Using `curl` this might look something like: + + `curl -F 'public[can_reply][always][0]=author' -F 'public[can_reply][always][1]=followers'` + + The JSON equivalent would be: + + `curl -H 'Content-Type: application/json' -d '{"public":{"can_reply":{"always":["author","followers"]}}}'` + + Any visibility level left unspecified in the request body will be returned to the default. + + Ie., in the example above, "public" would be updated, but "unlisted", "private", and "direct" would be reset to defaults. + + The server will perform some normalization on submitted policies so that you can't submit totally invalid policies. + operationId: policiesDefaultsUpdate + parameters: + - description: Nth entry for public.can_favourite.always. + in: formData + name: public[can_favourite][always][0] + type: string + - description: Nth entry for public.can_favourite.with_approval. + in: formData + name: public[can_favourite][with_approval][0] + type: string + - description: Nth entry for public.can_reply.always. + in: formData + name: public[can_reply][always][0] + type: string + - description: Nth entry for public.can_reply.with_approval. + in: formData + name: public[can_reply][with_approval][0] + type: string + - description: Nth entry for public.can_reblog.always. + in: formData + name: public[can_reblog][always][0] + type: string + - description: Nth entry for public.can_reblog.with_approval. + in: formData + name: public[can_reblog][with_approval][0] + type: string + - description: Nth entry for unlisted.can_favourite.always. + in: formData + name: unlisted[can_favourite][always][0] + type: string + - description: Nth entry for unlisted.can_favourite.with_approval. + in: formData + name: unlisted[can_favourite][with_approval][0] + type: string + - description: Nth entry for unlisted.can_reply.always. + in: formData + name: unlisted[can_reply][always][0] + type: string + - description: Nth entry for unlisted.can_reply.with_approval. + in: formData + name: unlisted[can_reply][with_approval][0] + type: string + - description: Nth entry for unlisted.can_reblog.always. + in: formData + name: unlisted[can_reblog][always][0] + type: string + - description: Nth entry for unlisted.can_reblog.with_approval. + in: formData + name: unlisted[can_reblog][with_approval][0] + type: string + - description: Nth entry for private.can_favourite.always. + in: formData + name: private[can_favourite][always][0] + type: string + - description: Nth entry for private.can_favourite.with_approval. + in: formData + name: private[can_favourite][with_approval][0] + type: string + - description: Nth entry for private.can_reply.always. + in: formData + name: private[can_reply][always][0] + type: string + - description: Nth entry for private.can_reply.with_approval. + in: formData + name: private[can_reply][with_approval][0] + type: string + - description: Nth entry for private.can_reblog.always. + in: formData + name: private[can_reblog][always][0] + type: string + - description: Nth entry for private.can_reblog.with_approval. + in: formData + name: private[can_reblog][with_approval][0] + type: string + - description: Nth entry for direct.can_favourite.always. + in: formData + name: direct[can_favourite][always][0] + type: string + - description: Nth entry for direct.can_favourite.with_approval. + in: formData + name: direct[can_favourite][with_approval][0] + type: string + - description: Nth entry for direct.can_reply.always. + in: formData + name: direct[can_reply][always][0] + type: string + - description: Nth entry for direct.can_reply.with_approval. + in: formData + name: direct[can_reply][with_approval][0] + type: string + - description: Nth entry for direct.can_reblog.always. + in: formData + name: direct[can_reblog][always][0] + type: string + - description: Nth entry for direct.can_reblog.with_approval. + in: formData + name: direct[can_reblog][with_approval][0] + type: string + produces: + - application/json + responses: + "200": + description: Updated default policies object containing a policy for each status visibility. + schema: + $ref: '#/definitions/defaultPolicies' + "400": + description: bad request + "401": + description: unauthorized + "406": + description: not acceptable + "422": + description: unprocessable + "500": + description: internal server error + security: + - OAuth2 Bearer: + - write:accounts + summary: Update default interaction policies per visibility level for new statuses created by you. + tags: + - interaction_policies /api/v1/lists: get: operationId: lists diff --git a/docs/assets/user-settings-interaction-policy-1.png b/docs/assets/user-settings-interaction-policy-1.png new file mode 100644 index 0000000000000000000000000000000000000000..4a9a37239dda5e3a55e3245767c142c2bc11346c GIT binary patch literal 63414 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

($qf1UDC4E+2}`CqvyVDPO$HasjT$8RJ0(ZpRU3 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. + +package interactionpolicies + +import ( + "net/http" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// PoliciesDefaultsGETHandler swagger:operation GET /api/v1/interaction_policies/defaults policiesDefaultsGet +// +// Get default interaction policies for new statuses created by you. +// +// --- +// tags: +// - interaction_policies +// +// produces: +// - application/json +// +// security: +// - OAuth2 Bearer: +// - read:accounts +// +// responses: +// '200': +// description: A default policies object containing a policy for each status visibility. +// schema: +// "$ref": "#/definitions/defaultPolicies" +// '401': +// description: unauthorized +// '406': +// description: not acceptable +// '500': +// description: internal server error +func (m *Module) PoliciesDefaultsGETHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) + return + } + + resp, errWithCode := m.processor.Account().DefaultInteractionPoliciesGet( + c.Request.Context(), + authed.Account, + ) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + apiutil.JSON(c, http.StatusOK, resp) +} diff --git a/internal/api/client/interactionpolicies/policies.go b/internal/api/client/interactionpolicies/policies.go new file mode 100644 index 000000000..9b34a8c80 --- /dev/null +++ b/internal/api/client/interactionpolicies/policies.go @@ -0,0 +1,45 @@ +// 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 . + +package interactionpolicies + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/superseriousbusiness/gotosocial/internal/processing" +) + +const ( + BasePath = "/v1/interaction_policies" + DefaultsPath = BasePath + "/defaults" +) + +type Module struct { + processor *processing.Processor +} + +func New(processor *processing.Processor) *Module { + return &Module{ + processor: processor, + } +} + +func (m *Module) Route(attachHandler func(method string, path string, f ...gin.HandlerFunc) gin.IRoutes) { + attachHandler(http.MethodGet, DefaultsPath, m.PoliciesDefaultsGETHandler) + attachHandler(http.MethodPatch, DefaultsPath, m.PoliciesDefaultsPATCHHandler) +} diff --git a/internal/api/client/interactionpolicies/updatedefaults.go b/internal/api/client/interactionpolicies/updatedefaults.go new file mode 100644 index 000000000..e11a3bd19 --- /dev/null +++ b/internal/api/client/interactionpolicies/updatedefaults.go @@ -0,0 +1,334 @@ +// 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 . + +package interactionpolicies + +import ( + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/gin-gonic/gin/binding" + "github.com/go-playground/form/v4" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// PoliciesDefaultsPATCHHandler swagger:operation PATCH /api/v1/interaction_policies/defaults policiesDefaultsUpdate +// +// Update default interaction policies per visibility level for new statuses created by you. +// +// If submitting using form data, use the following pattern: +// +// `VISIBILITY[INTERACTION_TYPE][CONDITION][INDEX]=Value` +// +// For example: `public[can_reply][always][0]=author` +// +// Using `curl` this might look something like: +// +// `curl -F 'public[can_reply][always][0]=author' -F 'public[can_reply][always][1]=followers'` +// +// The JSON equivalent would be: +// +// `curl -H 'Content-Type: application/json' -d '{"public":{"can_reply":{"always":["author","followers"]}}}'` +// +// Any visibility level left unspecified in the request body will be returned to the default. +// +// Ie., in the example above, "public" would be updated, but "unlisted", "private", and "direct" would be reset to defaults. +// +// The server will perform some normalization on submitted policies so that you can't submit totally invalid policies. +// +// --- +// tags: +// - interaction_policies +// +// consumes: +// - multipart/form-data +// - application/x-www-form-urlencoded +// - application/json +// +// produces: +// - application/json +// +// parameters: +// - +// name: public[can_favourite][always][0] +// in: formData +// description: Nth entry for public.can_favourite.always. +// type: string +// - +// name: public[can_favourite][with_approval][0] +// in: formData +// description: Nth entry for public.can_favourite.with_approval. +// type: string +// - +// name: public[can_reply][always][0] +// in: formData +// description: Nth entry for public.can_reply.always. +// type: string +// - +// name: public[can_reply][with_approval][0] +// in: formData +// description: Nth entry for public.can_reply.with_approval. +// type: string +// - +// name: public[can_reblog][always][0] +// in: formData +// description: Nth entry for public.can_reblog.always. +// type: string +// - +// name: public[can_reblog][with_approval][0] +// in: formData +// description: Nth entry for public.can_reblog.with_approval. +// type: string +// +// - +// name: unlisted[can_favourite][always][0] +// in: formData +// description: Nth entry for unlisted.can_favourite.always. +// type: string +// - +// name: unlisted[can_favourite][with_approval][0] +// in: formData +// description: Nth entry for unlisted.can_favourite.with_approval. +// type: string +// - +// name: unlisted[can_reply][always][0] +// in: formData +// description: Nth entry for unlisted.can_reply.always. +// type: string +// - +// name: unlisted[can_reply][with_approval][0] +// in: formData +// description: Nth entry for unlisted.can_reply.with_approval. +// type: string +// - +// name: unlisted[can_reblog][always][0] +// in: formData +// description: Nth entry for unlisted.can_reblog.always. +// type: string +// - +// name: unlisted[can_reblog][with_approval][0] +// in: formData +// description: Nth entry for unlisted.can_reblog.with_approval. +// type: string +// +// - +// name: private[can_favourite][always][0] +// in: formData +// description: Nth entry for private.can_favourite.always. +// type: string +// - +// name: private[can_favourite][with_approval][0] +// in: formData +// description: Nth entry for private.can_favourite.with_approval. +// type: string +// - +// name: private[can_reply][always][0] +// in: formData +// description: Nth entry for private.can_reply.always. +// type: string +// - +// name: private[can_reply][with_approval][0] +// in: formData +// description: Nth entry for private.can_reply.with_approval. +// type: string +// - +// name: private[can_reblog][always][0] +// in: formData +// description: Nth entry for private.can_reblog.always. +// type: string +// - +// name: private[can_reblog][with_approval][0] +// in: formData +// description: Nth entry for private.can_reblog.with_approval. +// type: string +// +// - +// name: direct[can_favourite][always][0] +// in: formData +// description: Nth entry for direct.can_favourite.always. +// type: string +// - +// name: direct[can_favourite][with_approval][0] +// in: formData +// description: Nth entry for direct.can_favourite.with_approval. +// type: string +// - +// name: direct[can_reply][always][0] +// in: formData +// description: Nth entry for direct.can_reply.always. +// type: string +// - +// name: direct[can_reply][with_approval][0] +// in: formData +// description: Nth entry for direct.can_reply.with_approval. +// type: string +// - +// name: direct[can_reblog][always][0] +// in: formData +// description: Nth entry for direct.can_reblog.always. +// type: string +// - +// name: direct[can_reblog][with_approval][0] +// in: formData +// description: Nth entry for direct.can_reblog.with_approval. +// type: string +// +// security: +// - OAuth2 Bearer: +// - write:accounts +// +// responses: +// '200': +// description: Updated default policies object containing a policy for each status visibility. +// schema: +// "$ref": "#/definitions/defaultPolicies" +// '400': +// description: bad request +// '401': +// description: unauthorized +// '406': +// description: not acceptable +// '422': +// description: unprocessable +// '500': +// description: internal server error +func (m *Module) PoliciesDefaultsPATCHHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) + return + } + + form, err := parseUpdateAccountForm(c) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1) + return + } + + resp, errWithCode := m.processor.Account().DefaultInteractionPoliciesUpdate( + c.Request.Context(), + authed.Account, + form, + ) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + apiutil.JSON(c, http.StatusOK, resp) +} + +// intPolicyFormBinding satisfies gin's binding.Binding interface. +// Should only be used specifically for multipart/form-data MIME type. +type intPolicyFormBinding struct { + visibility string +} + +func (i intPolicyFormBinding) Name() string { + return i.visibility +} + +func (intPolicyFormBinding) Bind(req *http.Request, obj any) error { + if err := req.ParseForm(); err != nil { + return err + } + + // Change default namespace prefix and suffix to + // allow correct parsing of the field attributes. + decoder := form.NewDecoder() + decoder.SetNamespacePrefix("[") + decoder.SetNamespaceSuffix("]") + + return decoder.Decode(obj, req.Form) +} + +// customBind does custom form binding for +// each visibility in the form data. +func customBind( + c *gin.Context, + form *apimodel.UpdateInteractionPoliciesRequest, +) error { + for _, vis := range []string{ + "Direct", + "Private", + "Unlisted", + "Public", + } { + if err := c.ShouldBindWith( + form, + intPolicyFormBinding{ + visibility: vis, + }, + ); err != nil { + return fmt.Errorf("custom form binding failed: %w", err) + } + } + + return nil +} + +func parseUpdateAccountForm(c *gin.Context) (*apimodel.UpdateInteractionPoliciesRequest, error) { + form := new(apimodel.UpdateInteractionPoliciesRequest) + + switch ct := c.ContentType(); ct { + case binding.MIMEJSON: + // Just bind with default json binding. + if err := c.ShouldBindWith(form, binding.JSON); err != nil { + return nil, err + } + + case binding.MIMEPOSTForm: + // Bind with default form binding first. + if err := c.ShouldBindWith(form, binding.FormPost); err != nil { + return nil, err + } + + // Now do custom binding. + if err := customBind(c, form); err != nil { + return nil, err + } + + case binding.MIMEMultipartPOSTForm: + // Bind with default form binding first. + if err := c.ShouldBindWith(form, binding.FormMultipart); err != nil { + return nil, err + } + + // Now do custom binding. + if err := customBind(c, form); err != nil { + return nil, err + } + + default: + err := fmt.Errorf( + "content-type %s not supported for this endpoint; supported content-types are %s, %s, %s", + ct, binding.MIMEJSON, binding.MIMEPOSTForm, binding.MIMEMultipartPOSTForm, + ) + return nil, err + } + + return form, nil +} diff --git a/internal/api/client/statuses/statusmute_test.go b/internal/api/client/statuses/statusmute_test.go index 01bea4e5c..9e517b36d 100644 --- a/internal/api/client/statuses/statusmute_test.go +++ b/internal/api/client/statuses/statusmute_test.go @@ -147,7 +147,27 @@ func (suite *StatusMuteTestSuite) TestMuteUnmuteStatus() { "emojis": [], "card": null, "poll": null, - "text": "hello everyone!" + "text": "hello everyone!", + "interaction_policy": { + "can_favourite": { + "always": [ + "public" + ], + "with_approval": [] + }, + "can_reply": { + "always": [ + "public" + ], + "with_approval": [] + }, + "can_reblog": { + "always": [ + "public" + ], + "with_approval": [] + } + } }`, muted) // Unmute the status, ensure `muted` is `false`. @@ -212,7 +232,27 @@ func (suite *StatusMuteTestSuite) TestMuteUnmuteStatus() { "emojis": [], "card": null, "poll": null, - "text": "hello everyone!" + "text": "hello everyone!", + "interaction_policy": { + "can_favourite": { + "always": [ + "public" + ], + "with_approval": [] + }, + "can_reply": { + "always": [ + "public" + ], + "with_approval": [] + }, + "can_reblog": { + "always": [ + "public" + ], + "with_approval": [] + } + } }`, unmuted) } diff --git a/internal/api/model/interactionpolicy.go b/internal/api/model/interactionpolicy.go new file mode 100644 index 000000000..7c5df09e8 --- /dev/null +++ b/internal/api/model/interactionpolicy.go @@ -0,0 +1,111 @@ +// 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 . + +package model + +// One interaction policy entry for a status. +// +// It can be EITHER one of the internal keywords listed below, OR a full-fledged ActivityPub URI of an Actor, like "https://example.org/users/some_user". +// +// Internal keywords: +// +// - public - Public, aka anyone who can see the status according to its visibility level. +// - followers - Followers of the status author. +// - following - People followed by the status author. +// - mutuals - Mutual follows of the status author (reserved, unused). +// - mentioned - Accounts mentioned in, or replied-to by, the status. +// - author - The status author themself. +// - me - If request was made with an authorized user, "me" represents the user who made the request and is now looking at this interaction policy. +// +// swagger:model interactionPolicyValue +type PolicyValue string + +const ( + PolicyValuePublic PolicyValue = "public" // Public, aka anyone who can see the status according to its visibility level. + PolicyValueFollowers PolicyValue = "followers" // Followers of the status author. + PolicyValueFollowing PolicyValue = "following" // People followed by the status author. + PolicyValueMutuals PolicyValue = "mutuals" // Mutual follows of the status author (reserved, unused). + PolicyValueMentioned PolicyValue = "mentioned" // Accounts mentioned in, or replied-to by, the status. + PolicyValueAuthor PolicyValue = "author" // The status author themself. + PolicyValueMe PolicyValue = "me" // If request was made with an authorized user, "me" represents the user who made the request and is now looking at this interaction policy. +) + +// Rules for one interaction type. +// +// swagger:model interactionPolicyRules +type PolicyRules struct { + // Policy entries for accounts that can always do this type of interaction. + Always []PolicyValue `form:"always" json:"always"` + // Policy entries for accounts that require approval to do this type of interaction. + WithApproval []PolicyValue `form:"with_approval" json:"with_approval"` +} + +// Interaction policy of a status. +// +// swagger:model interactionPolicy +type InteractionPolicy struct { + // Rules for who can favourite this status. + CanFavourite PolicyRules `form:"can_favourite" json:"can_favourite"` + // Rules for who can reply to this status. + CanReply PolicyRules `form:"can_reply" json:"can_reply"` + // Rules for who can reblog this status. + CanReblog PolicyRules `form:"can_reblog" json:"can_reblog"` +} + +// Default interaction policies to use for new statuses by requesting account. +// +// swagger:model defaultPolicies +type DefaultPolicies struct { + // TODO: Add mutuals only default. + + // Default policy for new direct visibility statuses. + Direct InteractionPolicy `json:"direct"` + // Default policy for new private/followers-only visibility statuses. + Private InteractionPolicy `json:"private"` + // Default policy for new unlisted/unlocked visibility statuses. + Unlisted InteractionPolicy `json:"unlisted"` + // Default policy for new public visibility statuses. + Public InteractionPolicy `json:"public"` +} + +// swagger:ignore +type UpdateInteractionPoliciesRequest struct { + // Default policy for new direct visibility statuses. + // Value `null` or omitted property resets policy to original default. + // + // in: formData + // nullable: true + Direct *InteractionPolicy `form:"direct" json:"direct"` + // Default policy for new private/followers-only visibility statuses. + // Value `null` or omitted property resets policy to original default. + // + // in: formData + // nullable: true + Private *InteractionPolicy `form:"private" json:"private"` + // Default policy for new unlisted/unlocked visibility statuses. + // Value `null` or omitted property resets policy to original default. + // + // in: formData + // nullable: true + Unlisted *InteractionPolicy `form:"unlisted" json:"unlisted"` + // Default policy for new public visibility statuses. + // Value `null` or omitted property resets policy to original default. + // + // in: formData + // nullable: true + Public *InteractionPolicy `form:"public" json:"public"` +} diff --git a/internal/api/model/status.go b/internal/api/model/status.go index e469835bd..7358916ab 100644 --- a/internal/api/model/status.go +++ b/internal/api/model/status.go @@ -102,6 +102,8 @@ type Status struct { Text string `json:"text,omitempty"` // A list of filters that matched this status and why they matched, if there are any such filters. Filtered []FilterResult `json:"filtered,omitempty"` + // The interaction policy for this status, as set by the status author. + InteractionPolicy InteractionPolicy `json:"interaction_policy"` } // WebStatus is like *model.Status, but contains diff --git a/internal/gtsmodel/interactionpolicy.go b/internal/gtsmodel/interactionpolicy.go index ecb525b47..993763dc3 100644 --- a/internal/gtsmodel/interactionpolicy.go +++ b/internal/gtsmodel/interactionpolicy.go @@ -180,135 +180,109 @@ func DefaultInteractionPolicyFor(v Visibility) *InteractionPolicy { } } +var defaultPolicyPublic = &InteractionPolicy{ + CanLike: PolicyRules{ + // Anyone can like. + Always: PolicyValues{ + PolicyValuePublic, + }, + WithApproval: make(PolicyValues, 0), + }, + CanReply: PolicyRules{ + // Anyone can reply. + Always: PolicyValues{ + PolicyValuePublic, + }, + WithApproval: make(PolicyValues, 0), + }, + CanAnnounce: PolicyRules{ + // Anyone can announce. + Always: PolicyValues{ + PolicyValuePublic, + }, + WithApproval: make(PolicyValues, 0), + }, +} + // Returns the default interaction policy // for a post with visibility of public. func DefaultInteractionPolicyPublic() *InteractionPolicy { - // Anyone can like. - canLikeAlways := make(PolicyValues, 1) - canLikeAlways[0] = PolicyValuePublic - - // Unused, set empty. - canLikeWithApproval := make(PolicyValues, 0) - - // Anyone can reply. - canReplyAlways := make(PolicyValues, 1) - canReplyAlways[0] = PolicyValuePublic - - // Unused, set empty. - canReplyWithApproval := make(PolicyValues, 0) - - // Anyone can announce. - canAnnounceAlways := make(PolicyValues, 1) - canAnnounceAlways[0] = PolicyValuePublic - - // Unused, set empty. - canAnnounceWithApproval := make(PolicyValues, 0) - - return &InteractionPolicy{ - CanLike: PolicyRules{ - Always: canLikeAlways, - WithApproval: canLikeWithApproval, - }, - CanReply: PolicyRules{ - Always: canReplyAlways, - WithApproval: canReplyWithApproval, - }, - CanAnnounce: PolicyRules{ - Always: canAnnounceAlways, - WithApproval: canAnnounceWithApproval, - }, - } + return defaultPolicyPublic } // Returns the default interaction policy // for a post with visibility of unlocked. func DefaultInteractionPolicyUnlocked() *InteractionPolicy { // Same as public (for now). - return DefaultInteractionPolicyPublic() + return defaultPolicyPublic +} + +var defaultPolicyFollowersOnly = &InteractionPolicy{ + CanLike: PolicyRules{ + // Self, followers and + // mentioned can like. + Always: PolicyValues{ + PolicyValueAuthor, + PolicyValueFollowers, + PolicyValueMentioned, + }, + WithApproval: make(PolicyValues, 0), + }, + CanReply: PolicyRules{ + // Self, followers and + // mentioned can reply. + Always: PolicyValues{ + PolicyValueAuthor, + PolicyValueFollowers, + PolicyValueMentioned, + }, + WithApproval: make(PolicyValues, 0), + }, + CanAnnounce: PolicyRules{ + // Only self can announce. + Always: PolicyValues{ + PolicyValueAuthor, + }, + WithApproval: make(PolicyValues, 0), + }, } // Returns the default interaction policy for // a post with visibility of followers only. func DefaultInteractionPolicyFollowersOnly() *InteractionPolicy { - // Self, followers and mentioned can like. - canLikeAlways := make(PolicyValues, 3) - canLikeAlways[0] = PolicyValueAuthor - canLikeAlways[1] = PolicyValueFollowers - canLikeAlways[2] = PolicyValueMentioned + return defaultPolicyFollowersOnly +} - // Unused, set empty. - canLikeWithApproval := make(PolicyValues, 0) - - // Self, followers and mentioned can reply. - canReplyAlways := make(PolicyValues, 3) - canReplyAlways[0] = PolicyValueAuthor - canReplyAlways[1] = PolicyValueFollowers - canReplyAlways[2] = PolicyValueMentioned - - // Unused, set empty. - canReplyWithApproval := make(PolicyValues, 0) - - // Only self can announce. - canAnnounceAlways := make(PolicyValues, 1) - canAnnounceAlways[0] = PolicyValueAuthor - - // Unused, set empty. - canAnnounceWithApproval := make(PolicyValues, 0) - - return &InteractionPolicy{ - CanLike: PolicyRules{ - Always: canLikeAlways, - WithApproval: canLikeWithApproval, +var defaultPolicyDirect = &InteractionPolicy{ + CanLike: PolicyRules{ + // Mentioned and self + // can always like. + Always: PolicyValues{ + PolicyValueAuthor, + PolicyValueMentioned, }, - CanReply: PolicyRules{ - Always: canReplyAlways, - WithApproval: canReplyWithApproval, + WithApproval: make(PolicyValues, 0), + }, + CanReply: PolicyRules{ + // Mentioned and self + // can always reply. + Always: PolicyValues{ + PolicyValueAuthor, + PolicyValueMentioned, }, - CanAnnounce: PolicyRules{ - Always: canAnnounceAlways, - WithApproval: canAnnounceWithApproval, + WithApproval: make(PolicyValues, 0), + }, + CanAnnounce: PolicyRules{ + // Only self can announce. + Always: PolicyValues{ + PolicyValueAuthor, }, - } + WithApproval: make(PolicyValues, 0), + }, } // Returns the default interaction policy // for a post with visibility of direct. func DefaultInteractionPolicyDirect() *InteractionPolicy { - // Mentioned and self can always like. - canLikeAlways := make(PolicyValues, 2) - canLikeAlways[0] = PolicyValueAuthor - canLikeAlways[1] = PolicyValueMentioned - - // Unused, set empty. - canLikeWithApproval := make(PolicyValues, 0) - - // Mentioned and self can always reply. - canReplyAlways := make(PolicyValues, 2) - canReplyAlways[0] = PolicyValueAuthor - canReplyAlways[1] = PolicyValueMentioned - - // Unused, set empty. - canReplyWithApproval := make(PolicyValues, 0) - - // Only self can announce. - canAnnounceAlways := make(PolicyValues, 1) - canAnnounceAlways[0] = PolicyValueAuthor - - // Unused, set empty. - canAnnounceWithApproval := make(PolicyValues, 0) - - return &InteractionPolicy{ - CanLike: PolicyRules{ - Always: canLikeAlways, - WithApproval: canLikeWithApproval, - }, - CanReply: PolicyRules{ - Always: canReplyAlways, - WithApproval: canReplyWithApproval, - }, - CanAnnounce: PolicyRules{ - Always: canAnnounceAlways, - WithApproval: canAnnounceWithApproval, - }, - } + return defaultPolicyDirect } diff --git a/internal/processing/account/interactionpolicies.go b/internal/processing/account/interactionpolicies.go new file mode 100644 index 000000000..e02b43e9e --- /dev/null +++ b/internal/processing/account/interactionpolicies.go @@ -0,0 +1,208 @@ +// 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 . + +package account + +import ( + "cmp" + "context" + + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/typeutils" +) + +func (p *Processor) DefaultInteractionPoliciesGet( + ctx context.Context, + requester *gtsmodel.Account, +) (*apimodel.DefaultPolicies, gtserror.WithCode) { + // Ensure account settings populated. + if err := p.populateAccountSettings(ctx, requester); err != nil { + return nil, gtserror.NewErrorInternalError(err) + } + + // Take set "direct" policy + // or global default. + direct := cmp.Or( + requester.Settings.InteractionPolicyDirect, + gtsmodel.DefaultInteractionPolicyDirect(), + ) + + directAPI, err := p.converter.InteractionPolicyToAPIInteractionPolicy(ctx, direct, nil, nil) + if err != nil { + err := gtserror.Newf("error converting interaction policy direct: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + // Take set "private" policy + // or global default. + private := cmp.Or( + requester.Settings.InteractionPolicyFollowersOnly, + gtsmodel.DefaultInteractionPolicyFollowersOnly(), + ) + + privateAPI, err := p.converter.InteractionPolicyToAPIInteractionPolicy(ctx, private, nil, nil) + if err != nil { + err := gtserror.Newf("error converting interaction policy private: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + // Take set "unlisted" policy + // or global default. + unlisted := cmp.Or( + requester.Settings.InteractionPolicyUnlocked, + gtsmodel.DefaultInteractionPolicyUnlocked(), + ) + + unlistedAPI, err := p.converter.InteractionPolicyToAPIInteractionPolicy(ctx, unlisted, nil, nil) + if err != nil { + err := gtserror.Newf("error converting interaction policy unlisted: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + // Take set "public" policy + // or global default. + public := cmp.Or( + requester.Settings.InteractionPolicyPublic, + gtsmodel.DefaultInteractionPolicyPublic(), + ) + + publicAPI, err := p.converter.InteractionPolicyToAPIInteractionPolicy(ctx, public, nil, nil) + if err != nil { + err := gtserror.Newf("error converting interaction policy public: %w", err) + return nil, gtserror.NewErrorInternalError(err) + } + + return &apimodel.DefaultPolicies{ + Direct: *directAPI, + Private: *privateAPI, + Unlisted: *unlistedAPI, + Public: *publicAPI, + }, nil +} + +func (p *Processor) DefaultInteractionPoliciesUpdate( + ctx context.Context, + requester *gtsmodel.Account, + form *apimodel.UpdateInteractionPoliciesRequest, +) (*apimodel.DefaultPolicies, gtserror.WithCode) { + // Lock on this account as we're modifying its Settings. + unlock := p.state.ProcessingLocks.Lock(requester.URI) + defer unlock() + + // Ensure account settings populated. + if err := p.populateAccountSettings(ctx, requester); err != nil { + return nil, gtserror.NewErrorInternalError(err) + } + + if form.Direct == nil { + // Unset/return to global default. + requester.Settings.InteractionPolicyDirect = nil + } else { + policy, err := typeutils.APIInteractionPolicyToInteractionPolicy( + form.Direct, + apimodel.VisibilityDirect, + ) + if err != nil { + return nil, gtserror.NewErrorUnprocessableEntity(err, err.Error()) + } + + // Set new default policy. + requester.Settings.InteractionPolicyDirect = policy + } + + if form.Private == nil { + // Unset/return to global default. + requester.Settings.InteractionPolicyFollowersOnly = nil + } else { + policy, err := typeutils.APIInteractionPolicyToInteractionPolicy( + form.Private, + apimodel.VisibilityPrivate, + ) + if err != nil { + return nil, gtserror.NewErrorUnprocessableEntity(err, err.Error()) + } + + // Set new default policy. + requester.Settings.InteractionPolicyFollowersOnly = policy + } + + if form.Unlisted == nil { + // Unset/return to global default. + requester.Settings.InteractionPolicyUnlocked = nil + } else { + policy, err := typeutils.APIInteractionPolicyToInteractionPolicy( + form.Unlisted, + apimodel.VisibilityUnlisted, + ) + if err != nil { + return nil, gtserror.NewErrorUnprocessableEntity(err, err.Error()) + } + + // Set new default policy. + requester.Settings.InteractionPolicyUnlocked = policy + } + + if form.Public == nil { + // Unset/return to global default. + requester.Settings.InteractionPolicyPublic = nil + } else { + policy, err := typeutils.APIInteractionPolicyToInteractionPolicy( + form.Public, + apimodel.VisibilityPublic, + ) + if err != nil { + return nil, gtserror.NewErrorUnprocessableEntity(err, err.Error()) + } + + // Set new default policy. + requester.Settings.InteractionPolicyPublic = policy + } + + if err := p.state.DB.UpdateAccountSettings(ctx, requester.Settings); err != nil { + err := gtserror.Newf("db error updating setttings: %w", err) + return nil, gtserror.NewErrorInternalError(err, err.Error()) + } + + return p.DefaultInteractionPoliciesGet(ctx, requester) +} + +// populateAccountSettings just ensures that +// Settings is populated on the given account. +func (p *Processor) populateAccountSettings( + ctx context.Context, + acct *gtsmodel.Account, +) error { + if acct.Settings != nil { + // Already populated. + return nil + } + + // Not populated, + // get from db. + var err error + acct.Settings, err = p.state.DB.GetAccountSettings(ctx, acct.ID) + if err != nil { + return gtserror.Newf( + "db error getting settings for account %s: %w", + acct.ID, err, + ) + } + + return nil +} diff --git a/internal/processing/status/create.go b/internal/processing/status/create.go index 8898181ae..a5978a999 100644 --- a/internal/processing/status/create.go +++ b/internal/processing/status/create.go @@ -121,6 +121,12 @@ func (p *Processor) Create( return nil, gtserror.NewErrorInternalError(err) } + // Process policy AFTER visibility as it + // relies on status.Visibility being set. + if err := processInteractionPolicy(form, requester.Settings, status); err != nil { + return nil, gtserror.NewErrorInternalError(err) + } + if err := processLanguage(form, requester.Settings.Language, status); err != nil { return nil, gtserror.NewErrorInternalError(err) } @@ -281,26 +287,79 @@ func (p *Processor) processMediaIDs(ctx context.Context, form *apimodel.Advanced return nil } -func processVisibility(form *apimodel.AdvancedStatusCreateForm, accountDefaultVis gtsmodel.Visibility, status *gtsmodel.Status) error { - // by default all flags are set to true - federated := true - - // If visibility isn't set on the form, then just take the account default. - // If that's also not set, take the default for the whole instance. - var vis gtsmodel.Visibility +func processVisibility( + form *apimodel.AdvancedStatusCreateForm, + accountDefaultVis gtsmodel.Visibility, + status *gtsmodel.Status, +) error { switch { + // Visibility set on form, use that. case form.Visibility != "": - vis = typeutils.APIVisToVis(form.Visibility) + status.Visibility = typeutils.APIVisToVis(form.Visibility) + + // Fall back to account default. case accountDefaultVis != "": - vis = accountDefaultVis + status.Visibility = accountDefaultVis + + // What? Fall back to global default. default: - vis = gtsmodel.VisibilityDefault + status.Visibility = gtsmodel.VisibilityDefault } - // Todo: sort out likeable/replyable/boostable in next PR. - - status.Visibility = vis + // Set federated flag to form value + // if provided, or default to true. + federated := util.PtrValueOr(form.Federated, true) status.Federated = &federated + + return nil +} + +func processInteractionPolicy( + _ *apimodel.AdvancedStatusCreateForm, + settings *gtsmodel.AccountSettings, + status *gtsmodel.Status, +) error { + // TODO: parse policy for this + // status from form and prefer this. + + // TODO: prevent scope widening by + // limiting interaction policy if + // inReplyTo status has a stricter + // interaction policy than this one. + + switch status.Visibility { + + case gtsmodel.VisibilityPublic: + // Take account's default "public" policy if set. + if p := settings.InteractionPolicyPublic; p != nil { + status.InteractionPolicy = p + } + + case gtsmodel.VisibilityUnlocked: + // Take account's default "unlisted" policy if set. + if p := settings.InteractionPolicyUnlocked; p != nil { + status.InteractionPolicy = p + } + + case gtsmodel.VisibilityFollowersOnly, + gtsmodel.VisibilityMutualsOnly: + // Take account's default followers-only policy if set. + // TODO: separate policy for mutuals-only vis. + if p := settings.InteractionPolicyFollowersOnly; p != nil { + status.InteractionPolicy = p + } + + case gtsmodel.VisibilityDirect: + // Take account's default direct policy if set. + if p := settings.InteractionPolicyDirect; p != nil { + status.InteractionPolicy = p + } + } + + // If no policy set by now, status interaction + // policy will be stored as nil, which just means + // "fall back to global default policy". We avoid + // setting it explicitly to save space. return nil } diff --git a/internal/processing/stream/statusupdate_test.go b/internal/processing/stream/statusupdate_test.go index 359212ee6..38be9ea5e 100644 --- a/internal/processing/stream/statusupdate_test.go +++ b/internal/processing/stream/statusupdate_test.go @@ -129,7 +129,27 @@ func (suite *StatusUpdateTestSuite) TestStreamNotification() { "tags": [], "emojis": [], "card": null, - "poll": null + "poll": null, + "interaction_policy": { + "can_favourite": { + "always": [ + "public" + ], + "with_approval": [] + }, + "can_reply": { + "always": [ + "public" + ], + "with_approval": [] + }, + "can_reblog": { + "always": [ + "public" + ], + "with_approval": [] + } + } }`, dst.String()) suite.Equal(msg.Event, "status.update") } diff --git a/internal/typeutils/frontendtointernal.go b/internal/typeutils/frontendtointernal.go index f194770df..8ced14d58 100644 --- a/internal/typeutils/frontendtointernal.go +++ b/internal/typeutils/frontendtointernal.go @@ -18,6 +18,10 @@ package typeutils import ( + "fmt" + "net/url" + "slices" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" ) @@ -57,3 +61,171 @@ func APIFilterActionToFilterAction(m apimodel.FilterAction) gtsmodel.FilterActio } return gtsmodel.FilterActionNone } + +func APIPolicyValueToPolicyValue(u apimodel.PolicyValue) (gtsmodel.PolicyValue, error) { + switch u { + case apimodel.PolicyValuePublic: + return gtsmodel.PolicyValuePublic, nil + + case apimodel.PolicyValueFollowers: + return gtsmodel.PolicyValueFollowers, nil + + case apimodel.PolicyValueFollowing: + return gtsmodel.PolicyValueFollowing, nil + + case apimodel.PolicyValueMutuals: + return gtsmodel.PolicyValueMutuals, nil + + case apimodel.PolicyValueMentioned: + return gtsmodel.PolicyValueMentioned, nil + + case apimodel.PolicyValueAuthor: + return gtsmodel.PolicyValueAuthor, nil + + case apimodel.PolicyValueMe: + err := fmt.Errorf("policyURI %s has no corresponding internal model", apimodel.PolicyValueMe) + return "", err + + default: + // Parse URI to ensure it's a + // url with a valid protocol. + url, err := url.Parse(string(u)) + if err != nil { + err := fmt.Errorf("could not parse non-predefined policy value as uri: %w", err) + return "", err + } + + if url.Host != "http" && url.Host != "https" { + err := fmt.Errorf("non-predefined policy values must have protocol 'http' or 'https' (%s)", u) + return "", err + } + + return gtsmodel.PolicyValue(u), nil + } +} + +func APIInteractionPolicyToInteractionPolicy( + p *apimodel.InteractionPolicy, + v apimodel.Visibility, +) (*gtsmodel.InteractionPolicy, error) { + visibility := APIVisToVis(v) + + convertURIs := func(apiURIs []apimodel.PolicyValue) (gtsmodel.PolicyValues, error) { + policyURIs := gtsmodel.PolicyValues{} + for _, apiURI := range apiURIs { + uri, err := APIPolicyValueToPolicyValue(apiURI) + if err != nil { + return nil, err + } + + if !uri.FeasibleForVisibility(visibility) { + err := fmt.Errorf("policyURI %s is not feasible for visibility %s", apiURI, v) + return nil, err + } + + policyURIs = append(policyURIs, uri) + } + return policyURIs, nil + } + + canLikeAlways, err := convertURIs(p.CanFavourite.Always) + if err != nil { + err := fmt.Errorf("error converting %s.can_favourite.always: %w", v, err) + return nil, err + } + + canLikeWithApproval, err := convertURIs(p.CanFavourite.WithApproval) + if err != nil { + err := fmt.Errorf("error converting %s.can_favourite.with_approval: %w", v, err) + return nil, err + } + + canReplyAlways, err := convertURIs(p.CanReply.Always) + if err != nil { + err := fmt.Errorf("error converting %s.can_reply.always: %w", v, err) + return nil, err + } + + canReplyWithApproval, err := convertURIs(p.CanReply.WithApproval) + if err != nil { + err := fmt.Errorf("error converting %s.can_reply.with_approval: %w", v, err) + return nil, err + } + + canAnnounceAlways, err := convertURIs(p.CanReblog.Always) + if err != nil { + err := fmt.Errorf("error converting %s.can_reblog.always: %w", v, err) + return nil, err + } + + canAnnounceWithApproval, err := convertURIs(p.CanReblog.WithApproval) + if err != nil { + err := fmt.Errorf("error converting %s.can_reblog.with_approval: %w", v, err) + return nil, err + } + + // Normalize URIs. + // + // 1. Ensure canLikeAlways, canReplyAlways, + // and canAnnounceAlways include self + // (either explicitly or within public). + + // ensureIncludesSelf adds the "author" PolicyValue + // to given slice of PolicyValues, if not already + // explicitly or implicitly included. + ensureIncludesSelf := func(vals gtsmodel.PolicyValues) gtsmodel.PolicyValues { + includesSelf := slices.ContainsFunc( + vals, + func(uri gtsmodel.PolicyValue) bool { + return uri == gtsmodel.PolicyValuePublic || + uri == gtsmodel.PolicyValueAuthor + }, + ) + + if includesSelf { + // This slice of policy values + // already includes self explicitly + // or implicitly, nothing to change. + return vals + } + + // Need to add self/author to + // this slice of policy values. + vals = append(vals, gtsmodel.PolicyValueAuthor) + return vals + } + + canLikeAlways = ensureIncludesSelf(canLikeAlways) + canReplyAlways = ensureIncludesSelf(canReplyAlways) + canAnnounceAlways = ensureIncludesSelf(canAnnounceAlways) + + // 2. Ensure canReplyAlways includes mentioned + // accounts (either explicitly or within public). + if !slices.ContainsFunc( + canReplyAlways, + func(uri gtsmodel.PolicyValue) bool { + return uri == gtsmodel.PolicyValuePublic || + uri == gtsmodel.PolicyValueMentioned + }, + ) { + canReplyAlways = append( + canReplyAlways, + gtsmodel.PolicyValueMentioned, + ) + } + + return >smodel.InteractionPolicy{ + CanLike: gtsmodel.PolicyRules{ + Always: canLikeAlways, + WithApproval: canLikeWithApproval, + }, + CanReply: gtsmodel.PolicyRules{ + Always: canReplyAlways, + WithApproval: canReplyWithApproval, + }, + CanAnnounce: gtsmodel.PolicyRules{ + Always: canAnnounceAlways, + WithApproval: canAnnounceWithApproval, + }, + }, nil +} diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go index d24ae3ea5..6350f3269 100644 --- a/internal/typeutils/internaltofrontend.go +++ b/internal/typeutils/internaltofrontend.go @@ -1234,6 +1234,20 @@ func (c *Converter) baseStatusToFrontend( log.Errorf(ctx, "error converting status emojis: %v", err) } + // Take status's interaction policy, or + // fall back to default for its visibility. + var p *gtsmodel.InteractionPolicy + if s.InteractionPolicy != nil { + p = s.InteractionPolicy + } else { + p = gtsmodel.DefaultInteractionPolicyFor(s.Visibility) + } + + apiInteractionPolicy, err := c.InteractionPolicyToAPIInteractionPolicy(ctx, p, s, requestingAccount) + if err != nil { + return nil, gtserror.Newf("error converting interaction policy: %w", err) + } + apiStatus := &apimodel.Status{ ID: s.ID, CreatedAt: util.FormatISO8601(s.CreatedAt), @@ -1258,6 +1272,7 @@ func (c *Converter) baseStatusToFrontend( Emojis: apiEmojis, Card: nil, // TODO: implement cards Text: s.Text, + InteractionPolicy: *apiInteractionPolicy, } // Nullable fields. @@ -2256,3 +2271,111 @@ func (c *Converter) ThemesToAPIThemes(themes []*gtsmodel.Theme) []apimodel.Theme } return apiThemes } + +// Convert the given gtsmodel policy +// into an apimodel interaction policy. +// +// Provided status can be nil to convert a +// policy without a particular status in mind. +// +// RequestingAccount can also be nil for +// unauthorized requests (web, public api etc). +func (c *Converter) InteractionPolicyToAPIInteractionPolicy( + ctx context.Context, + policy *gtsmodel.InteractionPolicy, + _ *gtsmodel.Status, // Used in upcoming PR. + _ *gtsmodel.Account, // Used in upcoming PR. +) (*apimodel.InteractionPolicy, error) { + apiPolicy := &apimodel.InteractionPolicy{ + CanFavourite: apimodel.PolicyRules{ + Always: policyValsToAPIPolicyVals(policy.CanLike.Always), + WithApproval: policyValsToAPIPolicyVals(policy.CanLike.WithApproval), + }, + CanReply: apimodel.PolicyRules{ + Always: policyValsToAPIPolicyVals(policy.CanReply.Always), + WithApproval: policyValsToAPIPolicyVals(policy.CanReply.WithApproval), + }, + CanReblog: apimodel.PolicyRules{ + Always: policyValsToAPIPolicyVals(policy.CanAnnounce.Always), + WithApproval: policyValsToAPIPolicyVals(policy.CanAnnounce.WithApproval), + }, + } + + return apiPolicy, nil +} + +func policyValsToAPIPolicyVals(vals gtsmodel.PolicyValues) []apimodel.PolicyValue { + + var ( + valsLen = len(vals) + + // Use a map to deduplicate added vals as we go. + addedVals = make(map[apimodel.PolicyValue]struct{}, valsLen) + + // Vals we'll be returning. + apiVals = make([]apimodel.PolicyValue, 0, valsLen) + ) + + for _, policyVal := range vals { + switch policyVal { + + case gtsmodel.PolicyValueAuthor: + // Author can do this. + newVal := apimodel.PolicyValueAuthor + if _, added := addedVals[newVal]; !added { + apiVals = append(apiVals, newVal) + addedVals[newVal] = struct{}{} + } + + case gtsmodel.PolicyValueMentioned: + // Mentioned can do this. + newVal := apimodel.PolicyValueMentioned + if _, added := addedVals[newVal]; !added { + apiVals = append(apiVals, newVal) + addedVals[newVal] = struct{}{} + } + + case gtsmodel.PolicyValueMutuals: + // Mutuals can do this. + newVal := apimodel.PolicyValueMutuals + if _, added := addedVals[newVal]; !added { + apiVals = append(apiVals, newVal) + addedVals[newVal] = struct{}{} + } + + case gtsmodel.PolicyValueFollowing: + // Following can do this. + newVal := apimodel.PolicyValueFollowing + if _, added := addedVals[newVal]; !added { + apiVals = append(apiVals, newVal) + addedVals[newVal] = struct{}{} + } + + case gtsmodel.PolicyValueFollowers: + // Followers can do this. + newVal := apimodel.PolicyValueFollowers + if _, added := addedVals[newVal]; !added { + apiVals = append(apiVals, newVal) + addedVals[newVal] = struct{}{} + } + + case gtsmodel.PolicyValuePublic: + // Public can do this. + newVal := apimodel.PolicyValuePublic + if _, added := addedVals[newVal]; !added { + apiVals = append(apiVals, newVal) + addedVals[newVal] = struct{}{} + } + + default: + // Specific URI of ActivityPub Actor. + newVal := apimodel.PolicyValue(policyVal) + if _, added := addedVals[newVal]; !added { + apiVals = append(apiVals, newVal) + addedVals[newVal] = struct{}{} + } + } + } + + return apiVals +} diff --git a/internal/typeutils/internaltofrontend_test.go b/internal/typeutils/internaltofrontend_test.go index c4da0d57c..9fd4cea46 100644 --- a/internal/typeutils/internaltofrontend_test.go +++ b/internal/typeutils/internaltofrontend_test.go @@ -546,7 +546,27 @@ func (suite *InternalToFrontendTestSuite) TestStatusToFrontend() { ], "card": null, "poll": null, - "text": "hello world! #welcome ! first post on the instance :rainbow: !" + "text": "hello world! #welcome ! first post on the instance :rainbow: !", + "interaction_policy": { + "can_favourite": { + "always": [ + "public" + ], + "with_approval": [] + }, + "can_reply": { + "always": [ + "public" + ], + "with_approval": [] + }, + "can_reblog": { + "always": [ + "public" + ], + "with_approval": [] + } + } }`, string(b)) } @@ -701,7 +721,27 @@ func (suite *InternalToFrontendTestSuite) TestWarnFilteredStatusToFrontend() { ], "status_matches": [] } - ] + ], + "interaction_policy": { + "can_favourite": { + "always": [ + "public" + ], + "with_approval": [] + }, + "can_reply": { + "always": [ + "public" + ], + "with_approval": [] + }, + "can_reblog": { + "always": [ + "public" + ], + "with_approval": [] + } + } }`, string(b)) } @@ -877,7 +917,27 @@ func (suite *InternalToFrontendTestSuite) TestStatusToFrontendUnknownAttachments "tags": [], "emojis": [], "card": null, - "poll": null + "poll": null, + "interaction_policy": { + "can_favourite": { + "always": [ + "public" + ], + "with_approval": [] + }, + "can_reply": { + "always": [ + "public" + ], + "with_approval": [] + }, + "can_reblog": { + "always": [ + "public" + ], + "with_approval": [] + } + } }`, string(b)) } @@ -955,6 +1015,26 @@ func (suite *InternalToFrontendTestSuite) TestStatusToWebStatus() { "emojis": [], "card": null, "poll": null, + "interaction_policy": { + "can_favourite": { + "always": [ + "public" + ], + "with_approval": [] + }, + "can_reply": { + "always": [ + "public" + ], + "with_approval": [] + }, + "can_reblog": { + "always": [ + "public" + ], + "with_approval": [] + } + }, "media_attachments": [ { "id": "01HE7Y3C432WRSNS10EZM86SA5", @@ -1137,7 +1217,121 @@ func (suite *InternalToFrontendTestSuite) TestStatusToFrontendUnknownLanguage() ], "card": null, "poll": null, - "text": "hello world! #welcome ! first post on the instance :rainbow: !" + "text": "hello world! #welcome ! first post on the instance :rainbow: !", + "interaction_policy": { + "can_favourite": { + "always": [ + "public" + ], + "with_approval": [] + }, + "can_reply": { + "always": [ + "public" + ], + "with_approval": [] + }, + "can_reblog": { + "always": [ + "public" + ], + "with_approval": [] + } + } +}`, string(b)) +} + +func (suite *InternalToFrontendTestSuite) TestStatusToFrontendPartialInteractions() { + testStatus := >smodel.Status{} + *testStatus = *suite.testStatuses["local_account_1_status_3"] + testStatus.Language = "" + requestingAccount := suite.testAccounts["admin_account"] + apiStatus, err := suite.typeconverter.StatusToAPIStatus(context.Background(), testStatus, requestingAccount, statusfilter.FilterContextNone, nil, nil) + suite.NoError(err) + + b, err := json.MarshalIndent(apiStatus, "", " ") + suite.NoError(err) + + suite.Equal(`{ + "id": "01F8MHBBN8120SYH7D5S050MGK", + "created_at": "2021-10-20T10:40:37.000Z", + "in_reply_to_id": null, + "in_reply_to_account_id": null, + "sensitive": false, + "spoiler_text": "test: you shouldn't be able to interact with this post in any way", + "visibility": "private", + "language": null, + "uri": "http://localhost:8080/users/the_mighty_zork/statuses/01F8MHBBN8120SYH7D5S050MGK", + "url": "http://localhost:8080/@the_mighty_zork/statuses/01F8MHBBN8120SYH7D5S050MGK", + "replies_count": 0, + "reblogs_count": 0, + "favourites_count": 0, + "favourited": false, + "reblogged": false, + "muted": false, + "bookmarked": false, + "pinned": false, + "content": "this is a very personal post that I don't want anyone to interact with at all, and i only want mutuals to see it", + "reblog": null, + "application": { + "name": "really cool gts application", + "website": "https://reallycool.app" + }, + "account": { + "id": "01F8MH1H7YV1Z7D2C8K2730QBF", + "username": "the_mighty_zork", + "acct": "the_mighty_zork", + "display_name": "original zork (he/they)", + "locked": false, + "discoverable": true, + "bot": false, + "created_at": "2022-05-20T11:09:18.000Z", + "note": "\u003cp\u003ehey yo this is my profile!\u003c/p\u003e", + "url": "http://localhost:8080/@the_mighty_zork", + "avatar": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpg", + "avatar_static": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/01F8MH58A357CV5K7R7TJMSH6S.jpg", + "avatar_description": "a green goblin looking nasty", + "header": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpg", + "header_static": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/small/01PFPMWK2FF0D9WMHEJHR07C3Q.jpg", + "header_description": "A very old-school screenshot of the original team fortress mod for quake", + "followers_count": 2, + "following_count": 2, + "statuses_count": 8, + "last_status_at": "2024-01-10T09:24:00.000Z", + "emojis": [], + "fields": [], + "enable_rss": true, + "role": { + "name": "user" + } + }, + "media_attachments": [], + "mentions": [], + "tags": [], + "emojis": [], + "card": null, + "poll": null, + "text": "this is a very personal post that I don't want anyone to interact with at all, and i only want mutuals to see it", + "interaction_policy": { + "can_favourite": { + "always": [ + "author" + ], + "with_approval": [] + }, + "can_reply": { + "always": [ + "author" + ], + "with_approval": [] + }, + "can_reblog": { + "always": [ + "author" + ], + "with_approval": [] + } + } }`, string(b)) } @@ -2014,7 +2208,27 @@ func (suite *InternalToFrontendTestSuite) TestAdminReportToFrontend2() { "tags": [], "emojis": [], "card": null, - "poll": null + "poll": null, + "interaction_policy": { + "can_favourite": { + "always": [ + "public" + ], + "with_approval": [] + }, + "can_reply": { + "always": [ + "public" + ], + "with_approval": [] + }, + "can_reblog": { + "always": [ + "public" + ], + "with_approval": [] + } + } } ], "rules": [ diff --git a/mkdocs.yml b/mkdocs.yml index 799b4bcbe..61b997dae 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -61,8 +61,8 @@ nav: - "Home": "index.md" - "FAQ": "faq.md" - "User Guide": - - "user_guide/posts.md" - "user_guide/settings.md" + - "user_guide/posts.md" - "user_guide/search.md" - "user_guide/custom_css.md" - "user_guide/password_management.md" diff --git a/web/source/settings/components/form/inputs.tsx b/web/source/settings/components/form/inputs.tsx index c68095d95..e6c530b53 100644 --- a/web/source/settings/components/form/inputs.tsx +++ b/web/source/settings/components/form/inputs.tsx @@ -141,9 +141,28 @@ export interface SelectProps extends React.DetailedHTMLProps< field: TextFormInputHook; children?: ReactNode; options: React.JSX.Element; + + /** + * Optional callback function that is + * triggered along with the select's onChange. + * + * _selectValue is the current value of + * the select after onChange is triggered. + * + * @param _selectValue + * @returns + */ + onChangeCallback?: (_selectValue: string | undefined) => void; } -export function Select({ label, field, children, options, ...props }: SelectProps) { +export function Select({ + label, + field, + children, + options, + onChangeCallback, + ...props +}: SelectProps) { const { onChange, value, ref } = field; return ( @@ -152,7 +171,12 @@ export function Select({ label, field, children, options, ...props }: SelectProp {label} {children} - }> - - - - - - - +

Email & Password Settings

+ ); } @@ -330,4 +261,4 @@ function EmailChangeForm({user, oidcEnabled}: { user: User, oidcEnabled?: boolea /> ); -} +} \ No newline at end of file diff --git a/web/source/settings/views/user/menu.tsx b/web/source/settings/views/user/menu.tsx index 578bd8ae0..3d90bfe21 100644 --- a/web/source/settings/views/user/menu.tsx +++ b/web/source/settings/views/user/menu.tsx @@ -22,7 +22,8 @@ import React from "react"; /** * - /settings/user/profile - * - /settings/user/settings + * - /settings/user/posts + * - /settings/user/emailpassword * - /settings/user/migration */ export default function UserMenu() { @@ -38,9 +39,14 @@ export default function UserMenu() { icon="fa-user" /> + . +*/ + +import React from "react"; +import { useTextInput, useBoolInput } from "../../../../lib/form"; +import useFormSubmit from "../../../../lib/form/submit"; +import { Select, Checkbox } from "../../../../components/form/inputs"; +import Languages from "../../../../components/languages"; +import MutationButton from "../../../../components/form/mutation-button"; +import { useUpdateCredentialsMutation } from "../../../../lib/query/user"; +import { Account } from "../../../../lib/types/account"; + +export default function BasicSettings({ account }: { account: Account }) { + /* form keys + - string source[privacy] + - bool source[sensitive] + - string source[language] + - string source[status_content_type] + */ + const form = { + defaultPrivacy: useTextInput("source[privacy]", { source: account, defaultValue: "unlisted" }), + isSensitive: useBoolInput("source[sensitive]", { source: account }), + language: useTextInput("source[language]", { source: account, valueSelector: (s: Account) => s.source?.language?.toUpperCase() ?? "EN" }), + statusContentType: useTextInput("source[status_content_type]", { source: account, defaultValue: "text/plain" }), + }; + + const [submitForm, result] = useFormSubmit(form, useUpdateCredentialsMutation()); + + return ( +
+ + + + + + + + ); +} \ No newline at end of file diff --git a/web/source/settings/views/user/posts/index.tsx b/web/source/settings/views/user/posts/index.tsx new file mode 100644 index 000000000..4d7669391 --- /dev/null +++ b/web/source/settings/views/user/posts/index.tsx @@ -0,0 +1,51 @@ +/* + 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 . +*/ + +import React from "react"; +import { useVerifyCredentialsQuery } from "../../../lib/query/oauth"; +import Loading from "../../../components/loading"; +import { Error } from "../../../components/error"; +import BasicSettings from "./basic-settings"; +import InteractionPolicySettings from "./interaction-policy-settings"; + +export default function PostSettings() { + const { + data: account, + isLoading, + isFetching, + isError, + error, + } = useVerifyCredentialsQuery(); + + if (isLoading || isFetching) { + return ; + } + + if (isError) { + return ; + } + + return ( + <> +

Post Settings

+ + + + ); +} diff --git a/web/source/settings/views/user/posts/interaction-policy-settings/basic.tsx b/web/source/settings/views/user/posts/interaction-policy-settings/basic.tsx new file mode 100644 index 000000000..8d229a3e0 --- /dev/null +++ b/web/source/settings/views/user/posts/interaction-policy-settings/basic.tsx @@ -0,0 +1,180 @@ +/* + 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 . +*/ + +import React, { useMemo } from "react"; +import { + InteractionPolicyValue, + PolicyValueAuthor, + PolicyValueFollowers, + PolicyValueMentioned, + PolicyValuePublic, +} from "../../../../lib/types/interaction"; +import { useTextInput } from "../../../../lib/form"; +import { Action, BasicValue, PolicyFormSub, Visibility } from "./types"; + +// Based on the given visibility, action, and states, +// derives what the initial basic Select value should be. +function useBasicValue( + forVis: Visibility, + forAction: Action, + always: InteractionPolicyValue[], + withApproval: InteractionPolicyValue[], +): BasicValue { + // Check if "always" value is just the author + // (and possibly mentioned accounts when dealing + // with replies -- still counts as "just_me"). + const alwaysJustAuthor = useMemo(() => { + if ( + always.length === 1 && + always[0] === PolicyValueAuthor + ) { + return true; + } + + if ( + forAction === "reply" && + always.length === 2 && + always.includes(PolicyValueAuthor) && + always.includes(PolicyValueMentioned) + ) { + return true; + } + + return false; + }, [forAction, always]); + + // Check if "always" includes the widest + // possible audience for this visibility. + const alwaysWidestAudience = useMemo(() => { + return ( + (forVis === "private" && always.includes(PolicyValueFollowers)) || + always.includes(PolicyValuePublic) + ); + }, [forVis, always]); + + // Check if "withApproval" includes the widest + // possible audience for this visibility. + const withApprovalWidestAudience = useMemo(() => { + return ( + (forVis === "private" && withApproval.includes(PolicyValueFollowers)) || + withApproval.includes(PolicyValuePublic) + ); + }, [forVis, withApproval]); + + return useMemo(() => { + // Simplest case: if "always" includes the + // widest possible audience for this visibility, + // then we don't need to check anything else. + if (alwaysWidestAudience) { + return "anyone"; + } + + // Next simplest case: there's no "with approval" + // URIs set, so check if it's always just author. + if (withApproval.length === 0 && alwaysJustAuthor) { + return "just_me"; + } + + // Third simplest case: always is just us, and with + // approval is addressed to the widest possible audience. + if (alwaysJustAuthor && withApprovalWidestAudience) { + return "anyone_with_approval"; + } + + // We've exhausted the + // simple possibilities. + return "something_else"; + }, [ + withApproval.length, + alwaysJustAuthor, + alwaysWidestAudience, + withApprovalWidestAudience, + ]); +} + +// Derive wording for the basic label for +// whatever visibility and action we're handling. +function useBasicLabel(visibility: Visibility, action: Action) { + return useMemo(() => { + let visPost = ""; + switch (visibility) { + case "public": + visPost = "a public post"; + break; + case "unlisted": + visPost = "an unlisted post"; + break; + case "private": + visPost = "a followers-only post"; + break; + } + + switch (action) { + case "favourite": + return "Who can like " + visPost + "?"; + case "reply": + return "Who else can reply to " + visPost + "?"; + case "reblog": + return "Who can boost " + visPost + "?"; + } + }, [visibility, action]); +} + +// Return whatever the "basic" options should +// be in the basic Select for this visibility. +function useBasicOptions(visibility: Visibility) { + return useMemo(() => { + const audience = visibility === "private" + ? "My followers" + : "Anyone"; + + return ( + <> + + + + { visibility !== "private" && + + } + + ); + }, [visibility]); +} + +export function useBasicFor( + forVis: Visibility, + forAction: Action, + currentAlways: InteractionPolicyValue[], + currentWithApproval: InteractionPolicyValue[], +): PolicyFormSub { + // Determine who's currently *basically* allowed + // to do this action for this visibility. + const defaultValue = useBasicValue( + forVis, + forAction, + currentAlways, + currentWithApproval, + ); + + return { + field: useTextInput("basic", { defaultValue: defaultValue }), + label: useBasicLabel(forVis, forAction), + options: useBasicOptions(forVis), + }; +} diff --git a/web/source/settings/views/user/posts/interaction-policy-settings/index.tsx b/web/source/settings/views/user/posts/interaction-policy-settings/index.tsx new file mode 100644 index 000000000..143cf0865 --- /dev/null +++ b/web/source/settings/views/user/posts/interaction-policy-settings/index.tsx @@ -0,0 +1,553 @@ +/* + 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 . +*/ + +import React, { useCallback, useMemo } from "react"; +import { + useDefaultInteractionPoliciesQuery, + useResetDefaultInteractionPoliciesMutation, + useUpdateDefaultInteractionPoliciesMutation, +} from "../../../../lib/query/user"; +import Loading from "../../../../components/loading"; +import { Error } from "../../../../components/error"; +import MutationButton from "../../../../components/form/mutation-button"; +import { + DefaultInteractionPolicies, + InteractionPolicy, + InteractionPolicyEntry, + InteractionPolicyValue, + PolicyValueAuthor, + PolicyValueFollowers, + PolicyValueFollowing, + PolicyValueMentioned, + PolicyValuePublic, +} from "../../../../lib/types/interaction"; +import { useTextInput } from "../../../../lib/form"; +import { Select } from "../../../../components/form/inputs"; +import { TextFormInputHook } from "../../../../lib/form/types"; +import { useBasicFor } from "./basic"; +import { PolicyFormSomethingElse, useSomethingElseFor } from "./something-else"; +import { Action, PolicyFormSub, SomethingElseValue, Visibility } from "./types"; + +export default function InteractionPolicySettings() { + const { + data: defaultPolicies, + isLoading, + isFetching, + isError, + error, + } = useDefaultInteractionPoliciesQuery(); + + if (isLoading || isFetching) { + return ; + } + + if (isError) { + return ; + } + + if (!defaultPolicies) { + throw "default policies undefined"; + } + + return ( + + ); +} + +interface InteractionPoliciesFormProps { + defaultPolicies: DefaultInteractionPolicies; +} + +function InteractionPoliciesForm({ defaultPolicies }: InteractionPoliciesFormProps) { + // Sub-form for visibility "public". + const formPublic = useFormForVis(defaultPolicies.public, "public"); + const assemblePublic = useCallback(() => { + return { + can_favourite: assemblePolicyEntry("public", "favourite", formPublic), + can_reply: assemblePolicyEntry("public", "reply", formPublic), + can_reblog: assemblePolicyEntry("public", "reblog", formPublic), + }; + }, [formPublic]); + + // Sub-form for visibility "unlisted". + const formUnlisted = useFormForVis(defaultPolicies.unlisted, "unlisted"); + const assembleUnlisted = useCallback(() => { + return { + can_favourite: assemblePolicyEntry("unlisted", "favourite", formUnlisted), + can_reply: assemblePolicyEntry("unlisted", "reply", formUnlisted), + can_reblog: assemblePolicyEntry("unlisted", "reblog", formUnlisted), + }; + }, [formUnlisted]); + + // Sub-form for visibility "private". + const formPrivate = useFormForVis(defaultPolicies.private, "private"); + const assemblePrivate = useCallback(() => { + return { + can_favourite: assemblePolicyEntry("private", "favourite", formPrivate), + can_reply: assemblePolicyEntry("private", "reply", formPrivate), + can_reblog: assemblePolicyEntry("private", "reblog", formPrivate), + }; + }, [formPrivate]); + + const selectedVis = useTextInput("selectedVis", { defaultValue: "public" }); + + const [updatePolicies, updateResult] = useUpdateDefaultInteractionPoliciesMutation(); + const [resetPolicies, resetResult] = useResetDefaultInteractionPoliciesMutation(); + + const onSubmit = (e) => { + e.preventDefault(); + updatePolicies({ + public: assemblePublic(), + unlisted: assembleUnlisted(), + private: assemblePrivate(), + // Always use the + // default for direct. + direct: null, + }); + }; + + return ( +
+
+

Default Interaction Policies

+

+ You can use this section to customize the default interaction + policy for posts created by you, per visibility setting. +
+ These settings apply only for new posts created by you after applying + these settings; they do not apply retroactively. +
+ The word "anyone" in the below options means anyone with + permission to see the post, taking account of blocks. +
+ Bear in mind that no matter what you set below, you will always + be able to like, reply-to, and boost your own posts. +

+ + Learn more about these settings (opens in a new tab) + +
+
+ + + + +
+ +
+ + + resetPolicies()} + label="Reset to defaults" + result={resetResult} + className="button danger" + showError={false} + /> +
+ +
+ ); +} + +// A tablist of tab buttons, one for each visibility. +function PolicyPanelsTablist({ selectedVis }: { selectedVis: TextFormInputHook}) { + return ( +
+ + + +
+ ); +} + +interface TabProps { + thisVisibility: string; + label: string, + selectedVis: TextFormInputHook +} + +// One tab in a tablist, corresponding to the given thisVisibility. +function Tab({ thisVisibility, label, selectedVis }: TabProps) { + const selected = useMemo(() => { + return selectedVis.value === thisVisibility; + }, [selectedVis, thisVisibility]); + + return ( + + ); +} + +interface PolicyPanelProps { + policyForm: PolicyForm; + forVis: Visibility; + isActive: boolean; +} + +// Tab panel for one policy form of the given visibility. +function PolicyPanel({ policyForm, forVis, isActive }: PolicyPanelProps) { + return ( + + ); +} + +interface PolicyComponentProps { + form: { + basic: PolicyFormSub; + somethingElse: PolicyFormSomethingElse; + }; + forAction: Action; +} + +// A component of one policy of the given +// visibility, corresponding to the given action. +function PolicyComponent({ form, forAction }: PolicyComponentProps) { + const legend = useLegend(forAction); + return ( +
+ {legend} + { forAction === "reply" && +
+ + Mentioned accounts can always reply. +
+ } + + + } +