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 @@ var Start action.GTSAction = func(ctx context.Context) error { } 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 @@ var Start action.GTSAction = func(ctx context.Context) error { 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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. + +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. +
+ } + + + } +