Browse Source
* [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 * oopspull/3113/head
36 changed files with 3178 additions and 316 deletions
|
After Width: | Height: | Size: 62 KiB |
@ -0,0 +1,77 @@
|
||||
// GoToSocial
|
||||
// Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
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) |
||||
} |
||||
@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
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) |
||||
} |
||||
@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
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 |
||||
} |
||||
@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
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"` |
||||
} |
||||
@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
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 |
||||
} |
||||
@ -0,0 +1,63 @@
|
||||
/* |
||||
GoToSocial |
||||
Copyright (C) GoToSocial Authors admin@gotosocial.org |
||||
SPDX-License-Identifier: AGPL-3.0-or-later |
||||
|
||||
This program is free software: you can redistribute it and/or modify |
||||
it under the terms of the GNU Affero General Public License as published by |
||||
the Free Software Foundation, either version 3 of the License, or |
||||
(at your option) any later version. |
||||
|
||||
This program is distributed in the hope that it will be useful, |
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of |
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||||
GNU Affero General Public License for more details. |
||||
|
||||
You should have received a copy of the GNU Affero General Public License |
||||
along with this program. If not, see <http://www.gnu.org/licenses/>. |
||||
*/ |
||||
|
||||
export interface DefaultInteractionPolicies { |
||||
direct: InteractionPolicy; |
||||
private: InteractionPolicy; |
||||
unlisted: InteractionPolicy; |
||||
public: InteractionPolicy; |
||||
} |
||||
|
||||
export interface UpdateDefaultInteractionPolicies { |
||||
direct: InteractionPolicy | null; |
||||
private: InteractionPolicy | null; |
||||
unlisted: InteractionPolicy | null; |
||||
public: InteractionPolicy | null; |
||||
} |
||||
|
||||
export interface InteractionPolicy { |
||||
can_favourite: InteractionPolicyEntry; |
||||
can_reply: InteractionPolicyEntry; |
||||
can_reblog: InteractionPolicyEntry; |
||||
} |
||||
|
||||
export interface InteractionPolicyEntry { |
||||
always: InteractionPolicyValue[]; |
||||
with_approval: InteractionPolicyValue[]; |
||||
} |
||||
|
||||
export type InteractionPolicyValue = string; |
||||
|
||||
const PolicyValuePublic: InteractionPolicyValue = "public"; |
||||
const PolicyValueFollowers: InteractionPolicyValue = "followers"; |
||||
const PolicyValueFollowing: InteractionPolicyValue = "following"; |
||||
const PolicyValueMutuals: InteractionPolicyValue = "mutuals"; |
||||
const PolicyValueMentioned: InteractionPolicyValue = "mentioned"; |
||||
const PolicyValueAuthor: InteractionPolicyValue = "author"; |
||||
const PolicyValueMe: InteractionPolicyValue = "me"; |
||||
|
||||
export { |
||||
PolicyValuePublic, |
||||
PolicyValueFollowers, |
||||
PolicyValueFollowing, |
||||
PolicyValueMutuals, |
||||
PolicyValueMentioned, |
||||
PolicyValueAuthor, |
||||
PolicyValueMe, |
||||
}; |
||||
@ -0,0 +1,88 @@
|
||||
/* |
||||
GoToSocial |
||||
Copyright (C) GoToSocial Authors admin@gotosocial.org |
||||
SPDX-License-Identifier: AGPL-3.0-or-later |
||||
|
||||
This program is free software: you can redistribute it and/or modify |
||||
it under the terms of the GNU Affero General Public License as published by |
||||
the Free Software Foundation, either version 3 of the License, or |
||||
(at your option) any later version. |
||||
|
||||
This program is distributed in the hope that it will be useful, |
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of |
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||||
GNU Affero General Public License for more details. |
||||
|
||||
You should have received a copy of the GNU Affero General Public License |
||||
along with this program. If not, see <http://www.gnu.org/licenses/>. |
||||
*/ |
||||
|
||||
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 ( |
||||
<form className="post-settings" onSubmit={submitForm}> |
||||
<div className="form-section-docs"> |
||||
<h3>Basic</h3> |
||||
<a |
||||
href="https://docs.gotosocial.org/en/latest/user_guide/settings#post-settings" |
||||
target="_blank" |
||||
className="docslink" |
||||
rel="noreferrer" |
||||
> |
||||
Learn more about these settings (opens in a new tab) |
||||
</a> |
||||
</div> |
||||
<Select field={form.language} label="Default post language" options={ |
||||
<Languages /> |
||||
}> |
||||
</Select> |
||||
<Select field={form.defaultPrivacy} label="Default post privacy" options={ |
||||
<> |
||||
<option value="public">Public</option> |
||||
<option value="unlisted">Unlisted</option> |
||||
<option value="private">Followers-only</option> |
||||
</> |
||||
}> |
||||
</Select> |
||||
<Select field={form.statusContentType} label="Default post (and bio) format" options={ |
||||
<> |
||||
<option value="text/plain">Plain (default)</option> |
||||
<option value="text/markdown">Markdown</option> |
||||
</> |
||||
}> |
||||
</Select> |
||||
<Checkbox |
||||
field={form.isSensitive} |
||||
label="Mark my posts as sensitive by default" |
||||
/> |
||||
<MutationButton |
||||
disabled={false} |
||||
label="Save settings" |
||||
result={result} |
||||
/> |
||||
</form> |
||||
); |
||||
} |
||||
@ -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 <http://www.gnu.org/licenses/>. |
||||
*/ |
||||
|
||||
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 <Loading />; |
||||
} |
||||
|
||||
if (isError) { |
||||
return <Error error={error} />; |
||||
} |
||||
|
||||
return ( |
||||
<> |
||||
<h1>Post Settings</h1> |
||||
<BasicSettings account={account} /> |
||||
<InteractionPolicySettings /> |
||||
</> |
||||
); |
||||
} |
||||
@ -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 <http://www.gnu.org/licenses/>. |
||||
*/ |
||||
|
||||
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 ( |
||||
<> |
||||
<option value="anyone">{audience}</option> |
||||
<option value="anyone_with_approval">{audience} (approval required)</option> |
||||
<option value="just_me">Just me</option> |
||||
{ visibility !== "private" && |
||||
<option value="something_else">Something else...</option> |
||||
} |
||||
</> |
||||
); |
||||
}, [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), |
||||
}; |
||||
} |
||||
@ -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 <http://www.gnu.org/licenses/>. |
||||
*/ |
||||
|
||||
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 <Loading />; |
||||
} |
||||
|
||||
if (isError) { |
||||
return <Error error={error} />; |
||||
} |
||||
|
||||
if (!defaultPolicies) { |
||||
throw "default policies undefined"; |
||||
} |
||||
|
||||
return ( |
||||
<InteractionPoliciesForm defaultPolicies={defaultPolicies} /> |
||||
); |
||||
} |
||||
|
||||
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 ( |
||||
<form className="interaction-default-settings" onSubmit={onSubmit}> |
||||
<div className="form-section-docs"> |
||||
<h3>Default Interaction Policies</h3> |
||||
<p> |
||||
You can use this section to customize the default interaction |
||||
policy for posts created by you, per visibility setting. |
||||
<br/> |
||||
These settings apply only for new posts created by you <em>after</em> applying |
||||
these settings; they do not apply retroactively. |
||||
<br/> |
||||
The word "anyone" in the below options means <em>anyone with |
||||
permission to see the post</em>, taking account of blocks. |
||||
<br/> |
||||
Bear in mind that no matter what you set below, you will always |
||||
be able to like, reply-to, and boost your own posts. |
||||
</p> |
||||
<a |
||||
href="https://docs.gotosocial.org/en/latest/user_guide/settings#default-interaction-policies" |
||||
target="_blank" |
||||
className="docslink" |
||||
rel="noreferrer" |
||||
> |
||||
Learn more about these settings (opens in a new tab) |
||||
</a> |
||||
</div> |
||||
<div className="tabbable-sections"> |
||||
<PolicyPanelsTablist selectedVis={selectedVis} /> |
||||
<PolicyPanel |
||||
policyForm={formPublic} |
||||
forVis={"public"} |
||||
isActive={selectedVis.value === "public"} |
||||
/> |
||||
<PolicyPanel |
||||
policyForm={formUnlisted} |
||||
forVis={"unlisted"} |
||||
isActive={selectedVis.value === "unlisted"} |
||||
/> |
||||
<PolicyPanel |
||||
policyForm={formPrivate} |
||||
forVis={"private"} |
||||
isActive={selectedVis.value === "private"} |
||||
/> |
||||
</div> |
||||
|
||||
<div className="action-buttons row"> |
||||
<MutationButton |
||||
disabled={false} |
||||
label="Save policies" |
||||
result={updateResult} |
||||
/> |
||||
|
||||
<MutationButton |
||||
disabled={false} |
||||
type="button" |
||||
onClick={() => resetPolicies()} |
||||
label="Reset to defaults" |
||||
result={resetResult} |
||||
className="button danger" |
||||
showError={false} |
||||
/> |
||||
</div> |
||||
|
||||
</form> |
||||
); |
||||
} |
||||
|
||||
// A tablist of tab buttons, one for each visibility.
|
||||
function PolicyPanelsTablist({ selectedVis }: { selectedVis: TextFormInputHook}) { |
||||
return ( |
||||
<div className="tab-buttons" role="tablist"> |
||||
<Tab |
||||
thisVisibility="public" |
||||
label="Public" |
||||
selectedVis={selectedVis} |
||||
/> |
||||
<Tab |
||||
thisVisibility="unlisted" |
||||
label="Unlisted" |
||||
selectedVis={selectedVis} |
||||
/> |
||||
<Tab |
||||
thisVisibility="private" |
||||
label="Followers-only" |
||||
selectedVis={selectedVis} |
||||
/> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
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 ( |
||||
<button |
||||
id={`tab-${thisVisibility}`} |
||||
title={label} |
||||
role="tab" |
||||
className={`tab-button ${selected && "active"}`} |
||||
onClick={(e) => { |
||||
e.preventDefault(); |
||||
selectedVis.setter(thisVisibility); |
||||
}} |
||||
aria-selected={selected} |
||||
aria-controls={`panel-${thisVisibility}`} |
||||
tabIndex={selected ? 0 : -1} |
||||
> |
||||
{label} |
||||
</button> |
||||
); |
||||
} |
||||
|
||||
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 ( |
||||
<div |
||||
className={`interaction-policy-section ${isActive && "active"}`} |
||||
role="tabpanel" |
||||
hidden={!isActive} |
||||
> |
||||
<PolicyComponent |
||||
form={policyForm.favourite} |
||||
forAction="favourite" |
||||
/> |
||||
<PolicyComponent |
||||
form={policyForm.reply} |
||||
forAction="reply" |
||||
/> |
||||
{ forVis !== "private" && |
||||
<PolicyComponent |
||||
form={policyForm.reblog} |
||||
forAction="reblog" |
||||
/> |
||||
} |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
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 ( |
||||
<fieldset> |
||||
<legend>{legend}</legend> |
||||
{ forAction === "reply" && |
||||
<div className="info"> |
||||
<i className="fa fa-fw fa-info-circle" aria-hidden="true"></i> |
||||
<b>Mentioned accounts can always reply.</b> |
||||
</div>
|
||||
} |
||||
<Select |
||||
field={form.basic.field} |
||||
label={form.basic.label} |
||||
options={form.basic.options} |
||||
/> |
||||
{/* Include advanced "something else" options if appropriate */} |
||||
{ (form.basic.field.value === "something_else") && |
||||
<> |
||||
<hr /> |
||||
<div className="something-else"> |
||||
<Select |
||||
field={form.somethingElse.followers.field} |
||||
label={form.somethingElse.followers.label} |
||||
options={form.somethingElse.followers.options} |
||||
/> |
||||
<Select |
||||
field={form.somethingElse.following.field} |
||||
label={form.somethingElse.following.label} |
||||
options={form.somethingElse.following.options} |
||||
/> |
||||
{/* |
||||
Skip mentioned accounts field for reply action, |
||||
since mentioned accounts can always reply. |
||||
*/} |
||||
{ forAction !== "reply" && |
||||
<Select |
||||
field={form.somethingElse.mentioned.field} |
||||
label={form.somethingElse.mentioned.label} |
||||
options={form.somethingElse.mentioned.options} |
||||
/> |
||||
} |
||||
<Select |
||||
field={form.somethingElse.everyoneElse.field} |
||||
label={form.somethingElse.everyoneElse.label} |
||||
options={form.somethingElse.everyoneElse.options} |
||||
/> |
||||
</div> |
||||
</> |
||||
} |
||||
</fieldset> |
||||
); |
||||
} |
||||
|
||||
/* |
||||
UTILITY FUNCTIONS |
||||
*/ |
||||
|
||||
// useLegend returns an appropriate
|
||||
// fieldset legend for the given action.
|
||||
function useLegend(action: Action) { |
||||
return useMemo(() => { |
||||
switch (action) { |
||||
case "favourite": |
||||
return ( |
||||
<> |
||||
<i className="fa fa-fw fa-star" aria-hidden="true"></i> |
||||
<span>Like</span> |
||||
</> |
||||
); |
||||
case "reply": |
||||
return ( |
||||
<> |
||||
<i className="fa fa-fw fa-reply-all" aria-hidden="true"></i> |
||||
<span>Reply</span> |
||||
</> |
||||
); |
||||
case "reblog": |
||||
return ( |
||||
<> |
||||
<i className="fa fa-fw fa-retweet" aria-hidden="true"></i> |
||||
<span>Boost</span> |
||||
</> |
||||
); |
||||
} |
||||
}, [action]); |
||||
} |
||||
|
||||
// Form encapsulating the different
|
||||
// actions for one visibility.
|
||||
interface PolicyForm { |
||||
favourite: { |
||||
basic: PolicyFormSub, |
||||
somethingElse: PolicyFormSomethingElse, |
||||
} |
||||
reply: { |
||||
basic: PolicyFormSub, |
||||
somethingElse: PolicyFormSomethingElse, |
||||
} |
||||
reblog: { |
||||
basic: PolicyFormSub, |
||||
somethingElse: PolicyFormSomethingElse, |
||||
} |
||||
} |
||||
|
||||
// Return a PolicyForm for the given visibility,
|
||||
// set already to whatever the defaultPolicies value is.
|
||||
function useFormForVis( |
||||
currentPolicy: InteractionPolicy, |
||||
forVis: Visibility, |
||||
): PolicyForm {
|
||||
return { |
||||
favourite: { |
||||
basic: useBasicFor( |
||||
forVis, |
||||
"favourite", |
||||
currentPolicy.can_favourite.always, |
||||
currentPolicy.can_favourite.with_approval, |
||||
), |
||||
somethingElse: useSomethingElseFor( |
||||
forVis, |
||||
"favourite", |
||||
currentPolicy.can_favourite.always, |
||||
currentPolicy.can_favourite.with_approval, |
||||
), |
||||
}, |
||||
reply: { |
||||
basic: useBasicFor( |
||||
forVis, |
||||
"reply", |
||||
currentPolicy.can_reply.always, |
||||
currentPolicy.can_reply.with_approval, |
||||
), |
||||
somethingElse: useSomethingElseFor( |
||||
forVis, |
||||
"reply", |
||||
currentPolicy.can_reply.always, |
||||
currentPolicy.can_reply.with_approval, |
||||
), |
||||
}, |
||||
reblog: { |
||||
basic: useBasicFor( |
||||
forVis, |
||||
"reblog", |
||||
currentPolicy.can_reblog.always, |
||||
currentPolicy.can_reblog.with_approval, |
||||
), |
||||
somethingElse: useSomethingElseFor( |
||||
forVis, |
||||
"reblog", |
||||
currentPolicy.can_reblog.always, |
||||
currentPolicy.can_reblog.with_approval, |
||||
), |
||||
}, |
||||
}; |
||||
} |
||||
|
||||
function assemblePolicyEntry( |
||||
forVis: Visibility, |
||||
forAction: Action, |
||||
policyForm: PolicyForm, |
||||
): InteractionPolicyEntry { |
||||
const basic = policyForm[forAction].basic; |
||||
|
||||
// If this is followers visibility then
|
||||
// "anyone" only means followers, not public.
|
||||
const anyone: InteractionPolicyValue = |
||||
(forVis === "private") |
||||
? PolicyValueFollowers |
||||
: PolicyValuePublic; |
||||
|
||||
// If this is a reply action then "just me"
|
||||
// must include mentioned accounts as well,
|
||||
// since they can always reply.
|
||||
const justMe: InteractionPolicyValue[] = |
||||
(forAction === "reply") |
||||
? [PolicyValueAuthor, PolicyValueMentioned] |
||||
: [PolicyValueAuthor]; |
||||
|
||||
switch (basic.field.value) { |
||||
case "anyone": |
||||
return { |
||||
// Anyone can do this.
|
||||
always: [anyone], |
||||
with_approval: [], |
||||
}; |
||||
case "anyone_with_approval": |
||||
return { |
||||
// Author and maybe mentioned can do
|
||||
// this, everyone else needs approval.
|
||||
always: justMe, |
||||
with_approval: [anyone], |
||||
}; |
||||
case "just_me": |
||||
return { |
||||
// Only author and maybe
|
||||
// mentioned can do this.
|
||||
always: justMe, |
||||
with_approval: [], |
||||
}; |
||||
} |
||||
|
||||
// Something else!
|
||||
const somethingElse = policyForm[forAction].somethingElse; |
||||
|
||||
// Start with basic "always"
|
||||
// and "with_approval" values.
|
||||
let always: InteractionPolicyValue[] = justMe; |
||||
let withApproval: InteractionPolicyValue[] = []; |
||||
|
||||
// Add PolicyValueFollowers depending on choices made.
|
||||
switch (somethingElse.followers.field.value as SomethingElseValue) { |
||||
case "always": |
||||
always.push(PolicyValueFollowers); |
||||
break; |
||||
case "with_approval": |
||||
withApproval.push(PolicyValueFollowers); |
||||
break; |
||||
} |
||||
|
||||
// Add PolicyValueFollowing depending on choices made.
|
||||
switch (somethingElse.following.field.value as SomethingElseValue) { |
||||
case "always": |
||||
always.push(PolicyValueFollowing); |
||||
break; |
||||
case "with_approval": |
||||
withApproval.push(PolicyValueFollowing); |
||||
break; |
||||
} |
||||
|
||||
// Add PolicyValueMentioned depending on choices made.
|
||||
// Note: mentioned can always reply, and that's already
|
||||
// included above, so only do this if action is not reply.
|
||||
if (forAction !== "reply") { |
||||
switch (somethingElse.mentioned.field.value as SomethingElseValue) { |
||||
case "always": |
||||
always.push(PolicyValueMentioned); |
||||
break; |
||||
case "with_approval": |
||||
withApproval.push(PolicyValueMentioned); |
||||
break; |
||||
} |
||||
} |
||||
|
||||
// Add anyone depending on choices made.
|
||||
switch (somethingElse.everyoneElse.field.value as SomethingElseValue) { |
||||
case "with_approval": |
||||
withApproval.push(anyone); |
||||
break; |
||||
} |
||||
|
||||
// Simplify a bit after
|
||||
// all the parsing above.
|
||||
if (always.includes(anyone)) { |
||||
always = [anyone]; |
||||
} |
||||
|
||||
if (withApproval.includes(anyone)) { |
||||
withApproval = [anyone]; |
||||
} |
||||
|
||||
return { |
||||
always: always, |
||||
with_approval: withApproval, |
||||
}; |
||||
} |
||||
@ -0,0 +1,124 @@
|
||||
/* |
||||
GoToSocial |
||||
Copyright (C) GoToSocial Authors admin@gotosocial.org |
||||
SPDX-License-Identifier: AGPL-3.0-or-later |
||||
|
||||
This program is free software: you can redistribute it and/or modify |
||||
it under the terms of the GNU Affero General Public License as published by |
||||
the Free Software Foundation, either version 3 of the License, or |
||||
(at your option) any later version. |
||||
|
||||
This program is distributed in the hope that it will be useful, |
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of |
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||||
GNU Affero General Public License for more details. |
||||
|
||||
You should have received a copy of the GNU Affero General Public License |
||||
along with this program. If not, see <http://www.gnu.org/licenses/>. |
||||
*/ |
||||
|
||||
import React, { useMemo } from "react"; |
||||
import { InteractionPolicyValue, PolicyValueFollowers, PolicyValueFollowing, PolicyValuePublic } from "../../../../lib/types/interaction"; |
||||
import { useTextInput } from "../../../../lib/form"; |
||||
import { Action, Audience, PolicyFormSub, SomethingElseValue, Visibility } from "./types"; |
||||
|
||||
export interface PolicyFormSomethingElse { |
||||
followers: PolicyFormSub, |
||||
following: PolicyFormSub, |
||||
mentioned: PolicyFormSub, |
||||
everyoneElse: PolicyFormSub, |
||||
} |
||||
|
||||
function useSomethingElseOptions( |
||||
forVis: Visibility, |
||||
forAction: Action, |
||||
forAudience: Audience, |
||||
) { |
||||
return ( |
||||
<> |
||||
{ forAudience !== "everyone_else" && |
||||
<option value="always">Always</option> |
||||
} |
||||
<option value="with_approval">With my approval</option> |
||||
<option value="no">No</option> |
||||
</> |
||||
); |
||||
} |
||||
|
||||
export function useSomethingElseFor( |
||||
forVis: Visibility, |
||||
forAction: Action, |
||||
currentAlways: InteractionPolicyValue[], |
||||
currentWithApproval: InteractionPolicyValue[], |
||||
): PolicyFormSomethingElse {
|
||||
const followersDefaultValue: SomethingElseValue = useMemo(() => { |
||||
if (currentAlways.includes(PolicyValueFollowers)) { |
||||
return "always"; |
||||
} |
||||
|
||||
if (currentWithApproval.includes(PolicyValueFollowers)) { |
||||
return "with_approval"; |
||||
} |
||||
|
||||
return "no"; |
||||
}, [currentAlways, currentWithApproval]); |
||||
|
||||
const followingDefaultValue: SomethingElseValue = useMemo(() => { |
||||
if (currentAlways.includes(PolicyValueFollowing)) { |
||||
return "always"; |
||||
} |
||||
|
||||
if (currentWithApproval.includes(PolicyValueFollowing)) { |
||||
return "with_approval"; |
||||
} |
||||
|
||||
return "no"; |
||||
}, [currentAlways, currentWithApproval]); |
||||
|
||||
const mentionedDefaultValue: SomethingElseValue = useMemo(() => { |
||||
if (currentAlways.includes(PolicyValueFollowing)) { |
||||
return "always"; |
||||
} |
||||
|
||||
if (currentWithApproval.includes(PolicyValueFollowing)) { |
||||
return "with_approval"; |
||||
} |
||||
|
||||
return "no"; |
||||
}, [currentAlways, currentWithApproval]); |
||||
|
||||
const everyoneElseDefaultValue: SomethingElseValue = useMemo(() => { |
||||
if (currentAlways.includes(PolicyValuePublic)) { |
||||
return "always"; |
||||
} |
||||
|
||||
if (currentWithApproval.includes(PolicyValuePublic)) { |
||||
return "with_approval"; |
||||
} |
||||
|
||||
return "no"; |
||||
}, [currentAlways, currentWithApproval]); |
||||
|
||||
return { |
||||
followers: { |
||||
field: useTextInput("followers", { defaultValue: followersDefaultValue }), |
||||
label: "My followers", |
||||
options: useSomethingElseOptions(forVis, forAction, "followers"), |
||||
}, |
||||
following: { |
||||
field: useTextInput("following", { defaultValue: followingDefaultValue }), |
||||
label: "Accounts I follow", |
||||
options: useSomethingElseOptions(forVis, forAction, "following"), |
||||
}, |
||||
mentioned: { |
||||
field: useTextInput("mentioned_accounts", { defaultValue: mentionedDefaultValue }), |
||||
label: "Accounts mentioned in the post", |
||||
options: useSomethingElseOptions(forVis, forAction, "mentioned_accounts"), |
||||
}, |
||||
everyoneElse: { |
||||
field: useTextInput("everyone_else", { defaultValue: everyoneElseDefaultValue }), |
||||
label: "Everyone else", |
||||
options: useSomethingElseOptions(forVis, forAction, "everyone_else"), |
||||
}, |
||||
}; |
||||
} |
||||
@ -0,0 +1,35 @@
|
||||
/* |
||||
GoToSocial |
||||
Copyright (C) GoToSocial Authors admin@gotosocial.org |
||||
SPDX-License-Identifier: AGPL-3.0-or-later |
||||
|
||||
This program is free software: you can redistribute it and/or modify |
||||
it under the terms of the GNU Affero General Public License as published by |
||||
the Free Software Foundation, either version 3 of the License, or |
||||
(at your option) any later version. |
||||
|
||||
This program is distributed in the hope that it will be useful, |
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of |
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||||
GNU Affero General Public License for more details. |
||||
|
||||
You should have received a copy of the GNU Affero General Public License |
||||
along with this program. If not, see <http://www.gnu.org/licenses/>. |
||||
*/ |
||||
|
||||
import { TextFormInputHook } from "../../../../lib/form/types"; |
||||
import React from "react"; |
||||
|
||||
export interface PolicyFormSub { |
||||
field: TextFormInputHook; |
||||
label: string; |
||||
options: React.JSX.Element; |
||||
} |
||||
|
||||
/* Form / select types */ |
||||
|
||||
export type Visibility = "public" | "unlisted" | "private";
|
||||
export type Action = "favourite" | "reply" | "reblog"; |
||||
export type BasicValue = "anyone" | "anyone_with_approval" | "just_me" | "something_else"; |
||||
export type SomethingElseValue = "always" | "with_approval" | "no"; |
||||
export type Audience = "followers" | "following" | "mentioned_accounts" | "everyone_else"; |
||||
Loading…
Reference in new issue