[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
This commit is contained in:
tobi 2024-07-17 16:46:52 +02:00 committed by GitHub
parent 401098191b
commit 0aadc2db2a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 3178 additions and 316 deletions

View file

@ -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)

View file

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

View file

@ -75,35 +75,6 @@ Public posts can be liked/faved, and they can be boosted.
**Public posts are accessible via a web URL on your GoToSocial instance!**
## Extra Flags
GoToSocial offers four extra flags on posts, which can be used to tweak how your post can be interacted with by others. These are:
* `federated`
* `boostable`
* `replyable`
* `likeable`
By default, all these flags are set to `true`.
Please note that while GoToSocial strictly respects these settings, other fediverse server implementations might not be aware of them. A consequence of this is that users on non-GoToSocial servers might think they are replying/boosting/liking your post, and their instance might behave as though that behavior was allowed, but those interactions will be denied by your GoToSocial server and you won't see them.
### Federated
When set to `false`, this post will not be federated out to other fediverse servers, and will be viewable only to accounts on your GoToSocial instance. This is sometimes called 'local-only' posting.
### Boostable
When set to `false`, your post will not be boostable, even if it is unlisted or public. GoToSocial enforces this by refusing dereferencing requests from remote servers in the event that someone tries to boost the post.
### Replyable
When set to `false`, replies to your post will not be accepted by your GoToSocial server, and will not appear in your timeline or create notifications. GoToSocial enforces this by giving an error message to attempted replies to the post from federated servers.
### Likeable
When set to `false`, likes/faves of your post will not be accepted by your GoToSocial server, and will not create notifications. GoToSocial enforces this by giving an error message to attempted likes/faves on the post from federated servers.
## Input Types
GoToSocial currently accepts two different types of input for posts (and user bio). The [user settings page](./settings.md) allows you to select between them. These are:

View file

@ -133,11 +133,7 @@ See the [Custom CSS](./custom_css.md) page for some tips on writing custom CSS f
!!! tip
Any custom CSS you add in this box will be applied *after* your selected theme, so you can pick a preset theme that you like and then make your own tweaks!
## Settings
![Screenshot of the settings section](../assets/user-settings-settings.png)
In the 'Settings' section, you can set various defaults for new posts, and change your password / email address.
## Posts
### Post Settings
@ -151,16 +147,39 @@ The plain (default) setting provides standard post formatting, similar to what m
The markdown setting indicates that your posts should be parsed as Markdown, which is a markup language that gives you more options for customizing the layout and appearance of your posts. For more information on the differences between plain and markdown post formats, see the [posts page](posts.md).
When you are finished updating your post settings, remember to click the `Save post settings` button at the bottom of the section to save your changes.
When you are finished updating your post settings, remember to click the `Save settings` button at the bottom of the section to save your changes.
### Password Change
### Default Interaction Policies
You can use the Password Change section of the panel to set a new password for your account. For security reasons, you must provide your current password to validate the change.
Using this section, you can set your default interaction policies for new posts per visibility level. This allows you to fine-tune how others are allowed to interact with your posts.
!!! info
If your instance is using OIDC as its authorization/identity provider, you will not be able to change your password via the GoToSocial settings panel, and you should contact your OIDC provider instead.
This allows you to do things like:
For more information on the way GoToSocial manages passwords, please see the [Password management document](./password_management.md).
- Create posts that nobody can interact with except you.
- Create posts that only your followers / people you follow can interact with.
- Create posts that anyone can like or boost, but only certain people can reply to.
- Etc.
For example, the below image shows a policy for Public visibility posts that allows anyone to like or boost, but only allows followers, and people you follow, to reply.
![Policy showing "Who can like" = "anyone", "Who can reply" = "followers" and "following", and "Who can boost" = "anyone".](../assets/user-settings-interaction-policy-1.png)
Bear in mind that policies do not apply retroactively. Posts created after you've applied a default interaction policy will use that policy, but any post created before then will use whatever policy was the default when the post was created.
No matter what policy you set on a post, visibility settings and blocks will still be taken into account *before* any policies apply. For example, if you set "anyone" for a type of interaction, that will still exclude accounts you have blocked, or accounts on domains that are blocked by your instance. "Anyone", in this case, essentially means "anyone who could normally see the post".
Finally, note that no matter what policy you set on a post, any accounts you mention in a post will **always** be able to reply to that post.
When you are finished updating your interaction policy settings, remember to click the `Save policies` button at the bottom of the section to save your changes.
If you want to reset all your policies to the initial defaults, you can click on `Reset to defaults` button.
!!! danger
While GoToSocial respects interaction policies, it is not guaranteed that other server softwares will, and it is possible that accounts on other servers will still send out replies and boosts of your post to their followers, even if your instance forbids these interactions.
As more ActivityPub servers roll out support for interaction policies, this issue will hopefully diminish, but in the meantime GoToSocial can offer only a "best effort" attempt to restrict interactions with your posts according to the policies you have set.
## Email & Password
### Email Change
@ -171,6 +190,15 @@ Once a new email address has been entered, and you have clicked "Change email ad
!!! info
If your instance is using OIDC as its authorization/identity provider, you will be able to change your email address via the settings panel, but it will only affect the email address GoToSocial uses to contact you, it will not change the email address you need to use to log in to your account. To change that, you should contact your OIDC provider.
### Password Change
You can use the Password Change section of the panel to set a new password for your account. For security reasons, you must provide your current password to validate the change.
!!! info
If your instance is using OIDC as its authorization/identity provider, you will not be able to change your password via the GoToSocial settings panel, and you should contact your OIDC provider instead.
For more information on the way GoToSocial manages passwords, please see the [Password management document](./password_management.md).
## Migration
In the migration section you can manage settings related to aliasing and/or migrating your account to another account.

