diff --git a/cmd/gotosocial/action/testrig/testrig.go b/cmd/gotosocial/action/testrig/testrig.go
index 7b99a2a13..99f366fbe 100644
--- a/cmd/gotosocial/action/testrig/testrig.go
+++ b/cmd/gotosocial/action/testrig/testrig.go
@@ -155,10 +155,6 @@ var Start action.GTSAction = func(ctx context.Context) error {
}
testrig.StandardStorageSetup(state.Storage, "./testrig/media")
- // Initialize workers.
- testrig.StartNoopWorkers(state)
- defer testrig.StopWorkers(state)
-
// build backend handlers
transportController := testrig.NewTestTransportController(state, testrig.NewMockHTTPClient(func(req *http.Request) (*http.Response, error) {
r := io.NopCloser(bytes.NewReader([]byte{}))
@@ -199,6 +195,10 @@ var Start action.GTSAction = func(ctx context.Context) error {
processor := testrig.NewTestProcessor(state, federator, emailSender, mediaManager)
+ // Initialize workers.
+ testrig.StartWorkers(state, processor.Workers())
+ defer testrig.StopWorkers(state)
+
// Initialize metrics.
if err := metrics.Initialize(state.DB); err != nil {
return fmt.Errorf("error initializing metrics: %w", err)
diff --git a/docs/api/swagger.yaml b/docs/api/swagger.yaml
index b91b4f4b0..66f7e53a5 100644
--- a/docs/api/swagger.yaml
+++ b/docs/api/swagger.yaml
@@ -895,6 +895,20 @@ definitions:
type: object
x-go-name: DebugAPUrlResponse
x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model
+ defaultPolicies:
+ properties:
+ direct:
+ $ref: '#/definitions/interactionPolicy'
+ private:
+ $ref: '#/definitions/interactionPolicy'
+ public:
+ $ref: '#/definitions/interactionPolicy'
+ unlisted:
+ $ref: '#/definitions/interactionPolicy'
+ title: Default interaction policies to use for new statuses by requesting account.
+ type: object
+ x-go-name: DefaultPolicies
+ x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model
domain:
description: Domain represents a remote domain
properties:
@@ -1821,6 +1835,53 @@ definitions:
type: object
x-go-name: InstanceV2Users
x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model
+ interactionPolicy:
+ properties:
+ can_favourite:
+ $ref: '#/definitions/interactionPolicyRules'
+ can_reblog:
+ $ref: '#/definitions/interactionPolicyRules'
+ can_reply:
+ $ref: '#/definitions/interactionPolicyRules'
+ title: Interaction policy of a status.
+ type: object
+ x-go-name: InteractionPolicy
+ x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model
+ interactionPolicyRules:
+ properties:
+ always:
+ description: Policy entries for accounts that can always do this type of interaction.
+ items:
+ $ref: '#/definitions/interactionPolicyValue'
+ type: array
+ x-go-name: Always
+ with_approval:
+ description: Policy entries for accounts that require approval to do this type of interaction.
+ items:
+ $ref: '#/definitions/interactionPolicyValue'
+ type: array
+ x-go-name: WithApproval
+ title: Rules for one interaction type.
+ type: object
+ x-go-name: PolicyRules
+ x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model
+ interactionPolicyValue:
+ description: |-
+ It can be EITHER one of the internal keywords listed below, OR a full-fledged ActivityPub URI of an Actor, like "https://example.org/users/some_user".
+
+ Internal keywords:
+
+ public - Public, aka anyone who can see the status according to its visibility level.
+ followers - Followers of the status author.
+ following - People followed by the status author.
+ mutuals - Mutual follows of the status author (reserved, unused).
+ mentioned - Accounts mentioned in, or replied-to by, the status.
+ author - The status author themself.
+ me - If request was made with an authorized user, "me" represents the user who made the request and is now looking at this interaction policy.
+ title: One interaction policy entry for a status.
+ type: string
+ x-go-name: PolicyValue
+ x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model
list:
properties:
id:
@@ -2429,6 +2490,8 @@ definitions:
example: 01FBVD42CQ3ZEEVMW180SBX03B
type: string
x-go-name: InReplyToID
+ interaction_policy:
+ $ref: '#/definitions/interactionPolicy'
language:
description: |-
Primary language of this status (ISO 639 Part 1 two-letter language code).
@@ -2620,6 +2683,8 @@ definitions:
example: 01FBVD42CQ3ZEEVMW180SBX03B
type: string
x-go-name: InReplyToID
+ interaction_policy:
+ $ref: '#/definitions/interactionPolicy'
language:
description: |-
Primary language of this status (ISO 639 Part 1 two-letter language code).
@@ -6850,6 +6915,174 @@ paths:
summary: View instance rules (public).
tags:
- instance
+ /api/v1/interaction_policies/defaults:
+ get:
+ operationId: policiesDefaultsGet
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: A default policies object containing a policy for each status visibility.
+ schema:
+ $ref: '#/definitions/defaultPolicies'
+ "401":
+ description: unauthorized
+ "406":
+ description: not acceptable
+ "500":
+ description: internal server error
+ security:
+ - OAuth2 Bearer:
+ - read:accounts
+ summary: Get default interaction policies for new statuses created by you.
+ tags:
+ - interaction_policies
+ patch:
+ consumes:
+ - multipart/form-data
+ - application/x-www-form-urlencoded
+ - application/json
+ description: |-
+ If submitting using form data, use the following pattern:
+
+ `VISIBILITY[INTERACTION_TYPE][CONDITION][INDEX]=Value`
+
+ For example: `public[can_reply][always][0]=author`
+
+ Using `curl` this might look something like:
+
+ `curl -F 'public[can_reply][always][0]=author' -F 'public[can_reply][always][1]=followers'`
+
+ The JSON equivalent would be:
+
+ `curl -H 'Content-Type: application/json' -d '{"public":{"can_reply":{"always":["author","followers"]}}}'`
+
+ Any visibility level left unspecified in the request body will be returned to the default.
+
+ Ie., in the example above, "public" would be updated, but "unlisted", "private", and "direct" would be reset to defaults.
+
+ The server will perform some normalization on submitted policies so that you can't submit totally invalid policies.
+ operationId: policiesDefaultsUpdate
+ parameters:
+ - description: Nth entry for public.can_favourite.always.
+ in: formData
+ name: public[can_favourite][always][0]
+ type: string
+ - description: Nth entry for public.can_favourite.with_approval.
+ in: formData
+ name: public[can_favourite][with_approval][0]
+ type: string
+ - description: Nth entry for public.can_reply.always.
+ in: formData
+ name: public[can_reply][always][0]
+ type: string
+ - description: Nth entry for public.can_reply.with_approval.
+ in: formData
+ name: public[can_reply][with_approval][0]
+ type: string
+ - description: Nth entry for public.can_reblog.always.
+ in: formData
+ name: public[can_reblog][always][0]
+ type: string
+ - description: Nth entry for public.can_reblog.with_approval.
+ in: formData
+ name: public[can_reblog][with_approval][0]
+ type: string
+ - description: Nth entry for unlisted.can_favourite.always.
+ in: formData
+ name: unlisted[can_favourite][always][0]
+ type: string
+ - description: Nth entry for unlisted.can_favourite.with_approval.
+ in: formData
+ name: unlisted[can_favourite][with_approval][0]
+ type: string
+ - description: Nth entry for unlisted.can_reply.always.
+ in: formData
+ name: unlisted[can_reply][always][0]
+ type: string
+ - description: Nth entry for unlisted.can_reply.with_approval.
+ in: formData
+ name: unlisted[can_reply][with_approval][0]
+ type: string
+ - description: Nth entry for unlisted.can_reblog.always.
+ in: formData
+ name: unlisted[can_reblog][always][0]
+ type: string
+ - description: Nth entry for unlisted.can_reblog.with_approval.
+ in: formData
+ name: unlisted[can_reblog][with_approval][0]
+ type: string
+ - description: Nth entry for private.can_favourite.always.
+ in: formData
+ name: private[can_favourite][always][0]
+ type: string
+ - description: Nth entry for private.can_favourite.with_approval.
+ in: formData
+ name: private[can_favourite][with_approval][0]
+ type: string
+ - description: Nth entry for private.can_reply.always.
+ in: formData
+ name: private[can_reply][always][0]
+ type: string
+ - description: Nth entry for private.can_reply.with_approval.
+ in: formData
+ name: private[can_reply][with_approval][0]
+ type: string
+ - description: Nth entry for private.can_reblog.always.
+ in: formData
+ name: private[can_reblog][always][0]
+ type: string
+ - description: Nth entry for private.can_reblog.with_approval.
+ in: formData
+ name: private[can_reblog][with_approval][0]
+ type: string
+ - description: Nth entry for direct.can_favourite.always.
+ in: formData
+ name: direct[can_favourite][always][0]
+ type: string
+ - description: Nth entry for direct.can_favourite.with_approval.
+ in: formData
+ name: direct[can_favourite][with_approval][0]
+ type: string
+ - description: Nth entry for direct.can_reply.always.
+ in: formData
+ name: direct[can_reply][always][0]
+ type: string
+ - description: Nth entry for direct.can_reply.with_approval.
+ in: formData
+ name: direct[can_reply][with_approval][0]
+ type: string
+ - description: Nth entry for direct.can_reblog.always.
+ in: formData
+ name: direct[can_reblog][always][0]
+ type: string
+ - description: Nth entry for direct.can_reblog.with_approval.
+ in: formData
+ name: direct[can_reblog][with_approval][0]
+ type: string
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: Updated default policies object containing a policy for each status visibility.
+ schema:
+ $ref: '#/definitions/defaultPolicies'
+ "400":
+ description: bad request
+ "401":
+ description: unauthorized
+ "406":
+ description: not acceptable
+ "422":
+ description: unprocessable
+ "500":
+ description: internal server error
+ security:
+ - OAuth2 Bearer:
+ - write:accounts
+ summary: Update default interaction policies per visibility level for new statuses created by you.
+ tags:
+ - interaction_policies
/api/v1/lists:
get:
operationId: lists
diff --git a/docs/assets/user-settings-interaction-policy-1.png b/docs/assets/user-settings-interaction-policy-1.png
new file mode 100644
index 000000000..4a9a37239
Binary files /dev/null and b/docs/assets/user-settings-interaction-policy-1.png differ
diff --git a/docs/user_guide/posts.md b/docs/user_guide/posts.md
index fb46bb20e..6e2f57e6f 100644
--- a/docs/user_guide/posts.md
+++ b/docs/user_guide/posts.md
@@ -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:
diff --git a/docs/user_guide/settings.md b/docs/user_guide/settings.md
index 1b243f166..9cef48eff 100644
--- a/docs/user_guide/settings.md
+++ b/docs/user_guide/settings.md
@@ -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.
diff --git a/internal/api/client.go b/internal/api/client.go
index 07fc82aaa..b8b226804 100644
--- a/internal/api/client.go
+++ b/internal/api/client.go
@@ -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),
}
}
diff --git a/internal/api/client/admin/reportsget_test.go b/internal/api/client/admin/reportsget_test.go
index 28efabc00..265920409 100644
--- a/internal/api/client/admin/reportsget_test.go
+++ b/internal/api/client/admin/reportsget_test.go
@@ -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": [
diff --git a/internal/api/client/interactionpolicies/getdefaults.go b/internal/api/client/interactionpolicies/getdefaults.go
new file mode 100644
index 000000000..4ad0071f4
--- /dev/null
+++ b/internal/api/client/interactionpolicies/getdefaults.go
@@ -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 .
+
+package interactionpolicies
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+// PoliciesDefaultsGETHandler swagger:operation GET /api/v1/interaction_policies/defaults policiesDefaultsGet
+//
+// Get default interaction policies for new statuses created by you.
+//
+// ---
+// tags:
+// - interaction_policies
+//
+// produces:
+// - application/json
+//
+// security:
+// - OAuth2 Bearer:
+// - read:accounts
+//
+// responses:
+// '200':
+// description: A default policies object containing a policy for each status visibility.
+// schema:
+// "$ref": "#/definitions/defaultPolicies"
+// '401':
+// description: unauthorized
+// '406':
+// description: not acceptable
+// '500':
+// description: internal server error
+func (m *Module) PoliciesDefaultsGETHandler(c *gin.Context) {
+ authed, err := oauth.Authed(c, true, true, true, true)
+ if err != nil {
+ apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
+ return
+ }
+
+ if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
+ apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
+ return
+ }
+
+ resp, errWithCode := m.processor.Account().DefaultInteractionPoliciesGet(
+ c.Request.Context(),
+ authed.Account,
+ )
+ if errWithCode != nil {
+ apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
+ return
+ }
+
+ apiutil.JSON(c, http.StatusOK, resp)
+}
diff --git a/internal/api/client/interactionpolicies/policies.go b/internal/api/client/interactionpolicies/policies.go
new file mode 100644
index 000000000..9b34a8c80
--- /dev/null
+++ b/internal/api/client/interactionpolicies/policies.go
@@ -0,0 +1,45 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package interactionpolicies
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "github.com/superseriousbusiness/gotosocial/internal/processing"
+)
+
+const (
+ BasePath = "/v1/interaction_policies"
+ DefaultsPath = BasePath + "/defaults"
+)
+
+type Module struct {
+ processor *processing.Processor
+}
+
+func New(processor *processing.Processor) *Module {
+ return &Module{
+ processor: processor,
+ }
+}
+
+func (m *Module) Route(attachHandler func(method string, path string, f ...gin.HandlerFunc) gin.IRoutes) {
+ attachHandler(http.MethodGet, DefaultsPath, m.PoliciesDefaultsGETHandler)
+ attachHandler(http.MethodPatch, DefaultsPath, m.PoliciesDefaultsPATCHHandler)
+}
diff --git a/internal/api/client/interactionpolicies/updatedefaults.go b/internal/api/client/interactionpolicies/updatedefaults.go
new file mode 100644
index 000000000..e11a3bd19
--- /dev/null
+++ b/internal/api/client/interactionpolicies/updatedefaults.go
@@ -0,0 +1,334 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package interactionpolicies
+
+import (
+ "fmt"
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "github.com/gin-gonic/gin/binding"
+ "github.com/go-playground/form/v4"
+ apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+// PoliciesDefaultsPATCHHandler swagger:operation PATCH /api/v1/interaction_policies/defaults policiesDefaultsUpdate
+//
+// Update default interaction policies per visibility level for new statuses created by you.
+//
+// If submitting using form data, use the following pattern:
+//
+// `VISIBILITY[INTERACTION_TYPE][CONDITION][INDEX]=Value`
+//
+// For example: `public[can_reply][always][0]=author`
+//
+// Using `curl` this might look something like:
+//
+// `curl -F 'public[can_reply][always][0]=author' -F 'public[can_reply][always][1]=followers'`
+//
+// The JSON equivalent would be:
+//
+// `curl -H 'Content-Type: application/json' -d '{"public":{"can_reply":{"always":["author","followers"]}}}'`
+//
+// Any visibility level left unspecified in the request body will be returned to the default.
+//
+// Ie., in the example above, "public" would be updated, but "unlisted", "private", and "direct" would be reset to defaults.
+//
+// The server will perform some normalization on submitted policies so that you can't submit totally invalid policies.
+//
+// ---
+// tags:
+// - interaction_policies
+//
+// consumes:
+// - multipart/form-data
+// - application/x-www-form-urlencoded
+// - application/json
+//
+// produces:
+// - application/json
+//
+// parameters:
+// -
+// name: public[can_favourite][always][0]
+// in: formData
+// description: Nth entry for public.can_favourite.always.
+// type: string
+// -
+// name: public[can_favourite][with_approval][0]
+// in: formData
+// description: Nth entry for public.can_favourite.with_approval.
+// type: string
+// -
+// name: public[can_reply][always][0]
+// in: formData
+// description: Nth entry for public.can_reply.always.
+// type: string
+// -
+// name: public[can_reply][with_approval][0]
+// in: formData
+// description: Nth entry for public.can_reply.with_approval.
+// type: string
+// -
+// name: public[can_reblog][always][0]
+// in: formData
+// description: Nth entry for public.can_reblog.always.
+// type: string
+// -
+// name: public[can_reblog][with_approval][0]
+// in: formData
+// description: Nth entry for public.can_reblog.with_approval.
+// type: string
+//
+// -
+// name: unlisted[can_favourite][always][0]
+// in: formData
+// description: Nth entry for unlisted.can_favourite.always.
+// type: string
+// -
+// name: unlisted[can_favourite][with_approval][0]
+// in: formData
+// description: Nth entry for unlisted.can_favourite.with_approval.
+// type: string
+// -
+// name: unlisted[can_reply][always][0]
+// in: formData
+// description: Nth entry for unlisted.can_reply.always.
+// type: string
+// -
+// name: unlisted[can_reply][with_approval][0]
+// in: formData
+// description: Nth entry for unlisted.can_reply.with_approval.
+// type: string
+// -
+// name: unlisted[can_reblog][always][0]
+// in: formData
+// description: Nth entry for unlisted.can_reblog.always.
+// type: string
+// -
+// name: unlisted[can_reblog][with_approval][0]
+// in: formData
+// description: Nth entry for unlisted.can_reblog.with_approval.
+// type: string
+//
+// -
+// name: private[can_favourite][always][0]
+// in: formData
+// description: Nth entry for private.can_favourite.always.
+// type: string
+// -
+// name: private[can_favourite][with_approval][0]
+// in: formData
+// description: Nth entry for private.can_favourite.with_approval.
+// type: string
+// -
+// name: private[can_reply][always][0]
+// in: formData
+// description: Nth entry for private.can_reply.always.
+// type: string
+// -
+// name: private[can_reply][with_approval][0]
+// in: formData
+// description: Nth entry for private.can_reply.with_approval.
+// type: string
+// -
+// name: private[can_reblog][always][0]
+// in: formData
+// description: Nth entry for private.can_reblog.always.
+// type: string
+// -
+// name: private[can_reblog][with_approval][0]
+// in: formData
+// description: Nth entry for private.can_reblog.with_approval.
+// type: string
+//
+// -
+// name: direct[can_favourite][always][0]
+// in: formData
+// description: Nth entry for direct.can_favourite.always.
+// type: string
+// -
+// name: direct[can_favourite][with_approval][0]
+// in: formData
+// description: Nth entry for direct.can_favourite.with_approval.
+// type: string
+// -
+// name: direct[can_reply][always][0]
+// in: formData
+// description: Nth entry for direct.can_reply.always.
+// type: string
+// -
+// name: direct[can_reply][with_approval][0]
+// in: formData
+// description: Nth entry for direct.can_reply.with_approval.
+// type: string
+// -
+// name: direct[can_reblog][always][0]
+// in: formData
+// description: Nth entry for direct.can_reblog.always.
+// type: string
+// -
+// name: direct[can_reblog][with_approval][0]
+// in: formData
+// description: Nth entry for direct.can_reblog.with_approval.
+// type: string
+//
+// security:
+// - OAuth2 Bearer:
+// - write:accounts
+//
+// responses:
+// '200':
+// description: Updated default policies object containing a policy for each status visibility.
+// schema:
+// "$ref": "#/definitions/defaultPolicies"
+// '400':
+// description: bad request
+// '401':
+// description: unauthorized
+// '406':
+// description: not acceptable
+// '422':
+// description: unprocessable
+// '500':
+// description: internal server error
+func (m *Module) PoliciesDefaultsPATCHHandler(c *gin.Context) {
+ authed, err := oauth.Authed(c, true, true, true, true)
+ if err != nil {
+ apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
+ return
+ }
+
+ if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
+ apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
+ return
+ }
+
+ form, err := parseUpdateAccountForm(c)
+ if err != nil {
+ apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
+ return
+ }
+
+ resp, errWithCode := m.processor.Account().DefaultInteractionPoliciesUpdate(
+ c.Request.Context(),
+ authed.Account,
+ form,
+ )
+ if errWithCode != nil {
+ apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
+ return
+ }
+
+ apiutil.JSON(c, http.StatusOK, resp)
+}
+
+// intPolicyFormBinding satisfies gin's binding.Binding interface.
+// Should only be used specifically for multipart/form-data MIME type.
+type intPolicyFormBinding struct {
+ visibility string
+}
+
+func (i intPolicyFormBinding) Name() string {
+ return i.visibility
+}
+
+func (intPolicyFormBinding) Bind(req *http.Request, obj any) error {
+ if err := req.ParseForm(); err != nil {
+ return err
+ }
+
+ // Change default namespace prefix and suffix to
+ // allow correct parsing of the field attributes.
+ decoder := form.NewDecoder()
+ decoder.SetNamespacePrefix("[")
+ decoder.SetNamespaceSuffix("]")
+
+ return decoder.Decode(obj, req.Form)
+}
+
+// customBind does custom form binding for
+// each visibility in the form data.
+func customBind(
+ c *gin.Context,
+ form *apimodel.UpdateInteractionPoliciesRequest,
+) error {
+ for _, vis := range []string{
+ "Direct",
+ "Private",
+ "Unlisted",
+ "Public",
+ } {
+ if err := c.ShouldBindWith(
+ form,
+ intPolicyFormBinding{
+ visibility: vis,
+ },
+ ); err != nil {
+ return fmt.Errorf("custom form binding failed: %w", err)
+ }
+ }
+
+ return nil
+}
+
+func parseUpdateAccountForm(c *gin.Context) (*apimodel.UpdateInteractionPoliciesRequest, error) {
+ form := new(apimodel.UpdateInteractionPoliciesRequest)
+
+ switch ct := c.ContentType(); ct {
+ case binding.MIMEJSON:
+ // Just bind with default json binding.
+ if err := c.ShouldBindWith(form, binding.JSON); err != nil {
+ return nil, err
+ }
+
+ case binding.MIMEPOSTForm:
+ // Bind with default form binding first.
+ if err := c.ShouldBindWith(form, binding.FormPost); err != nil {
+ return nil, err
+ }
+
+ // Now do custom binding.
+ if err := customBind(c, form); err != nil {
+ return nil, err
+ }
+
+ case binding.MIMEMultipartPOSTForm:
+ // Bind with default form binding first.
+ if err := c.ShouldBindWith(form, binding.FormMultipart); err != nil {
+ return nil, err
+ }
+
+ // Now do custom binding.
+ if err := customBind(c, form); err != nil {
+ return nil, err
+ }
+
+ default:
+ err := fmt.Errorf(
+ "content-type %s not supported for this endpoint; supported content-types are %s, %s, %s",
+ ct, binding.MIMEJSON, binding.MIMEPOSTForm, binding.MIMEMultipartPOSTForm,
+ )
+ return nil, err
+ }
+
+ return form, nil
+}
diff --git a/internal/api/client/statuses/statusmute_test.go b/internal/api/client/statuses/statusmute_test.go
index 01bea4e5c..9e517b36d 100644
--- a/internal/api/client/statuses/statusmute_test.go
+++ b/internal/api/client/statuses/statusmute_test.go
@@ -147,7 +147,27 @@ func (suite *StatusMuteTestSuite) TestMuteUnmuteStatus() {
"emojis": [],
"card": null,
"poll": null,
- "text": "hello everyone!"
+ "text": "hello everyone!",
+ "interaction_policy": {
+ "can_favourite": {
+ "always": [
+ "public"
+ ],
+ "with_approval": []
+ },
+ "can_reply": {
+ "always": [
+ "public"
+ ],
+ "with_approval": []
+ },
+ "can_reblog": {
+ "always": [
+ "public"
+ ],
+ "with_approval": []
+ }
+ }
}`, muted)
// Unmute the status, ensure `muted` is `false`.
@@ -212,7 +232,27 @@ func (suite *StatusMuteTestSuite) TestMuteUnmuteStatus() {
"emojis": [],
"card": null,
"poll": null,
- "text": "hello everyone!"
+ "text": "hello everyone!",
+ "interaction_policy": {
+ "can_favourite": {
+ "always": [
+ "public"
+ ],
+ "with_approval": []
+ },
+ "can_reply": {
+ "always": [
+ "public"
+ ],
+ "with_approval": []
+ },
+ "can_reblog": {
+ "always": [
+ "public"
+ ],
+ "with_approval": []
+ }
+ }
}`, unmuted)
}
diff --git a/internal/api/model/interactionpolicy.go b/internal/api/model/interactionpolicy.go
new file mode 100644
index 000000000..7c5df09e8
--- /dev/null
+++ b/internal/api/model/interactionpolicy.go
@@ -0,0 +1,111 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package model
+
+// One interaction policy entry for a status.
+//
+// It can be EITHER one of the internal keywords listed below, OR a full-fledged ActivityPub URI of an Actor, like "https://example.org/users/some_user".
+//
+// Internal keywords:
+//
+// - public - Public, aka anyone who can see the status according to its visibility level.
+// - followers - Followers of the status author.
+// - following - People followed by the status author.
+// - mutuals - Mutual follows of the status author (reserved, unused).
+// - mentioned - Accounts mentioned in, or replied-to by, the status.
+// - author - The status author themself.
+// - me - If request was made with an authorized user, "me" represents the user who made the request and is now looking at this interaction policy.
+//
+// swagger:model interactionPolicyValue
+type PolicyValue string
+
+const (
+ PolicyValuePublic PolicyValue = "public" // Public, aka anyone who can see the status according to its visibility level.
+ PolicyValueFollowers PolicyValue = "followers" // Followers of the status author.
+ PolicyValueFollowing PolicyValue = "following" // People followed by the status author.
+ PolicyValueMutuals PolicyValue = "mutuals" // Mutual follows of the status author (reserved, unused).
+ PolicyValueMentioned PolicyValue = "mentioned" // Accounts mentioned in, or replied-to by, the status.
+ PolicyValueAuthor PolicyValue = "author" // The status author themself.
+ PolicyValueMe PolicyValue = "me" // If request was made with an authorized user, "me" represents the user who made the request and is now looking at this interaction policy.
+)
+
+// Rules for one interaction type.
+//
+// swagger:model interactionPolicyRules
+type PolicyRules struct {
+ // Policy entries for accounts that can always do this type of interaction.
+ Always []PolicyValue `form:"always" json:"always"`
+ // Policy entries for accounts that require approval to do this type of interaction.
+ WithApproval []PolicyValue `form:"with_approval" json:"with_approval"`
+}
+
+// Interaction policy of a status.
+//
+// swagger:model interactionPolicy
+type InteractionPolicy struct {
+ // Rules for who can favourite this status.
+ CanFavourite PolicyRules `form:"can_favourite" json:"can_favourite"`
+ // Rules for who can reply to this status.
+ CanReply PolicyRules `form:"can_reply" json:"can_reply"`
+ // Rules for who can reblog this status.
+ CanReblog PolicyRules `form:"can_reblog" json:"can_reblog"`
+}
+
+// Default interaction policies to use for new statuses by requesting account.
+//
+// swagger:model defaultPolicies
+type DefaultPolicies struct {
+ // TODO: Add mutuals only default.
+
+ // Default policy for new direct visibility statuses.
+ Direct InteractionPolicy `json:"direct"`
+ // Default policy for new private/followers-only visibility statuses.
+ Private InteractionPolicy `json:"private"`
+ // Default policy for new unlisted/unlocked visibility statuses.
+ Unlisted InteractionPolicy `json:"unlisted"`
+ // Default policy for new public visibility statuses.
+ Public InteractionPolicy `json:"public"`
+}
+
+// swagger:ignore
+type UpdateInteractionPoliciesRequest struct {
+ // Default policy for new direct visibility statuses.
+ // Value `null` or omitted property resets policy to original default.
+ //
+ // in: formData
+ // nullable: true
+ Direct *InteractionPolicy `form:"direct" json:"direct"`
+ // Default policy for new private/followers-only visibility statuses.
+ // Value `null` or omitted property resets policy to original default.
+ //
+ // in: formData
+ // nullable: true
+ Private *InteractionPolicy `form:"private" json:"private"`
+ // Default policy for new unlisted/unlocked visibility statuses.
+ // Value `null` or omitted property resets policy to original default.
+ //
+ // in: formData
+ // nullable: true
+ Unlisted *InteractionPolicy `form:"unlisted" json:"unlisted"`
+ // Default policy for new public visibility statuses.
+ // Value `null` or omitted property resets policy to original default.
+ //
+ // in: formData
+ // nullable: true
+ Public *InteractionPolicy `form:"public" json:"public"`
+}
diff --git a/internal/api/model/status.go b/internal/api/model/status.go
index e469835bd..7358916ab 100644
--- a/internal/api/model/status.go
+++ b/internal/api/model/status.go
@@ -102,6 +102,8 @@ type Status struct {
Text string `json:"text,omitempty"`
// A list of filters that matched this status and why they matched, if there are any such filters.
Filtered []FilterResult `json:"filtered,omitempty"`
+ // The interaction policy for this status, as set by the status author.
+ InteractionPolicy InteractionPolicy `json:"interaction_policy"`
}
// WebStatus is like *model.Status, but contains
diff --git a/internal/gtsmodel/interactionpolicy.go b/internal/gtsmodel/interactionpolicy.go
index ecb525b47..993763dc3 100644
--- a/internal/gtsmodel/interactionpolicy.go
+++ b/internal/gtsmodel/interactionpolicy.go
@@ -180,135 +180,109 @@ func DefaultInteractionPolicyFor(v Visibility) *InteractionPolicy {
}
}
+var defaultPolicyPublic = &InteractionPolicy{
+ CanLike: PolicyRules{
+ // Anyone can like.
+ Always: PolicyValues{
+ PolicyValuePublic,
+ },
+ WithApproval: make(PolicyValues, 0),
+ },
+ CanReply: PolicyRules{
+ // Anyone can reply.
+ Always: PolicyValues{
+ PolicyValuePublic,
+ },
+ WithApproval: make(PolicyValues, 0),
+ },
+ CanAnnounce: PolicyRules{
+ // Anyone can announce.
+ Always: PolicyValues{
+ PolicyValuePublic,
+ },
+ WithApproval: make(PolicyValues, 0),
+ },
+}
+
// Returns the default interaction policy
// for a post with visibility of public.
func DefaultInteractionPolicyPublic() *InteractionPolicy {
- // Anyone can like.
- canLikeAlways := make(PolicyValues, 1)
- canLikeAlways[0] = PolicyValuePublic
-
- // Unused, set empty.
- canLikeWithApproval := make(PolicyValues, 0)
-
- // Anyone can reply.
- canReplyAlways := make(PolicyValues, 1)
- canReplyAlways[0] = PolicyValuePublic
-
- // Unused, set empty.
- canReplyWithApproval := make(PolicyValues, 0)
-
- // Anyone can announce.
- canAnnounceAlways := make(PolicyValues, 1)
- canAnnounceAlways[0] = PolicyValuePublic
-
- // Unused, set empty.
- canAnnounceWithApproval := make(PolicyValues, 0)
-
- return &InteractionPolicy{
- CanLike: PolicyRules{
- Always: canLikeAlways,
- WithApproval: canLikeWithApproval,
- },
- CanReply: PolicyRules{
- Always: canReplyAlways,
- WithApproval: canReplyWithApproval,
- },
- CanAnnounce: PolicyRules{
- Always: canAnnounceAlways,
- WithApproval: canAnnounceWithApproval,
- },
- }
+ return defaultPolicyPublic
}
// Returns the default interaction policy
// for a post with visibility of unlocked.
func DefaultInteractionPolicyUnlocked() *InteractionPolicy {
// Same as public (for now).
- return DefaultInteractionPolicyPublic()
+ return defaultPolicyPublic
+}
+
+var defaultPolicyFollowersOnly = &InteractionPolicy{
+ CanLike: PolicyRules{
+ // Self, followers and
+ // mentioned can like.
+ Always: PolicyValues{
+ PolicyValueAuthor,
+ PolicyValueFollowers,
+ PolicyValueMentioned,
+ },
+ WithApproval: make(PolicyValues, 0),
+ },
+ CanReply: PolicyRules{
+ // Self, followers and
+ // mentioned can reply.
+ Always: PolicyValues{
+ PolicyValueAuthor,
+ PolicyValueFollowers,
+ PolicyValueMentioned,
+ },
+ WithApproval: make(PolicyValues, 0),
+ },
+ CanAnnounce: PolicyRules{
+ // Only self can announce.
+ Always: PolicyValues{
+ PolicyValueAuthor,
+ },
+ WithApproval: make(PolicyValues, 0),
+ },
}
// Returns the default interaction policy for
// a post with visibility of followers only.
func DefaultInteractionPolicyFollowersOnly() *InteractionPolicy {
- // Self, followers and mentioned can like.
- canLikeAlways := make(PolicyValues, 3)
- canLikeAlways[0] = PolicyValueAuthor
- canLikeAlways[1] = PolicyValueFollowers
- canLikeAlways[2] = PolicyValueMentioned
+ return defaultPolicyFollowersOnly
+}
- // Unused, set empty.
- canLikeWithApproval := make(PolicyValues, 0)
-
- // Self, followers and mentioned can reply.
- canReplyAlways := make(PolicyValues, 3)
- canReplyAlways[0] = PolicyValueAuthor
- canReplyAlways[1] = PolicyValueFollowers
- canReplyAlways[2] = PolicyValueMentioned
-
- // Unused, set empty.
- canReplyWithApproval := make(PolicyValues, 0)
-
- // Only self can announce.
- canAnnounceAlways := make(PolicyValues, 1)
- canAnnounceAlways[0] = PolicyValueAuthor
-
- // Unused, set empty.
- canAnnounceWithApproval := make(PolicyValues, 0)
-
- return &InteractionPolicy{
- CanLike: PolicyRules{
- Always: canLikeAlways,
- WithApproval: canLikeWithApproval,
+var defaultPolicyDirect = &InteractionPolicy{
+ CanLike: PolicyRules{
+ // Mentioned and self
+ // can always like.
+ Always: PolicyValues{
+ PolicyValueAuthor,
+ PolicyValueMentioned,
},
- CanReply: PolicyRules{
- Always: canReplyAlways,
- WithApproval: canReplyWithApproval,
+ WithApproval: make(PolicyValues, 0),
+ },
+ CanReply: PolicyRules{
+ // Mentioned and self
+ // can always reply.
+ Always: PolicyValues{
+ PolicyValueAuthor,
+ PolicyValueMentioned,
},
- CanAnnounce: PolicyRules{
- Always: canAnnounceAlways,
- WithApproval: canAnnounceWithApproval,
+ WithApproval: make(PolicyValues, 0),
+ },
+ CanAnnounce: PolicyRules{
+ // Only self can announce.
+ Always: PolicyValues{
+ PolicyValueAuthor,
},
- }
+ WithApproval: make(PolicyValues, 0),
+ },
}
// Returns the default interaction policy
// for a post with visibility of direct.
func DefaultInteractionPolicyDirect() *InteractionPolicy {
- // Mentioned and self can always like.
- canLikeAlways := make(PolicyValues, 2)
- canLikeAlways[0] = PolicyValueAuthor
- canLikeAlways[1] = PolicyValueMentioned
-
- // Unused, set empty.
- canLikeWithApproval := make(PolicyValues, 0)
-
- // Mentioned and self can always reply.
- canReplyAlways := make(PolicyValues, 2)
- canReplyAlways[0] = PolicyValueAuthor
- canReplyAlways[1] = PolicyValueMentioned
-
- // Unused, set empty.
- canReplyWithApproval := make(PolicyValues, 0)
-
- // Only self can announce.
- canAnnounceAlways := make(PolicyValues, 1)
- canAnnounceAlways[0] = PolicyValueAuthor
-
- // Unused, set empty.
- canAnnounceWithApproval := make(PolicyValues, 0)
-
- return &InteractionPolicy{
- CanLike: PolicyRules{
- Always: canLikeAlways,
- WithApproval: canLikeWithApproval,
- },
- CanReply: PolicyRules{
- Always: canReplyAlways,
- WithApproval: canReplyWithApproval,
- },
- CanAnnounce: PolicyRules{
- Always: canAnnounceAlways,
- WithApproval: canAnnounceWithApproval,
- },
- }
+ return defaultPolicyDirect
}
diff --git a/internal/processing/account/interactionpolicies.go b/internal/processing/account/interactionpolicies.go
new file mode 100644
index 000000000..e02b43e9e
--- /dev/null
+++ b/internal/processing/account/interactionpolicies.go
@@ -0,0 +1,208 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package account
+
+import (
+ "cmp"
+ "context"
+
+ apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/typeutils"
+)
+
+func (p *Processor) DefaultInteractionPoliciesGet(
+ ctx context.Context,
+ requester *gtsmodel.Account,
+) (*apimodel.DefaultPolicies, gtserror.WithCode) {
+ // Ensure account settings populated.
+ if err := p.populateAccountSettings(ctx, requester); err != nil {
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+
+ // Take set "direct" policy
+ // or global default.
+ direct := cmp.Or(
+ requester.Settings.InteractionPolicyDirect,
+ gtsmodel.DefaultInteractionPolicyDirect(),
+ )
+
+ directAPI, err := p.converter.InteractionPolicyToAPIInteractionPolicy(ctx, direct, nil, nil)
+ if err != nil {
+ err := gtserror.Newf("error converting interaction policy direct: %w", err)
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+
+ // Take set "private" policy
+ // or global default.
+ private := cmp.Or(
+ requester.Settings.InteractionPolicyFollowersOnly,
+ gtsmodel.DefaultInteractionPolicyFollowersOnly(),
+ )
+
+ privateAPI, err := p.converter.InteractionPolicyToAPIInteractionPolicy(ctx, private, nil, nil)
+ if err != nil {
+ err := gtserror.Newf("error converting interaction policy private: %w", err)
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+
+ // Take set "unlisted" policy
+ // or global default.
+ unlisted := cmp.Or(
+ requester.Settings.InteractionPolicyUnlocked,
+ gtsmodel.DefaultInteractionPolicyUnlocked(),
+ )
+
+ unlistedAPI, err := p.converter.InteractionPolicyToAPIInteractionPolicy(ctx, unlisted, nil, nil)
+ if err != nil {
+ err := gtserror.Newf("error converting interaction policy unlisted: %w", err)
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+
+ // Take set "public" policy
+ // or global default.
+ public := cmp.Or(
+ requester.Settings.InteractionPolicyPublic,
+ gtsmodel.DefaultInteractionPolicyPublic(),
+ )
+
+ publicAPI, err := p.converter.InteractionPolicyToAPIInteractionPolicy(ctx, public, nil, nil)
+ if err != nil {
+ err := gtserror.Newf("error converting interaction policy public: %w", err)
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+
+ return &apimodel.DefaultPolicies{
+ Direct: *directAPI,
+ Private: *privateAPI,
+ Unlisted: *unlistedAPI,
+ Public: *publicAPI,
+ }, nil
+}
+
+func (p *Processor) DefaultInteractionPoliciesUpdate(
+ ctx context.Context,
+ requester *gtsmodel.Account,
+ form *apimodel.UpdateInteractionPoliciesRequest,
+) (*apimodel.DefaultPolicies, gtserror.WithCode) {
+ // Lock on this account as we're modifying its Settings.
+ unlock := p.state.ProcessingLocks.Lock(requester.URI)
+ defer unlock()
+
+ // Ensure account settings populated.
+ if err := p.populateAccountSettings(ctx, requester); err != nil {
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+
+ if form.Direct == nil {
+ // Unset/return to global default.
+ requester.Settings.InteractionPolicyDirect = nil
+ } else {
+ policy, err := typeutils.APIInteractionPolicyToInteractionPolicy(
+ form.Direct,
+ apimodel.VisibilityDirect,
+ )
+ if err != nil {
+ return nil, gtserror.NewErrorUnprocessableEntity(err, err.Error())
+ }
+
+ // Set new default policy.
+ requester.Settings.InteractionPolicyDirect = policy
+ }
+
+ if form.Private == nil {
+ // Unset/return to global default.
+ requester.Settings.InteractionPolicyFollowersOnly = nil
+ } else {
+ policy, err := typeutils.APIInteractionPolicyToInteractionPolicy(
+ form.Private,
+ apimodel.VisibilityPrivate,
+ )
+ if err != nil {
+ return nil, gtserror.NewErrorUnprocessableEntity(err, err.Error())
+ }
+
+ // Set new default policy.
+ requester.Settings.InteractionPolicyFollowersOnly = policy
+ }
+
+ if form.Unlisted == nil {
+ // Unset/return to global default.
+ requester.Settings.InteractionPolicyUnlocked = nil
+ } else {
+ policy, err := typeutils.APIInteractionPolicyToInteractionPolicy(
+ form.Unlisted,
+ apimodel.VisibilityUnlisted,
+ )
+ if err != nil {
+ return nil, gtserror.NewErrorUnprocessableEntity(err, err.Error())
+ }
+
+ // Set new default policy.
+ requester.Settings.InteractionPolicyUnlocked = policy
+ }
+
+ if form.Public == nil {
+ // Unset/return to global default.
+ requester.Settings.InteractionPolicyPublic = nil
+ } else {
+ policy, err := typeutils.APIInteractionPolicyToInteractionPolicy(
+ form.Public,
+ apimodel.VisibilityPublic,
+ )
+ if err != nil {
+ return nil, gtserror.NewErrorUnprocessableEntity(err, err.Error())
+ }
+
+ // Set new default policy.
+ requester.Settings.InteractionPolicyPublic = policy
+ }
+
+ if err := p.state.DB.UpdateAccountSettings(ctx, requester.Settings); err != nil {
+ err := gtserror.Newf("db error updating setttings: %w", err)
+ return nil, gtserror.NewErrorInternalError(err, err.Error())
+ }
+
+ return p.DefaultInteractionPoliciesGet(ctx, requester)
+}
+
+// populateAccountSettings just ensures that
+// Settings is populated on the given account.
+func (p *Processor) populateAccountSettings(
+ ctx context.Context,
+ acct *gtsmodel.Account,
+) error {
+ if acct.Settings != nil {
+ // Already populated.
+ return nil
+ }
+
+ // Not populated,
+ // get from db.
+ var err error
+ acct.Settings, err = p.state.DB.GetAccountSettings(ctx, acct.ID)
+ if err != nil {
+ return gtserror.Newf(
+ "db error getting settings for account %s: %w",
+ acct.ID, err,
+ )
+ }
+
+ return nil
+}
diff --git a/internal/processing/status/create.go b/internal/processing/status/create.go
index 8898181ae..a5978a999 100644
--- a/internal/processing/status/create.go
+++ b/internal/processing/status/create.go
@@ -121,6 +121,12 @@ func (p *Processor) Create(
return nil, gtserror.NewErrorInternalError(err)
}
+ // Process policy AFTER visibility as it
+ // relies on status.Visibility being set.
+ if err := processInteractionPolicy(form, requester.Settings, status); err != nil {
+ return nil, gtserror.NewErrorInternalError(err)
+ }
+
if err := processLanguage(form, requester.Settings.Language, status); err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
@@ -281,26 +287,79 @@ func (p *Processor) processMediaIDs(ctx context.Context, form *apimodel.Advanced
return nil
}
-func processVisibility(form *apimodel.AdvancedStatusCreateForm, accountDefaultVis gtsmodel.Visibility, status *gtsmodel.Status) error {
- // by default all flags are set to true
- federated := true
-
- // If visibility isn't set on the form, then just take the account default.
- // If that's also not set, take the default for the whole instance.
- var vis gtsmodel.Visibility
+func processVisibility(
+ form *apimodel.AdvancedStatusCreateForm,
+ accountDefaultVis gtsmodel.Visibility,
+ status *gtsmodel.Status,
+) error {
switch {
+ // Visibility set on form, use that.
case form.Visibility != "":
- vis = typeutils.APIVisToVis(form.Visibility)
+ status.Visibility = typeutils.APIVisToVis(form.Visibility)
+
+ // Fall back to account default.
case accountDefaultVis != "":
- vis = accountDefaultVis
+ status.Visibility = accountDefaultVis
+
+ // What? Fall back to global default.
default:
- vis = gtsmodel.VisibilityDefault
+ status.Visibility = gtsmodel.VisibilityDefault
}
- // Todo: sort out likeable/replyable/boostable in next PR.
-
- status.Visibility = vis
+ // Set federated flag to form value
+ // if provided, or default to true.
+ federated := util.PtrValueOr(form.Federated, true)
status.Federated = &federated
+
+ return nil
+}
+
+func processInteractionPolicy(
+ _ *apimodel.AdvancedStatusCreateForm,
+ settings *gtsmodel.AccountSettings,
+ status *gtsmodel.Status,
+) error {
+ // TODO: parse policy for this
+ // status from form and prefer this.
+
+ // TODO: prevent scope widening by
+ // limiting interaction policy if
+ // inReplyTo status has a stricter
+ // interaction policy than this one.
+
+ switch status.Visibility {
+
+ case gtsmodel.VisibilityPublic:
+ // Take account's default "public" policy if set.
+ if p := settings.InteractionPolicyPublic; p != nil {
+ status.InteractionPolicy = p
+ }
+
+ case gtsmodel.VisibilityUnlocked:
+ // Take account's default "unlisted" policy if set.
+ if p := settings.InteractionPolicyUnlocked; p != nil {
+ status.InteractionPolicy = p
+ }
+
+ case gtsmodel.VisibilityFollowersOnly,
+ gtsmodel.VisibilityMutualsOnly:
+ // Take account's default followers-only policy if set.
+ // TODO: separate policy for mutuals-only vis.
+ if p := settings.InteractionPolicyFollowersOnly; p != nil {
+ status.InteractionPolicy = p
+ }
+
+ case gtsmodel.VisibilityDirect:
+ // Take account's default direct policy if set.
+ if p := settings.InteractionPolicyDirect; p != nil {
+ status.InteractionPolicy = p
+ }
+ }
+
+ // If no policy set by now, status interaction
+ // policy will be stored as nil, which just means
+ // "fall back to global default policy". We avoid
+ // setting it explicitly to save space.
return nil
}
diff --git a/internal/processing/stream/statusupdate_test.go b/internal/processing/stream/statusupdate_test.go
index 359212ee6..38be9ea5e 100644
--- a/internal/processing/stream/statusupdate_test.go
+++ b/internal/processing/stream/statusupdate_test.go
@@ -129,7 +129,27 @@ func (suite *StatusUpdateTestSuite) TestStreamNotification() {
"tags": [],
"emojis": [],
"card": null,
- "poll": null
+ "poll": null,
+ "interaction_policy": {
+ "can_favourite": {
+ "always": [
+ "public"
+ ],
+ "with_approval": []
+ },
+ "can_reply": {
+ "always": [
+ "public"
+ ],
+ "with_approval": []
+ },
+ "can_reblog": {
+ "always": [
+ "public"
+ ],
+ "with_approval": []
+ }
+ }
}`, dst.String())
suite.Equal(msg.Event, "status.update")
}
diff --git a/internal/typeutils/frontendtointernal.go b/internal/typeutils/frontendtointernal.go
index f194770df..8ced14d58 100644
--- a/internal/typeutils/frontendtointernal.go
+++ b/internal/typeutils/frontendtointernal.go
@@ -18,6 +18,10 @@
package typeutils
import (
+ "fmt"
+ "net/url"
+ "slices"
+
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
)
@@ -57,3 +61,171 @@ func APIFilterActionToFilterAction(m apimodel.FilterAction) gtsmodel.FilterActio
}
return gtsmodel.FilterActionNone
}
+
+func APIPolicyValueToPolicyValue(u apimodel.PolicyValue) (gtsmodel.PolicyValue, error) {
+ switch u {
+ case apimodel.PolicyValuePublic:
+ return gtsmodel.PolicyValuePublic, nil
+
+ case apimodel.PolicyValueFollowers:
+ return gtsmodel.PolicyValueFollowers, nil
+
+ case apimodel.PolicyValueFollowing:
+ return gtsmodel.PolicyValueFollowing, nil
+
+ case apimodel.PolicyValueMutuals:
+ return gtsmodel.PolicyValueMutuals, nil
+
+ case apimodel.PolicyValueMentioned:
+ return gtsmodel.PolicyValueMentioned, nil
+
+ case apimodel.PolicyValueAuthor:
+ return gtsmodel.PolicyValueAuthor, nil
+
+ case apimodel.PolicyValueMe:
+ err := fmt.Errorf("policyURI %s has no corresponding internal model", apimodel.PolicyValueMe)
+ return "", err
+
+ default:
+ // Parse URI to ensure it's a
+ // url with a valid protocol.
+ url, err := url.Parse(string(u))
+ if err != nil {
+ err := fmt.Errorf("could not parse non-predefined policy value as uri: %w", err)
+ return "", err
+ }
+
+ if url.Host != "http" && url.Host != "https" {
+ err := fmt.Errorf("non-predefined policy values must have protocol 'http' or 'https' (%s)", u)
+ return "", err
+ }
+
+ return gtsmodel.PolicyValue(u), nil
+ }
+}
+
+func APIInteractionPolicyToInteractionPolicy(
+ p *apimodel.InteractionPolicy,
+ v apimodel.Visibility,
+) (*gtsmodel.InteractionPolicy, error) {
+ visibility := APIVisToVis(v)
+
+ convertURIs := func(apiURIs []apimodel.PolicyValue) (gtsmodel.PolicyValues, error) {
+ policyURIs := gtsmodel.PolicyValues{}
+ for _, apiURI := range apiURIs {
+ uri, err := APIPolicyValueToPolicyValue(apiURI)
+ if err != nil {
+ return nil, err
+ }
+
+ if !uri.FeasibleForVisibility(visibility) {
+ err := fmt.Errorf("policyURI %s is not feasible for visibility %s", apiURI, v)
+ return nil, err
+ }
+
+ policyURIs = append(policyURIs, uri)
+ }
+ return policyURIs, nil
+ }
+
+ canLikeAlways, err := convertURIs(p.CanFavourite.Always)
+ if err != nil {
+ err := fmt.Errorf("error converting %s.can_favourite.always: %w", v, err)
+ return nil, err
+ }
+
+ canLikeWithApproval, err := convertURIs(p.CanFavourite.WithApproval)
+ if err != nil {
+ err := fmt.Errorf("error converting %s.can_favourite.with_approval: %w", v, err)
+ return nil, err
+ }
+
+ canReplyAlways, err := convertURIs(p.CanReply.Always)
+ if err != nil {
+ err := fmt.Errorf("error converting %s.can_reply.always: %w", v, err)
+ return nil, err
+ }
+
+ canReplyWithApproval, err := convertURIs(p.CanReply.WithApproval)
+ if err != nil {
+ err := fmt.Errorf("error converting %s.can_reply.with_approval: %w", v, err)
+ return nil, err
+ }
+
+ canAnnounceAlways, err := convertURIs(p.CanReblog.Always)
+ if err != nil {
+ err := fmt.Errorf("error converting %s.can_reblog.always: %w", v, err)
+ return nil, err
+ }
+
+ canAnnounceWithApproval, err := convertURIs(p.CanReblog.WithApproval)
+ if err != nil {
+ err := fmt.Errorf("error converting %s.can_reblog.with_approval: %w", v, err)
+ return nil, err
+ }
+
+ // Normalize URIs.
+ //
+ // 1. Ensure canLikeAlways, canReplyAlways,
+ // and canAnnounceAlways include self
+ // (either explicitly or within public).
+
+ // ensureIncludesSelf adds the "author" PolicyValue
+ // to given slice of PolicyValues, if not already
+ // explicitly or implicitly included.
+ ensureIncludesSelf := func(vals gtsmodel.PolicyValues) gtsmodel.PolicyValues {
+ includesSelf := slices.ContainsFunc(
+ vals,
+ func(uri gtsmodel.PolicyValue) bool {
+ return uri == gtsmodel.PolicyValuePublic ||
+ uri == gtsmodel.PolicyValueAuthor
+ },
+ )
+
+ if includesSelf {
+ // This slice of policy values
+ // already includes self explicitly
+ // or implicitly, nothing to change.
+ return vals
+ }
+
+ // Need to add self/author to
+ // this slice of policy values.
+ vals = append(vals, gtsmodel.PolicyValueAuthor)
+ return vals
+ }
+
+ canLikeAlways = ensureIncludesSelf(canLikeAlways)
+ canReplyAlways = ensureIncludesSelf(canReplyAlways)
+ canAnnounceAlways = ensureIncludesSelf(canAnnounceAlways)
+
+ // 2. Ensure canReplyAlways includes mentioned
+ // accounts (either explicitly or within public).
+ if !slices.ContainsFunc(
+ canReplyAlways,
+ func(uri gtsmodel.PolicyValue) bool {
+ return uri == gtsmodel.PolicyValuePublic ||
+ uri == gtsmodel.PolicyValueMentioned
+ },
+ ) {
+ canReplyAlways = append(
+ canReplyAlways,
+ gtsmodel.PolicyValueMentioned,
+ )
+ }
+
+ return >smodel.InteractionPolicy{
+ CanLike: gtsmodel.PolicyRules{
+ Always: canLikeAlways,
+ WithApproval: canLikeWithApproval,
+ },
+ CanReply: gtsmodel.PolicyRules{
+ Always: canReplyAlways,
+ WithApproval: canReplyWithApproval,
+ },
+ CanAnnounce: gtsmodel.PolicyRules{
+ Always: canAnnounceAlways,
+ WithApproval: canAnnounceWithApproval,
+ },
+ }, nil
+}
diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go
index d24ae3ea5..6350f3269 100644
--- a/internal/typeutils/internaltofrontend.go
+++ b/internal/typeutils/internaltofrontend.go
@@ -1234,6 +1234,20 @@ func (c *Converter) baseStatusToFrontend(
log.Errorf(ctx, "error converting status emojis: %v", err)
}
+ // Take status's interaction policy, or
+ // fall back to default for its visibility.
+ var p *gtsmodel.InteractionPolicy
+ if s.InteractionPolicy != nil {
+ p = s.InteractionPolicy
+ } else {
+ p = gtsmodel.DefaultInteractionPolicyFor(s.Visibility)
+ }
+
+ apiInteractionPolicy, err := c.InteractionPolicyToAPIInteractionPolicy(ctx, p, s, requestingAccount)
+ if err != nil {
+ return nil, gtserror.Newf("error converting interaction policy: %w", err)
+ }
+
apiStatus := &apimodel.Status{
ID: s.ID,
CreatedAt: util.FormatISO8601(s.CreatedAt),
@@ -1258,6 +1272,7 @@ func (c *Converter) baseStatusToFrontend(
Emojis: apiEmojis,
Card: nil, // TODO: implement cards
Text: s.Text,
+ InteractionPolicy: *apiInteractionPolicy,
}
// Nullable fields.
@@ -2256,3 +2271,111 @@ func (c *Converter) ThemesToAPIThemes(themes []*gtsmodel.Theme) []apimodel.Theme
}
return apiThemes
}
+
+// Convert the given gtsmodel policy
+// into an apimodel interaction policy.
+//
+// Provided status can be nil to convert a
+// policy without a particular status in mind.
+//
+// RequestingAccount can also be nil for
+// unauthorized requests (web, public api etc).
+func (c *Converter) InteractionPolicyToAPIInteractionPolicy(
+ ctx context.Context,
+ policy *gtsmodel.InteractionPolicy,
+ _ *gtsmodel.Status, // Used in upcoming PR.
+ _ *gtsmodel.Account, // Used in upcoming PR.
+) (*apimodel.InteractionPolicy, error) {
+ apiPolicy := &apimodel.InteractionPolicy{
+ CanFavourite: apimodel.PolicyRules{
+ Always: policyValsToAPIPolicyVals(policy.CanLike.Always),
+ WithApproval: policyValsToAPIPolicyVals(policy.CanLike.WithApproval),
+ },
+ CanReply: apimodel.PolicyRules{
+ Always: policyValsToAPIPolicyVals(policy.CanReply.Always),
+ WithApproval: policyValsToAPIPolicyVals(policy.CanReply.WithApproval),
+ },
+ CanReblog: apimodel.PolicyRules{
+ Always: policyValsToAPIPolicyVals(policy.CanAnnounce.Always),
+ WithApproval: policyValsToAPIPolicyVals(policy.CanAnnounce.WithApproval),
+ },
+ }
+
+ return apiPolicy, nil
+}
+
+func policyValsToAPIPolicyVals(vals gtsmodel.PolicyValues) []apimodel.PolicyValue {
+
+ var (
+ valsLen = len(vals)
+
+ // Use a map to deduplicate added vals as we go.
+ addedVals = make(map[apimodel.PolicyValue]struct{}, valsLen)
+
+ // Vals we'll be returning.
+ apiVals = make([]apimodel.PolicyValue, 0, valsLen)
+ )
+
+ for _, policyVal := range vals {
+ switch policyVal {
+
+ case gtsmodel.PolicyValueAuthor:
+ // Author can do this.
+ newVal := apimodel.PolicyValueAuthor
+ if _, added := addedVals[newVal]; !added {
+ apiVals = append(apiVals, newVal)
+ addedVals[newVal] = struct{}{}
+ }
+
+ case gtsmodel.PolicyValueMentioned:
+ // Mentioned can do this.
+ newVal := apimodel.PolicyValueMentioned
+ if _, added := addedVals[newVal]; !added {
+ apiVals = append(apiVals, newVal)
+ addedVals[newVal] = struct{}{}
+ }
+
+ case gtsmodel.PolicyValueMutuals:
+ // Mutuals can do this.
+ newVal := apimodel.PolicyValueMutuals
+ if _, added := addedVals[newVal]; !added {
+ apiVals = append(apiVals, newVal)
+ addedVals[newVal] = struct{}{}
+ }
+
+ case gtsmodel.PolicyValueFollowing:
+ // Following can do this.
+ newVal := apimodel.PolicyValueFollowing
+ if _, added := addedVals[newVal]; !added {
+ apiVals = append(apiVals, newVal)
+ addedVals[newVal] = struct{}{}
+ }
+
+ case gtsmodel.PolicyValueFollowers:
+ // Followers can do this.
+ newVal := apimodel.PolicyValueFollowers
+ if _, added := addedVals[newVal]; !added {
+ apiVals = append(apiVals, newVal)
+ addedVals[newVal] = struct{}{}
+ }
+
+ case gtsmodel.PolicyValuePublic:
+ // Public can do this.
+ newVal := apimodel.PolicyValuePublic
+ if _, added := addedVals[newVal]; !added {
+ apiVals = append(apiVals, newVal)
+ addedVals[newVal] = struct{}{}
+ }
+
+ default:
+ // Specific URI of ActivityPub Actor.
+ newVal := apimodel.PolicyValue(policyVal)
+ if _, added := addedVals[newVal]; !added {
+ apiVals = append(apiVals, newVal)
+ addedVals[newVal] = struct{}{}
+ }
+ }
+ }
+
+ return apiVals
+}
diff --git a/internal/typeutils/internaltofrontend_test.go b/internal/typeutils/internaltofrontend_test.go
index c4da0d57c..9fd4cea46 100644
--- a/internal/typeutils/internaltofrontend_test.go
+++ b/internal/typeutils/internaltofrontend_test.go
@@ -546,7 +546,27 @@ func (suite *InternalToFrontendTestSuite) TestStatusToFrontend() {
],
"card": null,
"poll": null,
- "text": "hello world! #welcome ! first post on the instance :rainbow: !"
+ "text": "hello world! #welcome ! first post on the instance :rainbow: !",
+ "interaction_policy": {
+ "can_favourite": {
+ "always": [
+ "public"
+ ],
+ "with_approval": []
+ },
+ "can_reply": {
+ "always": [
+ "public"
+ ],
+ "with_approval": []
+ },
+ "can_reblog": {
+ "always": [
+ "public"
+ ],
+ "with_approval": []
+ }
+ }
}`, string(b))
}
@@ -701,7 +721,27 @@ func (suite *InternalToFrontendTestSuite) TestWarnFilteredStatusToFrontend() {
],
"status_matches": []
}
- ]
+ ],
+ "interaction_policy": {
+ "can_favourite": {
+ "always": [
+ "public"
+ ],
+ "with_approval": []
+ },
+ "can_reply": {
+ "always": [
+ "public"
+ ],
+ "with_approval": []
+ },
+ "can_reblog": {
+ "always": [
+ "public"
+ ],
+ "with_approval": []
+ }
+ }
}`, string(b))
}
@@ -877,7 +917,27 @@ func (suite *InternalToFrontendTestSuite) TestStatusToFrontendUnknownAttachments
"tags": [],
"emojis": [],
"card": null,
- "poll": null
+ "poll": null,
+ "interaction_policy": {
+ "can_favourite": {
+ "always": [
+ "public"
+ ],
+ "with_approval": []
+ },
+ "can_reply": {
+ "always": [
+ "public"
+ ],
+ "with_approval": []
+ },
+ "can_reblog": {
+ "always": [
+ "public"
+ ],
+ "with_approval": []
+ }
+ }
}`, string(b))
}
@@ -955,6 +1015,26 @@ func (suite *InternalToFrontendTestSuite) TestStatusToWebStatus() {
"emojis": [],
"card": null,
"poll": null,
+ "interaction_policy": {
+ "can_favourite": {
+ "always": [
+ "public"
+ ],
+ "with_approval": []
+ },
+ "can_reply": {
+ "always": [
+ "public"
+ ],
+ "with_approval": []
+ },
+ "can_reblog": {
+ "always": [
+ "public"
+ ],
+ "with_approval": []
+ }
+ },
"media_attachments": [
{
"id": "01HE7Y3C432WRSNS10EZM86SA5",
@@ -1137,7 +1217,121 @@ func (suite *InternalToFrontendTestSuite) TestStatusToFrontendUnknownLanguage()
],
"card": null,
"poll": null,
- "text": "hello world! #welcome ! first post on the instance :rainbow: !"
+ "text": "hello world! #welcome ! first post on the instance :rainbow: !",
+ "interaction_policy": {
+ "can_favourite": {
+ "always": [
+ "public"
+ ],
+ "with_approval": []
+ },
+ "can_reply": {
+ "always": [
+ "public"
+ ],
+ "with_approval": []
+ },
+ "can_reblog": {
+ "always": [
+ "public"
+ ],
+ "with_approval": []
+ }
+ }
+}`, string(b))
+}
+
+func (suite *InternalToFrontendTestSuite) TestStatusToFrontendPartialInteractions() {
+ testStatus := >smodel.Status{}
+ *testStatus = *suite.testStatuses["local_account_1_status_3"]
+ testStatus.Language = ""
+ requestingAccount := suite.testAccounts["admin_account"]
+ apiStatus, err := suite.typeconverter.StatusToAPIStatus(context.Background(), testStatus, requestingAccount, statusfilter.FilterContextNone, nil, nil)
+ suite.NoError(err)
+
+ b, err := json.MarshalIndent(apiStatus, "", " ")
+ suite.NoError(err)
+
+ suite.Equal(`{
+ "id": "01F8MHBBN8120SYH7D5S050MGK",
+ "created_at": "2021-10-20T10:40:37.000Z",
+ "in_reply_to_id": null,
+ "in_reply_to_account_id": null,
+ "sensitive": false,
+ "spoiler_text": "test: you shouldn't be able to interact with this post in any way",
+ "visibility": "private",
+ "language": null,
+ "uri": "http://localhost:8080/users/the_mighty_zork/statuses/01F8MHBBN8120SYH7D5S050MGK",
+ "url": "http://localhost:8080/@the_mighty_zork/statuses/01F8MHBBN8120SYH7D5S050MGK",
+ "replies_count": 0,
+ "reblogs_count": 0,
+ "favourites_count": 0,
+ "favourited": false,
+ "reblogged": false,
+ "muted": false,
+ "bookmarked": false,
+ "pinned": false,
+ "content": "this is a very personal post that I don't want anyone to interact with at all, and i only want mutuals to see it",
+ "reblog": null,
+ "application": {
+ "name": "really cool gts application",
+ "website": "https://reallycool.app"
+ },
+ "account": {
+ "id": "01F8MH1H7YV1Z7D2C8K2730QBF",
+ "username": "the_mighty_zork",
+ "acct": "the_mighty_zork",
+ "display_name": "original zork (he/they)",
+ "locked": false,
+ "discoverable": true,
+ "bot": false,
+ "created_at": "2022-05-20T11:09:18.000Z",
+ "note": "\u003cp\u003ehey yo this is my profile!\u003c/p\u003e",
+ "url": "http://localhost:8080/@the_mighty_zork",
+ "avatar": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpg",
+ "avatar_static": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/01F8MH58A357CV5K7R7TJMSH6S.jpg",
+ "avatar_description": "a green goblin looking nasty",
+ "header": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpg",
+ "header_static": "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/small/01PFPMWK2FF0D9WMHEJHR07C3Q.jpg",
+ "header_description": "A very old-school screenshot of the original team fortress mod for quake",
+ "followers_count": 2,
+ "following_count": 2,
+ "statuses_count": 8,
+ "last_status_at": "2024-01-10T09:24:00.000Z",
+ "emojis": [],
+ "fields": [],
+ "enable_rss": true,
+ "role": {
+ "name": "user"
+ }
+ },
+ "media_attachments": [],
+ "mentions": [],
+ "tags": [],
+ "emojis": [],
+ "card": null,
+ "poll": null,
+ "text": "this is a very personal post that I don't want anyone to interact with at all, and i only want mutuals to see it",
+ "interaction_policy": {
+ "can_favourite": {
+ "always": [
+ "author"
+ ],
+ "with_approval": []
+ },
+ "can_reply": {
+ "always": [
+ "author"
+ ],
+ "with_approval": []
+ },
+ "can_reblog": {
+ "always": [
+ "author"
+ ],
+ "with_approval": []
+ }
+ }
}`, string(b))
}
@@ -2014,7 +2208,27 @@ func (suite *InternalToFrontendTestSuite) TestAdminReportToFrontend2() {
"tags": [],
"emojis": [],
"card": null,
- "poll": null
+ "poll": null,
+ "interaction_policy": {
+ "can_favourite": {
+ "always": [
+ "public"
+ ],
+ "with_approval": []
+ },
+ "can_reply": {
+ "always": [
+ "public"
+ ],
+ "with_approval": []
+ },
+ "can_reblog": {
+ "always": [
+ "public"
+ ],
+ "with_approval": []
+ }
+ }
}
],
"rules": [
diff --git a/mkdocs.yml b/mkdocs.yml
index 799b4bcbe..61b997dae 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -61,8 +61,8 @@ nav:
- "Home": "index.md"
- "FAQ": "faq.md"
- "User Guide":
- - "user_guide/posts.md"
- "user_guide/settings.md"
+ - "user_guide/posts.md"
- "user_guide/search.md"
- "user_guide/custom_css.md"
- "user_guide/password_management.md"
diff --git a/web/source/settings/components/form/inputs.tsx b/web/source/settings/components/form/inputs.tsx
index c68095d95..e6c530b53 100644
--- a/web/source/settings/components/form/inputs.tsx
+++ b/web/source/settings/components/form/inputs.tsx
@@ -141,9 +141,28 @@ export interface SelectProps extends React.DetailedHTMLProps<
field: TextFormInputHook;
children?: ReactNode;
options: React.JSX.Element;
+
+ /**
+ * Optional callback function that is
+ * triggered along with the select's onChange.
+ *
+ * _selectValue is the current value of
+ * the select after onChange is triggered.
+ *
+ * @param _selectValue
+ * @returns
+ */
+ onChangeCallback?: (_selectValue: string | undefined) => void;
}
-export function Select({ label, field, children, options, ...props }: SelectProps) {
+export function Select({
+ label,
+ field,
+ children,
+ options,
+ onChangeCallback,
+ ...props
+}: SelectProps) {
const { onChange, value, ref } = field;
return (
@@ -152,7 +171,12 @@ export function Select({ label, field, children, options, ...props }: SelectProp
{label}
{children}
-
-
-
-
-
-
+
Email & Password Settings
+
>
);
}
@@ -330,4 +261,4 @@ function EmailChangeForm({user, oidcEnabled}: { user: User, oidcEnabled?: boolea
/>
);
-}
+}
\ No newline at end of file
diff --git a/web/source/settings/views/user/menu.tsx b/web/source/settings/views/user/menu.tsx
index 578bd8ae0..3d90bfe21 100644
--- a/web/source/settings/views/user/menu.tsx
+++ b/web/source/settings/views/user/menu.tsx
@@ -22,7 +22,8 @@ import React from "react";
/**
* - /settings/user/profile
- * - /settings/user/settings
+ * - /settings/user/posts
+ * - /settings/user/emailpassword
* - /settings/user/migration
*/
export default function UserMenu() {
@@ -38,9 +39,14 @@ export default function UserMenu() {
icon="fa-user"
/>
+
.
+*/
+
+import React from "react";
+import { useTextInput, useBoolInput } from "../../../../lib/form";
+import useFormSubmit from "../../../../lib/form/submit";
+import { Select, Checkbox } from "../../../../components/form/inputs";
+import Languages from "../../../../components/languages";
+import MutationButton from "../../../../components/form/mutation-button";
+import { useUpdateCredentialsMutation } from "../../../../lib/query/user";
+import { Account } from "../../../../lib/types/account";
+
+export default function BasicSettings({ account }: { account: Account }) {
+ /* form keys
+ - string source[privacy]
+ - bool source[sensitive]
+ - string source[language]
+ - string source[status_content_type]
+ */
+ const form = {
+ defaultPrivacy: useTextInput("source[privacy]", { source: account, defaultValue: "unlisted" }),
+ isSensitive: useBoolInput("source[sensitive]", { source: account }),
+ language: useTextInput("source[language]", { source: account, valueSelector: (s: Account) => s.source?.language?.toUpperCase() ?? "EN" }),
+ statusContentType: useTextInput("source[status_content_type]", { source: account, defaultValue: "text/plain" }),
+ };
+
+ const [submitForm, result] = useFormSubmit(form, useUpdateCredentialsMutation());
+
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/web/source/settings/views/user/posts/index.tsx b/web/source/settings/views/user/posts/index.tsx
new file mode 100644
index 000000000..4d7669391
--- /dev/null
+++ b/web/source/settings/views/user/posts/index.tsx
@@ -0,0 +1,51 @@
+/*
+ GoToSocial
+ Copyright (C) GoToSocial Authors admin@gotosocial.org
+ SPDX-License-Identifier: AGPL-3.0-or-later
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+*/
+
+import React from "react";
+import { useVerifyCredentialsQuery } from "../../../lib/query/oauth";
+import Loading from "../../../components/loading";
+import { Error } from "../../../components/error";
+import BasicSettings from "./basic-settings";
+import InteractionPolicySettings from "./interaction-policy-settings";
+
+export default function PostSettings() {
+ const {
+ data: account,
+ isLoading,
+ isFetching,
+ isError,
+ error,
+ } = useVerifyCredentialsQuery();
+
+ if (isLoading || isFetching) {
+ return ;
+ }
+
+ if (isError) {
+ return ;
+ }
+
+ return (
+ <>
+
Post Settings
+
+
+ >
+ );
+}
diff --git a/web/source/settings/views/user/posts/interaction-policy-settings/basic.tsx b/web/source/settings/views/user/posts/interaction-policy-settings/basic.tsx
new file mode 100644
index 000000000..8d229a3e0
--- /dev/null
+++ b/web/source/settings/views/user/posts/interaction-policy-settings/basic.tsx
@@ -0,0 +1,180 @@
+/*
+ GoToSocial
+ Copyright (C) GoToSocial Authors admin@gotosocial.org
+ SPDX-License-Identifier: AGPL-3.0-or-later
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+*/
+
+import React, { useMemo } from "react";
+import {
+ InteractionPolicyValue,
+ PolicyValueAuthor,
+ PolicyValueFollowers,
+ PolicyValueMentioned,
+ PolicyValuePublic,
+} from "../../../../lib/types/interaction";
+import { useTextInput } from "../../../../lib/form";
+import { Action, BasicValue, PolicyFormSub, Visibility } from "./types";
+
+// Based on the given visibility, action, and states,
+// derives what the initial basic Select value should be.
+function useBasicValue(
+ forVis: Visibility,
+ forAction: Action,
+ always: InteractionPolicyValue[],
+ withApproval: InteractionPolicyValue[],
+): BasicValue {
+ // Check if "always" value is just the author
+ // (and possibly mentioned accounts when dealing
+ // with replies -- still counts as "just_me").
+ const alwaysJustAuthor = useMemo(() => {
+ if (
+ always.length === 1 &&
+ always[0] === PolicyValueAuthor
+ ) {
+ return true;
+ }
+
+ if (
+ forAction === "reply" &&
+ always.length === 2 &&
+ always.includes(PolicyValueAuthor) &&
+ always.includes(PolicyValueMentioned)
+ ) {
+ return true;
+ }
+
+ return false;
+ }, [forAction, always]);
+
+ // Check if "always" includes the widest
+ // possible audience for this visibility.
+ const alwaysWidestAudience = useMemo(() => {
+ return (
+ (forVis === "private" && always.includes(PolicyValueFollowers)) ||
+ always.includes(PolicyValuePublic)
+ );
+ }, [forVis, always]);
+
+ // Check if "withApproval" includes the widest
+ // possible audience for this visibility.
+ const withApprovalWidestAudience = useMemo(() => {
+ return (
+ (forVis === "private" && withApproval.includes(PolicyValueFollowers)) ||
+ withApproval.includes(PolicyValuePublic)
+ );
+ }, [forVis, withApproval]);
+
+ return useMemo(() => {
+ // Simplest case: if "always" includes the
+ // widest possible audience for this visibility,
+ // then we don't need to check anything else.
+ if (alwaysWidestAudience) {
+ return "anyone";
+ }
+
+ // Next simplest case: there's no "with approval"
+ // URIs set, so check if it's always just author.
+ if (withApproval.length === 0 && alwaysJustAuthor) {
+ return "just_me";
+ }
+
+ // Third simplest case: always is just us, and with
+ // approval is addressed to the widest possible audience.
+ if (alwaysJustAuthor && withApprovalWidestAudience) {
+ return "anyone_with_approval";
+ }
+
+ // We've exhausted the
+ // simple possibilities.
+ return "something_else";
+ }, [
+ withApproval.length,
+ alwaysJustAuthor,
+ alwaysWidestAudience,
+ withApprovalWidestAudience,
+ ]);
+}
+
+// Derive wording for the basic label for
+// whatever visibility and action we're handling.
+function useBasicLabel(visibility: Visibility, action: Action) {
+ return useMemo(() => {
+ let visPost = "";
+ switch (visibility) {
+ case "public":
+ visPost = "a public post";
+ break;
+ case "unlisted":
+ visPost = "an unlisted post";
+ break;
+ case "private":
+ visPost = "a followers-only post";
+ break;
+ }
+
+ switch (action) {
+ case "favourite":
+ return "Who can like " + visPost + "?";
+ case "reply":
+ return "Who else can reply to " + visPost + "?";
+ case "reblog":
+ return "Who can boost " + visPost + "?";
+ }
+ }, [visibility, action]);
+}
+
+// Return whatever the "basic" options should
+// be in the basic Select for this visibility.
+function useBasicOptions(visibility: Visibility) {
+ return useMemo(() => {
+ const audience = visibility === "private"
+ ? "My followers"
+ : "Anyone";
+
+ return (
+ <>
+
+
+
+ { visibility !== "private" &&
+
+ }
+ >
+ );
+ }, [visibility]);
+}
+
+export function useBasicFor(
+ forVis: Visibility,
+ forAction: Action,
+ currentAlways: InteractionPolicyValue[],
+ currentWithApproval: InteractionPolicyValue[],
+): PolicyFormSub {
+ // Determine who's currently *basically* allowed
+ // to do this action for this visibility.
+ const defaultValue = useBasicValue(
+ forVis,
+ forAction,
+ currentAlways,
+ currentWithApproval,
+ );
+
+ return {
+ field: useTextInput("basic", { defaultValue: defaultValue }),
+ label: useBasicLabel(forVis, forAction),
+ options: useBasicOptions(forVis),
+ };
+}
diff --git a/web/source/settings/views/user/posts/interaction-policy-settings/index.tsx b/web/source/settings/views/user/posts/interaction-policy-settings/index.tsx
new file mode 100644
index 000000000..143cf0865
--- /dev/null
+++ b/web/source/settings/views/user/posts/interaction-policy-settings/index.tsx
@@ -0,0 +1,553 @@
+/*
+ GoToSocial
+ Copyright (C) GoToSocial Authors admin@gotosocial.org
+ SPDX-License-Identifier: AGPL-3.0-or-later
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+*/
+
+import React, { useCallback, useMemo } from "react";
+import {
+ useDefaultInteractionPoliciesQuery,
+ useResetDefaultInteractionPoliciesMutation,
+ useUpdateDefaultInteractionPoliciesMutation,
+} from "../../../../lib/query/user";
+import Loading from "../../../../components/loading";
+import { Error } from "../../../../components/error";
+import MutationButton from "../../../../components/form/mutation-button";
+import {
+ DefaultInteractionPolicies,
+ InteractionPolicy,
+ InteractionPolicyEntry,
+ InteractionPolicyValue,
+ PolicyValueAuthor,
+ PolicyValueFollowers,
+ PolicyValueFollowing,
+ PolicyValueMentioned,
+ PolicyValuePublic,
+} from "../../../../lib/types/interaction";
+import { useTextInput } from "../../../../lib/form";
+import { Select } from "../../../../components/form/inputs";
+import { TextFormInputHook } from "../../../../lib/form/types";
+import { useBasicFor } from "./basic";
+import { PolicyFormSomethingElse, useSomethingElseFor } from "./something-else";
+import { Action, PolicyFormSub, SomethingElseValue, Visibility } from "./types";
+
+export default function InteractionPolicySettings() {
+ const {
+ data: defaultPolicies,
+ isLoading,
+ isFetching,
+ isError,
+ error,
+ } = useDefaultInteractionPoliciesQuery();
+
+ if (isLoading || isFetching) {
+ return ;
+ }
+
+ if (isError) {
+ return ;
+ }
+
+ if (!defaultPolicies) {
+ throw "default policies undefined";
+ }
+
+ return (
+
+ );
+}
+
+interface InteractionPoliciesFormProps {
+ defaultPolicies: DefaultInteractionPolicies;
+}
+
+function InteractionPoliciesForm({ defaultPolicies }: InteractionPoliciesFormProps) {
+ // Sub-form for visibility "public".
+ const formPublic = useFormForVis(defaultPolicies.public, "public");
+ const assemblePublic = useCallback(() => {
+ return {
+ can_favourite: assemblePolicyEntry("public", "favourite", formPublic),
+ can_reply: assemblePolicyEntry("public", "reply", formPublic),
+ can_reblog: assemblePolicyEntry("public", "reblog", formPublic),
+ };
+ }, [formPublic]);
+
+ // Sub-form for visibility "unlisted".
+ const formUnlisted = useFormForVis(defaultPolicies.unlisted, "unlisted");
+ const assembleUnlisted = useCallback(() => {
+ return {
+ can_favourite: assemblePolicyEntry("unlisted", "favourite", formUnlisted),
+ can_reply: assemblePolicyEntry("unlisted", "reply", formUnlisted),
+ can_reblog: assemblePolicyEntry("unlisted", "reblog", formUnlisted),
+ };
+ }, [formUnlisted]);
+
+ // Sub-form for visibility "private".
+ const formPrivate = useFormForVis(defaultPolicies.private, "private");
+ const assemblePrivate = useCallback(() => {
+ return {
+ can_favourite: assemblePolicyEntry("private", "favourite", formPrivate),
+ can_reply: assemblePolicyEntry("private", "reply", formPrivate),
+ can_reblog: assemblePolicyEntry("private", "reblog", formPrivate),
+ };
+ }, [formPrivate]);
+
+ const selectedVis = useTextInput("selectedVis", { defaultValue: "public" });
+
+ const [updatePolicies, updateResult] = useUpdateDefaultInteractionPoliciesMutation();
+ const [resetPolicies, resetResult] = useResetDefaultInteractionPoliciesMutation();
+
+ const onSubmit = (e) => {
+ e.preventDefault();
+ updatePolicies({
+ public: assemblePublic(),
+ unlisted: assembleUnlisted(),
+ private: assemblePrivate(),
+ // Always use the
+ // default for direct.
+ direct: null,
+ });
+ };
+
+ return (
+
+ );
+}
+
+// A tablist of tab buttons, one for each visibility.
+function PolicyPanelsTablist({ selectedVis }: { selectedVis: TextFormInputHook}) {
+ return (
+
+
+
+
+
+ );
+}
+
+interface TabProps {
+ thisVisibility: string;
+ label: string,
+ selectedVis: TextFormInputHook
+}
+
+// One tab in a tablist, corresponding to the given thisVisibility.
+function Tab({ thisVisibility, label, selectedVis }: TabProps) {
+ const selected = useMemo(() => {
+ return selectedVis.value === thisVisibility;
+ }, [selectedVis, thisVisibility]);
+
+ return (
+
+ );
+}
+
+interface PolicyPanelProps {
+ policyForm: PolicyForm;
+ forVis: Visibility;
+ isActive: boolean;
+}
+
+// Tab panel for one policy form of the given visibility.
+function PolicyPanel({ policyForm, forVis, isActive }: PolicyPanelProps) {
+ return (
+
+
+
+ { forVis !== "private" &&
+
+ }
+
+ );
+}
+
+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 (
+
+ );
+}
+
+/*
+ UTILITY FUNCTIONS
+*/
+
+// useLegend returns an appropriate
+// fieldset legend for the given action.
+function useLegend(action: Action) {
+ return useMemo(() => {
+ switch (action) {
+ case "favourite":
+ return (
+ <>
+
+ Like
+ >
+ );
+ case "reply":
+ return (
+ <>
+
+ Reply
+ >
+ );
+ case "reblog":
+ return (
+ <>
+
+ Boost
+ >
+ );
+ }
+ }, [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,
+ };
+}
diff --git a/web/source/settings/views/user/posts/interaction-policy-settings/something-else.tsx b/web/source/settings/views/user/posts/interaction-policy-settings/something-else.tsx
new file mode 100644
index 000000000..8882060c4
--- /dev/null
+++ b/web/source/settings/views/user/posts/interaction-policy-settings/something-else.tsx
@@ -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 .
+*/
+
+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" &&
+
+ }
+
+
+ >
+ );
+}
+
+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"),
+ },
+ };
+}
\ No newline at end of file
diff --git a/web/source/settings/views/user/posts/interaction-policy-settings/types.ts b/web/source/settings/views/user/posts/interaction-policy-settings/types.ts
new file mode 100644
index 000000000..d523366ee
--- /dev/null
+++ b/web/source/settings/views/user/posts/interaction-policy-settings/types.ts
@@ -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 .
+*/
+
+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";
diff --git a/web/source/settings/views/user/router.tsx b/web/source/settings/views/user/router.tsx
index e763c0c2b..5b74aee68 100644
--- a/web/source/settings/views/user/router.tsx
+++ b/web/source/settings/views/user/router.tsx
@@ -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() {
-
+
+