mirror of
https://github.com/superseriousbusiness/gotosocial.git
synced 2024-11-28 22:52:47 +00:00
[feature] Allow users to set default interaction policies per status visibility (#3108)
* [feature] Allow users to set default interaction policies * use vars for default policies * avoid some code repetition * unfuck form binding * avoid bonkers loop * beep boop * put policyValsToAPIPolicyVals in separate function * don't bother with slices.Grow * oops
This commit is contained in:
parent
401098191b
commit
0aadc2db2a
|
@ -155,10 +155,6 @@
|
||||||
}
|
}
|
||||||
testrig.StandardStorageSetup(state.Storage, "./testrig/media")
|
testrig.StandardStorageSetup(state.Storage, "./testrig/media")
|
||||||
|
|
||||||
// Initialize workers.
|
|
||||||
testrig.StartNoopWorkers(state)
|
|
||||||
defer testrig.StopWorkers(state)
|
|
||||||
|
|
||||||
// build backend handlers
|
// build backend handlers
|
||||||
transportController := testrig.NewTestTransportController(state, testrig.NewMockHTTPClient(func(req *http.Request) (*http.Response, error) {
|
transportController := testrig.NewTestTransportController(state, testrig.NewMockHTTPClient(func(req *http.Request) (*http.Response, error) {
|
||||||
r := io.NopCloser(bytes.NewReader([]byte{}))
|
r := io.NopCloser(bytes.NewReader([]byte{}))
|
||||||
|
@ -199,6 +195,10 @@
|
||||||
|
|
||||||
processor := testrig.NewTestProcessor(state, federator, emailSender, mediaManager)
|
processor := testrig.NewTestProcessor(state, federator, emailSender, mediaManager)
|
||||||
|
|
||||||
|
// Initialize workers.
|
||||||
|
testrig.StartWorkers(state, processor.Workers())
|
||||||
|
defer testrig.StopWorkers(state)
|
||||||
|
|
||||||
// Initialize metrics.
|
// Initialize metrics.
|
||||||
if err := metrics.Initialize(state.DB); err != nil {
|
if err := metrics.Initialize(state.DB); err != nil {
|
||||||
return fmt.Errorf("error initializing metrics: %w", err)
|
return fmt.Errorf("error initializing metrics: %w", err)
|
||||||
|
|
|
@ -895,6 +895,20 @@ definitions:
|
||||||
type: object
|
type: object
|
||||||
x-go-name: DebugAPUrlResponse
|
x-go-name: DebugAPUrlResponse
|
||||||
x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model
|
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:
|
domain:
|
||||||
description: Domain represents a remote domain
|
description: Domain represents a remote domain
|
||||||
properties:
|
properties:
|
||||||
|
@ -1821,6 +1835,53 @@ definitions:
|
||||||
type: object
|
type: object
|
||||||
x-go-name: InstanceV2Users
|
x-go-name: InstanceV2Users
|
||||||
x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model
|
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:
|
list:
|
||||||
properties:
|
properties:
|
||||||
id:
|
id:
|
||||||
|
@ -2429,6 +2490,8 @@ definitions:
|
||||||
example: 01FBVD42CQ3ZEEVMW180SBX03B
|
example: 01FBVD42CQ3ZEEVMW180SBX03B
|
||||||
type: string
|
type: string
|
||||||
x-go-name: InReplyToID
|
x-go-name: InReplyToID
|
||||||
|
interaction_policy:
|
||||||
|
$ref: '#/definitions/interactionPolicy'
|
||||||
language:
|
language:
|
||||||
description: |-
|
description: |-
|
||||||
Primary language of this status (ISO 639 Part 1 two-letter language code).
|
Primary language of this status (ISO 639 Part 1 two-letter language code).
|
||||||
|
@ -2620,6 +2683,8 @@ definitions:
|
||||||
example: 01FBVD42CQ3ZEEVMW180SBX03B
|
example: 01FBVD42CQ3ZEEVMW180SBX03B
|
||||||
type: string
|
type: string
|
||||||
x-go-name: InReplyToID
|
x-go-name: InReplyToID
|
||||||
|
interaction_policy:
|
||||||
|
$ref: '#/definitions/interactionPolicy'
|
||||||
language:
|
language:
|
||||||
description: |-
|
description: |-
|
||||||
Primary language of this status (ISO 639 Part 1 two-letter language code).
|
Primary language of this status (ISO 639 Part 1 two-letter language code).
|
||||||
|
@ -6850,6 +6915,174 @@ paths:
|
||||||
summary: View instance rules (public).
|
summary: View instance rules (public).
|
||||||
tags:
|
tags:
|
||||||
- instance
|
- 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:
|
/api/v1/lists:
|
||||||
get:
|
get:
|
||||||
operationId: lists
|
operationId: lists
|
||||||
|
|
BIN
docs/assets/user-settings-interaction-policy-1.png
Normal file
BIN
docs/assets/user-settings-interaction-policy-1.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 62 KiB |
|
@ -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!**
|
**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
|
## 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:
|
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:
|
||||||
|
|
|
@ -133,11 +133,7 @@ See the [Custom CSS](./custom_css.md) page for some tips on writing custom CSS f
|
||||||
!!! tip
|
!!! 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!
|
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
|
## Posts
|
||||||
|
|
||||||
![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.
|
|
||||||
|
|
||||||
### Post Settings
|
### 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).
|
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
|
This allows you to do things like:
|
||||||
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).
|
- 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
|
### Email Change
|
||||||
|
|
||||||
|
@ -171,6 +190,15 @@ Once a new email address has been entered, and you have clicked "Change email ad
|
||||||
!!! info
|
!!! 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.
|
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
|
## Migration
|
||||||
|
|
||||||
In the migration section you can manage settings related to aliasing and/or migrating your account to another account.
|
In the migration section you can manage settings related to aliasing and/or migrating your account to another account.
|
||||||
|
|
|
@ -34,6 +34,7 @@
|
||||||
filtersV2 "github.com/superseriousbusiness/gotosocial/internal/api/client/filters/v2"
|
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/followrequests"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/api/client/instance"
|
"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/lists"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/api/client/markers"
|
"github.com/superseriousbusiness/gotosocial/internal/api/client/markers"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/api/client/media"
|
"github.com/superseriousbusiness/gotosocial/internal/api/client/media"
|
||||||
|
@ -58,32 +59,33 @@ type Client struct {
|
||||||
processor *processing.Processor
|
processor *processing.Processor
|
||||||
db db.DB
|
db db.DB
|
||||||
|
|
||||||
accounts *accounts.Module // api/v1/accounts
|
accounts *accounts.Module // api/v1/accounts
|
||||||
admin *admin.Module // api/v1/admin
|
admin *admin.Module // api/v1/admin
|
||||||
apps *apps.Module // api/v1/apps
|
apps *apps.Module // api/v1/apps
|
||||||
blocks *blocks.Module // api/v1/blocks
|
blocks *blocks.Module // api/v1/blocks
|
||||||
bookmarks *bookmarks.Module // api/v1/bookmarks
|
bookmarks *bookmarks.Module // api/v1/bookmarks
|
||||||
conversations *conversations.Module // api/v1/conversations
|
conversations *conversations.Module // api/v1/conversations
|
||||||
customEmojis *customemojis.Module // api/v1/custom_emojis
|
customEmojis *customemojis.Module // api/v1/custom_emojis
|
||||||
favourites *favourites.Module // api/v1/favourites
|
favourites *favourites.Module // api/v1/favourites
|
||||||
featuredTags *featuredtags.Module // api/v1/featured_tags
|
featuredTags *featuredtags.Module // api/v1/featured_tags
|
||||||
filtersV1 *filtersV1.Module // api/v1/filters
|
filtersV1 *filtersV1.Module // api/v1/filters
|
||||||
filtersV2 *filtersV2.Module // api/v2/filters
|
filtersV2 *filtersV2.Module // api/v2/filters
|
||||||
followRequests *followrequests.Module // api/v1/follow_requests
|
followRequests *followrequests.Module // api/v1/follow_requests
|
||||||
instance *instance.Module // api/v1/instance
|
instance *instance.Module // api/v1/instance
|
||||||
lists *lists.Module // api/v1/lists
|
interactionPolicies *interactionpolicies.Module // api/v1/interaction_policies
|
||||||
markers *markers.Module // api/v1/markers
|
lists *lists.Module // api/v1/lists
|
||||||
media *media.Module // api/v1/media, api/v2/media
|
markers *markers.Module // api/v1/markers
|
||||||
mutes *mutes.Module // api/v1/mutes
|
media *media.Module // api/v1/media, api/v2/media
|
||||||
notifications *notifications.Module // api/v1/notifications
|
mutes *mutes.Module // api/v1/mutes
|
||||||
polls *polls.Module // api/v1/polls
|
notifications *notifications.Module // api/v1/notifications
|
||||||
preferences *preferences.Module // api/v1/preferences
|
polls *polls.Module // api/v1/polls
|
||||||
reports *reports.Module // api/v1/reports
|
preferences *preferences.Module // api/v1/preferences
|
||||||
search *search.Module // api/v1/search, api/v2/search
|
reports *reports.Module // api/v1/reports
|
||||||
statuses *statuses.Module // api/v1/statuses
|
search *search.Module // api/v1/search, api/v2/search
|
||||||
streaming *streaming.Module // api/v1/streaming
|
statuses *statuses.Module // api/v1/statuses
|
||||||
timelines *timelines.Module // api/v1/timelines
|
streaming *streaming.Module // api/v1/streaming
|
||||||
user *user.Module // api/v1/user
|
timelines *timelines.Module // api/v1/timelines
|
||||||
|
user *user.Module // api/v1/user
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) Route(r *router.Router, m ...gin.HandlerFunc) {
|
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.filtersV2.Route(h)
|
||||||
c.followRequests.Route(h)
|
c.followRequests.Route(h)
|
||||||
c.instance.Route(h)
|
c.instance.Route(h)
|
||||||
|
c.interactionPolicies.Route(h)
|
||||||
c.lists.Route(h)
|
c.lists.Route(h)
|
||||||
c.markers.Route(h)
|
c.markers.Route(h)
|
||||||
c.media.Route(h)
|
c.media.Route(h)
|
||||||
|
@ -136,31 +139,32 @@ func NewClient(state *state.State, p *processing.Processor) *Client {
|
||||||
processor: p,
|
processor: p,
|
||||||
db: state.DB,
|
db: state.DB,
|
||||||
|
|
||||||
accounts: accounts.New(p),
|
accounts: accounts.New(p),
|
||||||
admin: admin.New(state, p),
|
admin: admin.New(state, p),
|
||||||
apps: apps.New(p),
|
apps: apps.New(p),
|
||||||
blocks: blocks.New(p),
|
blocks: blocks.New(p),
|
||||||
bookmarks: bookmarks.New(p),
|
bookmarks: bookmarks.New(p),
|
||||||
conversations: conversations.New(p),
|
conversations: conversations.New(p),
|
||||||
customEmojis: customemojis.New(p),
|
customEmojis: customemojis.New(p),
|
||||||
favourites: favourites.New(p),
|
favourites: favourites.New(p),
|
||||||
featuredTags: featuredtags.New(p),
|
featuredTags: featuredtags.New(p),
|
||||||
filtersV1: filtersV1.New(p),
|
filtersV1: filtersV1.New(p),
|
||||||
filtersV2: filtersV2.New(p),
|
filtersV2: filtersV2.New(p),
|
||||||
followRequests: followrequests.New(p),
|
followRequests: followrequests.New(p),
|
||||||
instance: instance.New(p),
|
instance: instance.New(p),
|
||||||
lists: lists.New(p),
|
interactionPolicies: interactionpolicies.New(p),
|
||||||
markers: markers.New(p),
|
lists: lists.New(p),
|
||||||
media: media.New(p),
|
markers: markers.New(p),
|
||||||
mutes: mutes.New(p),
|
media: media.New(p),
|
||||||
notifications: notifications.New(p),
|
mutes: mutes.New(p),
|
||||||
polls: polls.New(p),
|
notifications: notifications.New(p),
|
||||||
preferences: preferences.New(p),
|
polls: polls.New(p),
|
||||||
reports: reports.New(p),
|
preferences: preferences.New(p),
|
||||||
search: search.New(p),
|
reports: reports.New(p),
|
||||||
statuses: statuses.New(p),
|
search: search.New(p),
|
||||||
streaming: streaming.New(p, time.Second*30, 4096),
|
statuses: statuses.New(p),
|
||||||
timelines: timelines.New(p),
|
streaming: streaming.New(p, time.Second*30, 4096),
|
||||||
user: user.New(p),
|
timelines: timelines.New(p),
|
||||||
|
user: user.New(p),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -528,7 +528,27 @@ func (suite *ReportsGetTestSuite) TestReportsGetAll() {
|
||||||
"tags": [],
|
"tags": [],
|
||||||
"emojis": [],
|
"emojis": [],
|
||||||
"card": null,
|
"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": [
|
"rules": [
|
||||||
|
@ -750,7 +770,27 @@ func (suite *ReportsGetTestSuite) TestReportsGetCreatedByAccount() {
|
||||||
"tags": [],
|
"tags": [],
|
||||||
"emojis": [],
|
"emojis": [],
|
||||||
"card": null,
|
"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": [
|
"rules": [
|
||||||
|
@ -972,7 +1012,27 @@ func (suite *ReportsGetTestSuite) TestReportsGetTargetAccount() {
|
||||||
"tags": [],
|
"tags": [],
|
||||||
"emojis": [],
|
"emojis": [],
|
||||||
"card": null,
|
"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": [
|
"rules": [
|
||||||
|
|
77
internal/api/client/interactionpolicies/getdefaults.go
Normal file
77
internal/api/client/interactionpolicies/getdefaults.go
Normal file
|
@ -0,0 +1,77 @@
|
||||||
|
// GoToSocial
|
||||||
|
// Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package interactionpolicies
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PoliciesDefaultsGETHandler swagger:operation GET /api/v1/interaction_policies/defaults policiesDefaultsGet
|
||||||
|
//
|
||||||
|
// Get default interaction policies for new statuses created by you.
|
||||||
|
//
|
||||||
|
// ---
|
||||||
|
// tags:
|
||||||
|
// - interaction_policies
|
||||||
|
//
|
||||||
|
// produces:
|
||||||
|
// - application/json
|
||||||
|
//
|
||||||
|
// security:
|
||||||
|
// - OAuth2 Bearer:
|
||||||
|
// - read:accounts
|
||||||
|
//
|
||||||
|
// responses:
|
||||||
|
// '200':
|
||||||
|
// description: A default policies object containing a policy for each status visibility.
|
||||||
|
// schema:
|
||||||
|
// "$ref": "#/definitions/defaultPolicies"
|
||||||
|
// '401':
|
||||||
|
// description: unauthorized
|
||||||
|
// '406':
|
||||||
|
// description: not acceptable
|
||||||
|
// '500':
|
||||||
|
// description: internal server error
|
||||||
|
func (m *Module) PoliciesDefaultsGETHandler(c *gin.Context) {
|
||||||
|
authed, err := oauth.Authed(c, true, true, true, true)
|
||||||
|
if err != nil {
|
||||||
|
apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
|
||||||
|
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, errWithCode := m.processor.Account().DefaultInteractionPoliciesGet(
|
||||||
|
c.Request.Context(),
|
||||||
|
authed.Account,
|
||||||
|
)
|
||||||
|
if errWithCode != nil {
|
||||||
|
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
apiutil.JSON(c, http.StatusOK, resp)
|
||||||
|
}
|
45
internal/api/client/interactionpolicies/policies.go
Normal file
45
internal/api/client/interactionpolicies/policies.go
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
// GoToSocial
|
||||||
|
// Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package interactionpolicies
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/processing"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
BasePath = "/v1/interaction_policies"
|
||||||
|
DefaultsPath = BasePath + "/defaults"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Module struct {
|
||||||
|
processor *processing.Processor
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(processor *processing.Processor) *Module {
|
||||||
|
return &Module{
|
||||||
|
processor: processor,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Module) Route(attachHandler func(method string, path string, f ...gin.HandlerFunc) gin.IRoutes) {
|
||||||
|
attachHandler(http.MethodGet, DefaultsPath, m.PoliciesDefaultsGETHandler)
|
||||||
|
attachHandler(http.MethodPatch, DefaultsPath, m.PoliciesDefaultsPATCHHandler)
|
||||||
|
}
|
334
internal/api/client/interactionpolicies/updatedefaults.go
Normal file
334
internal/api/client/interactionpolicies/updatedefaults.go
Normal file
|
@ -0,0 +1,334 @@
|
||||||
|
// GoToSocial
|
||||||
|
// Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package interactionpolicies
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/gin-gonic/gin/binding"
|
||||||
|
"github.com/go-playground/form/v4"
|
||||||
|
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||||
|
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/oauth"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PoliciesDefaultsPATCHHandler swagger:operation PATCH /api/v1/interaction_policies/defaults policiesDefaultsUpdate
|
||||||
|
//
|
||||||
|
// Update default interaction policies per visibility level for new statuses created by you.
|
||||||
|
//
|
||||||
|
// If submitting using form data, use the following pattern:
|
||||||
|
//
|
||||||
|
// `VISIBILITY[INTERACTION_TYPE][CONDITION][INDEX]=Value`
|
||||||
|
//
|
||||||
|
// For example: `public[can_reply][always][0]=author`
|
||||||
|
//
|
||||||
|
// Using `curl` this might look something like:
|
||||||
|
//
|
||||||
|
// `curl -F 'public[can_reply][always][0]=author' -F 'public[can_reply][always][1]=followers'`
|
||||||
|
//
|
||||||
|
// The JSON equivalent would be:
|
||||||
|
//
|
||||||
|
// `curl -H 'Content-Type: application/json' -d '{"public":{"can_reply":{"always":["author","followers"]}}}'`
|
||||||
|
//
|
||||||
|
// Any visibility level left unspecified in the request body will be returned to the default.
|
||||||
|
//
|
||||||
|
// Ie., in the example above, "public" would be updated, but "unlisted", "private", and "direct" would be reset to defaults.
|
||||||
|
//
|
||||||
|
// The server will perform some normalization on submitted policies so that you can't submit totally invalid policies.
|
||||||
|
//
|
||||||
|
// ---
|
||||||
|
// tags:
|
||||||
|
// - interaction_policies
|
||||||
|
//
|
||||||
|
// consumes:
|
||||||
|
// - multipart/form-data
|
||||||
|
// - application/x-www-form-urlencoded
|
||||||
|
// - application/json
|
||||||
|
//
|
||||||
|
// produces:
|
||||||
|
// - application/json
|
||||||
|
//
|
||||||
|
// parameters:
|
||||||
|
// -
|
||||||
|
// name: public[can_favourite][always][0]
|
||||||
|
// in: formData
|
||||||
|
// description: Nth entry for public.can_favourite.always.
|
||||||
|
// type: string
|
||||||
|
// -
|
||||||
|
// name: public[can_favourite][with_approval][0]
|
||||||
|
// in: formData
|
||||||
|
// description: Nth entry for public.can_favourite.with_approval.
|
||||||
|
// type: string
|
||||||
|
// -
|
||||||
|
// name: public[can_reply][always][0]
|
||||||
|
// in: formData
|
||||||
|
// description: Nth entry for public.can_reply.always.
|
||||||
|
// type: string
|
||||||
|
// -
|
||||||
|
// name: public[can_reply][with_approval][0]
|
||||||
|
// in: formData
|
||||||
|
// description: Nth entry for public.can_reply.with_approval.
|
||||||
|
// type: string
|
||||||
|
// -
|
||||||
|
// name: public[can_reblog][always][0]
|
||||||
|
// in: formData
|
||||||
|
// description: Nth entry for public.can_reblog.always.
|
||||||
|
// type: string
|
||||||
|
// -
|
||||||
|
// name: public[can_reblog][with_approval][0]
|
||||||
|
// in: formData
|
||||||
|
// description: Nth entry for public.can_reblog.with_approval.
|
||||||
|
// type: string
|
||||||
|
//
|
||||||
|
// -
|
||||||
|
// name: unlisted[can_favourite][always][0]
|
||||||
|
// in: formData
|
||||||
|
// description: Nth entry for unlisted.can_favourite.always.
|
||||||
|
// type: string
|
||||||
|
// -
|
||||||
|
// name: unlisted[can_favourite][with_approval][0]
|
||||||
|
// in: formData
|
||||||
|
// description: Nth entry for unlisted.can_favourite.with_approval.
|
||||||
|
// type: string
|
||||||
|
// -
|
||||||
|
// name: unlisted[can_reply][always][0]
|
||||||
|
// in: formData
|
||||||
|
// description: Nth entry for unlisted.can_reply.always.
|
||||||
|
// type: string
|
||||||
|
// -
|
||||||
|
// name: unlisted[can_reply][with_approval][0]
|
||||||
|
// in: formData
|
||||||
|
// description: Nth entry for unlisted.can_reply.with_approval.
|
||||||
|
// type: string
|
||||||
|
// -
|
||||||
|
// name: unlisted[can_reblog][always][0]
|
||||||
|
// in: formData
|
||||||
|
// description: Nth entry for unlisted.can_reblog.always.
|
||||||
|
// type: string
|
||||||
|
// -
|
||||||
|
// name: unlisted[can_reblog][with_approval][0]
|
||||||
|
// in: formData
|
||||||
|
// description: Nth entry for unlisted.can_reblog.with_approval.
|
||||||
|
// type: string
|
||||||
|
//
|
||||||
|
// -
|
||||||
|
// name: private[can_favourite][always][0]
|
||||||
|
// in: formData
|
||||||
|
// description: Nth entry for private.can_favourite.always.
|
||||||
|
// type: string
|
||||||
|
// -
|
||||||
|
// name: private[can_favourite][with_approval][0]
|
||||||
|
// in: formData
|
||||||
|
// description: Nth entry for private.can_favourite.with_approval.
|
||||||
|
// type: string
|
||||||
|
// -
|
||||||
|
// name: private[can_reply][always][0]
|
||||||
|
// in: formData
|
||||||
|
// description: Nth entry for private.can_reply.always.
|
||||||
|
// type: string
|
||||||
|
// -
|
||||||
|
// name: private[can_reply][with_approval][0]
|
||||||
|
// in: formData
|
||||||
|
// description: Nth entry for private.can_reply.with_approval.
|
||||||
|
// type: string
|
||||||
|
// -
|
||||||
|
// name: private[can_reblog][always][0]
|
||||||
|
// in: formData
|
||||||
|
// description: Nth entry for private.can_reblog.always.
|
||||||
|
// type: string
|
||||||
|
// -
|
||||||
|
// name: private[can_reblog][with_approval][0]
|
||||||
|
// in: formData
|
||||||
|
// description: Nth entry for private.can_reblog.with_approval.
|
||||||
|
// type: string
|
||||||
|
//
|
||||||
|
// -
|
||||||
|
// name: direct[can_favourite][always][0]
|
||||||
|
// in: formData
|
||||||
|
// description: Nth entry for direct.can_favourite.always.
|
||||||
|
// type: string
|
||||||
|
// -
|
||||||
|
// name: direct[can_favourite][with_approval][0]
|
||||||
|
// in: formData
|
||||||
|
// description: Nth entry for direct.can_favourite.with_approval.
|
||||||
|
// type: string
|
||||||
|
// -
|
||||||
|
// name: direct[can_reply][always][0]
|
||||||
|
// in: formData
|
||||||
|
// description: Nth entry for direct.can_reply.always.
|
||||||
|
// type: string
|
||||||
|
// -
|
||||||
|
// name: direct[can_reply][with_approval][0]
|
||||||
|
// in: formData
|
||||||
|
// description: Nth entry for direct.can_reply.with_approval.
|
||||||
|
// type: string
|
||||||
|
// -
|
||||||
|
// name: direct[can_reblog][always][0]
|
||||||
|
// in: formData
|
||||||
|
// description: Nth entry for direct.can_reblog.always.
|
||||||
|
// type: string
|
||||||
|
// -
|
||||||
|
// name: direct[can_reblog][with_approval][0]
|
||||||
|
// in: formData
|
||||||
|
// description: Nth entry for direct.can_reblog.with_approval.
|
||||||
|
// type: string
|
||||||
|
//
|
||||||
|
// security:
|
||||||
|
// - OAuth2 Bearer:
|
||||||
|
// - write:accounts
|
||||||
|
//
|
||||||
|
// responses:
|
||||||
|
// '200':
|
||||||
|
// description: Updated default policies object containing a policy for each status visibility.
|
||||||
|
// schema:
|
||||||
|
// "$ref": "#/definitions/defaultPolicies"
|
||||||
|
// '400':
|
||||||
|
// description: bad request
|
||||||
|
// '401':
|
||||||
|
// description: unauthorized
|
||||||
|
// '406':
|
||||||
|
// description: not acceptable
|
||||||
|
// '422':
|
||||||
|
// description: unprocessable
|
||||||
|
// '500':
|
||||||
|
// description: internal server error
|
||||||
|
func (m *Module) PoliciesDefaultsPATCHHandler(c *gin.Context) {
|
||||||
|
authed, err := oauth.Authed(c, true, true, true, true)
|
||||||
|
if err != nil {
|
||||||
|
apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil {
|
||||||
|
apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
form, err := parseUpdateAccountForm(c)
|
||||||
|
if err != nil {
|
||||||
|
apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, errWithCode := m.processor.Account().DefaultInteractionPoliciesUpdate(
|
||||||
|
c.Request.Context(),
|
||||||
|
authed.Account,
|
||||||
|
form,
|
||||||
|
)
|
||||||
|
if errWithCode != nil {
|
||||||
|
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
apiutil.JSON(c, http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// intPolicyFormBinding satisfies gin's binding.Binding interface.
|
||||||
|
// Should only be used specifically for multipart/form-data MIME type.
|
||||||
|
type intPolicyFormBinding struct {
|
||||||
|
visibility string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i intPolicyFormBinding) Name() string {
|
||||||
|
return i.visibility
|
||||||
|
}
|
||||||
|
|
||||||
|
func (intPolicyFormBinding) Bind(req *http.Request, obj any) error {
|
||||||
|
if err := req.ParseForm(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Change default namespace prefix and suffix to
|
||||||
|
// allow correct parsing of the field attributes.
|
||||||
|
decoder := form.NewDecoder()
|
||||||
|
decoder.SetNamespacePrefix("[")
|
||||||
|
decoder.SetNamespaceSuffix("]")
|
||||||
|
|
||||||
|
return decoder.Decode(obj, req.Form)
|
||||||
|
}
|
||||||
|
|
||||||
|
// customBind does custom form binding for
|
||||||
|
// each visibility in the form data.
|
||||||
|
func customBind(
|
||||||
|
c *gin.Context,
|
||||||
|
form *apimodel.UpdateInteractionPoliciesRequest,
|
||||||
|
) error {
|
||||||
|
for _, vis := range []string{
|
||||||
|
"Direct",
|
||||||
|
"Private",
|
||||||
|
"Unlisted",
|
||||||
|
"Public",
|
||||||
|
} {
|
||||||
|
if err := c.ShouldBindWith(
|
||||||
|
form,
|
||||||
|
intPolicyFormBinding{
|
||||||
|
visibility: vis,
|
||||||
|
},
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf("custom form binding failed: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseUpdateAccountForm(c *gin.Context) (*apimodel.UpdateInteractionPoliciesRequest, error) {
|
||||||
|
form := new(apimodel.UpdateInteractionPoliciesRequest)
|
||||||
|
|
||||||
|
switch ct := c.ContentType(); ct {
|
||||||
|
case binding.MIMEJSON:
|
||||||
|
// Just bind with default json binding.
|
||||||
|
if err := c.ShouldBindWith(form, binding.JSON); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
case binding.MIMEPOSTForm:
|
||||||
|
// Bind with default form binding first.
|
||||||
|
if err := c.ShouldBindWith(form, binding.FormPost); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now do custom binding.
|
||||||
|
if err := customBind(c, form); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
case binding.MIMEMultipartPOSTForm:
|
||||||
|
// Bind with default form binding first.
|
||||||
|
if err := c.ShouldBindWith(form, binding.FormMultipart); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now do custom binding.
|
||||||
|
if err := customBind(c, form); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
err := fmt.Errorf(
|
||||||
|
"content-type %s not supported for this endpoint; supported content-types are %s, %s, %s",
|
||||||
|
ct, binding.MIMEJSON, binding.MIMEPOSTForm, binding.MIMEMultipartPOSTForm,
|
||||||
|
)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return form, nil
|
||||||
|
}
|
|
@ -147,7 +147,27 @@ func (suite *StatusMuteTestSuite) TestMuteUnmuteStatus() {
|
||||||
"emojis": [],
|
"emojis": [],
|
||||||
"card": null,
|
"card": null,
|
||||||
"poll": 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)
|
}`, muted)
|
||||||
|
|
||||||
// Unmute the status, ensure `muted` is `false`.
|
// Unmute the status, ensure `muted` is `false`.
|
||||||
|
@ -212,7 +232,27 @@ func (suite *StatusMuteTestSuite) TestMuteUnmuteStatus() {
|
||||||
"emojis": [],
|
"emojis": [],
|
||||||
"card": null,
|
"card": null,
|
||||||
"poll": 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)
|
}`, unmuted)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
111
internal/api/model/interactionpolicy.go
Normal file
111
internal/api/model/interactionpolicy.go
Normal file
|
@ -0,0 +1,111 @@
|
||||||
|
// GoToSocial
|
||||||
|
// Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package model
|
||||||
|
|
||||||
|
// One interaction policy entry for a status.
|
||||||
|
//
|
||||||
|
// It can be EITHER one of the internal keywords listed below, OR a full-fledged ActivityPub URI of an Actor, like "https://example.org/users/some_user".
|
||||||
|
//
|
||||||
|
// Internal keywords:
|
||||||
|
//
|
||||||
|
// - public - Public, aka anyone who can see the status according to its visibility level.
|
||||||
|
// - followers - Followers of the status author.
|
||||||
|
// - following - People followed by the status author.
|
||||||
|
// - mutuals - Mutual follows of the status author (reserved, unused).
|
||||||
|
// - mentioned - Accounts mentioned in, or replied-to by, the status.
|
||||||
|
// - author - The status author themself.
|
||||||
|
// - me - If request was made with an authorized user, "me" represents the user who made the request and is now looking at this interaction policy.
|
||||||
|
//
|
||||||
|
// swagger:model interactionPolicyValue
|
||||||
|
type PolicyValue string
|
||||||
|
|
||||||
|
const (
|
||||||
|
PolicyValuePublic PolicyValue = "public" // Public, aka anyone who can see the status according to its visibility level.
|
||||||
|
PolicyValueFollowers PolicyValue = "followers" // Followers of the status author.
|
||||||
|
PolicyValueFollowing PolicyValue = "following" // People followed by the status author.
|
||||||
|
PolicyValueMutuals PolicyValue = "mutuals" // Mutual follows of the status author (reserved, unused).
|
||||||
|
PolicyValueMentioned PolicyValue = "mentioned" // Accounts mentioned in, or replied-to by, the status.
|
||||||
|
PolicyValueAuthor PolicyValue = "author" // The status author themself.
|
||||||
|
PolicyValueMe PolicyValue = "me" // If request was made with an authorized user, "me" represents the user who made the request and is now looking at this interaction policy.
|
||||||
|
)
|
||||||
|
|
||||||
|
// Rules for one interaction type.
|
||||||
|
//
|
||||||
|
// swagger:model interactionPolicyRules
|
||||||
|
type PolicyRules struct {
|
||||||
|
// Policy entries for accounts that can always do this type of interaction.
|
||||||
|
Always []PolicyValue `form:"always" json:"always"`
|
||||||
|
// Policy entries for accounts that require approval to do this type of interaction.
|
||||||
|
WithApproval []PolicyValue `form:"with_approval" json:"with_approval"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interaction policy of a status.
|
||||||
|
//
|
||||||
|
// swagger:model interactionPolicy
|
||||||
|
type InteractionPolicy struct {
|
||||||
|
// Rules for who can favourite this status.
|
||||||
|
CanFavourite PolicyRules `form:"can_favourite" json:"can_favourite"`
|
||||||
|
// Rules for who can reply to this status.
|
||||||
|
CanReply PolicyRules `form:"can_reply" json:"can_reply"`
|
||||||
|
// Rules for who can reblog this status.
|
||||||
|
CanReblog PolicyRules `form:"can_reblog" json:"can_reblog"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default interaction policies to use for new statuses by requesting account.
|
||||||
|
//
|
||||||
|
// swagger:model defaultPolicies
|
||||||
|
type DefaultPolicies struct {
|
||||||
|
// TODO: Add mutuals only default.
|
||||||
|
|
||||||
|
// Default policy for new direct visibility statuses.
|
||||||
|
Direct InteractionPolicy `json:"direct"`
|
||||||
|
// Default policy for new private/followers-only visibility statuses.
|
||||||
|
Private InteractionPolicy `json:"private"`
|
||||||
|
// Default policy for new unlisted/unlocked visibility statuses.
|
||||||
|
Unlisted InteractionPolicy `json:"unlisted"`
|
||||||
|
// Default policy for new public visibility statuses.
|
||||||
|
Public InteractionPolicy `json:"public"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// swagger:ignore
|
||||||
|
type UpdateInteractionPoliciesRequest struct {
|
||||||
|
// Default policy for new direct visibility statuses.
|
||||||
|
// Value `null` or omitted property resets policy to original default.
|
||||||
|
//
|
||||||
|
// in: formData
|
||||||
|
// nullable: true
|
||||||
|
Direct *InteractionPolicy `form:"direct" json:"direct"`
|
||||||
|
// Default policy for new private/followers-only visibility statuses.
|
||||||
|
// Value `null` or omitted property resets policy to original default.
|
||||||
|
//
|
||||||
|
// in: formData
|
||||||
|
// nullable: true
|
||||||
|
Private *InteractionPolicy `form:"private" json:"private"`
|
||||||
|
// Default policy for new unlisted/unlocked visibility statuses.
|
||||||
|
// Value `null` or omitted property resets policy to original default.
|
||||||
|
//
|
||||||
|
// in: formData
|
||||||
|
// nullable: true
|
||||||
|
Unlisted *InteractionPolicy `form:"unlisted" json:"unlisted"`
|
||||||
|
// Default policy for new public visibility statuses.
|
||||||
|
// Value `null` or omitted property resets policy to original default.
|
||||||
|
//
|
||||||
|
// in: formData
|
||||||
|
// nullable: true
|
||||||
|
Public *InteractionPolicy `form:"public" json:"public"`
|
||||||
|
}
|
|
@ -102,6 +102,8 @@ type Status struct {
|
||||||
Text string `json:"text,omitempty"`
|
Text string `json:"text,omitempty"`
|
||||||
// A list of filters that matched this status and why they matched, if there are any such filters.
|
// A list of filters that matched this status and why they matched, if there are any such filters.
|
||||||
Filtered []FilterResult `json:"filtered,omitempty"`
|
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
|
// WebStatus is like *model.Status, but contains
|
||||||
|
|
|
@ -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
|
// Returns the default interaction policy
|
||||||
// for a post with visibility of public.
|
// for a post with visibility of public.
|
||||||
func DefaultInteractionPolicyPublic() *InteractionPolicy {
|
func DefaultInteractionPolicyPublic() *InteractionPolicy {
|
||||||
// Anyone can like.
|
return defaultPolicyPublic
|
||||||
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,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns the default interaction policy
|
// Returns the default interaction policy
|
||||||
// for a post with visibility of unlocked.
|
// for a post with visibility of unlocked.
|
||||||
func DefaultInteractionPolicyUnlocked() *InteractionPolicy {
|
func DefaultInteractionPolicyUnlocked() *InteractionPolicy {
|
||||||
// Same as public (for now).
|
// 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
|
// Returns the default interaction policy for
|
||||||
// a post with visibility of followers only.
|
// a post with visibility of followers only.
|
||||||
func DefaultInteractionPolicyFollowersOnly() *InteractionPolicy {
|
func DefaultInteractionPolicyFollowersOnly() *InteractionPolicy {
|
||||||
// Self, followers and mentioned can like.
|
return defaultPolicyFollowersOnly
|
||||||
canLikeAlways := make(PolicyValues, 3)
|
}
|
||||||
canLikeAlways[0] = PolicyValueAuthor
|
|
||||||
canLikeAlways[1] = PolicyValueFollowers
|
|
||||||
canLikeAlways[2] = PolicyValueMentioned
|
|
||||||
|
|
||||||
// Unused, set empty.
|
var defaultPolicyDirect = &InteractionPolicy{
|
||||||
canLikeWithApproval := make(PolicyValues, 0)
|
CanLike: PolicyRules{
|
||||||
|
// Mentioned and self
|
||||||
// Self, followers and mentioned can reply.
|
// can always like.
|
||||||
canReplyAlways := make(PolicyValues, 3)
|
Always: PolicyValues{
|
||||||
canReplyAlways[0] = PolicyValueAuthor
|
PolicyValueAuthor,
|
||||||
canReplyAlways[1] = PolicyValueFollowers
|
PolicyValueMentioned,
|
||||||
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,
|
|
||||||
},
|
},
|
||||||
CanReply: PolicyRules{
|
WithApproval: make(PolicyValues, 0),
|
||||||
Always: canReplyAlways,
|
},
|
||||||
WithApproval: canReplyWithApproval,
|
CanReply: PolicyRules{
|
||||||
|
// Mentioned and self
|
||||||
|
// can always reply.
|
||||||
|
Always: PolicyValues{
|
||||||
|
PolicyValueAuthor,
|
||||||
|
PolicyValueMentioned,
|
||||||
},
|
},
|
||||||
CanAnnounce: PolicyRules{
|
WithApproval: make(PolicyValues, 0),
|
||||||
Always: canAnnounceAlways,
|
},
|
||||||
WithApproval: canAnnounceWithApproval,
|
CanAnnounce: PolicyRules{
|
||||||
|
// Only self can announce.
|
||||||
|
Always: PolicyValues{
|
||||||
|
PolicyValueAuthor,
|
||||||
},
|
},
|
||||||
}
|
WithApproval: make(PolicyValues, 0),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns the default interaction policy
|
// Returns the default interaction policy
|
||||||
// for a post with visibility of direct.
|
// for a post with visibility of direct.
|
||||||
func DefaultInteractionPolicyDirect() *InteractionPolicy {
|
func DefaultInteractionPolicyDirect() *InteractionPolicy {
|
||||||
// Mentioned and self can always like.
|
return defaultPolicyDirect
|
||||||
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,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
208
internal/processing/account/interactionpolicies.go
Normal file
208
internal/processing/account/interactionpolicies.go
Normal file
|
@ -0,0 +1,208 @@
|
||||||
|
// GoToSocial
|
||||||
|
// Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package account
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cmp"
|
||||||
|
"context"
|
||||||
|
|
||||||
|
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (p *Processor) DefaultInteractionPoliciesGet(
|
||||||
|
ctx context.Context,
|
||||||
|
requester *gtsmodel.Account,
|
||||||
|
) (*apimodel.DefaultPolicies, gtserror.WithCode) {
|
||||||
|
// Ensure account settings populated.
|
||||||
|
if err := p.populateAccountSettings(ctx, requester); err != nil {
|
||||||
|
return nil, gtserror.NewErrorInternalError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Take set "direct" policy
|
||||||
|
// or global default.
|
||||||
|
direct := cmp.Or(
|
||||||
|
requester.Settings.InteractionPolicyDirect,
|
||||||
|
gtsmodel.DefaultInteractionPolicyDirect(),
|
||||||
|
)
|
||||||
|
|
||||||
|
directAPI, err := p.converter.InteractionPolicyToAPIInteractionPolicy(ctx, direct, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
err := gtserror.Newf("error converting interaction policy direct: %w", err)
|
||||||
|
return nil, gtserror.NewErrorInternalError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Take set "private" policy
|
||||||
|
// or global default.
|
||||||
|
private := cmp.Or(
|
||||||
|
requester.Settings.InteractionPolicyFollowersOnly,
|
||||||
|
gtsmodel.DefaultInteractionPolicyFollowersOnly(),
|
||||||
|
)
|
||||||
|
|
||||||
|
privateAPI, err := p.converter.InteractionPolicyToAPIInteractionPolicy(ctx, private, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
err := gtserror.Newf("error converting interaction policy private: %w", err)
|
||||||
|
return nil, gtserror.NewErrorInternalError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Take set "unlisted" policy
|
||||||
|
// or global default.
|
||||||
|
unlisted := cmp.Or(
|
||||||
|
requester.Settings.InteractionPolicyUnlocked,
|
||||||
|
gtsmodel.DefaultInteractionPolicyUnlocked(),
|
||||||
|
)
|
||||||
|
|
||||||
|
unlistedAPI, err := p.converter.InteractionPolicyToAPIInteractionPolicy(ctx, unlisted, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
err := gtserror.Newf("error converting interaction policy unlisted: %w", err)
|
||||||
|
return nil, gtserror.NewErrorInternalError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Take set "public" policy
|
||||||
|
// or global default.
|
||||||
|
public := cmp.Or(
|
||||||
|
requester.Settings.InteractionPolicyPublic,
|
||||||
|
gtsmodel.DefaultInteractionPolicyPublic(),
|
||||||
|
)
|
||||||
|
|
||||||
|
publicAPI, err := p.converter.InteractionPolicyToAPIInteractionPolicy(ctx, public, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
err := gtserror.Newf("error converting interaction policy public: %w", err)
|
||||||
|
return nil, gtserror.NewErrorInternalError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &apimodel.DefaultPolicies{
|
||||||
|
Direct: *directAPI,
|
||||||
|
Private: *privateAPI,
|
||||||
|
Unlisted: *unlistedAPI,
|
||||||
|
Public: *publicAPI,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Processor) DefaultInteractionPoliciesUpdate(
|
||||||
|
ctx context.Context,
|
||||||
|
requester *gtsmodel.Account,
|
||||||
|
form *apimodel.UpdateInteractionPoliciesRequest,
|
||||||
|
) (*apimodel.DefaultPolicies, gtserror.WithCode) {
|
||||||
|
// Lock on this account as we're modifying its Settings.
|
||||||
|
unlock := p.state.ProcessingLocks.Lock(requester.URI)
|
||||||
|
defer unlock()
|
||||||
|
|
||||||
|
// Ensure account settings populated.
|
||||||
|
if err := p.populateAccountSettings(ctx, requester); err != nil {
|
||||||
|
return nil, gtserror.NewErrorInternalError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if form.Direct == nil {
|
||||||
|
// Unset/return to global default.
|
||||||
|
requester.Settings.InteractionPolicyDirect = nil
|
||||||
|
} else {
|
||||||
|
policy, err := typeutils.APIInteractionPolicyToInteractionPolicy(
|
||||||
|
form.Direct,
|
||||||
|
apimodel.VisibilityDirect,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, gtserror.NewErrorUnprocessableEntity(err, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set new default policy.
|
||||||
|
requester.Settings.InteractionPolicyDirect = policy
|
||||||
|
}
|
||||||
|
|
||||||
|
if form.Private == nil {
|
||||||
|
// Unset/return to global default.
|
||||||
|
requester.Settings.InteractionPolicyFollowersOnly = nil
|
||||||
|
} else {
|
||||||
|
policy, err := typeutils.APIInteractionPolicyToInteractionPolicy(
|
||||||
|
form.Private,
|
||||||
|
apimodel.VisibilityPrivate,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, gtserror.NewErrorUnprocessableEntity(err, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set new default policy.
|
||||||
|
requester.Settings.InteractionPolicyFollowersOnly = policy
|
||||||
|
}
|
||||||
|
|
||||||
|
if form.Unlisted == nil {
|
||||||
|
// Unset/return to global default.
|
||||||
|
requester.Settings.InteractionPolicyUnlocked = nil
|
||||||
|
} else {
|
||||||
|
policy, err := typeutils.APIInteractionPolicyToInteractionPolicy(
|
||||||
|
form.Unlisted,
|
||||||
|
apimodel.VisibilityUnlisted,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, gtserror.NewErrorUnprocessableEntity(err, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set new default policy.
|
||||||
|
requester.Settings.InteractionPolicyUnlocked = policy
|
||||||
|
}
|
||||||
|
|
||||||
|
if form.Public == nil {
|
||||||
|
// Unset/return to global default.
|
||||||
|
requester.Settings.InteractionPolicyPublic = nil
|
||||||
|
} else {
|
||||||
|
policy, err := typeutils.APIInteractionPolicyToInteractionPolicy(
|
||||||
|
form.Public,
|
||||||
|
apimodel.VisibilityPublic,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, gtserror.NewErrorUnprocessableEntity(err, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set new default policy.
|
||||||
|
requester.Settings.InteractionPolicyPublic = policy
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := p.state.DB.UpdateAccountSettings(ctx, requester.Settings); err != nil {
|
||||||
|
err := gtserror.Newf("db error updating setttings: %w", err)
|
||||||
|
return nil, gtserror.NewErrorInternalError(err, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.DefaultInteractionPoliciesGet(ctx, requester)
|
||||||
|
}
|
||||||
|
|
||||||
|
// populateAccountSettings just ensures that
|
||||||
|
// Settings is populated on the given account.
|
||||||
|
func (p *Processor) populateAccountSettings(
|
||||||
|
ctx context.Context,
|
||||||
|
acct *gtsmodel.Account,
|
||||||
|
) error {
|
||||||
|
if acct.Settings != nil {
|
||||||
|
// Already populated.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not populated,
|
||||||
|
// get from db.
|
||||||
|
var err error
|
||||||
|
acct.Settings, err = p.state.DB.GetAccountSettings(ctx, acct.ID)
|
||||||
|
if err != nil {
|
||||||
|
return gtserror.Newf(
|
||||||
|
"db error getting settings for account %s: %w",
|
||||||
|
acct.ID, err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
|
@ -121,6 +121,12 @@ func (p *Processor) Create(
|
||||||
return nil, gtserror.NewErrorInternalError(err)
|
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 {
|
if err := processLanguage(form, requester.Settings.Language, status); err != nil {
|
||||||
return nil, gtserror.NewErrorInternalError(err)
|
return nil, gtserror.NewErrorInternalError(err)
|
||||||
}
|
}
|
||||||
|
@ -281,26 +287,79 @@ func (p *Processor) processMediaIDs(ctx context.Context, form *apimodel.Advanced
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func processVisibility(form *apimodel.AdvancedStatusCreateForm, accountDefaultVis gtsmodel.Visibility, status *gtsmodel.Status) error {
|
func processVisibility(
|
||||||
// by default all flags are set to true
|
form *apimodel.AdvancedStatusCreateForm,
|
||||||
federated := true
|
accountDefaultVis gtsmodel.Visibility,
|
||||||
|
status *gtsmodel.Status,
|
||||||
// If visibility isn't set on the form, then just take the account default.
|
) error {
|
||||||
// If that's also not set, take the default for the whole instance.
|
|
||||||
var vis gtsmodel.Visibility
|
|
||||||
switch {
|
switch {
|
||||||
|
// Visibility set on form, use that.
|
||||||
case form.Visibility != "":
|
case form.Visibility != "":
|
||||||
vis = typeutils.APIVisToVis(form.Visibility)
|
status.Visibility = typeutils.APIVisToVis(form.Visibility)
|
||||||
|
|
||||||
|
// Fall back to account default.
|
||||||
case accountDefaultVis != "":
|
case accountDefaultVis != "":
|
||||||
vis = accountDefaultVis
|
status.Visibility = accountDefaultVis
|
||||||
|
|
||||||
|
// What? Fall back to global default.
|
||||||
default:
|
default:
|
||||||
vis = gtsmodel.VisibilityDefault
|
status.Visibility = gtsmodel.VisibilityDefault
|
||||||
}
|
}
|
||||||
|
|
||||||
// Todo: sort out likeable/replyable/boostable in next PR.
|
// Set federated flag to form value
|
||||||
|
// if provided, or default to true.
|
||||||
status.Visibility = vis
|
federated := util.PtrValueOr(form.Federated, true)
|
||||||
status.Federated = &federated
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -129,7 +129,27 @@ func (suite *StatusUpdateTestSuite) TestStreamNotification() {
|
||||||
"tags": [],
|
"tags": [],
|
||||||
"emojis": [],
|
"emojis": [],
|
||||||
"card": null,
|
"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())
|
}`, dst.String())
|
||||||
suite.Equal(msg.Event, "status.update")
|
suite.Equal(msg.Event, "status.update")
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,6 +18,10 @@
|
||||||
package typeutils
|
package typeutils
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"slices"
|
||||||
|
|
||||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||||
)
|
)
|
||||||
|
@ -57,3 +61,171 @@ func APIFilterActionToFilterAction(m apimodel.FilterAction) gtsmodel.FilterActio
|
||||||
}
|
}
|
||||||
return gtsmodel.FilterActionNone
|
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
|
||||||
|
}
|
||||||
|
|
|
@ -1234,6 +1234,20 @@ func (c *Converter) baseStatusToFrontend(
|
||||||
log.Errorf(ctx, "error converting status emojis: %v", err)
|
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{
|
apiStatus := &apimodel.Status{
|
||||||
ID: s.ID,
|
ID: s.ID,
|
||||||
CreatedAt: util.FormatISO8601(s.CreatedAt),
|
CreatedAt: util.FormatISO8601(s.CreatedAt),
|
||||||
|
@ -1258,6 +1272,7 @@ func (c *Converter) baseStatusToFrontend(
|
||||||
Emojis: apiEmojis,
|
Emojis: apiEmojis,
|
||||||
Card: nil, // TODO: implement cards
|
Card: nil, // TODO: implement cards
|
||||||
Text: s.Text,
|
Text: s.Text,
|
||||||
|
InteractionPolicy: *apiInteractionPolicy,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nullable fields.
|
// Nullable fields.
|
||||||
|
@ -2256,3 +2271,111 @@ func (c *Converter) ThemesToAPIThemes(themes []*gtsmodel.Theme) []apimodel.Theme
|
||||||
}
|
}
|
||||||
return apiThemes
|
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
|
||||||
|
}
|
||||||
|
|
|
@ -546,7 +546,27 @@ func (suite *InternalToFrontendTestSuite) TestStatusToFrontend() {
|
||||||
],
|
],
|
||||||
"card": null,
|
"card": null,
|
||||||
"poll": 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))
|
}`, string(b))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -701,7 +721,27 @@ func (suite *InternalToFrontendTestSuite) TestWarnFilteredStatusToFrontend() {
|
||||||
],
|
],
|
||||||
"status_matches": []
|
"status_matches": []
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"interaction_policy": {
|
||||||
|
"can_favourite": {
|
||||||
|
"always": [
|
||||||
|
"public"
|
||||||
|
],
|
||||||
|
"with_approval": []
|
||||||
|
},
|
||||||
|
"can_reply": {
|
||||||
|
"always": [
|
||||||
|
"public"
|
||||||
|
],
|
||||||
|
"with_approval": []
|
||||||
|
},
|
||||||
|
"can_reblog": {
|
||||||
|
"always": [
|
||||||
|
"public"
|
||||||
|
],
|
||||||
|
"with_approval": []
|
||||||
|
}
|
||||||
|
}
|
||||||
}`, string(b))
|
}`, string(b))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -877,7 +917,27 @@ func (suite *InternalToFrontendTestSuite) TestStatusToFrontendUnknownAttachments
|
||||||
"tags": [],
|
"tags": [],
|
||||||
"emojis": [],
|
"emojis": [],
|
||||||
"card": null,
|
"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))
|
}`, string(b))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -955,6 +1015,26 @@ func (suite *InternalToFrontendTestSuite) TestStatusToWebStatus() {
|
||||||
"emojis": [],
|
"emojis": [],
|
||||||
"card": null,
|
"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": []
|
||||||
|
}
|
||||||
|
},
|
||||||
"media_attachments": [
|
"media_attachments": [
|
||||||
{
|
{
|
||||||
"id": "01HE7Y3C432WRSNS10EZM86SA5",
|
"id": "01HE7Y3C432WRSNS10EZM86SA5",
|
||||||
|
@ -1137,7 +1217,121 @@ func (suite *InternalToFrontendTestSuite) TestStatusToFrontendUnknownLanguage()
|
||||||
],
|
],
|
||||||
"card": null,
|
"card": null,
|
||||||
"poll": 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))
|
}`, string(b))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2014,7 +2208,27 @@ func (suite *InternalToFrontendTestSuite) TestAdminReportToFrontend2() {
|
||||||
"tags": [],
|
"tags": [],
|
||||||
"emojis": [],
|
"emojis": [],
|
||||||
"card": null,
|
"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": [
|
"rules": [
|
||||||
|
|
|
@ -61,8 +61,8 @@ nav:
|
||||||
- "Home": "index.md"
|
- "Home": "index.md"
|
||||||
- "FAQ": "faq.md"
|
- "FAQ": "faq.md"
|
||||||
- "User Guide":
|
- "User Guide":
|
||||||
- "user_guide/posts.md"
|
|
||||||
- "user_guide/settings.md"
|
- "user_guide/settings.md"
|
||||||
|
- "user_guide/posts.md"
|
||||||
- "user_guide/search.md"
|
- "user_guide/search.md"
|
||||||
- "user_guide/custom_css.md"
|
- "user_guide/custom_css.md"
|
||||||
- "user_guide/password_management.md"
|
- "user_guide/password_management.md"
|
||||||
|
|
|
@ -141,9 +141,28 @@ export interface SelectProps extends React.DetailedHTMLProps<
|
||||||
field: TextFormInputHook;
|
field: TextFormInputHook;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
options: React.JSX.Element;
|
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;
|
const { onChange, value, ref } = field;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -152,7 +171,12 @@ export function Select({ label, field, children, options, ...props }: SelectProp
|
||||||
{label}
|
{label}
|
||||||
{children}
|
{children}
|
||||||
<select
|
<select
|
||||||
onChange={onChange}
|
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
|
onChange(e);
|
||||||
|
if (onChangeCallback !== undefined) {
|
||||||
|
onChangeCallback(e.target.value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
value={value}
|
value={value}
|
||||||
ref={ref as RefObject<HTMLSelectElement>}
|
ref={ref as RefObject<HTMLSelectElement>}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
|
@ -141,6 +141,7 @@ export const gtsApi = createApi({
|
||||||
"InstanceRules",
|
"InstanceRules",
|
||||||
"HTTPHeaderAllows",
|
"HTTPHeaderAllows",
|
||||||
"HTTPHeaderBlocks",
|
"HTTPHeaderBlocks",
|
||||||
|
"DefaultInteractionPolicies",
|
||||||
],
|
],
|
||||||
endpoints: (build) => ({
|
endpoints: (build) => ({
|
||||||
instanceV1: build.query<InstanceV1, void>({
|
instanceV1: build.query<InstanceV1, void>({
|
||||||
|
|
|
@ -25,6 +25,7 @@ import type {
|
||||||
} from "../../types/migration";
|
} from "../../types/migration";
|
||||||
import type { Theme } from "../../types/theme";
|
import type { Theme } from "../../types/theme";
|
||||||
import { User } from "../../types/user";
|
import { User } from "../../types/user";
|
||||||
|
import { DefaultInteractionPolicies, UpdateDefaultInteractionPolicies } from "../../types/interaction";
|
||||||
|
|
||||||
const extended = gtsApi.injectEndpoints({
|
const extended = gtsApi.injectEndpoints({
|
||||||
endpoints: (build) => ({
|
endpoints: (build) => ({
|
||||||
|
@ -38,9 +39,11 @@ const extended = gtsApi.injectEndpoints({
|
||||||
}),
|
}),
|
||||||
...replaceCacheOnMutation("verifyCredentials")
|
...replaceCacheOnMutation("verifyCredentials")
|
||||||
}),
|
}),
|
||||||
|
|
||||||
user: build.query<User, void>({
|
user: build.query<User, void>({
|
||||||
query: () => ({url: `/api/v1/user`})
|
query: () => ({url: `/api/v1/user`})
|
||||||
}),
|
}),
|
||||||
|
|
||||||
passwordChange: build.mutation({
|
passwordChange: build.mutation({
|
||||||
query: (data) => ({
|
query: (data) => ({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
@ -48,6 +51,7 @@ const extended = gtsApi.injectEndpoints({
|
||||||
body: data
|
body: data
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
|
|
||||||
emailChange: build.mutation<User, { password: string, new_email: string }>({
|
emailChange: build.mutation<User, { password: string, new_email: string }>({
|
||||||
query: (data) => ({
|
query: (data) => ({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
@ -56,6 +60,7 @@ const extended = gtsApi.injectEndpoints({
|
||||||
}),
|
}),
|
||||||
...replaceCacheOnMutation("user")
|
...replaceCacheOnMutation("user")
|
||||||
}),
|
}),
|
||||||
|
|
||||||
aliasAccount: build.mutation<any, UpdateAliasesFormData>({
|
aliasAccount: build.mutation<any, UpdateAliasesFormData>({
|
||||||
async queryFn(formData, _api, _extraOpts, fetchWithBQ) {
|
async queryFn(formData, _api, _extraOpts, fetchWithBQ) {
|
||||||
// Pull entries out from the hooked form.
|
// Pull entries out from the hooked form.
|
||||||
|
@ -73,6 +78,7 @@ const extended = gtsApi.injectEndpoints({
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
moveAccount: build.mutation<any, MoveAccountFormData>({
|
moveAccount: build.mutation<any, MoveAccountFormData>({
|
||||||
query: (data) => ({
|
query: (data) => ({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
@ -80,11 +86,37 @@ const extended = gtsApi.injectEndpoints({
|
||||||
body: data
|
body: data
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
|
|
||||||
accountThemes: build.query<Theme[], void>({
|
accountThemes: build.query<Theme[], void>({
|
||||||
query: () => ({
|
query: () => ({
|
||||||
url: `/api/v1/accounts/themes`
|
url: `/api/v1/accounts/themes`
|
||||||
})
|
})
|
||||||
})
|
}),
|
||||||
|
|
||||||
|
defaultInteractionPolicies: build.query<DefaultInteractionPolicies, void>({
|
||||||
|
query: () => ({
|
||||||
|
url: `/api/v1/interaction_policies/defaults`
|
||||||
|
}),
|
||||||
|
providesTags: ["DefaultInteractionPolicies"]
|
||||||
|
}),
|
||||||
|
|
||||||
|
updateDefaultInteractionPolicies: build.mutation<DefaultInteractionPolicies, UpdateDefaultInteractionPolicies>({
|
||||||
|
query: (data) => ({
|
||||||
|
method: "PATCH",
|
||||||
|
url: `/api/v1/interaction_policies/defaults`,
|
||||||
|
body: data,
|
||||||
|
}),
|
||||||
|
...replaceCacheOnMutation("defaultInteractionPolicies")
|
||||||
|
}),
|
||||||
|
|
||||||
|
resetDefaultInteractionPolicies: build.mutation<DefaultInteractionPolicies, void>({
|
||||||
|
query: () => ({
|
||||||
|
method: "PATCH",
|
||||||
|
url: `/api/v1/interaction_policies/defaults`,
|
||||||
|
body: {},
|
||||||
|
}),
|
||||||
|
invalidatesTags: ["DefaultInteractionPolicies"]
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -96,4 +128,7 @@ export const {
|
||||||
useAliasAccountMutation,
|
useAliasAccountMutation,
|
||||||
useMoveAccountMutation,
|
useMoveAccountMutation,
|
||||||
useAccountThemesQuery,
|
useAccountThemesQuery,
|
||||||
|
useDefaultInteractionPoliciesQuery,
|
||||||
|
useUpdateDefaultInteractionPoliciesMutation,
|
||||||
|
useResetDefaultInteractionPoliciesMutation,
|
||||||
} = extended;
|
} = extended;
|
||||||
|
|
|
@ -64,6 +64,17 @@ export interface Account {
|
||||||
enable_rss: boolean,
|
enable_rss: boolean,
|
||||||
role: any,
|
role: any,
|
||||||
suspended?: boolean,
|
suspended?: boolean,
|
||||||
|
source?: AccountSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AccountSource {
|
||||||
|
fields: any[];
|
||||||
|
follow_requests_count: number;
|
||||||
|
language: string;
|
||||||
|
note: string;
|
||||||
|
privacy: string;
|
||||||
|
sensitive: boolean;
|
||||||
|
status_content_type: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SearchAccountParams {
|
export interface SearchAccountParams {
|
||||||
|
|
63
web/source/settings/lib/types/interaction.ts
Normal file
63
web/source/settings/lib/types/interaction.ts
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
/*
|
||||||
|
GoToSocial
|
||||||
|
Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface DefaultInteractionPolicies {
|
||||||
|
direct: InteractionPolicy;
|
||||||
|
private: InteractionPolicy;
|
||||||
|
unlisted: InteractionPolicy;
|
||||||
|
public: InteractionPolicy;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateDefaultInteractionPolicies {
|
||||||
|
direct: InteractionPolicy | null;
|
||||||
|
private: InteractionPolicy | null;
|
||||||
|
unlisted: InteractionPolicy | null;
|
||||||
|
public: InteractionPolicy | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InteractionPolicy {
|
||||||
|
can_favourite: InteractionPolicyEntry;
|
||||||
|
can_reply: InteractionPolicyEntry;
|
||||||
|
can_reblog: InteractionPolicyEntry;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InteractionPolicyEntry {
|
||||||
|
always: InteractionPolicyValue[];
|
||||||
|
with_approval: InteractionPolicyValue[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InteractionPolicyValue = string;
|
||||||
|
|
||||||
|
const PolicyValuePublic: InteractionPolicyValue = "public";
|
||||||
|
const PolicyValueFollowers: InteractionPolicyValue = "followers";
|
||||||
|
const PolicyValueFollowing: InteractionPolicyValue = "following";
|
||||||
|
const PolicyValueMutuals: InteractionPolicyValue = "mutuals";
|
||||||
|
const PolicyValueMentioned: InteractionPolicyValue = "mentioned";
|
||||||
|
const PolicyValueAuthor: InteractionPolicyValue = "author";
|
||||||
|
const PolicyValueMe: InteractionPolicyValue = "me";
|
||||||
|
|
||||||
|
export {
|
||||||
|
PolicyValuePublic,
|
||||||
|
PolicyValueFollowers,
|
||||||
|
PolicyValueFollowing,
|
||||||
|
PolicyValueMutuals,
|
||||||
|
PolicyValueMentioned,
|
||||||
|
PolicyValueAuthor,
|
||||||
|
PolicyValueMe,
|
||||||
|
};
|
|
@ -343,7 +343,7 @@ section.with-sidebar > form {
|
||||||
|
|
||||||
.labelinput .border {
|
.labelinput .border {
|
||||||
border-radius: 0.2rem;
|
border-radius: 0.2rem;
|
||||||
border: 0.15rem solid $border_accent;
|
border: 0.15rem solid $border-accent;
|
||||||
padding: 0.3rem;
|
padding: 0.3rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
@ -867,6 +867,41 @@ button.with-padding {
|
||||||
padding: 0.5rem calc(0.5rem + $fa-fw);
|
padding: 0.5rem calc(0.5rem + $fa-fw);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tab-buttons {
|
||||||
|
display: flex;
|
||||||
|
max-width: fit-content;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.tab-button {
|
||||||
|
border-top-left-radius: $br;
|
||||||
|
border-top-right-radius: $br;
|
||||||
|
border-bottom-left-radius: 0;
|
||||||
|
border-bottom-right-radius: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
background: $blue1;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $button-hover-bg;
|
||||||
|
}
|
||||||
|
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
font-size: 1rem;
|
||||||
|
|
||||||
|
@media screen and (max-width: 20rem) {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: $button-bg;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.loading-icon {
|
.loading-icon {
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
}
|
}
|
||||||
|
@ -1370,6 +1405,53 @@ button.with-padding {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.interaction-default-settings {
|
||||||
|
.interaction-policy-section {
|
||||||
|
padding: 1rem;
|
||||||
|
display: none;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
border: 0.15rem solid $input-border;
|
||||||
|
|
||||||
|
fieldset {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.5rem 1rem 1rem 1rem;
|
||||||
|
|
||||||
|
border: $boxshadow-border;
|
||||||
|
border-radius: 0.1rem;
|
||||||
|
box-shadow: $boxshadow;
|
||||||
|
|
||||||
|
>legend {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: large;
|
||||||
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.something-else {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: -0.3rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media screen and (orientation: portrait) {
|
@media screen and (orientation: portrait) {
|
||||||
.reports .report .byline {
|
.reports .report .byline {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|
|
@ -18,90 +18,21 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { useTextInput, useBoolInput } from "../../lib/form";
|
import { useTextInput } from "../../lib/form";
|
||||||
import useFormSubmit from "../../lib/form/submit";
|
import useFormSubmit from "../../lib/form/submit";
|
||||||
import { Select, TextInput, Checkbox } from "../../components/form/inputs";
|
import { TextInput } from "../../components/form/inputs";
|
||||||
import FormWithData from "../../lib/form/form-with-data";
|
|
||||||
import Languages from "../../components/languages";
|
|
||||||
import MutationButton from "../../components/form/mutation-button";
|
import MutationButton from "../../components/form/mutation-button";
|
||||||
import { useVerifyCredentialsQuery } from "../../lib/query/oauth";
|
import { useEmailChangeMutation, usePasswordChangeMutation, useUserQuery } from "../../lib/query/user";
|
||||||
import { useEmailChangeMutation, usePasswordChangeMutation, useUpdateCredentialsMutation, useUserQuery } from "../../lib/query/user";
|
|
||||||
import Loading from "../../components/loading";
|
import Loading from "../../components/loading";
|
||||||
import { User } from "../../lib/types/user";
|
import { User } from "../../lib/types/user";
|
||||||
import { useInstanceV1Query } from "../../lib/query/gts-api";
|
import { useInstanceV1Query } from "../../lib/query/gts-api";
|
||||||
|
|
||||||
export default function UserSettings() {
|
export default function EmailPassword() {
|
||||||
return (
|
|
||||||
<FormWithData
|
|
||||||
dataQuery={useVerifyCredentialsQuery}
|
|
||||||
DataForm={UserSettingsForm}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function UserSettingsForm({ data }) {
|
|
||||||
/* form keys
|
|
||||||
- string source[privacy]
|
|
||||||
- bool source[sensitive]
|
|
||||||
- string source[language]
|
|
||||||
- string source[status_content_type]
|
|
||||||
*/
|
|
||||||
|
|
||||||
const form = {
|
|
||||||
defaultPrivacy: useTextInput("source[privacy]", { source: data, defaultValue: "unlisted" }),
|
|
||||||
isSensitive: useBoolInput("source[sensitive]", { source: data }),
|
|
||||||
language: useTextInput("source[language]", { source: data, valueSelector: (s) => s.source.language?.toUpperCase() ?? "EN" }),
|
|
||||||
statusContentType: useTextInput("source[status_content_type]", { source: data, defaultValue: "text/plain" }),
|
|
||||||
};
|
|
||||||
|
|
||||||
const [submitForm, result] = useFormSubmit(form, useUpdateCredentialsMutation());
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h1>Account Settings</h1>
|
<h1>Email & Password Settings</h1>
|
||||||
<form className="user-settings" onSubmit={submitForm}>
|
|
||||||
<div className="form-section-docs">
|
|
||||||
<h3>Post Settings</h3>
|
|
||||||
<a
|
|
||||||
href="https://docs.gotosocial.org/en/latest/user_guide/posts"
|
|
||||||
target="_blank"
|
|
||||||
className="docslink"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
Learn more about these settings (opens in a new tab)
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<Select field={form.language} label="Default post language" options={
|
|
||||||
<Languages />
|
|
||||||
}>
|
|
||||||
</Select>
|
|
||||||
<Select field={form.defaultPrivacy} label="Default post privacy" options={
|
|
||||||
<>
|
|
||||||
<option value="private">Private / followers-only</option>
|
|
||||||
<option value="unlisted">Unlisted</option>
|
|
||||||
<option value="public">Public</option>
|
|
||||||
</>
|
|
||||||
}>
|
|
||||||
</Select>
|
|
||||||
<Select field={form.statusContentType} label="Default post (and bio) format" options={
|
|
||||||
<>
|
|
||||||
<option value="text/plain">Plain (default)</option>
|
|
||||||
<option value="text/markdown">Markdown</option>
|
|
||||||
</>
|
|
||||||
}>
|
|
||||||
</Select>
|
|
||||||
<Checkbox
|
|
||||||
field={form.isSensitive}
|
|
||||||
label="Mark my posts as sensitive by default"
|
|
||||||
/>
|
|
||||||
<MutationButton
|
|
||||||
disabled={false}
|
|
||||||
label="Save settings"
|
|
||||||
result={result}
|
|
||||||
/>
|
|
||||||
</form>
|
|
||||||
<PasswordChange />
|
|
||||||
<EmailChange />
|
<EmailChange />
|
||||||
|
<PasswordChange />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -330,4 +261,4 @@ function EmailChangeForm({user, oidcEnabled}: { user: User, oidcEnabled?: boolea
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
|
@ -22,7 +22,8 @@ import React from "react";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* - /settings/user/profile
|
* - /settings/user/profile
|
||||||
* - /settings/user/settings
|
* - /settings/user/posts
|
||||||
|
* - /settings/user/emailpassword
|
||||||
* - /settings/user/migration
|
* - /settings/user/migration
|
||||||
*/
|
*/
|
||||||
export default function UserMenu() {
|
export default function UserMenu() {
|
||||||
|
@ -38,9 +39,14 @@ export default function UserMenu() {
|
||||||
icon="fa-user"
|
icon="fa-user"
|
||||||
/>
|
/>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
name="Settings"
|
name="Posts"
|
||||||
itemUrl="settings"
|
itemUrl="posts"
|
||||||
icon="fa-cogs"
|
icon="fa-paper-plane"
|
||||||
|
/>
|
||||||
|
<MenuItem
|
||||||
|
name="Email & Password"
|
||||||
|
itemUrl="emailpassword"
|
||||||
|
icon="fa-user-secret"
|
||||||
/>
|
/>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
name="Migration"
|
name="Migration"
|
||||||
|
|
|
@ -0,0 +1,88 @@
|
||||||
|
/*
|
||||||
|
GoToSocial
|
||||||
|
Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { useTextInput, useBoolInput } from "../../../../lib/form";
|
||||||
|
import useFormSubmit from "../../../../lib/form/submit";
|
||||||
|
import { Select, Checkbox } from "../../../../components/form/inputs";
|
||||||
|
import Languages from "../../../../components/languages";
|
||||||
|
import MutationButton from "../../../../components/form/mutation-button";
|
||||||
|
import { useUpdateCredentialsMutation } from "../../../../lib/query/user";
|
||||||
|
import { Account } from "../../../../lib/types/account";
|
||||||
|
|
||||||
|
export default function BasicSettings({ account }: { account: Account }) {
|
||||||
|
/* form keys
|
||||||
|
- string source[privacy]
|
||||||
|
- bool source[sensitive]
|
||||||
|
- string source[language]
|
||||||
|
- string source[status_content_type]
|
||||||
|
*/
|
||||||
|
const form = {
|
||||||
|
defaultPrivacy: useTextInput("source[privacy]", { source: account, defaultValue: "unlisted" }),
|
||||||
|
isSensitive: useBoolInput("source[sensitive]", { source: account }),
|
||||||
|
language: useTextInput("source[language]", { source: account, valueSelector: (s: Account) => s.source?.language?.toUpperCase() ?? "EN" }),
|
||||||
|
statusContentType: useTextInput("source[status_content_type]", { source: account, defaultValue: "text/plain" }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const [submitForm, result] = useFormSubmit(form, useUpdateCredentialsMutation());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="post-settings" onSubmit={submitForm}>
|
||||||
|
<div className="form-section-docs">
|
||||||
|
<h3>Basic</h3>
|
||||||
|
<a
|
||||||
|
href="https://docs.gotosocial.org/en/latest/user_guide/settings#post-settings"
|
||||||
|
target="_blank"
|
||||||
|
className="docslink"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
Learn more about these settings (opens in a new tab)
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<Select field={form.language} label="Default post language" options={
|
||||||
|
<Languages />
|
||||||
|
}>
|
||||||
|
</Select>
|
||||||
|
<Select field={form.defaultPrivacy} label="Default post privacy" options={
|
||||||
|
<>
|
||||||
|
<option value="public">Public</option>
|
||||||
|
<option value="unlisted">Unlisted</option>
|
||||||
|
<option value="private">Followers-only</option>
|
||||||
|
</>
|
||||||
|
}>
|
||||||
|
</Select>
|
||||||
|
<Select field={form.statusContentType} label="Default post (and bio) format" options={
|
||||||
|
<>
|
||||||
|
<option value="text/plain">Plain (default)</option>
|
||||||
|
<option value="text/markdown">Markdown</option>
|
||||||
|
</>
|
||||||
|
}>
|
||||||
|
</Select>
|
||||||
|
<Checkbox
|
||||||
|
field={form.isSensitive}
|
||||||
|
label="Mark my posts as sensitive by default"
|
||||||
|
/>
|
||||||
|
<MutationButton
|
||||||
|
disabled={false}
|
||||||
|
label="Save settings"
|
||||||
|
result={result}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
51
web/source/settings/views/user/posts/index.tsx
Normal file
51
web/source/settings/views/user/posts/index.tsx
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
/*
|
||||||
|
GoToSocial
|
||||||
|
Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { useVerifyCredentialsQuery } from "../../../lib/query/oauth";
|
||||||
|
import Loading from "../../../components/loading";
|
||||||
|
import { Error } from "../../../components/error";
|
||||||
|
import BasicSettings from "./basic-settings";
|
||||||
|
import InteractionPolicySettings from "./interaction-policy-settings";
|
||||||
|
|
||||||
|
export default function PostSettings() {
|
||||||
|
const {
|
||||||
|
data: account,
|
||||||
|
isLoading,
|
||||||
|
isFetching,
|
||||||
|
isError,
|
||||||
|
error,
|
||||||
|
} = useVerifyCredentialsQuery();
|
||||||
|
|
||||||
|
if (isLoading || isFetching) {
|
||||||
|
return <Loading />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return <Error error={error} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h1>Post Settings</h1>
|
||||||
|
<BasicSettings account={account} />
|
||||||
|
<InteractionPolicySettings />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,180 @@
|
||||||
|
/*
|
||||||
|
GoToSocial
|
||||||
|
Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useMemo } from "react";
|
||||||
|
import {
|
||||||
|
InteractionPolicyValue,
|
||||||
|
PolicyValueAuthor,
|
||||||
|
PolicyValueFollowers,
|
||||||
|
PolicyValueMentioned,
|
||||||
|
PolicyValuePublic,
|
||||||
|
} from "../../../../lib/types/interaction";
|
||||||
|
import { useTextInput } from "../../../../lib/form";
|
||||||
|
import { Action, BasicValue, PolicyFormSub, Visibility } from "./types";
|
||||||
|
|
||||||
|
// Based on the given visibility, action, and states,
|
||||||
|
// derives what the initial basic Select value should be.
|
||||||
|
function useBasicValue(
|
||||||
|
forVis: Visibility,
|
||||||
|
forAction: Action,
|
||||||
|
always: InteractionPolicyValue[],
|
||||||
|
withApproval: InteractionPolicyValue[],
|
||||||
|
): BasicValue {
|
||||||
|
// Check if "always" value is just the author
|
||||||
|
// (and possibly mentioned accounts when dealing
|
||||||
|
// with replies -- still counts as "just_me").
|
||||||
|
const alwaysJustAuthor = useMemo(() => {
|
||||||
|
if (
|
||||||
|
always.length === 1 &&
|
||||||
|
always[0] === PolicyValueAuthor
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
forAction === "reply" &&
|
||||||
|
always.length === 2 &&
|
||||||
|
always.includes(PolicyValueAuthor) &&
|
||||||
|
always.includes(PolicyValueMentioned)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}, [forAction, always]);
|
||||||
|
|
||||||
|
// Check if "always" includes the widest
|
||||||
|
// possible audience for this visibility.
|
||||||
|
const alwaysWidestAudience = useMemo(() => {
|
||||||
|
return (
|
||||||
|
(forVis === "private" && always.includes(PolicyValueFollowers)) ||
|
||||||
|
always.includes(PolicyValuePublic)
|
||||||
|
);
|
||||||
|
}, [forVis, always]);
|
||||||
|
|
||||||
|
// Check if "withApproval" includes the widest
|
||||||
|
// possible audience for this visibility.
|
||||||
|
const withApprovalWidestAudience = useMemo(() => {
|
||||||
|
return (
|
||||||
|
(forVis === "private" && withApproval.includes(PolicyValueFollowers)) ||
|
||||||
|
withApproval.includes(PolicyValuePublic)
|
||||||
|
);
|
||||||
|
}, [forVis, withApproval]);
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
// Simplest case: if "always" includes the
|
||||||
|
// widest possible audience for this visibility,
|
||||||
|
// then we don't need to check anything else.
|
||||||
|
if (alwaysWidestAudience) {
|
||||||
|
return "anyone";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Next simplest case: there's no "with approval"
|
||||||
|
// URIs set, so check if it's always just author.
|
||||||
|
if (withApproval.length === 0 && alwaysJustAuthor) {
|
||||||
|
return "just_me";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Third simplest case: always is just us, and with
|
||||||
|
// approval is addressed to the widest possible audience.
|
||||||
|
if (alwaysJustAuthor && withApprovalWidestAudience) {
|
||||||
|
return "anyone_with_approval";
|
||||||
|
}
|
||||||
|
|
||||||
|
// We've exhausted the
|
||||||
|
// simple possibilities.
|
||||||
|
return "something_else";
|
||||||
|
}, [
|
||||||
|
withApproval.length,
|
||||||
|
alwaysJustAuthor,
|
||||||
|
alwaysWidestAudience,
|
||||||
|
withApprovalWidestAudience,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive wording for the basic label for
|
||||||
|
// whatever visibility and action we're handling.
|
||||||
|
function useBasicLabel(visibility: Visibility, action: Action) {
|
||||||
|
return useMemo(() => {
|
||||||
|
let visPost = "";
|
||||||
|
switch (visibility) {
|
||||||
|
case "public":
|
||||||
|
visPost = "a public post";
|
||||||
|
break;
|
||||||
|
case "unlisted":
|
||||||
|
visPost = "an unlisted post";
|
||||||
|
break;
|
||||||
|
case "private":
|
||||||
|
visPost = "a followers-only post";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case "favourite":
|
||||||
|
return "Who can like " + visPost + "?";
|
||||||
|
case "reply":
|
||||||
|
return "Who else can reply to " + visPost + "?";
|
||||||
|
case "reblog":
|
||||||
|
return "Who can boost " + visPost + "?";
|
||||||
|
}
|
||||||
|
}, [visibility, action]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return whatever the "basic" options should
|
||||||
|
// be in the basic Select for this visibility.
|
||||||
|
function useBasicOptions(visibility: Visibility) {
|
||||||
|
return useMemo(() => {
|
||||||
|
const audience = visibility === "private"
|
||||||
|
? "My followers"
|
||||||
|
: "Anyone";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<option value="anyone">{audience}</option>
|
||||||
|
<option value="anyone_with_approval">{audience} (approval required)</option>
|
||||||
|
<option value="just_me">Just me</option>
|
||||||
|
{ visibility !== "private" &&
|
||||||
|
<option value="something_else">Something else...</option>
|
||||||
|
}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}, [visibility]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBasicFor(
|
||||||
|
forVis: Visibility,
|
||||||
|
forAction: Action,
|
||||||
|
currentAlways: InteractionPolicyValue[],
|
||||||
|
currentWithApproval: InteractionPolicyValue[],
|
||||||
|
): PolicyFormSub {
|
||||||
|
// Determine who's currently *basically* allowed
|
||||||
|
// to do this action for this visibility.
|
||||||
|
const defaultValue = useBasicValue(
|
||||||
|
forVis,
|
||||||
|
forAction,
|
||||||
|
currentAlways,
|
||||||
|
currentWithApproval,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
field: useTextInput("basic", { defaultValue: defaultValue }),
|
||||||
|
label: useBasicLabel(forVis, forAction),
|
||||||
|
options: useBasicOptions(forVis),
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,553 @@
|
||||||
|
/*
|
||||||
|
GoToSocial
|
||||||
|
Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useCallback, useMemo } from "react";
|
||||||
|
import {
|
||||||
|
useDefaultInteractionPoliciesQuery,
|
||||||
|
useResetDefaultInteractionPoliciesMutation,
|
||||||
|
useUpdateDefaultInteractionPoliciesMutation,
|
||||||
|
} from "../../../../lib/query/user";
|
||||||
|
import Loading from "../../../../components/loading";
|
||||||
|
import { Error } from "../../../../components/error";
|
||||||
|
import MutationButton from "../../../../components/form/mutation-button";
|
||||||
|
import {
|
||||||
|
DefaultInteractionPolicies,
|
||||||
|
InteractionPolicy,
|
||||||
|
InteractionPolicyEntry,
|
||||||
|
InteractionPolicyValue,
|
||||||
|
PolicyValueAuthor,
|
||||||
|
PolicyValueFollowers,
|
||||||
|
PolicyValueFollowing,
|
||||||
|
PolicyValueMentioned,
|
||||||
|
PolicyValuePublic,
|
||||||
|
} from "../../../../lib/types/interaction";
|
||||||
|
import { useTextInput } from "../../../../lib/form";
|
||||||
|
import { Select } from "../../../../components/form/inputs";
|
||||||
|
import { TextFormInputHook } from "../../../../lib/form/types";
|
||||||
|
import { useBasicFor } from "./basic";
|
||||||
|
import { PolicyFormSomethingElse, useSomethingElseFor } from "./something-else";
|
||||||
|
import { Action, PolicyFormSub, SomethingElseValue, Visibility } from "./types";
|
||||||
|
|
||||||
|
export default function InteractionPolicySettings() {
|
||||||
|
const {
|
||||||
|
data: defaultPolicies,
|
||||||
|
isLoading,
|
||||||
|
isFetching,
|
||||||
|
isError,
|
||||||
|
error,
|
||||||
|
} = useDefaultInteractionPoliciesQuery();
|
||||||
|
|
||||||
|
if (isLoading || isFetching) {
|
||||||
|
return <Loading />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return <Error error={error} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!defaultPolicies) {
|
||||||
|
throw "default policies undefined";
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<InteractionPoliciesForm defaultPolicies={defaultPolicies} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InteractionPoliciesFormProps {
|
||||||
|
defaultPolicies: DefaultInteractionPolicies;
|
||||||
|
}
|
||||||
|
|
||||||
|
function InteractionPoliciesForm({ defaultPolicies }: InteractionPoliciesFormProps) {
|
||||||
|
// Sub-form for visibility "public".
|
||||||
|
const formPublic = useFormForVis(defaultPolicies.public, "public");
|
||||||
|
const assemblePublic = useCallback(() => {
|
||||||
|
return {
|
||||||
|
can_favourite: assemblePolicyEntry("public", "favourite", formPublic),
|
||||||
|
can_reply: assemblePolicyEntry("public", "reply", formPublic),
|
||||||
|
can_reblog: assemblePolicyEntry("public", "reblog", formPublic),
|
||||||
|
};
|
||||||
|
}, [formPublic]);
|
||||||
|
|
||||||
|
// Sub-form for visibility "unlisted".
|
||||||
|
const formUnlisted = useFormForVis(defaultPolicies.unlisted, "unlisted");
|
||||||
|
const assembleUnlisted = useCallback(() => {
|
||||||
|
return {
|
||||||
|
can_favourite: assemblePolicyEntry("unlisted", "favourite", formUnlisted),
|
||||||
|
can_reply: assemblePolicyEntry("unlisted", "reply", formUnlisted),
|
||||||
|
can_reblog: assemblePolicyEntry("unlisted", "reblog", formUnlisted),
|
||||||
|
};
|
||||||
|
}, [formUnlisted]);
|
||||||
|
|
||||||
|
// Sub-form for visibility "private".
|
||||||
|
const formPrivate = useFormForVis(defaultPolicies.private, "private");
|
||||||
|
const assemblePrivate = useCallback(() => {
|
||||||
|
return {
|
||||||
|
can_favourite: assemblePolicyEntry("private", "favourite", formPrivate),
|
||||||
|
can_reply: assemblePolicyEntry("private", "reply", formPrivate),
|
||||||
|
can_reblog: assemblePolicyEntry("private", "reblog", formPrivate),
|
||||||
|
};
|
||||||
|
}, [formPrivate]);
|
||||||
|
|
||||||
|
const selectedVis = useTextInput("selectedVis", { defaultValue: "public" });
|
||||||
|
|
||||||
|
const [updatePolicies, updateResult] = useUpdateDefaultInteractionPoliciesMutation();
|
||||||
|
const [resetPolicies, resetResult] = useResetDefaultInteractionPoliciesMutation();
|
||||||
|
|
||||||
|
const onSubmit = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
updatePolicies({
|
||||||
|
public: assemblePublic(),
|
||||||
|
unlisted: assembleUnlisted(),
|
||||||
|
private: assemblePrivate(),
|
||||||
|
// Always use the
|
||||||
|
// default for direct.
|
||||||
|
direct: null,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="interaction-default-settings" onSubmit={onSubmit}>
|
||||||
|
<div className="form-section-docs">
|
||||||
|
<h3>Default Interaction Policies</h3>
|
||||||
|
<p>
|
||||||
|
You can use this section to customize the default interaction
|
||||||
|
policy for posts created by you, per visibility setting.
|
||||||
|
<br/>
|
||||||
|
These settings apply only for new posts created by you <em>after</em> applying
|
||||||
|
these settings; they do not apply retroactively.
|
||||||
|
<br/>
|
||||||
|
The word "anyone" in the below options means <em>anyone with
|
||||||
|
permission to see the post</em>, taking account of blocks.
|
||||||
|
<br/>
|
||||||
|
Bear in mind that no matter what you set below, you will always
|
||||||
|
be able to like, reply-to, and boost your own posts.
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href="https://docs.gotosocial.org/en/latest/user_guide/settings#default-interaction-policies"
|
||||||
|
target="_blank"
|
||||||
|
className="docslink"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
Learn more about these settings (opens in a new tab)
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className="tabbable-sections">
|
||||||
|
<PolicyPanelsTablist selectedVis={selectedVis} />
|
||||||
|
<PolicyPanel
|
||||||
|
policyForm={formPublic}
|
||||||
|
forVis={"public"}
|
||||||
|
isActive={selectedVis.value === "public"}
|
||||||
|
/>
|
||||||
|
<PolicyPanel
|
||||||
|
policyForm={formUnlisted}
|
||||||
|
forVis={"unlisted"}
|
||||||
|
isActive={selectedVis.value === "unlisted"}
|
||||||
|
/>
|
||||||
|
<PolicyPanel
|
||||||
|
policyForm={formPrivate}
|
||||||
|
forVis={"private"}
|
||||||
|
isActive={selectedVis.value === "private"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="action-buttons row">
|
||||||
|
<MutationButton
|
||||||
|
disabled={false}
|
||||||
|
label="Save policies"
|
||||||
|
result={updateResult}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MutationButton
|
||||||
|
disabled={false}
|
||||||
|
type="button"
|
||||||
|
onClick={() => resetPolicies()}
|
||||||
|
label="Reset to defaults"
|
||||||
|
result={resetResult}
|
||||||
|
className="button danger"
|
||||||
|
showError={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A tablist of tab buttons, one for each visibility.
|
||||||
|
function PolicyPanelsTablist({ selectedVis }: { selectedVis: TextFormInputHook}) {
|
||||||
|
return (
|
||||||
|
<div className="tab-buttons" role="tablist">
|
||||||
|
<Tab
|
||||||
|
thisVisibility="public"
|
||||||
|
label="Public"
|
||||||
|
selectedVis={selectedVis}
|
||||||
|
/>
|
||||||
|
<Tab
|
||||||
|
thisVisibility="unlisted"
|
||||||
|
label="Unlisted"
|
||||||
|
selectedVis={selectedVis}
|
||||||
|
/>
|
||||||
|
<Tab
|
||||||
|
thisVisibility="private"
|
||||||
|
label="Followers-only"
|
||||||
|
selectedVis={selectedVis}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TabProps {
|
||||||
|
thisVisibility: string;
|
||||||
|
label: string,
|
||||||
|
selectedVis: TextFormInputHook
|
||||||
|
}
|
||||||
|
|
||||||
|
// One tab in a tablist, corresponding to the given thisVisibility.
|
||||||
|
function Tab({ thisVisibility, label, selectedVis }: TabProps) {
|
||||||
|
const selected = useMemo(() => {
|
||||||
|
return selectedVis.value === thisVisibility;
|
||||||
|
}, [selectedVis, thisVisibility]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
id={`tab-${thisVisibility}`}
|
||||||
|
title={label}
|
||||||
|
role="tab"
|
||||||
|
className={`tab-button ${selected && "active"}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
selectedVis.setter(thisVisibility);
|
||||||
|
}}
|
||||||
|
aria-selected={selected}
|
||||||
|
aria-controls={`panel-${thisVisibility}`}
|
||||||
|
tabIndex={selected ? 0 : -1}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PolicyPanelProps {
|
||||||
|
policyForm: PolicyForm;
|
||||||
|
forVis: Visibility;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tab panel for one policy form of the given visibility.
|
||||||
|
function PolicyPanel({ policyForm, forVis, isActive }: PolicyPanelProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`interaction-policy-section ${isActive && "active"}`}
|
||||||
|
role="tabpanel"
|
||||||
|
hidden={!isActive}
|
||||||
|
>
|
||||||
|
<PolicyComponent
|
||||||
|
form={policyForm.favourite}
|
||||||
|
forAction="favourite"
|
||||||
|
/>
|
||||||
|
<PolicyComponent
|
||||||
|
form={policyForm.reply}
|
||||||
|
forAction="reply"
|
||||||
|
/>
|
||||||
|
{ forVis !== "private" &&
|
||||||
|
<PolicyComponent
|
||||||
|
form={policyForm.reblog}
|
||||||
|
forAction="reblog"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PolicyComponentProps {
|
||||||
|
form: {
|
||||||
|
basic: PolicyFormSub;
|
||||||
|
somethingElse: PolicyFormSomethingElse;
|
||||||
|
};
|
||||||
|
forAction: Action;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A component of one policy of the given
|
||||||
|
// visibility, corresponding to the given action.
|
||||||
|
function PolicyComponent({ form, forAction }: PolicyComponentProps) {
|
||||||
|
const legend = useLegend(forAction);
|
||||||
|
return (
|
||||||
|
<fieldset>
|
||||||
|
<legend>{legend}</legend>
|
||||||
|
{ forAction === "reply" &&
|
||||||
|
<div className="info">
|
||||||
|
<i className="fa fa-fw fa-info-circle" aria-hidden="true"></i>
|
||||||
|
<b>Mentioned accounts can always reply.</b>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<Select
|
||||||
|
field={form.basic.field}
|
||||||
|
label={form.basic.label}
|
||||||
|
options={form.basic.options}
|
||||||
|
/>
|
||||||
|
{/* Include advanced "something else" options if appropriate */}
|
||||||
|
{ (form.basic.field.value === "something_else") &&
|
||||||
|
<>
|
||||||
|
<hr />
|
||||||
|
<div className="something-else">
|
||||||
|
<Select
|
||||||
|
field={form.somethingElse.followers.field}
|
||||||
|
label={form.somethingElse.followers.label}
|
||||||
|
options={form.somethingElse.followers.options}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
field={form.somethingElse.following.field}
|
||||||
|
label={form.somethingElse.following.label}
|
||||||
|
options={form.somethingElse.following.options}
|
||||||
|
/>
|
||||||
|
{/*
|
||||||
|
Skip mentioned accounts field for reply action,
|
||||||
|
since mentioned accounts can always reply.
|
||||||
|
*/}
|
||||||
|
{ forAction !== "reply" &&
|
||||||
|
<Select
|
||||||
|
field={form.somethingElse.mentioned.field}
|
||||||
|
label={form.somethingElse.mentioned.label}
|
||||||
|
options={form.somethingElse.mentioned.options}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
<Select
|
||||||
|
field={form.somethingElse.everyoneElse.field}
|
||||||
|
label={form.somethingElse.everyoneElse.label}
|
||||||
|
options={form.somethingElse.everyoneElse.options}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
</fieldset>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
UTILITY FUNCTIONS
|
||||||
|
*/
|
||||||
|
|
||||||
|
// useLegend returns an appropriate
|
||||||
|
// fieldset legend for the given action.
|
||||||
|
function useLegend(action: Action) {
|
||||||
|
return useMemo(() => {
|
||||||
|
switch (action) {
|
||||||
|
case "favourite":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<i className="fa fa-fw fa-star" aria-hidden="true"></i>
|
||||||
|
<span>Like</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case "reply":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<i className="fa fa-fw fa-reply-all" aria-hidden="true"></i>
|
||||||
|
<span>Reply</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case "reblog":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<i className="fa fa-fw fa-retweet" aria-hidden="true"></i>
|
||||||
|
<span>Boost</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [action]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Form encapsulating the different
|
||||||
|
// actions for one visibility.
|
||||||
|
interface PolicyForm {
|
||||||
|
favourite: {
|
||||||
|
basic: PolicyFormSub,
|
||||||
|
somethingElse: PolicyFormSomethingElse,
|
||||||
|
}
|
||||||
|
reply: {
|
||||||
|
basic: PolicyFormSub,
|
||||||
|
somethingElse: PolicyFormSomethingElse,
|
||||||
|
}
|
||||||
|
reblog: {
|
||||||
|
basic: PolicyFormSub,
|
||||||
|
somethingElse: PolicyFormSomethingElse,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return a PolicyForm for the given visibility,
|
||||||
|
// set already to whatever the defaultPolicies value is.
|
||||||
|
function useFormForVis(
|
||||||
|
currentPolicy: InteractionPolicy,
|
||||||
|
forVis: Visibility,
|
||||||
|
): PolicyForm {
|
||||||
|
return {
|
||||||
|
favourite: {
|
||||||
|
basic: useBasicFor(
|
||||||
|
forVis,
|
||||||
|
"favourite",
|
||||||
|
currentPolicy.can_favourite.always,
|
||||||
|
currentPolicy.can_favourite.with_approval,
|
||||||
|
),
|
||||||
|
somethingElse: useSomethingElseFor(
|
||||||
|
forVis,
|
||||||
|
"favourite",
|
||||||
|
currentPolicy.can_favourite.always,
|
||||||
|
currentPolicy.can_favourite.with_approval,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
reply: {
|
||||||
|
basic: useBasicFor(
|
||||||
|
forVis,
|
||||||
|
"reply",
|
||||||
|
currentPolicy.can_reply.always,
|
||||||
|
currentPolicy.can_reply.with_approval,
|
||||||
|
),
|
||||||
|
somethingElse: useSomethingElseFor(
|
||||||
|
forVis,
|
||||||
|
"reply",
|
||||||
|
currentPolicy.can_reply.always,
|
||||||
|
currentPolicy.can_reply.with_approval,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
reblog: {
|
||||||
|
basic: useBasicFor(
|
||||||
|
forVis,
|
||||||
|
"reblog",
|
||||||
|
currentPolicy.can_reblog.always,
|
||||||
|
currentPolicy.can_reblog.with_approval,
|
||||||
|
),
|
||||||
|
somethingElse: useSomethingElseFor(
|
||||||
|
forVis,
|
||||||
|
"reblog",
|
||||||
|
currentPolicy.can_reblog.always,
|
||||||
|
currentPolicy.can_reblog.with_approval,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function assemblePolicyEntry(
|
||||||
|
forVis: Visibility,
|
||||||
|
forAction: Action,
|
||||||
|
policyForm: PolicyForm,
|
||||||
|
): InteractionPolicyEntry {
|
||||||
|
const basic = policyForm[forAction].basic;
|
||||||
|
|
||||||
|
// If this is followers visibility then
|
||||||
|
// "anyone" only means followers, not public.
|
||||||
|
const anyone: InteractionPolicyValue =
|
||||||
|
(forVis === "private")
|
||||||
|
? PolicyValueFollowers
|
||||||
|
: PolicyValuePublic;
|
||||||
|
|
||||||
|
// If this is a reply action then "just me"
|
||||||
|
// must include mentioned accounts as well,
|
||||||
|
// since they can always reply.
|
||||||
|
const justMe: InteractionPolicyValue[] =
|
||||||
|
(forAction === "reply")
|
||||||
|
? [PolicyValueAuthor, PolicyValueMentioned]
|
||||||
|
: [PolicyValueAuthor];
|
||||||
|
|
||||||
|
switch (basic.field.value) {
|
||||||
|
case "anyone":
|
||||||
|
return {
|
||||||
|
// Anyone can do this.
|
||||||
|
always: [anyone],
|
||||||
|
with_approval: [],
|
||||||
|
};
|
||||||
|
case "anyone_with_approval":
|
||||||
|
return {
|
||||||
|
// Author and maybe mentioned can do
|
||||||
|
// this, everyone else needs approval.
|
||||||
|
always: justMe,
|
||||||
|
with_approval: [anyone],
|
||||||
|
};
|
||||||
|
case "just_me":
|
||||||
|
return {
|
||||||
|
// Only author and maybe
|
||||||
|
// mentioned can do this.
|
||||||
|
always: justMe,
|
||||||
|
with_approval: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Something else!
|
||||||
|
const somethingElse = policyForm[forAction].somethingElse;
|
||||||
|
|
||||||
|
// Start with basic "always"
|
||||||
|
// and "with_approval" values.
|
||||||
|
let always: InteractionPolicyValue[] = justMe;
|
||||||
|
let withApproval: InteractionPolicyValue[] = [];
|
||||||
|
|
||||||
|
// Add PolicyValueFollowers depending on choices made.
|
||||||
|
switch (somethingElse.followers.field.value as SomethingElseValue) {
|
||||||
|
case "always":
|
||||||
|
always.push(PolicyValueFollowers);
|
||||||
|
break;
|
||||||
|
case "with_approval":
|
||||||
|
withApproval.push(PolicyValueFollowers);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add PolicyValueFollowing depending on choices made.
|
||||||
|
switch (somethingElse.following.field.value as SomethingElseValue) {
|
||||||
|
case "always":
|
||||||
|
always.push(PolicyValueFollowing);
|
||||||
|
break;
|
||||||
|
case "with_approval":
|
||||||
|
withApproval.push(PolicyValueFollowing);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add PolicyValueMentioned depending on choices made.
|
||||||
|
// Note: mentioned can always reply, and that's already
|
||||||
|
// included above, so only do this if action is not reply.
|
||||||
|
if (forAction !== "reply") {
|
||||||
|
switch (somethingElse.mentioned.field.value as SomethingElseValue) {
|
||||||
|
case "always":
|
||||||
|
always.push(PolicyValueMentioned);
|
||||||
|
break;
|
||||||
|
case "with_approval":
|
||||||
|
withApproval.push(PolicyValueMentioned);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add anyone depending on choices made.
|
||||||
|
switch (somethingElse.everyoneElse.field.value as SomethingElseValue) {
|
||||||
|
case "with_approval":
|
||||||
|
withApproval.push(anyone);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simplify a bit after
|
||||||
|
// all the parsing above.
|
||||||
|
if (always.includes(anyone)) {
|
||||||
|
always = [anyone];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (withApproval.includes(anyone)) {
|
||||||
|
withApproval = [anyone];
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
always: always,
|
||||||
|
with_approval: withApproval,
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,124 @@
|
||||||
|
/*
|
||||||
|
GoToSocial
|
||||||
|
Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useMemo } from "react";
|
||||||
|
import { InteractionPolicyValue, PolicyValueFollowers, PolicyValueFollowing, PolicyValuePublic } from "../../../../lib/types/interaction";
|
||||||
|
import { useTextInput } from "../../../../lib/form";
|
||||||
|
import { Action, Audience, PolicyFormSub, SomethingElseValue, Visibility } from "./types";
|
||||||
|
|
||||||
|
export interface PolicyFormSomethingElse {
|
||||||
|
followers: PolicyFormSub,
|
||||||
|
following: PolicyFormSub,
|
||||||
|
mentioned: PolicyFormSub,
|
||||||
|
everyoneElse: PolicyFormSub,
|
||||||
|
}
|
||||||
|
|
||||||
|
function useSomethingElseOptions(
|
||||||
|
forVis: Visibility,
|
||||||
|
forAction: Action,
|
||||||
|
forAudience: Audience,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{ forAudience !== "everyone_else" &&
|
||||||
|
<option value="always">Always</option>
|
||||||
|
}
|
||||||
|
<option value="with_approval">With my approval</option>
|
||||||
|
<option value="no">No</option>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSomethingElseFor(
|
||||||
|
forVis: Visibility,
|
||||||
|
forAction: Action,
|
||||||
|
currentAlways: InteractionPolicyValue[],
|
||||||
|
currentWithApproval: InteractionPolicyValue[],
|
||||||
|
): PolicyFormSomethingElse {
|
||||||
|
const followersDefaultValue: SomethingElseValue = useMemo(() => {
|
||||||
|
if (currentAlways.includes(PolicyValueFollowers)) {
|
||||||
|
return "always";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentWithApproval.includes(PolicyValueFollowers)) {
|
||||||
|
return "with_approval";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "no";
|
||||||
|
}, [currentAlways, currentWithApproval]);
|
||||||
|
|
||||||
|
const followingDefaultValue: SomethingElseValue = useMemo(() => {
|
||||||
|
if (currentAlways.includes(PolicyValueFollowing)) {
|
||||||
|
return "always";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentWithApproval.includes(PolicyValueFollowing)) {
|
||||||
|
return "with_approval";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "no";
|
||||||
|
}, [currentAlways, currentWithApproval]);
|
||||||
|
|
||||||
|
const mentionedDefaultValue: SomethingElseValue = useMemo(() => {
|
||||||
|
if (currentAlways.includes(PolicyValueFollowing)) {
|
||||||
|
return "always";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentWithApproval.includes(PolicyValueFollowing)) {
|
||||||
|
return "with_approval";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "no";
|
||||||
|
}, [currentAlways, currentWithApproval]);
|
||||||
|
|
||||||
|
const everyoneElseDefaultValue: SomethingElseValue = useMemo(() => {
|
||||||
|
if (currentAlways.includes(PolicyValuePublic)) {
|
||||||
|
return "always";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentWithApproval.includes(PolicyValuePublic)) {
|
||||||
|
return "with_approval";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "no";
|
||||||
|
}, [currentAlways, currentWithApproval]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
followers: {
|
||||||
|
field: useTextInput("followers", { defaultValue: followersDefaultValue }),
|
||||||
|
label: "My followers",
|
||||||
|
options: useSomethingElseOptions(forVis, forAction, "followers"),
|
||||||
|
},
|
||||||
|
following: {
|
||||||
|
field: useTextInput("following", { defaultValue: followingDefaultValue }),
|
||||||
|
label: "Accounts I follow",
|
||||||
|
options: useSomethingElseOptions(forVis, forAction, "following"),
|
||||||
|
},
|
||||||
|
mentioned: {
|
||||||
|
field: useTextInput("mentioned_accounts", { defaultValue: mentionedDefaultValue }),
|
||||||
|
label: "Accounts mentioned in the post",
|
||||||
|
options: useSomethingElseOptions(forVis, forAction, "mentioned_accounts"),
|
||||||
|
},
|
||||||
|
everyoneElse: {
|
||||||
|
field: useTextInput("everyone_else", { defaultValue: everyoneElseDefaultValue }),
|
||||||
|
label: "Everyone else",
|
||||||
|
options: useSomethingElseOptions(forVis, forAction, "everyone_else"),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,35 @@
|
||||||
|
/*
|
||||||
|
GoToSocial
|
||||||
|
Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { TextFormInputHook } from "../../../../lib/form/types";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
export interface PolicyFormSub {
|
||||||
|
field: TextFormInputHook;
|
||||||
|
label: string;
|
||||||
|
options: React.JSX.Element;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form / select types */
|
||||||
|
|
||||||
|
export type Visibility = "public" | "unlisted" | "private";
|
||||||
|
export type Action = "favourite" | "reply" | "reblog";
|
||||||
|
export type BasicValue = "anyone" | "anyone_with_approval" | "just_me" | "something_else";
|
||||||
|
export type SomethingElseValue = "always" | "with_approval" | "no";
|
||||||
|
export type Audience = "followers" | "following" | "mentioned_accounts" | "everyone_else";
|
|
@ -23,11 +23,13 @@ import { Redirect, Route, Router, Switch } from "wouter";
|
||||||
import { ErrorBoundary } from "../../lib/navigation/error";
|
import { ErrorBoundary } from "../../lib/navigation/error";
|
||||||
import UserProfile from "./profile";
|
import UserProfile from "./profile";
|
||||||
import UserMigration from "./migration";
|
import UserMigration from "./migration";
|
||||||
import UserSettings from "./settings";
|
import PostSettings from "./posts";
|
||||||
|
import EmailPassword from "./emailpassword";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* - /settings/user/profile
|
* - /settings/user/profile
|
||||||
* - /settings/user/settings
|
* - /settings/user/posts
|
||||||
|
* - /settings/user/emailpassword
|
||||||
* - /settings/user/migration
|
* - /settings/user/migration
|
||||||
*/
|
*/
|
||||||
export default function UserRouter() {
|
export default function UserRouter() {
|
||||||
|
@ -41,7 +43,8 @@ export default function UserRouter() {
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route path="/profile" component={UserProfile} />
|
<Route path="/profile" component={UserProfile} />
|
||||||
<Route path="/settings" component={UserSettings} />
|
<Route path="/posts" component={PostSettings} />
|
||||||
|
<Route path="/emailpassword" component={EmailPassword} />
|
||||||
<Route path="/migration" component={UserMigration} />
|
<Route path="/migration" component={UserMigration} />
|
||||||
<Route><Redirect to="/profile" /></Route>
|
<Route><Redirect to="/profile" /></Route>
|
||||||
</Switch>
|
</Switch>
|
||||||
|
|
Loading…
Reference in a new issue