View file

@ -34,6 +34,7 @@ import (
filtersV2 "github.com/superseriousbusiness/gotosocial/internal/api/client/filters/v2"
"github.com/superseriousbusiness/gotosocial/internal/api/client/followrequests"
"github.com/superseriousbusiness/gotosocial/internal/api/client/instance"
"github.com/superseriousbusiness/gotosocial/internal/api/client/interactionpolicies"
"github.com/superseriousbusiness/gotosocial/internal/api/client/lists"
"github.com/superseriousbusiness/gotosocial/internal/api/client/markers"
"github.com/superseriousbusiness/gotosocial/internal/api/client/media"
@ -58,32 +59,33 @@ type Client struct {
processor *processing.Processor
db db.DB
accounts *accounts.Module // api/v1/accounts
admin *admin.Module // api/v1/admin
apps *apps.Module // api/v1/apps
blocks *blocks.Module // api/v1/blocks
bookmarks *bookmarks.Module // api/v1/bookmarks
conversations *conversations.Module // api/v1/conversations
customEmojis *customemojis.Module // api/v1/custom_emojis
favourites *favourites.Module // api/v1/favourites
featuredTags *featuredtags.Module // api/v1/featured_tags
filtersV1 *filtersV1.Module // api/v1/filters
filtersV2 *filtersV2.Module // api/v2/filters
followRequests *followrequests.Module // api/v1/follow_requests
instance *instance.Module // api/v1/instance
lists *lists.Module // api/v1/lists
markers *markers.Module // api/v1/markers
media *media.Module // api/v1/media, api/v2/media
mutes *mutes.Module // api/v1/mutes
notifications *notifications.Module // api/v1/notifications
polls *polls.Module // api/v1/polls
preferences *preferences.Module // api/v1/preferences
reports *reports.Module // api/v1/reports
search *search.Module // api/v1/search, api/v2/search
statuses *statuses.Module // api/v1/statuses
streaming *streaming.Module // api/v1/streaming
timelines *timelines.Module // api/v1/timelines
user *user.Module // api/v1/user
accounts *accounts.Module // api/v1/accounts
admin *admin.Module // api/v1/admin
apps *apps.Module // api/v1/apps
blocks *blocks.Module // api/v1/blocks
bookmarks *bookmarks.Module // api/v1/bookmarks
conversations *conversations.Module // api/v1/conversations
customEmojis *customemojis.Module // api/v1/custom_emojis
favourites *favourites.Module // api/v1/favourites
featuredTags *featuredtags.Module // api/v1/featured_tags
filtersV1 *filtersV1.Module // api/v1/filters
filtersV2 *filtersV2.Module // api/v2/filters
followRequests *followrequests.Module // api/v1/follow_requests
instance *instance.Module // api/v1/instance
interactionPolicies *interactionpolicies.Module // api/v1/interaction_policies
lists *lists.Module // api/v1/lists
markers *markers.Module // api/v1/markers
media *media.Module // api/v1/media, api/v2/media
mutes *mutes.Module // api/v1/mutes
notifications *notifications.Module // api/v1/notifications
polls *polls.Module // api/v1/polls
preferences *preferences.Module // api/v1/preferences
reports *reports.Module // api/v1/reports
search *search.Module // api/v1/search, api/v2/search
statuses *statuses.Module // api/v1/statuses
streaming *streaming.Module // api/v1/streaming
timelines *timelines.Module // api/v1/timelines
user *user.Module // api/v1/user
}
func (c *Client) Route(r *router.Router, m ...gin.HandlerFunc) {
@ -116,6 +118,7 @@ func (c *Client) Route(r *router.Router, m ...gin.HandlerFunc) {
c.filtersV2.Route(h)
c.followRequests.Route(h)
c.instance.Route(h)
c.interactionPolicies.Route(h)
c.lists.Route(h)
c.markers.Route(h)
c.media.Route(h)
@ -136,31 +139,32 @@ func NewClient(state *state.State, p *processing.Processor) *Client {
processor: p,
db: state.DB,
accounts: accounts.New(p),
admin: admin.New(state, p),
apps: apps.New(p),
blocks: blocks.New(p),
bookmarks: bookmarks.New(p),
conversations: conversations.New(p),
customEmojis: customemojis.New(p),
favourites: favourites.New(p),
featuredTags: featuredtags.New(p),
filtersV1: filtersV1.New(p),
filtersV2: filtersV2.New(p),
followRequests: followrequests.New(p),
instance: instance.New(p),
lists: lists.New(p),
markers: markers.New(p),
media: media.New(p),
mutes: mutes.New(p),
notifications: notifications.New(p),
polls: polls.New(p),
preferences: preferences.New(p),
reports: reports.New(p),
search: search.New(p),
statuses: statuses.New(p),
streaming: streaming.New(p, time.Second*30, 4096),
timelines: timelines.New(p),
user: user.New(p),
accounts: accounts.New(p),
admin: admin.New(state, p),
apps: apps.New(p),
blocks: blocks.New(p),
bookmarks: bookmarks.New(p),
conversations: conversations.New(p),
customEmojis: customemojis.New(p),
favourites: favourites.New(p),
featuredTags: featuredtags.New(p),
filtersV1: filtersV1.New(p),
filtersV2: filtersV2.New(p),
followRequests: followrequests.New(p),
instance: instance.New(p),
interactionPolicies: interactionpolicies.New(p),
lists: lists.New(p),
markers: markers.New(p),
media: media.New(p),
mutes: mutes.New(p),
notifications: notifications.New(p),
polls: polls.New(p),
preferences: preferences.New(p),
reports: reports.New(p),
search: search.New(p),
statuses: statuses.New(p),
streaming: streaming.New(p, time.Second*30, 4096),
timelines: timelines.New(p),
user: user.New(p),
}
}

View file

@ -528,7 +528,27 @@ func (suite *ReportsGetTestSuite) TestReportsGetAll() {
"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": [
@ -750,7 +770,27 @@ func (suite *ReportsGetTestSuite) TestReportsGetCreatedByAccount() {
"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": [
@ -972,7 +1012,27 @@ func (suite *ReportsGetTestSuite) TestReportsGetTargetAccount() {
"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": [

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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"`
}

View file

@ -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

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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")
}

View file

@ -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 &gtsmodel.InteractionPolicy{
CanLike: gtsmodel.PolicyRules{
Always: canLikeAlways,
WithApproval: canLikeWithApproval,
},
CanReply: gtsmodel.PolicyRules{
Always: canReplyAlways,
WithApproval: canReplyWithApproval,
},
CanAnnounce: gtsmodel.PolicyRules{
Always: canAnnounceAlways,
WithApproval: canAnnounceWithApproval,
},
}, nil
}

View file

@ -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
}

View file

@ -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 := &gtsmodel.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": [

View file

@ -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"

View file

@ -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}
<select
onChange={onChange}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => {
onChange(e);
if (onChangeCallback !== undefined) {
onChangeCallback(e.target.value);
}
}}
value={value}
ref={ref as RefObject<HTMLSelectElement>}
{...props}

View file

@ -141,6 +141,7 @@ export const gtsApi = createApi({
"InstanceRules",
"HTTPHeaderAllows",
"HTTPHeaderBlocks",
"DefaultInteractionPolicies",
],
endpoints: (build) => ({
instanceV1: build.query<InstanceV1, void>({

View file

@ -25,6 +25,7 @@ import type {
} from "../../types/migration";
import type { Theme } from "../../types/theme";
import { User } from "../../types/user";
import { DefaultInteractionPolicies, UpdateDefaultInteractionPolicies } from "../../types/interaction";
const extended = gtsApi.injectEndpoints({
endpoints: (build) => ({
@ -38,9 +39,11 @@ const extended = gtsApi.injectEndpoints({
}),
...replaceCacheOnMutation("verifyCredentials")
}),
user: build.query<User, void>({
query: () => ({url: `/api/v1/user`})
}),
passwordChange: build.mutation({
query: (data) => ({
method: "POST",
@ -48,6 +51,7 @@ const extended = gtsApi.injectEndpoints({
body: data
})
}),
emailChange: build.mutation<User, { password: string, new_email: string }>({
query: (data) => ({
method: "POST",
@ -56,6 +60,7 @@ const extended = gtsApi.injectEndpoints({
}),
...replaceCacheOnMutation("user")
}),
aliasAccount: build.mutation<any, UpdateAliasesFormData>({
async queryFn(formData, _api, _extraOpts, fetchWithBQ) {
// Pull entries out from the hooked form.
@ -73,6 +78,7 @@ const extended = gtsApi.injectEndpoints({
});
}
}),
moveAccount: build.mutation<any, MoveAccountFormData>({
query: (data) => ({
method: "POST",
@ -80,11 +86,37 @@ const extended = gtsApi.injectEndpoints({
body: data
})
}),
accountThemes: build.query<Theme[], void>({
query: () => ({
url: `/api/v1/accounts/themes`
})
})
}),
defaultInteractionPolicies: build.query<DefaultInteractionPolicies, void>({
query: () => ({
url: `/api/v1/interaction_policies/defaults`
}),
providesTags: ["DefaultInteractionPolicies"]
}),
updateDefaultInteractionPolicies: build.mutation<DefaultInteractionPolicies, UpdateDefaultInteractionPolicies>({
query: (data) => ({
method: "PATCH",
url: `/api/v1/interaction_policies/defaults`,
body: data,
}),
...replaceCacheOnMutation("defaultInteractionPolicies")
}),
resetDefaultInteractionPolicies: build.mutation<DefaultInteractionPolicies, void>({
query: () => ({
method: "PATCH",
url: `/api/v1/interaction_policies/defaults`,
body: {},
}),
invalidatesTags: ["DefaultInteractionPolicies"]
}),
})
});
@ -96,4 +128,7 @@ export const {
useAliasAccountMutation,
useMoveAccountMutation,
useAccountThemesQuery,
useDefaultInteractionPoliciesQuery,
useUpdateDefaultInteractionPoliciesMutation,
useResetDefaultInteractionPoliciesMutation,
} = extended;

View file

@ -64,6 +64,17 @@ export interface Account {
enable_rss: boolean,
role: any,
suspended?: boolean,
source?: AccountSource;
}
export interface AccountSource {
fields: any[];
follow_requests_count: number;
language: string;
note: string;
privacy: string;
sensitive: boolean;
status_content_type: string;
}
export interface SearchAccountParams {

View file

@ -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,
};

View file

@ -343,7 +343,7 @@ section.with-sidebar > form {
.labelinput .border {
border-radius: 0.2rem;
border: 0.15rem solid $border_accent;
border: 0.15rem solid $border-accent;
padding: 0.3rem;
display: flex;
flex-direction: column;
@ -867,6 +867,41 @@ button.with-padding {
padding: 0.5rem calc(0.5rem + $fa-fw);
}
.tab-buttons {
display: flex;
max-width: fit-content;
justify-content: space-between;
gap: 0.15rem;
}
button.tab-button {
border-top-left-radius: $br;
border-top-right-radius: $br;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
box-shadow: none;
background: $blue1;
&:hover {
background: $button-hover-bg;
}
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
font-size: 1rem;
@media screen and (max-width: 20rem) {
font-size: 0.75rem;
}
&.active {
background: $button-bg;
cursor: default;
}
}
.loading-icon {
align-self: flex-start;
}
@ -1370,6 +1405,53 @@ button.with-padding {
}
}
.interaction-default-settings {
.interaction-policy-section {
padding: 1rem;
display: none;
&.active {
display: flex;
}
flex-direction: column;
gap: 1rem;
border: 0.15rem solid $input-border;
fieldset {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin: 0;
padding: 0.5rem 1rem 1rem 1rem;
border: $boxshadow-border;
border-radius: 0.1rem;
box-shadow: $boxshadow;
>legend {
display: flex;
gap: 0.5rem;
align-items: center;
font-weight: bold;
font-size: large;
}
hr {
width: 100%;
}
.something-else {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: -0.3rem;
}
}
}
}
@media screen and (orientation: portrait) {
.reports .report .byline {
grid-template-columns: 1fr;

View file

@ -18,90 +18,21 @@
*/
import React from "react";
import { useTextInput, useBoolInput } from "../../lib/form";
import { useTextInput } from "../../lib/form";
import useFormSubmit from "../../lib/form/submit";
import { Select, TextInput, Checkbox } from "../../components/form/inputs";
import FormWithData from "../../lib/form/form-with-data";
import Languages from "../../components/languages";
import { TextInput } from "../../components/form/inputs";
import MutationButton from "../../components/form/mutation-button";
import { useVerifyCredentialsQuery } from "../../lib/query/oauth";
import { useEmailChangeMutation, usePasswordChangeMutation, useUpdateCredentialsMutation, useUserQuery } from "../../lib/query/user";
import { useEmailChangeMutation, usePasswordChangeMutation, useUserQuery } from "../../lib/query/user";
import Loading from "../../components/loading";
import { User } from "../../lib/types/user";
import { useInstanceV1Query } from "../../lib/query/gts-api";
export default function UserSettings() {
return (
<FormWithData
dataQuery={useVerifyCredentialsQuery}
DataForm={UserSettingsForm}
/>
);
}
function UserSettingsForm({ data }) {
/* form keys
- string source[privacy]
- bool source[sensitive]
- string source[language]
- string source[status_content_type]
*/
const form = {
defaultPrivacy: useTextInput("source[privacy]", { source: data, defaultValue: "unlisted" }),
isSensitive: useBoolInput("source[sensitive]", { source: data }),
language: useTextInput("source[language]", { source: data, valueSelector: (s) => s.source.language?.toUpperCase() ?? "EN" }),
statusContentType: useTextInput("source[status_content_type]", { source: data, defaultValue: "text/plain" }),
};
const [submitForm, result] = useFormSubmit(form, useUpdateCredentialsMutation());
export default function EmailPassword() {
return (
<>
<h1>Account Settings</h1>
<form className="user-settings" onSubmit={submitForm}>
<div className="form-section-docs">
<h3>Post Settings</h3>
<a
href="https://docs.gotosocial.org/en/latest/user_guide/posts"
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="private">Private / followers-only</option>
<option value="unlisted">Unlisted</option>
<option value="public">Public</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>
<PasswordChange />
<h1>Email & Password Settings</h1>
<EmailChange />
<PasswordChange />
</>
);
}
@ -330,4 +261,4 @@ function EmailChangeForm({user, oidcEnabled}: { user: User, oidcEnabled?: boolea
/>
</form>
);
}
}

View file

@ -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"
/>
<MenuItem
name="Settings"
itemUrl="settings"
icon="fa-cogs"
name="Posts"
itemUrl="posts"
icon="fa-paper-plane"
/>
<MenuItem
name="Email & Password"
itemUrl="emailpassword"
icon="fa-user-secret"
/>
<MenuItem
name="Migration"

View file

@ -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>
);
}

View file

@ -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 />
</>
);
}

View file

@ -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),
};
}

View file

@ -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,
};
}

View file

@ -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"),
},
};
}

View file

@ -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";

View file

@ -23,11 +23,13 @@ import { Redirect, Route, Router, Switch } from "wouter";
import { ErrorBoundary } from "../../lib/navigation/error";
import UserProfile from "./profile";
import UserMigration from "./migration";
import UserSettings from "./settings";
import PostSettings from "./posts";
import EmailPassword from "./emailpassword";
/**
* - /settings/user/profile
* - /settings/user/settings
* - /settings/user/posts
* - /settings/user/emailpassword
* - /settings/user/migration
*/
export default function UserRouter() {
@ -41,7 +43,8 @@ export default function UserRouter() {
<ErrorBoundary>
<Switch>
<Route path="/profile" component={UserProfile} />
<Route path="/settings" component={UserSettings} />
<Route path="/posts" component={PostSettings} />
<Route path="/emailpassword" component={EmailPassword} />
<Route path="/migration" component={UserMigration} />
<Route><Redirect to="/profile" /></Route>
</Switch>