diff --git a/config/mbin_routes/tag.yaml b/config/mbin_routes/tag.yaml index 2165a47aae..47a4078e7c 100644 --- a/config/mbin_routes/tag.yaml +++ b/config/mbin_routes/tag.yaml @@ -39,4 +39,24 @@ tag_ban: tag_unban: path: /tag/{name}/unban methods: [POST] - controller: App\Controller\Tag\TagBanController::unban \ No newline at end of file + controller: App\Controller\Tag\TagBanController::unban + +tag_block: + controller: App\Controller\Tag\TagBlockController::block + path: /tag/{name}/block + methods: [ POST ] + +tag_unblock: + controller: App\Controller\Tag\TagBlockController::unblock + path: /tag/{name}/unblock + methods: [ POST ] + +tag_subscribe: + controller: App\Controller\Tag\TagSubscriptionController::subscribe + path: /tag/{name}/subscribe + methods: [ POST ] + +tag_unsubscribe: + controller: App\Controller\Tag\TagSubscriptionController::unsubscribe + path: /tag/{name}/unsubscribe + methods: [ POST ] diff --git a/config/mbin_routes/tag_api.yaml b/config/mbin_routes/tag_api.yaml index bb63d231c7..a36fb79e00 100644 --- a/config/mbin_routes/tag_api.yaml +++ b/config/mbin_routes/tag_api.yaml @@ -21,3 +21,39 @@ api_tag_post_comments: path: /api/tag/{name}/postComments methods: [ GET ] format: json + +api_tag_block: + controller: App\Controller\Api\Tag\TagBlockApiController::block + path: /api/tag/{name}/block + methods: [ PUT ] + format: json + +api_tag_unblock: + controller: App\Controller\Api\Tag\TagBlockApiController::unblock + path: /api/tag/{name}/unblock + methods: [ PUT ] + format: json + +api_tag_blocked: + controller: App\Controller\Api\Tag\TagBlockApiController::list + path: /api/tags/blocked + methods: [ GET ] + format: json + +api_tag_subscribe: + controller: App\Controller\Api\Tag\TagSubscriptionApiController::subscribe + path: /api/tag/{name}/subscribe + methods: [ PUT ] + format: json + +api_tag_unsubscribe: + controller: App\Controller\Api\Tag\TagSubscriptionApiController::unsubscribe + path: /api/tag/{name}/unsubscribe + methods: [ PUT ] + format: json + +api_tag_subscribed: + controller: App\Controller\Api\Tag\TagSubscriptionApiController::list + path: /api/tags/subscribed + methods: [ GET ] + format: json diff --git a/config/mbin_routes/user.yaml b/config/mbin_routes/user.yaml index 50374bd983..5cdb91e173 100644 --- a/config/mbin_routes/user.yaml +++ b/config/mbin_routes/user.yaml @@ -160,6 +160,11 @@ user_settings_domain_blocks: path: /settings/blocked/domains methods: [GET] +user_settings_tag_blocks: + controller: App\Controller\User\Profile\UserBlockController::hashtags + path: /settings/blocked/tags + methods: [GET] + user_settings_user_blocks: controller: App\Controller\User\Profile\UserBlockController::users path: /settings/blocked/people @@ -180,6 +185,11 @@ user_settings_domain_subscriptions: path: /settings/subscriptions/domains methods: [GET] +user_settings_hashtag_subscriptions: + controller: App\Controller\User\Profile\UserSubController::hashtags + path: /settings/subscriptions/hashtags + methods: [GET] + user_settings_user_subscriptions: controller: App\Controller\User\Profile\UserSubController::users path: /settings/subscriptions/people diff --git a/config/packages/league_oauth2_server.yaml b/config/packages/league_oauth2_server.yaml index f7388c121c..356c440689 100644 --- a/config/packages/league_oauth2_server.yaml +++ b/config/packages/league_oauth2_server.yaml @@ -28,6 +28,9 @@ league_oauth2_server: "domain", "domain:subscribe", "domain:block", + "hashtag", + "hashtag:block", + "hashtag:subscribe", "entry", "entry:create", "entry:edit", diff --git a/config/packages/nelmio_api_doc.yaml b/config/packages/nelmio_api_doc.yaml index f728838bc8..f9472f51e6 100644 --- a/config/packages/nelmio_api_doc.yaml +++ b/config/packages/nelmio_api_doc.yaml @@ -109,11 +109,14 @@ nelmio_api_doc: delete: Delete any of your threads, posts, or comments. report: Report threads, posts, or comments. vote: Upvote, downvote, or boost threads, posts, or comments. - subscribe: Subscribe or follow any magazine, domain, or user, and view the magazines, domains, and users you subscribe to. + subscribe: Subscribe or follow any magazine, domain, or user, and view the magazines, domains, and users you subscribed to. block: Block or unblock any magazine, domain, or user, and view the magazines, domains, and users you have blocked. - domain: Subscribe to or block domains, and view the domains you subscribe to or block. - domain:subscribe: Subscribe or unsubscribe to domains and view the domains you subscribe to. + domain: Subscribe to or block domains, and view the domains you subscribed to or block. + domain:subscribe: Subscribe or unsubscribe to domains and view the domains you subscribed to. domain:block: Block or unblock domains and view the domains you have blocked. + hashtag: Block hashtags, and view the hashtags you subscribed to. + hashtag:block: Block or unblock hashtags and view the hashtags you have blocked. + hashtag:subscribe: Subscribe or unsubscribe to hashtags and view the hashtags you subscribed to. entry: Create, edit, or delete your threads, and vote, boost, or report any thread. entry:create: Create new threads. entry:edit: Edit your existing threads. @@ -226,11 +229,14 @@ nelmio_api_doc: delete: Delete any of your threads, posts, or comments. subscribe: Report threads, posts, or comments. block: Upvote, downvote, or boost threads, posts, or comments. - vote: Subscribe or follow any magazine, domain, or user, and view the magazines, domains, and users you subscribe to. + vote: Subscribe or follow any magazine, domain, or user, and view the magazines, domains, and users you subscribed to. report: Block or unblock any magazine, domain, or user, and view the magazines, domains, and users you have blocked. - domain: Subscribe to or block domains, and view the domains you subscribe to or block. - domain:subscribe: Subscribe or unsubscribe to domains and view the domains you subscribe to. + domain: Subscribe to or block domains, and view the domains you subscribed to or block. + domain:subscribe: Subscribe or unsubscribe to domains and view the domains you subscribed to. domain:block: Block or unblock domains and view the domains you have blocked. + hashtag: Block hashtags, and view the hashtags you block. + hashtag:block: Block or unblock hashtags and view the hashtags you have blocked. + hashtag:subscribe: Subscribe or unsubscribe to hashtags and view the hashtags you subscribed to. entry: Create, edit, or delete your threads, and vote, boost, or report any thread. entry:create: Create new threads. entry:edit: Edit your existing threads. diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 37d5f8858e..eadd451b73 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -186,6 +186,7 @@ security: [ 'ROLE_OAUTH2_DOMAIN:SUBSCRIBE', 'ROLE_OAUTH2_MAGAZINE:SUBSCRIBE', + 'ROLE_OAUTH2_HASHTAG:SUBSCRIBE', 'ROLE_OAUTH2_USER:FOLLOW', ] 'ROLE_OAUTH2_BOOKMARK': @@ -202,11 +203,14 @@ security: ROLE_OAUTH2_BLOCK: [ 'ROLE_OAUTH2_DOMAIN:BLOCK', + 'ROLE_OAUTH2_HASHTAG:BLOCK', 'ROLE_OAUTH2_MAGAZINE:BLOCK', 'ROLE_OAUTH2_USER:BLOCK', ] ROLE_OAUTH2_DOMAIN: ['ROLE_OAUTH2_DOMAIN:SUBSCRIBE', 'ROLE_OAUTH2_DOMAIN:BLOCK'] + ROLE_OAUTH2_HASHTAG: + ['ROLE_OAUTH2_HASHTAG:BLOCK', 'ROLE_OAUTH2_HASHTAG:SUBSCRIBE'] ROLE_OAUTH2_ENTRY: [ 'ROLE_OAUTH2_ENTRY:CREATE', diff --git a/docs/04-app_developers/README.md b/docs/04-app_developers/README.md index 73547ae411..8b8a1b97ef 100644 --- a/docs/04-app_developers/README.md +++ b/docs/04-app_developers/README.md @@ -13,19 +13,19 @@ Or use the Swagger documentation on an existing Mbin instance: `https://mbin_sit ### Available Grants 1. `client_credentials` - - [documentation here](https://www.oauth.com/oauth2-servers/access-tokens/client-credentials/) - - Best used for bots and clients that only ever need to authenticate as a single user, from a trusted device. - - Note that bots authenticating with this grant type will be distinguished as bots and will not be allowed to vote on content. + - [documentation here](https://www.oauth.com/oauth2-servers/access-tokens/client-credentials/) + - Best used for bots and clients that only ever need to authenticate as a single user, from a trusted device. + - Note that bots authenticating with this grant type will be distinguished as bots and will not be allowed to vote on content. 2. `authorization_code` - - [documentation here](https://www.oauth.com/oauth2-servers/access-tokens/authorization-code-request/) - - public clients must use [PKCE](https://www.oauth.com/oauth2-servers/pkce/) to authenticate. - - A public client is any client that will be installed on a device that is not controlled by the client's creator - - Native apps - - Single page web apps - - Or similar + - [documentation here](https://www.oauth.com/oauth2-servers/access-tokens/authorization-code-request/) + - public clients must use [PKCE](https://www.oauth.com/oauth2-servers/pkce/) to authenticate. + - A public client is any client that will be installed on a device that is not controlled by the client's creator + - Native apps + - Single page web apps + - Or similar 3. `refresh_token` - - [documentation here](https://www.oauth.com/oauth2-servers/making-authenticated-requests/refreshing-an-access-token/) - - Refresh tokens are used with the `authorization_code` grant type to reduce the number of times the user must log in. + - [documentation here](https://www.oauth.com/oauth2-servers/making-authenticated-requests/refreshing-an-access-token/) + - Refresh tokens are used with the `authorization_code` grant type to reduce the number of times the user must log in. ### Obtaining OAuth2 credentials from a new server @@ -70,38 +70,42 @@ POST /api/client 3. Use the OAuth2 client id (`identifier`) and `secret` you just created to obtain credentials for a user (This is a standard authorization_code OAuth2 flow, which is supported by many libraries for your preferred language) - 1. Begin authorization_code OAuth2 flow, by providing the `/authorize` endpint with the following query parameters: + 1. Begin authorization_code OAuth2 flow, by providing the `/authorize` endpint with the following query parameters: - ``` - GET /authorize?response_type=code&client_id=(the client id generated at client creation)&redirect_uri=(One of the URIs added during client creation)&scope=(space-delimited list of scopes)&state=(random string for CSRF protection) - ``` + ``` + GET /authorize?response_type=code&client_id=(the client id generated at client creation)&redirect_uri=(One of the URIs added during client creation)&scope=(space-delimited list of scopes)&state=(random string for CSRF protection) + ``` - 2. The user will be directed to log in to their account and grant their consent for the scopes you have requested. - 3. When the user grants their consent, their browser will be redirected to the given redirect_uri with a `code` query parameter, as long as it matches one of the URIs provided when the client was created. - 4. After obtaining the code, obtain an authorization token with a `multipart/form-data` POST request towards the `/token` endpoint: + 2. The user will be directed to log in to their account and grant their consent for the scopes you have requested. + 3. When the user grants their consent, their browser will be redirected to the given redirect_uri with a `code` + query parameter, as long as it matches one of the URIs provided when the client was created. + 4. After obtaining the code, obtain an authorization token with a `multipart/form-data` POST request towards the + `/token` endpoint: - ``` - POST /token + ``` + POST /token + + grant_type=authorization_code + client_id=(the client id generated at client creation) + client_secret=(the client secret generated at client creation) + code=(OAuth2 code received from redirect) + redirect_uri=(One of the URIs added during client creation) + ``` - grant_type=authorization_code - client_id=(the client id generated at client creation) - client_secret=(the client secret generated at client creation) - code=(OAuth2 code received from redirect) - redirect_uri=(One of the URIs added during client creation) - ``` + 5. The `/token` endpoint will respond with the access token, refresh token and information about it: - 5. The `/token` endpoint will respond with the access token, refresh token and information about it: + ```json + { + "token_type": "Bearer", + "expires_in": 3600, // seconds + "access_token": "aLargeEncodedTokenToBeUsedInTheAuthorizationHeader", + "refresh_token": "aLargeEncodedTokenToBeUsedInTheRefreshTokenFlow" + } + ``` - ```json - { - "token_type": "Bearer", - "expires_in": 3600, // seconds - "access_token": "aLargeEncodedTokenToBeUsedInTheAuthorizationHeader", - "refresh_token": "aLargeEncodedTokenToBeUsedInTheRefreshTokenFlow" - } - ``` - - 6. Once you have obtained an access token, you can use it to make authenticated requests to the API end-points that need authentication. This is done by adding the `Authorization` header to the request with the value: `Bearer `. + 6. Once you have obtained an access token, you can use it to make authenticated requests to the API end-points that need + authentication. This is done by adding the `Authorization` header to the request with the value: + `Bearer `. ### Available Scopes @@ -109,201 +113,211 @@ POST /api/client 1. `read` - Allows retrieval of threads from the user's subscribed magazines/domains and viewing the user's favorited entries. 2. `write` - Provides all of the following nested scopes - - `entry:create` - - `entry:edit` - - `entry_comment:create` - - `entry_comment:edit` - - `post:create` - - `post:edit` - - `post_comment:create` - - `post_comment:edit` + - `entry:create` + - `entry:edit` + - `entry_comment:create` + - `entry_comment:edit` + - `post:create` + - `post:edit` + - `post_comment:create` + - `post_comment:edit` 3. `delete` - Provides all of the following nested scopes, for deleting the current user's content - - `entry:delete` - - `entry_comment:delete` - - `post:delete` - - `post_comment:delete` + - `entry:delete` + - `entry_comment:delete` + - `post:delete` + - `post_comment:delete` 4. `subscribe` - Provides the following nested scopes - - `domain:subscribe` - - Allows viewing and editing domain subscriptions - - `magazine:subscribe` - - Allows viewing and editing magazine subscriptions - - `user:follow` - - Allows viewing and editing user follows + - `domain:subscribe` + - Allows viewing and editing domain subscriptions + - `magazine:subscribe` + - Allows viewing and editing magazine subscriptions + - `hashtag:subscribe` + - Allows viewing and editing hashtag subscriptions + - `user:follow` + - Allows viewing and editing user follows 5. `block` - Provides the following nested scopes - - `domain:block` - - Allows viewing and editing domain blocks - - `magazine:block` - - Allows viewing and editing magazine blocks - - `user:block` - - Allows viewing and editing user blocks + - `domain:block` + - Allows viewing and editing domain blocks + - `hashtag:block` + - Allows viewing and editing hashtag blocks + - `magazine:block` + - Allows viewing and editing magazine blocks + - `user:block` + - Allows viewing and editing user blocks 6. `vote` - Provides the following nested scopes, for up/down voting and boosting content - - `entry:vote` - - `entry_comment:vote` - - `post:vote` - - `post_comment:vote` + - `entry:vote` + - `entry_comment:vote` + - `post:vote` + - `post_comment:vote` 7. `report` - Provides the following nested scopes - - `entry:report` - - `entry_comment:report` - - `post:report` - - `post_comment:report` + - `entry:report` + - `entry_comment:report` + - `post:report` + - `post_comment:report` 8. `domain` - Provides all domain scopes - - `domain:subscribe` - - `domain:block` -9. `entry` - Provides all entry scopes - - `entry:create` - - `entry:edit` - - `entry:delete` - - `entry:vote` - - `entry:report` -10. `entry_comment` - Provides all entry comment scopes + - `domain:subscribe` + - `domain:block` +9. `hashtag` - Provides all hashtag scopes + - `hashtag:block` + - `hashtag:subscribe` +10. `entry` - Provides all entry scopes + - `entry:create` + - `entry:edit` + - `entry:delete` + - `entry:vote` + - `entry:report` +11. `entry_comment` - Provides all entry comment scopes - `entry_comment:create` - `entry_comment:edit` - `entry_comment:delete` - `entry_comment:vote` - `entry_comment:report` -11. `magazine` - Provides all magazine user level scopes +12. `magazine` - Provides all magazine user level scopes - `magazine:subscribe` - `magazine:block` -12. `post` - Provides all post scopes +13. `post` - Provides all post scopes - `post:create` - `post:edit` - `post:delete` - `post:vote` - `post:report` -13. `post_comment` - Provides all post comment scopes +14. `post_comment` - Provides all post comment scopes - `post_comment:create` - `post_comment:edit` - `post_comment:delete` - `post_comment:vote` - `post_comment:report` -14. `user` - Provides all user access scopes +15. `user` - Provides all user access scopes - `user:profile` - - `user:profile:read` - - Allows access to current user's settings and profile via the `/api/user/me` endpoint - - `user:profile:edit` - - Allows updating the current user's settings and profile + - `user:profile:read` + - Allows access to current user's settings and profile via the `/api/user/me` endpoint + - `user:profile:edit` + - Allows updating the current user's settings and profile - `user:message` - - `user:message:read` - - Allows the client to view the current user's messages - - Also allows the client to mark unread messages as read or read messages as unread - - `user:message:create` - - Allows the client to create new messages to other users or reply to existing messages + - `user:message:read` + - Allows the client to view the current user's messages + - Also allows the client to mark unread messages as read or read messages as unread + - `user:message:create` + - Allows the client to create new messages to other users or reply to existing messages - `user:notification` - - `user:notification:read` - - Allows the client to read notifications about threads, posts, or comments being replied to, as well as moderation notifications. - - Does not allow the client to read the content of messages. Message notifications will have their content censored unless the `user:message:read` scope is granted. - - Allows the client to read the number of unread notifications, and mark them as read/unread - - `user:notification:delete` - - Allows the client to clear notifications -15. `moderate` - grants all moderation permissions. The user must be a moderator to perform these actions + - `user:notification:read` + - Allows the client to read notifications about threads, posts, or comments being replied to, as well as + moderation notifications. + - Does not allow the client to read the content of messages. Message notifications will have their content + censored unless the `user:message:read` scope is granted. + - Allows the client to read the number of unread notifications, and mark them as read/unread + - `user:notification:delete` + - Allows the client to clear notifications +16. `moderate` - grants all moderation permissions. The user must be a moderator to perform these actions - `moderate:entry` - Allows the client to retrieve a list of threads from magazines moderated by the user - - `moderate:entry:language` - - Allows changing the language of threads moderated by the user - - `moderate:entry:pin` - - Allows pinning/unpinning threads to the top of magazines moderated by the user - - `moderate:entry:lock` - - Allows locking/unlocking of threads - - `moderate:entry:set_adult` - - Allows toggling the NSFW status of threads moderated by the user - - `moderate:entry:trash` - - Allows soft deletion or restoration of threads moderated by the user + - `moderate:entry:language` + - Allows changing the language of threads moderated by the user + - `moderate:entry:pin` + - Allows pinning/unpinning threads to the top of magazines moderated by the user + - `moderate:entry:lock` + - Allows locking/unlocking of threads + - `moderate:entry:set_adult` + - Allows toggling the NSFW status of threads moderated by the user + - `moderate:entry:trash` + - Allows soft deletion or restoration of threads moderated by the user - `moderate:entry_comment` - - `moderate:entry_comment:language` - - Allows changing the language of comments in threads moderated by the user - - `moderate:entry_comment:set_adult` - - Allows toggling the NSFW status of comments in threads moderated by the user - - `moderate:entry_comment:trash` - - Allows soft deletion or restoration of comments in threads moderated by the user + - `moderate:entry_comment:language` + - Allows changing the language of comments in threads moderated by the user + - `moderate:entry_comment:set_adult` + - Allows toggling the NSFW status of comments in threads moderated by the user + - `moderate:entry_comment:trash` + - Allows soft deletion or restoration of comments in threads moderated by the user - `moderate:post` - - `moderate:post:language` - - Allows changing the language of posts moderated by the user - - `moderate:post:set_adult` - - Allows toggling the NSFW status of posts moderated by the user - - `moderate:post:trash` - - Allows soft deletion or restoration of posts moderated by the user - - `moderate:post:pin` - - Allows pinning/unpinning posts to the top of magazines moderated by the user - - `moderate:post:lock` - - Allows locking/unlocking of posts + - `moderate:post:language` + - Allows changing the language of posts moderated by the user + - `moderate:post:set_adult` + - Allows toggling the NSFW status of posts moderated by the user + - `moderate:post:trash` + - Allows soft deletion or restoration of posts moderated by the user + - `moderate:post:pin` + - Allows pinning/unpinning posts to the top of magazines moderated by the user + - `moderate:post:lock` + - Allows locking/unlocking of posts - `moderate:post_comment` - - `moderate:post_comment:language` - - Allows changing the language of comments on posts moderated by the user - - `moderate:post_comment:set_adult` - - Allows toggling the NSFW status of comments on posts moderated by the user - - `moderate:post_comment:trash` - - Allows soft deletion or restoration of comments on posts moderated by the user + - `moderate:post_comment:language` + - Allows changing the language of comments on posts moderated by the user + - `moderate:post_comment:set_adult` + - Allows toggling the NSFW status of comments on posts moderated by the user + - `moderate:post_comment:trash` + - Allows soft deletion or restoration of comments on posts moderated by the user - `moderate:magazine` - - `moderate:magazine:ban` - - `moderate:magazine:ban:read` - - Allows viewing the users banned from the magazine - - `moderate:magazine:ban:create` - - Allows the client to ban a user from the magazine - - `moderate:magazine:ban:delete` - - Allows the client to unban a user from the magazine - - `moderate:magazine:list` - - Allows the client to view a list of magazines the user moderates - - `moderate:magazine:reports` - - `moderate:magazine:reports:read` - - Allows the client to read reports about content from magazines the user moderates - - `moderate:magazine:reports:action` - - Allows the client to take action on reports, either accepting or rejecting them - - `moderate:magazine:trash:read` - - Allows viewing the removed content of a moderated magazine + - `moderate:magazine:ban` + - `moderate:magazine:ban:read` + - Allows viewing the users banned from the magazine + - `moderate:magazine:ban:create` + - Allows the client to ban a user from the magazine + - `moderate:magazine:ban:delete` + - Allows the client to unban a user from the magazine + - `moderate:magazine:list` + - Allows the client to view a list of magazines the user moderates + - `moderate:magazine:reports` + - `moderate:magazine:reports:read` + - Allows the client to read reports about content from magazines the user moderates + - `moderate:magazine:reports:action` + - Allows the client to take action on reports, either accepting or rejecting them + - `moderate:magazine:trash:read` + - Allows viewing the removed content of a moderated magazine - `moderate:magazine_admin` - - `moderate:magazine_admin:create` - - Allows the creation of new magazines - - `moderate:magazine_admin:delete` - - Allows the deletion of magazines the user has permission to delete - - `moderate:magazine_admin:update` - - Allows magazine rules, description, settings, title, etc to be updated - - `moderate:magazine_admin:theme` - - Allows updates to the magazine theme - - `moderate:magazine_admin:moderators` - - Allows the addition or removal of moderators to/from an owned magazine - - `moderate:magazine_admin:badges` - - Allows the addition or removal of badges to/from an owned magazine - - `moderate:magazine_admin:tags` - - Allows the addition or removal of tags to/from an owned magazine - - `moderate:magazine_admin:stats` - - Allows the client to view stats from an owned magazine -16. `admin` - All scopes require the instance admin role to perform + - `moderate:magazine_admin:create` + - Allows the creation of new magazines + - `moderate:magazine_admin:delete` + - Allows the deletion of magazines the user has permission to delete + - `moderate:magazine_admin:update` + - Allows magazine rules, description, settings, title, etc to be updated + - `moderate:magazine_admin:theme` + - Allows updates to the magazine theme + - `moderate:magazine_admin:moderators` + - Allows the addition or removal of moderators to/from an owned magazine + - `moderate:magazine_admin:badges` + - Allows the addition or removal of badges to/from an owned magazine + - `moderate:magazine_admin:tags` + - Allows the addition or removal of tags to/from an owned magazine + - `moderate:magazine_admin:stats` + - Allows the client to view stats from an owned magazine +17. `admin` - All scopes require the instance admin role to perform - `admin:entry:purge` - - Allows threads to be completely removed from the instance + - Allows threads to be completely removed from the instance - `admin:entry_comment:purge` - - Allows comments in threads to be completely removed from the instance + - Allows comments in threads to be completely removed from the instance - `admin:post:purge` - - Allows posts to be completely removed from the instance + - Allows posts to be completely removed from the instance - `admin:post_comment:purge` - - Allows post comments to be completely removed from the instance + - Allows post comments to be completely removed from the instance - `admin:magazine` - - `admin:magazine:move_entry` - - Allows an admin to move an entry to another magazine - - `admin:magazine:purge` - - Allows an admin to completely purge a magazine from the instance - - `admin:magazine:moderate` - - Allows an admin to accept or reject moderator and ownership requests of magazines + - `admin:magazine:move_entry` + - Allows an admin to move an entry to another magazine + - `admin:magazine:purge` + - Allows an admin to completely purge a magazine from the instance + - `admin:magazine:moderate` + - Allows an admin to accept or reject moderator and ownership requests of magazines - `admin:user` - - `admin:user:ban` - - Allows the admin to ban or unban users from the instance - - `admin:user:verify` - - Allows the admin to verify a user on the instance - - `admin:user:purge` - - Allows the admin to completely purge a user from the instance + - `admin:user:ban` + - Allows the admin to ban or unban users from the instance + - `admin:user:verify` + - Allows the admin to verify a user on the instance + - `admin:user:purge` + - Allows the admin to completely purge a user from the instance - `admin:instance` - - `admin:instance:settings` - - `admin:instance:settings:read` - - Allows the admin to read instance settings - - `admin:instance:settings:edit` - - Allows the admin to update instance settings - - `admin:instance:information:edit` - - Allows the admin to update information on the About, Contact, FAQ, Privacy Policy, and Terms of Service pages. + - `admin:instance:settings` + - `admin:instance:settings:read` + - Allows the admin to read instance settings + - `admin:instance:settings:edit` + - Allows the admin to update instance settings + - `admin:instance:information:edit` + - Allows the admin to update information on the About, Contact, FAQ, Privacy Policy, and Terms of Service + pages. - `admin:federation` - - `admin:federation:read` - - Allows the admin to read a list of defederated instances - - `admin:federation:update` - - Allows the admin to edit the list of defederated instances + - `admin:federation:read` + - Allows the admin to read a list of defederated instances + - `admin:federation:update` + - Allows the admin to edit the list of defederated instances - `admin:oauth_clients` - - `admin:oauth_clients:read` - - Allows the admin to read usage stats of oauth clients, as well as list clients on the instance - - `admin:oauth_clients:revoke` - - Allows the admin to revoke a client's permission to access the instance + - `admin:oauth_clients:read` + - Allows the admin to read usage stats of oauth clients, as well as list clients on the instance + - `admin:oauth_clients:revoke` + - Allows the admin to revoke a client's permission to access the instance diff --git a/migrations/Version20260725164132.php b/migrations/Version20260725164132.php new file mode 100644 index 0000000000..cc3821ce41 --- /dev/null +++ b/migrations/Version20260725164132.php @@ -0,0 +1,35 @@ +addSql('CREATE SEQUENCE hashtag_block_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE TABLE hashtag_block (id INT NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, user_id INT NOT NULL, hashtag_id INT NOT NULL, PRIMARY KEY (id))'); + $this->addSql('CREATE INDEX IDX_A7D852AA76ED395 ON hashtag_block (user_id)'); + $this->addSql('CREATE INDEX IDX_A7D852AFB34EF56 ON hashtag_block (hashtag_id)'); + $this->addSql('CREATE UNIQUE INDEX hashtag_block_idx ON hashtag_block (user_id, hashtag_id)'); + $this->addSql('ALTER TABLE hashtag_block ADD CONSTRAINT FK_A7D852AA76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE'); + $this->addSql('ALTER TABLE hashtag_block ADD CONSTRAINT FK_A7D852AFB34EF56 FOREIGN KEY (hashtag_id) REFERENCES hashtag (id) ON DELETE CASCADE NOT DEFERRABLE'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP SEQUENCE hashtag_block_id_seq CASCADE'); + $this->addSql('ALTER TABLE hashtag_block DROP CONSTRAINT FK_A7D852AA76ED395'); + $this->addSql('ALTER TABLE hashtag_block DROP CONSTRAINT FK_A7D852AFB34EF56'); + $this->addSql('DROP TABLE hashtag_block'); + } +} diff --git a/migrations/Version20260726182822.php b/migrations/Version20260726182822.php new file mode 100644 index 0000000000..e52ed26aa9 --- /dev/null +++ b/migrations/Version20260726182822.php @@ -0,0 +1,37 @@ +addSql('CREATE SEQUENCE hashtag_subscription_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE TABLE hashtag_subscription (id INT NOT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, user_id INT NOT NULL, hashtag_id INT NOT NULL, PRIMARY KEY (id))'); + $this->addSql('CREATE INDEX IDX_5814F278A76ED395 ON hashtag_subscription (user_id)'); + $this->addSql('CREATE INDEX IDX_5814F278FB34EF56 ON hashtag_subscription (hashtag_id)'); + $this->addSql('CREATE UNIQUE INDEX hashtag_subscription_idx ON hashtag_subscription (user_id, hashtag_id)'); + $this->addSql('ALTER TABLE hashtag_subscription ADD CONSTRAINT FK_5814F278A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE'); + $this->addSql('ALTER TABLE hashtag_subscription ADD CONSTRAINT FK_5814F278FB34EF56 FOREIGN KEY (hashtag_id) REFERENCES hashtag (id) ON DELETE CASCADE NOT DEFERRABLE'); + $this->addSql('ALTER TABLE "user" ADD show_comments_of_subscribed_hashtags BOOLEAN DEFAULT false NOT NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP SEQUENCE hashtag_subscription_id_seq CASCADE'); + $this->addSql('ALTER TABLE hashtag_subscription DROP CONSTRAINT FK_5814F278A76ED395'); + $this->addSql('ALTER TABLE hashtag_subscription DROP CONSTRAINT FK_5814F278FB34EF56'); + $this->addSql('DROP TABLE hashtag_subscription'); + $this->addSql('ALTER TABLE "user" DROP show_comments_of_subscribed_hashtags'); + } +} diff --git a/src/Controller/Api/Combined/CombinedRetrieveApi.php b/src/Controller/Api/Combined/CombinedRetrieveApi.php index 67a004d296..2e1c876461 100644 --- a/src/Controller/Api/Combined/CombinedRetrieveApi.php +++ b/src/Controller/Api/Combined/CombinedRetrieveApi.php @@ -129,6 +129,12 @@ class CombinedRetrieveApi extends BaseApi in: 'query', schema: new OA\Schema(type: 'boolean', default: false) )] + #[OA\Parameter( + name: 'includeCommentsWithSubscribedHashtag', + description: 'if true then comments containing a subscribed hashtag will be included; requires includeBoosts to be true', + in: 'query', + schema: new OA\Schema(type: 'boolean', default: false) + )] #[OA\Tag(name: 'combined')] public function collection( RateLimiterFactoryInterface $apiReadLimiter, @@ -142,9 +148,10 @@ public function collection( #[MapQueryParameter] ?string $time, #[MapQueryParameter] ?string $federation, #[MapQueryParameter] ?bool $includeBoosts, + #[MapQueryParameter] ?bool $includeCommentsWithSubscribedHashtag, ): JsonResponse { $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter); - $criteria = $this->getCriteria(null, $p, $security, $sort, $time, $federation, $includeBoosts, $perPage, $sqlHelpers, null); + $criteria = $this->getCriteria(null, $p, $security, $sort, $time, $federation, $includeBoosts, $includeCommentsWithSubscribedHashtag, $perPage, $sqlHelpers, null); $content = $contentRepository->findByCriteria($criteria); @@ -248,6 +255,12 @@ public function collection( in: 'query', schema: new OA\Schema(type: 'boolean', default: false) )] + #[OA\Parameter( + name: 'includeCommentsWithSubscribedHashtag', + description: 'if true then comments containing a subscribed hashtag will be included; requires includeBoosts to be true', + in: 'query', + schema: new OA\Schema(type: 'boolean', default: false) + )] #[OA\Tag(name: 'combined')] #[\Nelmio\ApiDocBundle\Attribute\Security(name: 'oauth2', scopes: ['read'])] #[IsGranted('ROLE_OAUTH2_READ')] @@ -264,9 +277,10 @@ public function userCollection( #[MapQueryParameter] ?string $time, #[MapQueryParameter] ?string $federation, #[MapQueryParameter] ?bool $includeBoosts, + #[MapQueryParameter] ?bool $includeCommentsWithSubscribedHashtag, ): JsonResponse { $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter); - $criteria = $this->getCriteria(null, $p, $security, $sort, $time, $federation, $includeBoosts, $perPage, $sqlHelpers, $collectionType); + $criteria = $this->getCriteria(null, $p, $security, $sort, $time, $federation, $includeBoosts, $includeCommentsWithSubscribedHashtag, $perPage, $sqlHelpers, $collectionType); $content = $contentRepository->findByCriteria($criteria); @@ -370,6 +384,12 @@ public function userCollection( in: 'query', schema: new OA\Schema(type: 'boolean', default: false) )] + #[OA\Parameter( + name: 'includeCommentsWithSubscribedHashtag', + description: 'if true then comments containing a subscribed hashtag will be included; requires includeBoosts to be true', + in: 'query', + schema: new OA\Schema(type: 'boolean', default: false) + )] #[OA\Tag(name: 'combined')] public function cursorCollection( RateLimiterFactoryInterface $apiReadLimiter, @@ -383,10 +403,11 @@ public function cursorCollection( #[MapQueryParameter] ?string $time, #[MapQueryParameter] ?string $federation, #[MapQueryParameter] ?bool $includeBoosts, + #[MapQueryParameter] ?bool $includeCommentsWithSubscribedHashtag, SqlHelpers $sqlHelpers, ): JsonResponse { $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter); - $criteria = $this->getCriteria(null, 1, $security, $sort, $time, $federation, $includeBoosts, $perPage, $sqlHelpers, null); + $criteria = $this->getCriteria(null, 1, $security, $sort, $time, $federation, $includeBoosts, $includeCommentsWithSubscribedHashtag, $perPage, $sqlHelpers, null); $currentCursor = $this->getCursor($contentRepository, $criteria->sortOption, $cursor); $currentCursor2 = $cursor2 ? $this->getCursor($contentRepository, Criteria::SORT_NEW, $cursor2) : null; @@ -492,6 +513,12 @@ public function cursorCollection( in: 'query', schema: new OA\Schema(type: 'boolean', default: false) )] + #[OA\Parameter( + name: 'includeCommentsWithSubscribedHashtag', + description: 'if true then comments containing a subscribed hashtag will be included; requires includeBoosts to be true', + in: 'query', + schema: new OA\Schema(type: 'boolean', default: false) + )] #[OA\Tag(name: 'combined')] #[\Nelmio\ApiDocBundle\Attribute\Security(name: 'oauth2', scopes: ['read'])] #[IsGranted('ROLE_OAUTH2_READ')] @@ -508,10 +535,11 @@ public function cursorUserCollection( #[MapQueryParameter] ?string $time, #[MapQueryParameter] ?string $federation, #[MapQueryParameter] ?bool $includeBoosts, + #[MapQueryParameter] ?bool $includeCommentsWithSubscribedHashtag, SqlHelpers $sqlHelpers, ): JsonResponse { $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter); - $criteria = $this->getCriteria(null, 1, $security, $sort, $time, $federation, $includeBoosts, $perPage, $sqlHelpers, $collectionType); + $criteria = $this->getCriteria(null, 1, $security, $sort, $time, $federation, $includeBoosts, $includeCommentsWithSubscribedHashtag, $perPage, $sqlHelpers, $collectionType); $currentCursor = $this->getCursor($contentRepository, $criteria->sortOption, $cursor); $currentCursor2 = $cursor2 ? $this->getCursor($contentRepository, Criteria::SORT_NEW, $cursor2) : null; @@ -617,6 +645,12 @@ public function cursorUserCollection( in: 'query', schema: new OA\Schema(type: 'boolean', default: false) )] + #[OA\Parameter( + name: 'includeCommentsWithSubscribedHashtag', + description: 'if true then comments containing a subscribed hashtag will be included; requires includeBoosts to be true', + in: 'query', + schema: new OA\Schema(type: 'boolean', default: false) + )] #[OA\Tag(name: 'combined')] public function magazineCollection( RateLimiterFactoryInterface $apiReadLimiter, @@ -632,9 +666,10 @@ public function magazineCollection( #[MapQueryParameter] ?string $time, #[MapQueryParameter] ?string $federation, #[MapQueryParameter] ?bool $includeBoosts, + #[MapQueryParameter] ?bool $includeCommentsWithSubscribedHashtag, ): JsonResponse { $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter); - $criteria = $this->getCriteria($magazine, $p, $security, $sort, $time, $federation, $includeBoosts, $perPage, $sqlHelpers, null); + $criteria = $this->getCriteria($magazine, $p, $security, $sort, $time, $federation, $includeBoosts, $includeCommentsWithSubscribedHashtag, $perPage, $sqlHelpers, null); $content = $contentRepository->findByCriteria($criteria); @@ -744,6 +779,12 @@ public function magazineCollection( in: 'query', schema: new OA\Schema(type: 'boolean', default: false) )] + #[OA\Parameter( + name: 'includeCommentsWithSubscribedHashtag', + description: 'if true then comments containing a subscribed hashtag will be included; requires includeBoosts to be true', + in: 'query', + schema: new OA\Schema(type: 'boolean', default: false) + )] #[OA\Tag(name: 'combined')] public function cursorMagazineCollection( RateLimiterFactoryInterface $apiReadLimiter, @@ -759,10 +800,11 @@ public function cursorMagazineCollection( #[MapQueryParameter] ?string $time, #[MapQueryParameter] ?string $federation, #[MapQueryParameter] ?bool $includeBoosts, + #[MapQueryParameter] ?bool $includeCommentsWithSubscribedHashtag, SqlHelpers $sqlHelpers, ): JsonResponse { $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter); - $criteria = $this->getCriteria($magazine, 1, $security, $sort, $time, $federation, $includeBoosts, $perPage, $sqlHelpers, null); + $criteria = $this->getCriteria($magazine, 1, $security, $sort, $time, $federation, $includeBoosts, $includeCommentsWithSubscribedHashtag, $perPage, $sqlHelpers, null); $currentCursor = $this->getCursor($contentRepository, $criteria->sortOption, $cursor); $currentCursor2 = $cursor2 ? $this->getCursor($contentRepository, Criteria::SORT_NEW, $cursor2) : null; @@ -771,7 +813,7 @@ public function cursorMagazineCollection( return $this->serializeContentCursored($content, $headers); } - private function getCriteria(?Magazine $magazine, ?int $p, Security $security, ?string $sort, ?string $time, ?string $federation, ?bool $includeBoosts, ?int $perPage, SqlHelpers $sqlHelpers, ?string $collectionType): ContentPageView + private function getCriteria(?Magazine $magazine, ?int $p, Security $security, ?string $sort, ?string $time, ?string $federation, ?bool $includeBoosts, ?bool $includeCommentsWithSubHashtag, ?int $perPage, SqlHelpers $sqlHelpers, ?string $collectionType): ContentPageView { $criteria = new ContentPageView($p ?? 1, $security); $criteria->sortOption = $sort ?? Criteria::SORT_HOT; @@ -784,6 +826,7 @@ private function getCriteria(?Magazine $magazine, ?int $p, Security $security, ? $user = $security->getUser(); if ($user instanceof User) { $criteria->includeBoosts = Criteria::SORT_NEW === $criteria->sortOption && ($includeBoosts ?? $user->showBoostsOfFollowing); + $criteria->includeCommentsWithSubscribedHashtag = $includeCommentsWithSubHashtag ?? $user->showCommentsOfSubscribedHashtags; $criteria->fetchCachedItems($sqlHelpers, $user); } diff --git a/src/Controller/Api/Post/PostsRetrieveApi.php b/src/Controller/Api/Post/PostsRetrieveApi.php index 277974de7b..4448ef9ad5 100644 --- a/src/Controller/Api/Post/PostsRetrieveApi.php +++ b/src/Controller/Api/Post/PostsRetrieveApi.php @@ -442,6 +442,12 @@ public function subscribed( in: 'query', schema: new OA\Schema(type: 'string', default: Criteria::AP_ALL, enum: Criteria::AP_OPTIONS) )] + #[OA\Parameter( + name: 'includeCommentsWithSubscribedHashtag', + description: 'if true then comments containing a subscribed hashtag will be included', + in: 'query', + schema: new OA\Schema(type: 'boolean', default: false) + )] #[OA\Tag(name: 'post')] #[Security(name: 'oauth2', scopes: ['read'])] #[IsGranted('ROLE_OAUTH2_READ')] @@ -456,9 +462,12 @@ public function subscribedWithBoosts( #[MapQueryParameter] ?string $sort, #[MapQueryParameter] ?string $time, #[MapQueryParameter] ?string $federation, + #[MapQueryParameter] ?bool $includeCommentsWithSubscribedHashtag, ): JsonResponse { $headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter); + $user = $this->getUserOrThrow(); + $criteria = new PostPageView($p ?? 1, $security); $criteria->sortOption = $sort ?? Criteria::SORT_HOT; $criteria->time = $criteria->resolveTime($time ?? Criteria::TIME_ALL); @@ -467,11 +476,11 @@ public function subscribedWithBoosts( $criteria->subscribed = true; $criteria->includeBoosts = Criteria::SORT_NEW === $criteria->sortOption; + $criteria->includeCommentsWithSubscribedHashtag = $includeCommentsWithSubscribedHashtag ?? $user->showCommentsOfSubscribedHashtags; $criteria->setContent(Criteria::CONTENT_MICROBLOG); $this->handleLanguageCriteria($criteria); - $user = $this->getUserOrThrow(); $criteria->fetchCachedItems($sqlHelpers, $user); $posts = $repository->findByCriteria($criteria); diff --git a/src/Controller/Api/Tag/TagBaseApi.php b/src/Controller/Api/Tag/TagBaseApi.php new file mode 100644 index 0000000000..c982331fd2 --- /dev/null +++ b/src/Controller/Api/Tag/TagBaseApi.php @@ -0,0 +1,37 @@ +factory = $factory; + } + + #[Required] + public function setRepository(TagRepository $repository): void + { + $this->repository = $repository; + } + + /** + * Serialize a domain to JSON. + */ + protected function serializeHashtag(Hashtag $tag): HashtagResponseDto + { + return $this->factory->createDto($tag); + } +} diff --git a/src/Controller/Api/Tag/TagBlockApiController.php b/src/Controller/Api/Tag/TagBlockApiController.php new file mode 100644 index 0000000000..3e39cfc6bb --- /dev/null +++ b/src/Controller/Api/Tag/TagBlockApiController.php @@ -0,0 +1,209 @@ + 'tag'])] + Hashtag $tag, + TagManager $manager, + RateLimiterFactoryInterface $apiUpdateLimiter, + ): JsonResponse { + $headers = $this->rateLimit($apiUpdateLimiter); + + $manager->block($this->getUserOrThrow(), $tag); + + return new JsonResponse( + $this->serializeHashtag($tag), + headers: $headers + ); + } + + #[OA\Response( + response: 200, + description: 'Hashtag unblocked', + content: new Model(type: HashtagResponseDto::class), + headers: [ + new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), + new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), + new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), + ] + )] + #[OA\Response( + response: 401, + description: 'Permission denied due to missing or expired token', + content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class)) + )] + #[OA\Response( + response: 404, + description: 'Hashtag not found', + content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\NotFoundErrorSchema::class)) + )] + #[OA\Response( + response: 429, + description: 'You are being rate limited', + content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)), + headers: [ + new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), + new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), + new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), + ] + )] + #[OA\Parameter( + name: 'name', + in: 'path', + description: 'The hashtag to unblock', + schema: new OA\Schema(type: 'string'), + )] + #[OA\Tag(name: 'tag')] + #[Security(name: 'oauth2', scopes: ['hashtag:block'])] + #[IsGranted('ROLE_OAUTH2_HASHTAG:BLOCK')] + public function unblock( + #[MapEntity(mapping: ['name' => 'tag'])] + Hashtag $tag, + TagManager $manager, + RateLimiterFactoryInterface $apiUpdateLimiter, + ): JsonResponse { + $headers = $this->rateLimit($apiUpdateLimiter); + + $manager->unblock($this->getUserOrThrow(), $tag); + + return new JsonResponse( + $this->serializeHashtag($tag), + headers: $headers + ); + } + + #[OA\Response( + response: 200, + description: 'Returns a paginated list of blocked hashtags', + content: new OA\JsonContent( + type: 'object', + properties: [ + new OA\Property( + property: 'items', + type: 'array', + items: new OA\Items(ref: new Model(type: HashtagResponseDto::class)) + ), + new OA\Property( + property: 'pagination', + ref: new Model(type: PaginationSchema::class) + ), + ] + ), + headers: [ + new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), + new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), + new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), + ] + )] + #[OA\Response( + response: 401, + description: 'Permission denied due to missing or expired token', + content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class)) + )] + #[OA\Response( + response: 429, + description: 'You are being rate limited', + content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)), + headers: [ + new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), + new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), + new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), + ] + )] + #[OA\Parameter( + name: 'p', + description: 'Page of hashtags to retrieve', + in: 'query', + schema: new OA\Schema(type: 'integer', default: 1, minimum: 1) + )] + #[OA\Parameter( + name: 'perPage', + description: 'Number of hashtags per page', + in: 'query', + schema: new OA\Schema(type: 'integer', default: TagRepository::PER_PAGE, minimum: self::MIN_PER_PAGE, maximum: self::MAX_PER_PAGE) + )] + #[OA\Tag(name: 'tag')] + #[Security(name: 'oauth2', scopes: ['hashtag:block'])] + #[IsGranted('ROLE_OAUTH2_HASHTAG:BLOCK')] + public function list( + RateLimiterFactoryInterface $apiReadLimiter, + ): JsonResponse { + $headers = $this->rateLimit($apiReadLimiter); + + $request = $this->request->getCurrentRequest(); + $blocks = $this->repository->findBlockedTags( + $this->getPageNb($request), + $this->getUserOrThrow(), + self::constrainPerPage($request->get('perPage', TagRepository::PER_PAGE)) + ); + + $dtos = []; + foreach ($blocks->getCurrentPageResults() as $value) { + \assert($value instanceof HashtagBlock); + $dtos[] = $this->serializeHashtag($value->hashtag); + } + + return new JsonResponse( + $this->serializePaginated($dtos, $blocks), + headers: $headers + ); + } +} diff --git a/src/Controller/Api/Tag/TagSubscriptionApiController.php b/src/Controller/Api/Tag/TagSubscriptionApiController.php new file mode 100644 index 0000000000..2498a2267f --- /dev/null +++ b/src/Controller/Api/Tag/TagSubscriptionApiController.php @@ -0,0 +1,209 @@ + 'tag'])] + Hashtag $tag, + TagManager $manager, + RateLimiterFactoryInterface $apiUpdateLimiter, + ): JsonResponse { + $headers = $this->rateLimit($apiUpdateLimiter); + + $manager->subscribe($this->getUserOrThrow(), $tag); + + return new JsonResponse( + $this->serializeHashtag($tag), + headers: $headers + ); + } + + #[OA\Response( + response: 200, + description: 'Hashtag unsubscribed', + content: new Model(type: HashtagResponseDto::class), + headers: [ + new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), + new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), + new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), + ] + )] + #[OA\Response( + response: 401, + description: 'Permission denied due to missing or expired token', + content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class)) + )] + #[OA\Response( + response: 404, + description: 'Hashtag not found', + content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\NotFoundErrorSchema::class)) + )] + #[OA\Response( + response: 429, + description: 'You are being rate limited', + content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)), + headers: [ + new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), + new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), + new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), + ] + )] + #[OA\Parameter( + name: 'name', + in: 'path', + description: 'The hashtag to unsubscribe', + schema: new OA\Schema(type: 'string'), + )] + #[OA\Tag(name: 'tag')] + #[Security(name: 'oauth2', scopes: ['hashtag:subscribe'])] + #[IsGranted('ROLE_OAUTH2_HASHTAG:SUBSCRIBE')] + public function unsubscribe( + #[MapEntity(mapping: ['name' => 'tag'])] + Hashtag $tag, + TagManager $manager, + RateLimiterFactoryInterface $apiUpdateLimiter, + ): JsonResponse { + $headers = $this->rateLimit($apiUpdateLimiter); + + $manager->unsubscribe($this->getUserOrThrow(), $tag); + + return new JsonResponse( + $this->serializeHashtag($tag), + headers: $headers + ); + } + + #[OA\Response( + response: 200, + description: 'Returns a paginated list of subscribed hashtags', + content: new OA\JsonContent( + type: 'object', + properties: [ + new OA\Property( + property: 'items', + type: 'array', + items: new OA\Items(ref: new Model(type: HashtagResponseDto::class)) + ), + new OA\Property( + property: 'pagination', + ref: new Model(type: PaginationSchema::class) + ), + ] + ), + headers: [ + new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), + new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), + new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), + ] + )] + #[OA\Response( + response: 401, + description: 'Permission denied due to missing or expired token', + content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\UnauthorizedErrorSchema::class)) + )] + #[OA\Response( + response: 429, + description: 'You are being rate limited', + content: new OA\JsonContent(ref: new Model(type: \App\Schema\Errors\TooManyRequestsErrorSchema::class)), + headers: [ + new OA\Header(header: 'X-RateLimit-Remaining', schema: new OA\Schema(type: 'integer'), description: 'Number of requests left until you will be rate limited'), + new OA\Header(header: 'X-RateLimit-Retry-After', schema: new OA\Schema(type: 'integer'), description: 'Unix timestamp to retry the request after'), + new OA\Header(header: 'X-RateLimit-Limit', schema: new OA\Schema(type: 'integer'), description: 'Number of requests available'), + ] + )] + #[OA\Parameter( + name: 'p', + description: 'Page of hashtags to retrieve', + in: 'query', + schema: new OA\Schema(type: 'integer', default: 1, minimum: 1) + )] + #[OA\Parameter( + name: 'perPage', + description: 'Number of hashtags per page', + in: 'query', + schema: new OA\Schema(type: 'integer', default: TagRepository::PER_PAGE, minimum: self::MIN_PER_PAGE, maximum: self::MAX_PER_PAGE) + )] + #[OA\Tag(name: 'tag')] + #[Security(name: 'oauth2', scopes: ['hashtag:subscribe'])] + #[IsGranted('ROLE_OAUTH2_HASHTAG:SUBSCRIBE')] + public function list( + RateLimiterFactoryInterface $apiReadLimiter, + ): JsonResponse { + $headers = $this->rateLimit($apiReadLimiter); + + $request = $this->request->getCurrentRequest(); + $subs = $this->repository->findSubscribedTags( + $this->getPageNb($request), + $this->getUserOrThrow(), + self::constrainPerPage($request->get('perPage', TagRepository::PER_PAGE)) + ); + + $dtos = []; + foreach ($subs->getCurrentPageResults() as $value) { + \assert($value instanceof HashtagSubscription); + $dtos[] = $this->serializeHashtag($value->hashtag); + } + + return new JsonResponse( + $this->serializePaginated($dtos, $subs), + headers: $headers + ); + } +} diff --git a/src/Controller/Entry/EntryFrontController.php b/src/Controller/Entry/EntryFrontController.php index 90f756e3d6..290f9e45f5 100644 --- a/src/Controller/Entry/EntryFrontController.php +++ b/src/Controller/Entry/EntryFrontController.php @@ -217,6 +217,7 @@ private function setUserPreferences(?User $user, Criteria &$criteria): void } $criteria->includeBoosts = Criteria::SORT_NEW === $criteria->sortOption && $user->showBoostsOfFollowing; + $criteria->includeCommentsWithSubscribedHashtag = $user->showCommentsOfSubscribedHashtags; if (0 < \count($user->preferredLanguages)) { $criteria->languages = $user->preferredLanguages; diff --git a/src/Controller/Tag/TagBlockController.php b/src/Controller/Tag/TagBlockController.php new file mode 100644 index 0000000000..d09bb54876 --- /dev/null +++ b/src/Controller/Tag/TagBlockController.php @@ -0,0 +1,63 @@ + 'tag'])] Hashtag $tag, Request $request): Response + { + $this->manager->block($this->getUserOrThrow(), $tag); + + if ($request->isXmlHttpRequest()) { + return $this->getJsonResponse($tag); + } + + return $this->redirectToRefererOrHome($request); + } + + #[IsGranted('ROLE_USER')] + public function unblock(#[MapEntity(mapping: ['name' => 'tag'])] Hashtag $tag, Request $request): Response + { + $this->manager->unblock($this->getUserOrThrow(), $tag); + + if ($request->isXmlHttpRequest()) { + return $this->getJsonResponse($tag); + } + + return $this->redirectToRefererOrHome($request); + } + + private function getJsonResponse(Hashtag $tag): JsonResponse + { + return new JsonResponse( + [ + 'html' => $this->renderView( + 'components/_ajax.html.twig', + [ + 'component' => 'hashtag_sub', + 'attributes' => [ + 'hashtag' => $tag, + ], + ] + ), + ] + ); + } +} diff --git a/src/Controller/Tag/TagCommentFrontController.php b/src/Controller/Tag/TagCommentFrontController.php index 862a1f9103..56f464b274 100644 --- a/src/Controller/Tag/TagCommentFrontController.php +++ b/src/Controller/Tag/TagCommentFrontController.php @@ -25,12 +25,17 @@ public function __construct( public function __invoke(string $name, ?string $sortBy, ?string $time, Request $request): Response { + $tag = $this->tagManager->transliterate(strtolower($name)); + $criteria = new EntryCommentPageView($this->getPageNb($request), $this->security); $criteria->showSortOption($criteria->resolveSort($sortBy)) ->setTime($criteria->resolveTime($time)) - ->setTag($this->tagManager->transliterate(strtolower($name))); + ->setTag($tag); + + $hashtag = $this->tagRepository->findOneBy(['tag' => $tag]); $params = [ + 'hashtag' => $hashtag, 'comments' => $this->repository->findByCriteria($criteria), 'tag' => $name, 'counts' => $this->tagRepository->getCounts($name), diff --git a/src/Controller/Tag/TagEntryFrontController.php b/src/Controller/Tag/TagEntryFrontController.php index c9ab5dfb6f..c7a9e23b6f 100644 --- a/src/Controller/Tag/TagEntryFrontController.php +++ b/src/Controller/Tag/TagEntryFrontController.php @@ -27,17 +27,22 @@ public function __construct( public function __invoke(?string $name, ?string $sortBy, ?string $time, ?string $type, Request $request): Response { + $tag = $this->tagManager->transliterate(strtolower($name)); + $criteria = new EntryPageView($this->getPageNb($request), $this->security); $criteria->showSortOption($criteria->resolveSort($sortBy)) ->setTime($criteria->resolveTime($time)) ->setType($criteria->resolveType($type)) - ->setTag($this->tagManager->transliterate(strtolower($name))); + ->setTag($tag); $method = $criteria->resolveSort($sortBy); $listing = $this->$method($criteria); + $hashtag = $this->tagRepository->findOneBy(['tag' => $tag]); + return $this->render( 'tag/front.html.twig', [ + 'hashtag' => $hashtag, 'tag' => $name, 'entries' => $listing, 'counts' => $this->tagRepository->getCounts($name), diff --git a/src/Controller/Tag/TagOverviewController.php b/src/Controller/Tag/TagOverviewController.php index 33d8adcbb5..84e6860dde 100644 --- a/src/Controller/Tag/TagOverviewController.php +++ b/src/Controller/Tag/TagOverviewController.php @@ -23,12 +23,17 @@ public function __construct( public function __invoke(string $name, Request $request): Response { + $tag = $this->tagManager->transliterate(strtolower($name)); + + $hashtag = $this->tagRepository->findOneBy(['tag' => $tag]); + $activity = $this->tagRepository->findOverall( $this->getPageNb($request), - $this->tagManager->transliterate(strtolower($name)) + $tag ); $params = [ + 'hashtag' => $hashtag, 'tag' => $name, 'results' => $this->overviewManager->buildList($activity), 'pagination' => $activity, diff --git a/src/Controller/Tag/TagPeopleFrontController.php b/src/Controller/Tag/TagPeopleFrontController.php index f52334a6d8..6d0bb6da21 100644 --- a/src/Controller/Tag/TagPeopleFrontController.php +++ b/src/Controller/Tag/TagPeopleFrontController.php @@ -9,6 +9,7 @@ use App\Repository\PostRepository; use App\Repository\TagRepository; use App\Service\PeopleManager; +use App\Service\TagExtractor; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -16,6 +17,7 @@ class TagPeopleFrontController extends AbstractController { public function __construct( private readonly PeopleManager $manager, + private readonly TagExtractor $tagManager, private readonly TagRepository $tagRepository, private readonly MagazineRepository $magazineRepository, ) { @@ -28,16 +30,25 @@ public function __invoke( PostRepository $repository, Request $request, ): Response { + $tag = $this->tagManager->transliterate(strtolower($name)); + $hashtag = $this->tagRepository->findOneBy(['tag' => $tag]); + + $magazines = array_filter( + $this->magazineRepository->findByActivity(), + fn ($val) => 'random' !== $val->name + ); + $localPeople = $this->manager->general(); + $generalPeople = $this->manager->general(true); + $counts = $this->tagRepository->getCounts($tag); + return $this->render( 'tag/people.html.twig', [ + 'hashtag' => $hashtag, 'tag' => $name, - 'magazines' => array_filter( - $this->magazineRepository->findByActivity(), - fn ($val) => 'random' !== $val->name - ), - 'local' => $this->manager->general(), - 'federated' => $this->manager->general(true), - 'counts' => $this->tagRepository->getCounts($name), + 'magazines' => $magazines, + 'local' => $localPeople, + 'federated' => $generalPeople, + 'counts' => $counts, ] ); } diff --git a/src/Controller/Tag/TagPostFrontController.php b/src/Controller/Tag/TagPostFrontController.php index ccf65e69e2..8611245ea8 100644 --- a/src/Controller/Tag/TagPostFrontController.php +++ b/src/Controller/Tag/TagPostFrontController.php @@ -29,16 +29,21 @@ public function __invoke( PostRepository $repository, Request $request, ): Response { + $tag = $this->tagManager->transliterate(strtolower($name)); + $criteria = new PostPageView($this->getPageNb($request), $this->security); $criteria->showSortOption($criteria->resolveSort($sortBy)) ->setTime($criteria->resolveTime($time)) - ->setTag($this->tagManager->transliterate(strtolower($name))); + ->setTag($tag); $posts = $repository->findByCriteria($criteria); + $hashtag = $this->tagRepository->findOneBy(['tag' => $tag]); + return $this->render( 'tag/posts.html.twig', [ + 'hashtag' => $hashtag, 'tag' => $name, 'posts' => $posts, 'counts' => $this->tagRepository->getCounts($name), diff --git a/src/Controller/Tag/TagSubscriptionController.php b/src/Controller/Tag/TagSubscriptionController.php new file mode 100644 index 0000000000..3484cc588f --- /dev/null +++ b/src/Controller/Tag/TagSubscriptionController.php @@ -0,0 +1,63 @@ + 'tag'])] Hashtag $tag, Request $request): Response + { + $this->manager->subscribe($this->getUserOrThrow(), $tag); + + if ($request->isXmlHttpRequest()) { + return $this->getJsonResponse($tag); + } + + return $this->redirectToRefererOrHome($request); + } + + #[IsGranted('ROLE_USER')] + public function unsubscribe(#[MapEntity(mapping: ['name' => 'tag'])] Hashtag $tag, Request $request): Response + { + $this->manager->unsubscribe($this->getUserOrThrow(), $tag); + + if ($request->isXmlHttpRequest()) { + return $this->getJsonResponse($tag); + } + + return $this->redirectToRefererOrHome($request); + } + + private function getJsonResponse(Hashtag $tag): JsonResponse + { + return new JsonResponse( + [ + 'html' => $this->renderView( + 'components/_ajax.html.twig', + [ + 'component' => 'hashtag_sub', + 'attributes' => [ + 'hashtag' => $tag, + ], + ] + ), + ] + ); + } +} diff --git a/src/Controller/User/Profile/UserBlockController.php b/src/Controller/User/Profile/UserBlockController.php index 090e7a9d88..9f48254e20 100644 --- a/src/Controller/User/Profile/UserBlockController.php +++ b/src/Controller/User/Profile/UserBlockController.php @@ -9,6 +9,7 @@ use App\Repository\DomainRepository; use App\Repository\InstanceBlockRepository; use App\Repository\MagazineRepository; +use App\Repository\TagRepository; use App\Repository\UserRepository; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -58,6 +59,20 @@ public function domains(DomainRepository $repository, Request $request): Respons ); } + #[IsGranted('ROLE_USER')] + public function hashtags(TagRepository $repository, Request $request): Response + { + $user = $this->getUserOrThrow(); + + return $this->render( + 'user/settings/block_hashtags.html.twig', + [ + 'user' => $user, + 'hashtags' => $repository->findBlockedTags($this->getPageNb($request), $user), + ] + ); + } + #[IsGranted('ROLE_USER')] public function instances(InstanceBlockRepository $repository, Request $request): Response { diff --git a/src/Controller/User/Profile/UserSubController.php b/src/Controller/User/Profile/UserSubController.php index b5b80d99db..a458e44171 100644 --- a/src/Controller/User/Profile/UserSubController.php +++ b/src/Controller/User/Profile/UserSubController.php @@ -7,6 +7,7 @@ use App\Controller\AbstractController; use App\Repository\DomainRepository; use App\Repository\MagazineRepository; +use App\Repository\TagRepository; use App\Repository\UserRepository; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -58,4 +59,18 @@ public function domains(DomainRepository $repository, Request $request): Respons ] ); } + + #[IsGranted('ROLE_USER')] + public function hashtags(TagRepository $repository, Request $request): Response + { + $user = $this->getUserOrThrow(); + + return $this->render( + 'user/settings/sub_hashtags.html.twig', + [ + 'user' => $user, + 'hashtags' => $repository->findSubscribedTags($this->getPageNb($request), $user), + ] + ); + } } diff --git a/src/DTO/HashtagResponseDto.php b/src/DTO/HashtagResponseDto.php new file mode 100644 index 0000000000..3307a28487 --- /dev/null +++ b/src/DTO/HashtagResponseDto.php @@ -0,0 +1,49 @@ +tag = $tag; + $toReturn->entryCount = $entryCount; + $toReturn->entryCommentCount = $entryCommentCount; + $toReturn->postCount = $postCount; + $toReturn->postCommentCount = $postCommentCount; + $toReturn->isBlockedByUser = null; + + return $toReturn; + } + + public function jsonSerialize(): mixed + { + return [ + 'tag' => $this->tag, + 'entryCount' => $this->entryCount, + 'entryCommentCount' => $this->entryCommentCount, + 'postCount' => $this->postCount, + 'postCommentCount' => $this->postCommentCount, + 'isBlockedByUser' => $this->isBlockedByUser, + 'isSubscribedByUser' => $this->isSubscribedByUser, + ]; + } +} diff --git a/src/DTO/OAuth2ClientDto.php b/src/DTO/OAuth2ClientDto.php index d4f80d8a9c..7c172ee726 100644 --- a/src/DTO/OAuth2ClientDto.php +++ b/src/DTO/OAuth2ClientDto.php @@ -31,6 +31,9 @@ class OAuth2ClientDto extends ImageUploadDto implements \JsonSerializable 'domain', 'domain:subscribe', 'domain:block', + 'hashtag', + 'hashtag:block', + 'hashtag:subscribe', 'entry', 'entry:create', 'entry:edit', diff --git a/src/DTO/UserSettingsDto.php b/src/DTO/UserSettingsDto.php index 49a2bcd5e8..62bb7b0001 100644 --- a/src/DTO/UserSettingsDto.php +++ b/src/DTO/UserSettingsDto.php @@ -33,6 +33,7 @@ public function __construct( #[OA\Property(type: 'string', enum: EntryCommentPageView::SORT_OPTIONS)] public ?string $commentDefaultSort = null, public ?bool $showFollowingBoosts = null, + public ?bool $showCommentsOfSubscribedHashtags = null, #[OA\Property(type: 'array', items: new OA\Items(type: 'string'))] public ?array $featuredMagazines = null, #[OA\Property(type: 'array', items: new OA\Items(type: 'string'))] @@ -67,6 +68,8 @@ public function jsonSerialize(): mixed 'frontDefaultSort' => $this->frontDefaultSort, 'frontDefaultContent' => $this->frontDefaultContent, 'commentDefaultSort' => $this->commentDefaultSort, + 'showFollowingBoosts' => $this->showFollowingBoosts, + 'showCommentsOfSubscribedHashtags' => $this->showCommentsOfSubscribedHashtags, 'featuredMagazines' => $this->featuredMagazines, 'preferredLanguages' => $this->preferredLanguages, 'customCss' => $this->customCss, @@ -94,6 +97,8 @@ public function mergeIntoDto(UserSettingsDto $dto): UserSettingsDto $dto->homepage = $this->homepage ?? $dto->homepage; $dto->frontDefaultSort = $this->frontDefaultSort ?? $dto->frontDefaultSort; $dto->commentDefaultSort = $this->commentDefaultSort ?? $dto->commentDefaultSort; + $dto->showFollowingBoosts = $this->showFollowingBoosts ?? $dto->showFollowingBoosts; + $dto->showCommentsOfSubscribedHashtags = $this->showCommentsOfSubscribedHashtags ?? $dto->showCommentsOfSubscribedHashtags; $dto->featuredMagazines = $this->featuredMagazines ?? $dto->featuredMagazines; $dto->preferredLanguages = $this->preferredLanguages ?? $dto->preferredLanguages; $dto->customCss = $this->customCss ?? $dto->customCss; diff --git a/src/Entity/Hashtag.php b/src/Entity/Hashtag.php index 64b4756c4d..f7a802e367 100644 --- a/src/Entity/Hashtag.php +++ b/src/Entity/Hashtag.php @@ -5,7 +5,9 @@ namespace App\Entity; use App\Repository\TagRepository; +use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; +use Doctrine\Common\Collections\Criteria; use Doctrine\ORM\Mapping\Column; use Doctrine\ORM\Mapping\Entity; use Doctrine\ORM\Mapping\GeneratedValue; @@ -25,6 +27,51 @@ class Hashtag #[Column(type: 'boolean', options: ['default' => false])] public bool $banned = false; + #[OneToMany(mappedBy: 'hashtag', targetEntity: HashtagSubscription::class, fetch: 'EXTRA_LAZY', cascade: [ + 'persist', + 'remove', + ], orphanRemoval: true)] + public Collection $subscriptions; + #[OneToMany(mappedBy: 'hashtag', targetEntity: HashtagLink::class, fetch: 'EXTRA_LAZY', orphanRemoval: true)] public Collection $linkedPosts; + + public function __construct() + { + $this->subscriptions = new ArrayCollection(); + $this->linkedPosts = new ArrayCollection(); + } + + public function subscribe(User $user): void + { + if (!$this->isSubscribed($user)) { + $subscription = new HashtagSubscription($user, $this); + $this->subscriptions->add($subscription); + $user->subscribedHashtags->add($subscription); + } + } + + public function unsubscribe(User $user): void + { + $criteria = Criteria::create() + ->where(Criteria::expr()->eq('user', $user)); + + /** @var HashtagSubscription $subscription */ + $subscription = $this->subscriptions->matching($criteria)->first(); + + if ($this->subscriptions->removeElement($subscription)) { + if ($subscription->hashtag === $this) { + $subscription->hashtag = null; + } + $user->subscribedHashtags->removeElement($subscription); + } + } + + public function isSubscribed(User $user): bool + { + $criteria = Criteria::create() + ->where(Criteria::expr()->eq('user', $user)); + + return !$this->subscriptions->matching($criteria)->isEmpty(); + } } diff --git a/src/Entity/HashtagBlock.php b/src/Entity/HashtagBlock.php new file mode 100644 index 0000000000..7594b585e2 --- /dev/null +++ b/src/Entity/HashtagBlock.php @@ -0,0 +1,50 @@ +createdAtTraitConstruct(); + + $this->user = $user; + $this->hashtag = $hashtag; + } + + public function getId(): ?int + { + return $this->id; + } +} diff --git a/src/Entity/HashtagSubscription.php b/src/Entity/HashtagSubscription.php new file mode 100644 index 0000000000..e03b6591dd --- /dev/null +++ b/src/Entity/HashtagSubscription.php @@ -0,0 +1,50 @@ +createdAtTraitConstruct(); + + $this->user = $user; + $this->hashtag = $hashtag; + } + + public function getId(): ?int + { + return $this->id; + } +} diff --git a/src/Entity/OAuth2UserConsent.php b/src/Entity/OAuth2UserConsent.php index f1ebe4ad04..5803dab49a 100644 --- a/src/Entity/OAuth2UserConsent.php +++ b/src/Entity/OAuth2UserConsent.php @@ -38,6 +38,10 @@ class OAuth2UserConsent 'domain' => 'oauth2.grant.domain.all', 'domain:subscribe' => 'oauth2.grant.domain.subscribe', 'domain:block' => 'oauth2.grant.domain.block', + // Grants allowing applications to (un)subscribe or (un)block hashtags on behalf of the user + 'hashtag' => 'oauth2.grant.hashtag.all', + 'hashtag:subscribe' => 'oauth2.grant.hashtag.subscribe', + 'hashtag:block' => 'oauth2.grant.hashtag.block', // Grants allowing the application to create, edit, delete, (up/down)vote, boost, or report entries on behalf of the user 'entry' => 'oauth2.grant.entry.all', 'entry:create' => 'oauth2.grant.entry.create', diff --git a/src/Entity/User.php b/src/Entity/User.php index 73f187959e..d1f2e14184 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -112,6 +112,8 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, Visibil public string $homepage = self::HOMEPAGE_ALL; #[Column(type: 'boolean', nullable: false, options: ['default' => false])] public bool $showBoostsOfFollowing = false; + #[Column(type: 'boolean', nullable: false, options: ['default' => false])] + public bool $showCommentsOfSubscribedHashtags = false; #[Column(type: 'enumSortOptions', nullable: false, options: ['default' => ESortOptions::Hot->value])] public string $frontDefaultSort = ESortOptions::Hot->value; #[Column(type: 'enumFrontContentOptions', nullable: true)] @@ -214,6 +216,8 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, Visibil public Collection $subscriptions; #[OneToMany(mappedBy: 'user', targetEntity: DomainSubscription::class, cascade: ['persist', 'remove'], orphanRemoval: true)] public Collection $subscribedDomains; + #[OneToMany(mappedBy: 'user', targetEntity: HashtagSubscription::class, cascade: ['persist', 'remove'], orphanRemoval: true)] + public Collection $subscribedHashtags; #[OneToMany(mappedBy: 'follower', targetEntity: UserFollow::class, cascade: ['persist', 'remove'], orphanRemoval: true)] #[OrderBy(['createdAt' => 'DESC'])] public Collection $follows; @@ -232,6 +236,9 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, Visibil #[OneToMany(mappedBy: 'user', targetEntity: DomainBlock::class, cascade: ['persist', 'remove'], orphanRemoval: true)] #[OrderBy(['createdAt' => 'DESC'])] public Collection $blockedDomains; + #[OneToMany(mappedBy: 'user', targetEntity: HashtagBlock::class, cascade: ['persist', 'remove'], orphanRemoval: true)] + #[OrderBy(['createdAt' => 'DESC'])] + public Collection $blockedHashtags; #[OneToMany(mappedBy: 'reporting', targetEntity: Report::class, cascade: ['persist'], fetch: 'EXTRA_LAZY')] #[OrderBy(['createdAt' => 'DESC'])] public Collection $reports; @@ -307,12 +314,14 @@ public function __construct( $this->postCommentVotes = new ArrayCollection(); $this->subscriptions = new ArrayCollection(); $this->subscribedDomains = new ArrayCollection(); + $this->subscribedHashtags = new ArrayCollection(); $this->follows = new ArrayCollection(); $this->followers = new ArrayCollection(); $this->blocks = new ArrayCollection(); $this->blockers = new ArrayCollection(); $this->blockedMagazines = new ArrayCollection(); $this->blockedDomains = new ArrayCollection(); + $this->blockedHashtags = new ArrayCollection(); $this->reports = new ArrayCollection(); $this->favourites = new ArrayCollection(); $this->violations = new ArrayCollection(); @@ -671,7 +680,42 @@ public function unblockDomain(Domain $domain): void if ($this->blockedDomains->removeElement($domainBlock)) { if ($domainBlock->user === $this) { $domainBlock->domain = null; - $this->blockedMagazines->removeElement($domainBlock); + $this->blockedDomains->removeElement($domainBlock); + } + } + } + + public function blockHashtag(Hashtag $hashtag): self + { + if (!$this->isBlockedHashtag($hashtag)) { + $this->blockedHashtags->add(new HashtagBlock($this, $hashtag)); + } + + return $this; + } + + public function isBlockedHashtag(Hashtag $hashtag): bool + { + $criteria = Criteria::create() + ->where(Criteria::expr()->eq('hashtag', $hashtag)); + + return $this->blockedHashtags->matching($criteria)->count() > 0; + } + + public function unblockHashtag(Hashtag $hashtag): void + { + $criteria = Criteria::create() + ->where(Criteria::expr()->eq('hashtag', $hashtag)); + + /** + * @var HashtagBlock $hashtagBlock + */ + $hashtagBlock = $this->blockedHashtags->matching($criteria)->first(); + + if ($this->blockedHashtags->removeElement($hashtagBlock)) { + if ($hashtagBlock->user === $this) { + $hashtagBlock->hashtag = null; + $this->blockedHashtags->removeElement($hashtagBlock); } } } diff --git a/src/Event/HashtagBlockChangedEvent.php b/src/Event/HashtagBlockChangedEvent.php new file mode 100644 index 0000000000..9562c3225e --- /dev/null +++ b/src/Event/HashtagBlockChangedEvent.php @@ -0,0 +1,15 @@ + 'handleHashtagBlockChangedEvent']; + } + + public function handleHashtagBlockChangedEvent(HashtagBlockChangedEvent $event): void + { + $this->sqlHelpers->clearCachedUserHashtagBlocks($event->user); + } +} diff --git a/src/EventSubscriber/Hashtag/HashtagFollowSubscriber.php b/src/EventSubscriber/Hashtag/HashtagFollowSubscriber.php new file mode 100644 index 0000000000..83f63d141e --- /dev/null +++ b/src/EventSubscriber/Hashtag/HashtagFollowSubscriber.php @@ -0,0 +1,27 @@ + 'handleHashtagSubscriptionChangedEvent']; + } + + public function handleHashtagSubscriptionChangedEvent(HashtagSubscriptionChangedEvent $event): void + { + $this->sqlHelpers->clearCachedUserSubscribedHashtags($event->user); + } +} diff --git a/src/Factory/HashtagFactory.php b/src/Factory/HashtagFactory.php new file mode 100644 index 0000000000..b7ef5a2514 --- /dev/null +++ b/src/Factory/HashtagFactory.php @@ -0,0 +1,42 @@ +tagRepository->getCounts($tag->tag); + $dto = HashtagResponseDto::create( + $tag->tag, + $counts['entry'], + $counts['entry_comment'], + $counts['post'], + $counts['post_comment'], + ); + + /** @var User $currentUser */ + $currentUser = $this->security->getUser(); + if ($currentUser instanceof User) { + // Only return the user's settings if permission to control settings has been given + $dto->isBlockedByUser = $this->security->isGranted('ROLE_OAUTH2_HASHTAG:BLOCK') ? $currentUser->isBlockedHashtag($tag) : null; + $dto->isSubscribedByUser = $this->security->isGranted('ROLE_OAUTH2_HASHTAG:SUBSCRIBE') ? $tag->isSubscribed($currentUser) : null; + } + + return $dto; + } +} diff --git a/src/Form/UserSettingsType.php b/src/Form/UserSettingsType.php index 27b37f541c..a9fc4e4865 100644 --- a/src/Form/UserSettingsType.php +++ b/src/Form/UserSettingsType.php @@ -86,6 +86,10 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'required' => false, 'help' => 'show_boost_following_help', ]) + ->add('showCommentsOfSubscribedHashtags', CheckboxType::class, [ + 'required' => false, + 'help' => 'show_hashtag_sub_comments_help', + ]) ->add('discoverable', CheckboxType::class, [ 'required' => false, 'help' => 'user_discoverable_help', diff --git a/src/Repository/ContentRepository.php b/src/Repository/ContentRepository.php index adea10eaae..3aafc1c499 100644 --- a/src/Repository/ContentRepository.php +++ b/src/Repository/ContentRepository.php @@ -37,9 +37,9 @@ public function __construct( ) { } - public function findByCriteria(Criteria $criteria): PagerfantaInterface + public function findByCriteria(Criteria $criteria, ?User $loggedInUser = null): PagerfantaInterface { - $query = $this->getQueryAndParameters($criteria, false); + $query = $this->getQueryAndParameters($criteria, false, $loggedInUser); $conn = $this->entityManager->getConnection(); $numResults = null; @@ -63,9 +63,9 @@ public function findByCriteria(Criteria $criteria): PagerfantaInterface * * @throws Exception */ - public function findByCriteriaCursored(Criteria $criteria, mixed $currentCursor, mixed $currentCursor2 = null): CursorPaginationInterface + public function findByCriteriaCursored(Criteria $criteria, mixed $currentCursor, mixed $currentCursor2 = null, ?User $loggedInUser = null): CursorPaginationInterface { - $query = $this->getQueryAndParameters($criteria, true); + $query = $this->getQueryAndParameters($criteria, true, $loggedInUser); $conn = $this->entityManager->getConnection(); $orderings = $this->getOrderings($criteria); $start = new \DateTimeImmutable(); @@ -99,11 +99,12 @@ public function findByCriteriaCursored(Criteria $criteria, mixed $currentCursor, /** * @return array{sql: string, parameters: array}> */ - private function getQueryAndParameters(Criteria $criteria, bool $addCursor): array + private function getQueryAndParameters(Criteria $criteria, bool $addCursor, ?User $user): array { $includeEntries = Criteria::CONTENT_COMBINED === $criteria->content || Criteria::CONTENT_THREADS === $criteria->content; + $includePosts = Criteria::CONTENT_COMBINED === $criteria->content || Criteria::CONTENT_MICROBLOG === $criteria->content; $includeEntryComments = Criteria::CONTENT_COMBINED === $criteria->content && $criteria->includeBoosts; - $includePostComments = (Criteria::CONTENT_COMBINED === $criteria->content || Criteria::CONTENT_MICROBLOG === $criteria->content) && $criteria->includeBoosts; + $includePostComments = $includePosts && $criteria->includeBoosts; $parameters = [ 'visible' => VisibilityInterface::VISIBILITY_VISIBLE, @@ -111,7 +112,7 @@ private function getQueryAndParameters(Criteria $criteria, bool $addCursor): arr ]; /** @var ?User $user */ - $user = $this->security->getUser(); + $user = $user ?? $this->security->getUser(); $currenFilterLists = $user?->getCurrentFilterLists() ?? []; $parameters['loggedInUser'] = $user?->getId(); @@ -195,7 +196,19 @@ private function getQueryAndParameters(Criteria $criteria, bool $addCursor): arr $subClauseEntryComment = ''; $subClausePostComment = ''; if ($user && $criteria->subscribed) { + $clauseFragmentHashtag = ''; + // only include the subclause if there are (/ might be) subscriptions + if (null === $criteria->cachedUserSubscribedHashtags || !empty($criteria->cachedUserSubscribedHashtags)) { + if (null === $criteria->cachedUserSubscribedHashtags) { + $clauseFragmentHashtag = ' OR EXISTS (SELECT 1 FROM hashtag_subscription hs INNER JOIN hashtag_link hl ON hs.hashtag_id = hl.hashtag_id WHERE hs.user_id = :loggedInUser AND hl.%hl_type%_id = c.id)'; + } else { + $clauseFragmentHashtag = ' OR EXISTS (SELECT 1 FROM hashtag_link hl WHERE hl.%hl_type%_id = c.id AND hl.hashtag_id IN (:cachedUserSubscribedHashtags))'; + $parameters['cachedUserSubscribedHashtags'] = $criteria->cachedUserSubscribedHashtags; + } + } + $subClausePost = 'c.user_id = :loggedInUser' + .$clauseFragmentHashtag .(null === $criteria->cachedUserSubscribedMagazines ? ' OR EXISTS (SELECT 1 FROM magazine_subscription ms WHERE ms.user_id = :loggedInUser AND ms.magazine_id = c.magazine_id)' : ' OR c.magazine_id IN (:cachedUserSubscribedMagazines)') @@ -207,12 +220,27 @@ private function getQueryAndParameters(Criteria $criteria, bool $addCursor): arr ' OR EXISTS (SELECT 1 FROM domain_subscription ds WHERE ds.domain_id = c.domain_id AND ds.user_id = :loggedInUser)' : ' OR c.domain_id IN (:cachedUserSubscribedDomains)'); + $subClausePost = str_replace('%hl_type%', 'post', $subClausePost); + $subClauseEntry = str_replace('%hl_type%', 'entry', $subClauseEntry); + if ($criteria->includeBoosts) { $repliesCommonWhere = 'c.user_id = :loggedInUser' .(null === $criteria->cachedUserFollows ? ' OR EXISTS (SELECT 1 FROM user_follow uf WHERE uf.follower_id = :loggedInUser AND uf.following_id = c.user_id)' : ' OR c.user_id IN (:cachedUserFollows)'); + if ($criteria->includeCommentsWithSubscribedHashtag) { + // only include the subclause if there are (/ might be) subscriptions + if (null === $criteria->cachedUserSubscribedHashtags || !empty($criteria->cachedUserSubscribedHashtags)) { + if (null === $criteria->cachedUserSubscribedHashtags) { + $repliesCommonWhere .= ' OR EXISTS (SELECT 1 FROM hashtag_subscription hs INNER JOIN hashtag_link hl ON hs.hashtag_id = hl.hashtag_id WHERE hs.user_id = :loggedInUser AND hl.%hl_type%_id = c.id)'; + } else { + $repliesCommonWhere .= ' OR EXISTS (SELECT 1 FROM hashtag_link hl WHERE hl.%hl_type%_id = c.id AND hl.hashtag_id IN (:cachedUserSubscribedHashtags))'; + $parameters['cachedUserSubscribedHashtags'] = $criteria->cachedUserSubscribedHashtags; + } + } + } + $subClauseEntryComment = $repliesCommonWhere. (null === $criteria->cachedUserFollows ? ' OR EXISTS (SELECT 1 FROM user_follow uf RIGHT OUTER JOIN entry_comment_vote v ON uf.following_id = v.user_id WHERE c.id = v.comment_id AND (uf.follower_id = :loggedInUser OR v.user_id = :loggedInUser) AND v.choice = 1)' : @@ -222,6 +250,11 @@ private function getQueryAndParameters(Criteria $criteria, bool $addCursor): arr ' OR EXISTS (SELECT 1 FROM user_follow uf RIGHT OUTER JOIN post_comment_vote v ON uf.following_id = v.user_id WHERE c.id = v.comment_id AND (uf.follower_id = :loggedInUser OR v.user_id = :loggedInUser) AND v.choice = 1)' : ' OR EXISTS (SELECT 1 FROM post_comment_vote v WHERE c.id = v.comment_id AND (v.user_id IN (:cachedUserFollows) OR v.user_id = :loggedInUser) AND v.choice = 1)'); + if ($criteria->includeCommentsWithSubscribedHashtag) { + $subClauseEntryComment = str_replace('%hl_type%', 'entry_comment', $subClauseEntryComment); + $subClausePostComment = str_replace('%hl_type%', 'post_comment', $subClausePostComment); + } + $subClausePost = $subClausePost .(null === $criteria->cachedUserFollows ? ' OR EXISTS (SELECT 1 FROM user_follow uf RIGHT OUTER JOIN post_vote v ON uf.following_id = v.user_id WHERE c.id = v.post_id AND (uf.follower_id = :loggedInUser OR v.user_id = :loggedInUser) AND v.choice = 1)' : @@ -273,6 +306,8 @@ private function getQueryAndParameters(Criteria $criteria, bool $addCursor): arr $blockingClauseEntry = ''; $instanceBlockClauseUser = ''; $instanceBlockClauseMagazine = ''; + $blockingClausePostComment = ''; + $blockingClauseEntryComment = ''; if ($user && (!$criteria->magazine || !$criteria->magazine->userIsModerator($user)) && !$criteria->moderated) { if (null === $criteria->cachedUserBlocks) { $blockingClausePost = 'NOT EXISTS (SELECT * FROM user_block ub WHERE ub.blocker_id = :loggedInUser AND ub.blocked_id = c.user_id)'; @@ -299,6 +334,26 @@ private function getQueryAndParameters(Criteria $criteria, bool $addCursor): arr } } + $blockingClauseEntryComment = $blockingClausePost; + $blockingClausePostComment = $blockingClausePost; + + // only include the subcluase if there are (/ might be) blocks + if (null === $criteria->cachedUserBlockedHashtags || !empty($criteria->cachedUserBlockedHashtags)) { + if (null === $criteria->cachedUserBlockedHashtags) { + $blockingClauseEntry = $blockingClauseEntry.' AND NOT EXISTS (SELECT 1 FROM hashtag_link hl INNER JOIN hashtag_block hb ON hl.hashtag_id = hb.hashtag_id WHERE hl.entry_id = c.id AND hb.user_id = :loggedInUser)'; + $blockingClausePost = $blockingClausePost.' AND NOT EXISTS (SELECT 1 FROM hashtag_link hl INNER JOIN hashtag_block hb ON hl.hashtag_id = hb.hashtag_id WHERE hl.post_id = c.id AND hb.user_id = :loggedInUser)'; + $blockingClauseEntryComment = $blockingClauseEntryComment.' AND NOT EXISTS (SELECT 1 FROM hashtag_link hl INNER JOIN hashtag_block hb ON hl.hashtag_id = hb.hashtag_id WHERE hl.entry_comment_id = c.id AND hb.user_id = :loggedInUser)'; + $blockingClausePostComment = $blockingClausePostComment.' AND NOT EXISTS (SELECT 1 FROM hashtag_link hl INNER JOIN hashtag_block hb ON hl.hashtag_id = hb.hashtag_id WHERE hl.post_comment_id = c.id AND hb.user_id = :loggedInUser)'; + } else { + $blockingClauseEntry = $blockingClauseEntry.' AND NOT EXISTS (SELECT 1 FROM hashtag_link hl WHERE hl.entry_id = c.id AND hl.hashtag_id IN (:cachedUserBlockedHashtags))'; + $blockingClausePost = $blockingClausePost.' AND NOT EXISTS (SELECT 1 FROM hashtag_link hl WHERE hl.post_id = c.id AND hl.hashtag_id IN (:cachedUserBlockedHashtags))'; + $blockingClauseEntryComment = $blockingClauseEntryComment.' AND NOT EXISTS (SELECT 1 FROM hashtag_link hl WHERE hl.entry_comment_id = c.id AND hl.hashtag_id IN (:cachedUserBlockedHashtags))'; + $blockingClausePostComment = $blockingClausePostComment.' AND NOT EXISTS (SELECT 1 FROM hashtag_link hl WHERE hl.post_comment_id = c.id AND hl.hashtag_id IN (:cachedUserBlockedHashtags))'; + + $parameters['cachedUserBlockedHashtags'] = $criteria->cachedUserBlockedHashtags; + } + } + if (null === $criteria->cachedUserBlockedInstances) { $instanceBlockClauseUser = 'u.ap_domain IS NULL OR NOT EXISTS (SELECT id FROM instance_block ib WHERE ib.user_id = :loggedInUser AND ib.instance_domain = u.ap_domain)'; if (!$criteria->magazine) { @@ -434,7 +489,7 @@ private function getQueryAndParameters(Criteria $criteria, bool $addCursor): arr $subClauseEntryComment, $modClause, $favClauseEntryComment, - $blockingClausePost, + $blockingClauseEntryComment, $instanceBlockClauseMagazine, $hideAdultClause, $visibilityClauseM, @@ -457,7 +512,7 @@ private function getQueryAndParameters(Criteria $criteria, bool $addCursor): arr $subClausePostComment, $modClause, $favClausePostComment, - $blockingClausePost, + $blockingClausePostComment, $instanceBlockClauseMagazine, $hideAdultClause, $visibilityClauseM, @@ -509,7 +564,7 @@ private function getQueryAndParameters(Criteria $criteria, bool $addCursor): arr } else { $innerSql = "$postSql $orderBy $innerLimit"; } - } else { + } else { // Criteria::CONTENT_COMBINED $innerSql = "($entrySql $orderBy $innerLimit) UNION ALL ($postSql $orderBy $innerLimit)"; if ($includeEntryComments) { $innerSql .= " UNION ALL ($entryCommentSql $orderBy $innerLimit)"; diff --git a/src/Repository/Criteria.php b/src/Repository/Criteria.php index fab9406f67..ab0c7c5751 100644 --- a/src/Repository/Criteria.php +++ b/src/Repository/Criteria.php @@ -95,6 +95,7 @@ abstract class Criteria public bool $moderated = false; public bool $favourite = false; public bool $includeBoosts = false; + public bool $includeCommentsWithSubscribedHashtag = false; public ?string $tag = null; public ?string $domain = null; public ?array $languages = null; @@ -112,6 +113,9 @@ abstract class Criteria /** @var int[]|null */ public ?array $cachedUserSubscribedDomains = null; + /** @var int[]|null */ + public ?array $cachedUserSubscribedHashtags = null; + /** @var int[]|null */ public ?array $cachedUserBlocks = null; @@ -124,6 +128,9 @@ abstract class Criteria /** @var string[]|null */ public ?array $cachedUserBlockedInstances = null; + /** @var int[]|null */ + public ?array $cachedUserBlockedHashtags = null; + public const THEME_MBIN = 'mbin'; public const THEME_KBIN = 'kbin'; public const THEME_AUTO = 'default'; @@ -354,6 +361,7 @@ public function fetchCachedItems(SqlHelpers $sqlHelpers, User $loggedInUser): vo if ($this->subscribed) { $this->cachedUserSubscribedDomains = $sqlHelpers->getCachedUserSubscribedDomains($loggedInUser); $this->cachedUserSubscribedMagazines = $sqlHelpers->getCachedUserSubscribedMagazines($loggedInUser); + $this->cachedUserSubscribedHashtags = $sqlHelpers->getCachedUserSubscribedHashtags($loggedInUser); } if ($this->moderated) { @@ -362,6 +370,7 @@ public function fetchCachedItems(SqlHelpers $sqlHelpers, User $loggedInUser): vo $this->cachedUserBlocks = $sqlHelpers->getCachedUserBlocks($loggedInUser); $this->cachedUserBlockedDomains = $sqlHelpers->getCachedUserDomainBlocks($loggedInUser); + $this->cachedUserBlockedHashtags = $sqlHelpers->getCachedUserHashtagBlocks($loggedInUser); $this->cachedUserBlockedMagazines = $sqlHelpers->getCachedUserMagazineBlocks($loggedInUser); $this->cachedUserBlockedInstances = $sqlHelpers->getCachedUserInstanceBlocks($loggedInUser); } diff --git a/src/Repository/EntryCommentRepository.php b/src/Repository/EntryCommentRepository.php index 1e5e0136dc..0af884358f 100644 --- a/src/Repository/EntryCommentRepository.php +++ b/src/Repository/EntryCommentRepository.php @@ -14,7 +14,9 @@ use App\Entity\Entry; use App\Entity\EntryComment; use App\Entity\EntryCommentFavourite; +use App\Entity\HashtagBlock; use App\Entity\HashtagLink; +use App\Entity\HashtagSubscription; use App\Entity\Image; use App\Entity\MagazineBlock; use App\Entity\MagazineSubscription; @@ -55,11 +57,11 @@ public function __construct( parent::__construct($registry, EntryComment::class); } - public function findByCriteria(Criteria $criteria): Pagerfanta + public function findByCriteria(Criteria $criteria, ?User $loggedInUser = null): Pagerfanta { $pagerfanta = new Pagerfanta( new QueryAdapter( - $this->getEntryQueryBuilder($criteria), + $this->getEntryQueryBuilder($criteria, $loggedInUser), false ) ); @@ -74,9 +76,9 @@ public function findByCriteria(Criteria $criteria): Pagerfanta return $pagerfanta; } - private function getEntryQueryBuilder(Criteria $criteria): QueryBuilder + private function getEntryQueryBuilder(Criteria $criteria, ?User $user): QueryBuilder { - $user = $this->security->getUser(); + $user = $user ?? $this->security->getUser(); $qb = $this->createQueryBuilder('c') ->select('c', 'u') @@ -103,7 +105,7 @@ private function getEntryQueryBuilder(Criteria $criteria): QueryBuilder ->setParameter('visible', VisibilityInterface::VISIBILITY_VISIBLE); $this->addTimeClause($qb, $criteria); - $this->filter($qb, $criteria); + $this->filter($qb, $criteria, $user); $this->addBannedHashtagClause($qb); if ($user instanceof User) { $this->filterWords($qb, $user); @@ -134,10 +136,8 @@ private function addBannedHashtagClause(QueryBuilder $qb): void $qb->andWhere($qb->expr()->not($qb->expr()->exists($dql))); } - private function filter(QueryBuilder $qb, Criteria $criteria): QueryBuilder + private function filter(QueryBuilder $qb, Criteria $criteria, ?User $user): QueryBuilder { - $user = $this->security->getUser(); - if (Criteria::AP_LOCAL === $criteria->federation) { $qb->andWhere('c.apId IS NULL'); } @@ -183,7 +183,7 @@ private function filter(QueryBuilder $qb, Criteria $criteria): QueryBuilder ->setParameter('tag', $criteria->tag); } - if ($criteria->subscribed) { + if ($user && $criteria->subscribed) { $qb->andWhere( 'c.magazine IN (SELECT IDENTITY(ms.magazine) FROM '.MagazineSubscription::class.' ms WHERE ms.user = :follower) OR @@ -191,7 +191,9 @@ private function filter(QueryBuilder $qb, Criteria $criteria): QueryBuilder OR c.user = :follower OR - ce.domain IN (SELECT IDENTITY(ds.domain) FROM '.DomainSubscription::class.' ds WHERE ds.user = :follower)' + ce.domain IN (SELECT IDENTITY(ds.domain) FROM '.DomainSubscription::class.' ds WHERE ds.user = :follower) + OR + EXISTS (SELECT 1 FROM '.HashtagSubscription::class.' hs INNER JOIN '.HashtagLink::class.' hsl ON hs.hashtag = hsl.hashtag WHERE hsl.entryComment = c AND hs.user = :follower)' ); $qb->setParameter('follower', $user); } @@ -200,14 +202,14 @@ private function filter(QueryBuilder $qb, Criteria $criteria): QueryBuilder $qb->andWhere( 'c.magazine IN (SELECT IDENTITY(cm.magazine) FROM '.Moderator::class.' cm WHERE cm.user = :user)' ); - $qb->setParameter('user', $this->security->getUser()); + $qb->setParameter('user', $user); } if ($criteria->favourite) { $qb->andWhere( 'c.id IN (SELECT IDENTITY(cf.entryComment) FROM '.EntryCommentFavourite::class.' cf WHERE cf.user = :user)' ); - $qb->setParameter('user', $this->security->getUser()); + $qb->setParameter('user', $user); } if ($user && (!$criteria->magazine || !$criteria->magazine->userIsModerator($user)) && !$criteria->moderated) { @@ -223,6 +225,13 @@ private function filter(QueryBuilder $qb, Criteria $criteria): QueryBuilder 'c.magazine NOT IN (SELECT IDENTITY(mb.magazine) FROM '.MagazineBlock::class.' mb WHERE mb.user = :blocker)' ); + $qb->andWhere( + 'NOT EXISTS (' + .'SELECT 1 FROM '.HashtagBlock::class.' hb INNER JOIN '.HashtagLink::class.' hbl ON hb.hashtag = hbl.hashtag ' + .'WHERE hbl.entryComment = c AND hb.user = :blocker' + .')' + ); + if (!$criteria->domain) { $qb->andWhere( 'ce.domain IS null OR ce.domain NOT IN (SELECT IDENTITY(db.domain) FROM '.DomainBlock::class.' db WHERE db.user = :blocker)' diff --git a/src/Repository/EntryRepository.php b/src/Repository/EntryRepository.php index 4540c4ff51..68123d87d7 100644 --- a/src/Repository/EntryRepository.php +++ b/src/Repository/EntryRepository.php @@ -13,7 +13,9 @@ use App\Entity\DomainSubscription; use App\Entity\Entry; use App\Entity\EntryFavourite; +use App\Entity\HashtagBlock; use App\Entity\HashtagLink; +use App\Entity\HashtagSubscription; use App\Entity\Magazine; use App\Entity\MagazineBlock; use App\Entity\MagazineSubscription; @@ -196,7 +198,7 @@ private function filter(QueryBuilder $qb, EntryPageView $criteria): QueryBuilder ->setParameter('languages', $criteria->languages, ArrayParameterType::STRING); } - if ($criteria->subscribed) { + if ($user && $criteria->subscribed) { $qb->andWhere( 'e.magazine IN (SELECT IDENTITY(ms.magazine) FROM '.MagazineSubscription::class.' ms WHERE ms.user = :user) OR @@ -204,7 +206,9 @@ private function filter(QueryBuilder $qb, EntryPageView $criteria): QueryBuilder OR e.domain IN (SELECT IDENTITY(ds.domain) FROM '.DomainSubscription::class.' ds WHERE ds.user = :user) OR - e.user = :user' + e.user = :user + OR + EXISTS (SELECT 1 FROM '.HashtagSubscription::class.' hs INNER JOIN '.HashtagLink::class.' hsl ON hs.hashtag = hsl.hashtag WHERE hsl.entry = e AND hs.user = :user)' ) ->setParameter('user', $this->security->getUser()); } @@ -238,6 +242,13 @@ private function filter(QueryBuilder $qb, EntryPageView $criteria): QueryBuilder ); } + $qb->andWhere( + 'NOT EXISTS (' + .'SELECT 1 FROM '.HashtagBlock::class.' hb INNER JOIN '.HashtagLink::class.' hbl ON hb.hashtag = hbl.hashtag ' + .'WHERE hbl.entry = e AND hb.user = :blocker' + .')' + ); + $qb->setParameter('blocker', $user); } diff --git a/src/Repository/PostCommentRepository.php b/src/Repository/PostCommentRepository.php index c5f9035335..c7725910aa 100644 --- a/src/Repository/PostCommentRepository.php +++ b/src/Repository/PostCommentRepository.php @@ -9,8 +9,11 @@ namespace App\Repository; use App\Entity\Contracts\VisibilityInterface; +use App\Entity\HashtagBlock; use App\Entity\HashtagLink; +use App\Entity\HashtagSubscription; use App\Entity\Image; +use App\Entity\MagazineSubscription; use App\Entity\Post; use App\Entity\PostComment; use App\Entity\User; @@ -46,7 +49,7 @@ public function __construct( parent::__construct($registry, PostComment::class); } - public function findByCriteria(PostCommentPageView $criteria) + public function findByCriteria(PostCommentPageView $criteria, ?User $loggedInUser = null) { // return $this->createQueryBuilder('pc') // ->orderBy('pc.createdAt', 'DESC') @@ -55,7 +58,7 @@ public function findByCriteria(PostCommentPageView $criteria) // ->getResult(); $pagerfanta = new Pagerfanta( new QueryAdapter( - $this->getCommentQueryBuilder($criteria), + $this->getCommentQueryBuilder($criteria, $loggedInUser), false ) ); @@ -70,9 +73,9 @@ public function findByCriteria(PostCommentPageView $criteria) return $pagerfanta; } - private function getCommentQueryBuilder(Criteria $criteria): QueryBuilder + private function getCommentQueryBuilder(Criteria $criteria, ?User $user): QueryBuilder { - $user = $this->security->getUser(); + $user = $user ?? $this->security->getUser(); $qb = $this->createQueryBuilder('c') ->select('c', 'u') @@ -99,7 +102,7 @@ private function getCommentQueryBuilder(Criteria $criteria): QueryBuilder ->setParameter('visible', VisibilityInterface::VISIBILITY_VISIBLE); $this->addTimeClause($qb, $criteria); - $this->filter($qb, $criteria); + $this->filter($qb, $criteria, $user); $this->addBannedHashtagClause($qb); if ($user instanceof User) { @@ -131,7 +134,7 @@ private function addBannedHashtagClause(QueryBuilder $qb): void $qb->andWhere($qb->expr()->not($qb->expr()->exists($dql))); } - private function filter(QueryBuilder $qb, Criteria $criteria) + private function filter(QueryBuilder $qb, Criteria $criteria, ?User $user): void { if ($criteria->post) { $qb->andWhere('c.post = :post') @@ -160,12 +163,31 @@ private function filter(QueryBuilder $qb, Criteria $criteria) ->setParameter('tag', $criteria->tag); } - $user = $this->security->getUser(); + if ($user && $criteria->subscribed) { + $qb->andWhere( + 'c.magazine IN (SELECT IDENTITY(ms.magazine) FROM '.MagazineSubscription::class.' ms WHERE ms.user = :follower) + OR + c.user IN (SELECT IDENTITY(uf.following) FROM '.UserFollow::class.' uf WHERE uf.follower = :follower) + OR + c.user = :follower + OR + EXISTS (SELECT 1 FROM '.HashtagSubscription::class.' hs INNER JOIN '.HashtagLink::class.' hsl ON hs.hashtag = hsl.hashtag WHERE hsl.postComment = c AND hs.user = :follower)' + ); + $qb->setParameter('follower', $user); + } + if ($user && !$criteria->moderated) { $qb->andWhere( 'c.user NOT IN (SELECT IDENTITY(ub.blocked) FROM '.UserBlock::class.' ub WHERE ub.blocker = :blocker)' ); + $qb->andWhere( + 'NOT EXISTS (' + .'SELECT 1 FROM '.HashtagBlock::class.' hb INNER JOIN '.HashtagLink::class.' hbl ON hb.hashtag = hbl.hashtag ' + .'WHERE hbl.postComment = c AND hb.user = :blocker' + .')' + ); + $qb->setParameter('blocker', $user); } diff --git a/src/Repository/PostRepository.php b/src/Repository/PostRepository.php index d4726a3451..246e888986 100644 --- a/src/Repository/PostRepository.php +++ b/src/Repository/PostRepository.php @@ -9,7 +9,9 @@ namespace App\Repository; use App\Entity\Contracts\VisibilityInterface; +use App\Entity\HashtagBlock; use App\Entity\HashtagLink; +use App\Entity\HashtagSubscription; use App\Entity\Magazine; use App\Entity\MagazineBlock; use App\Entity\MagazineSubscription; @@ -171,13 +173,15 @@ private function filter(QueryBuilder $qb, Criteria $criteria): QueryBuilder ->setParameter('tag', $criteria->tag); } - if ($criteria->subscribed) { + if ($user && $criteria->subscribed) { $qb->andWhere( 'EXISTS (SELECT IDENTITY(ms.magazine) FROM '.MagazineSubscription::class.' ms WHERE ms.user = :user AND ms.magazine = p.magazine) OR EXISTS (SELECT IDENTITY(uf.following) FROM '.UserFollow::class.' uf WHERE uf.follower = :user AND uf.following = p.user) OR - p.user = :user' + p.user = :user + OR + EXISTS (SELECT 1 FROM '.HashtagSubscription::class.' hs INNER JOIN '.HashtagLink::class.' hsl ON hs.hashtag = hsl.hashtag WHERE hsl.post = p AND hs.user = :user)' ); $qb->setParameter('user', $this->security->getUser()); } @@ -205,12 +209,19 @@ private function filter(QueryBuilder $qb, Criteria $criteria): QueryBuilder $qb->andWhere( 'NOT EXISTS (SELECT IDENTITY(ub.blocked) FROM '.UserBlock::class.' ub WHERE ub.blocker = :blocker AND ub.blocked = p.user)' ); - $qb->setParameter('blocker', $user); $qb->andWhere( - 'NOT EXISTS (SELECT IDENTITY(mb.magazine) FROM '.MagazineBlock::class.' mb WHERE mb.user = :magazineBlocker AND mb.magazine = p.magazine)' + 'NOT EXISTS (SELECT IDENTITY(mb.magazine) FROM '.MagazineBlock::class.' mb WHERE mb.user = :blocker AND mb.magazine = p.magazine)' ); - $qb->setParameter('magazineBlocker', $user); + + $qb->andWhere( + 'NOT EXISTS (' + .'SELECT 1 FROM '.HashtagBlock::class.' hb INNER JOIN '.HashtagLink::class.' hbl ON hb.hashtag = hbl.hashtag ' + .'WHERE hbl.post = p AND hb.user = :blocker' + .')' + ); + + $qb->setParameter('blocker', $user); } if (!$user || $user->hideAdult) { diff --git a/src/Repository/TagRepository.php b/src/Repository/TagRepository.php index 97f24b28d5..fbe5b4a353 100644 --- a/src/Repository/TagRepository.php +++ b/src/Repository/TagRepository.php @@ -6,11 +6,13 @@ use App\Entity\Contracts\VisibilityInterface; use App\Entity\Hashtag; +use App\Entity\User; use App\Pagination\NativeQueryAdapter; use App\Pagination\Transformation\ContentPopulationTransformer; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Persistence\ManagerRegistry; use JetBrains\PhpStorm\ArrayShape; +use Pagerfanta\Doctrine\Collections\CollectionAdapter; use Pagerfanta\Exception\NotValidCurrentPageException; use Pagerfanta\Pagerfanta; use Pagerfanta\PagerfantaInterface; @@ -116,4 +118,40 @@ public function getCounts(string $tag): ?array 'post_comment' => 0, ]; } + + public function findSubscribedTags(int $page, User $user, int $perPage = self::PER_PAGE): Pagerfanta + { + $pagerfanta = new Pagerfanta( + new CollectionAdapter( + $user->subscribedHashtags + ) + ); + + try { + $pagerfanta->setMaxPerPage($perPage); + $pagerfanta->setCurrentPage($page); + } catch (NotValidCurrentPageException $e) { + throw new NotFoundHttpException(); + } + + return $pagerfanta; + } + + public function findBlockedTags(int $page, User $user, int $perPage = self::PER_PAGE): Pagerfanta + { + $pagerfanta = new Pagerfanta( + new CollectionAdapter( + $user->blockedHashtags + ) + ); + + try { + $pagerfanta->setMaxPerPage($perPage); + $pagerfanta->setCurrentPage($page); + } catch (NotValidCurrentPageException $e) { + throw new NotFoundHttpException(); + } + + return $pagerfanta; + } } diff --git a/src/Service/TagManager.php b/src/Service/TagManager.php index b637adfba3..2b7f99b7bf 100644 --- a/src/Service/TagManager.php +++ b/src/Service/TagManager.php @@ -11,10 +11,14 @@ use App\Entity\Hashtag; use App\Entity\Post; use App\Entity\PostComment; +use App\Entity\User; +use App\Event\HashtagBlockChangedEvent; +use App\Event\HashtagSubscriptionChangedEvent; use App\Repository\TagLinkRepository; use App\Repository\TagRepository; use Doctrine\ORM\EntityManagerInterface; use JetBrains\PhpStorm\ArrayShape; +use Psr\EventDispatcher\EventDispatcherInterface; class TagManager { @@ -23,6 +27,7 @@ public function __construct( private readonly TagLinkRepository $tagLinkRepository, private readonly EntityManagerInterface $entityManager, private readonly TagExtractor $tagExtractor, + private readonly EventDispatcherInterface $dispatcher, ) { } @@ -177,4 +182,42 @@ public function isAnyTagBanned(?array $tags): bool return false; } + + public function subscribe(User $user, Hashtag $hashtag): void + { + $this->unblock($user, $hashtag); + + $hashtag->subscribe($user); + + $this->entityManager->flush(); + + $this->dispatcher->dispatch(new HashtagSubscriptionChangedEvent($hashtag, $user, true)); + } + + public function unsubscribe(User $user, Hashtag $hashtag): void + { + $hashtag->unsubscribe($user); + + $this->entityManager->flush(); + + $this->dispatcher->dispatch(new HashtagSubscriptionChangedEvent($hashtag, $user, false)); + } + + public function block(User $user, Hashtag $hashtag): void + { + $this->unsubscribe($user, $hashtag); + + $user->blockHashtag($hashtag); + $this->entityManager->flush(); + + $this->dispatcher->dispatch(new HashtagBlockChangedEvent($hashtag, $user, true)); + } + + public function unblock(User $user, Hashtag $hashtag): void + { + $user->unblockHashtag($hashtag); + $this->entityManager->flush(); + + $this->dispatcher->dispatch(new HashtagBlockChangedEvent($hashtag, $user, false)); + } } diff --git a/src/Service/UserSettingsManager.php b/src/Service/UserSettingsManager.php index d5e8d7af44..87858cf923 100644 --- a/src/Service/UserSettingsManager.php +++ b/src/Service/UserSettingsManager.php @@ -32,6 +32,7 @@ public function createDto(User $user): UserSettingsDto $user->frontDefaultSort, $user->commentDefaultSort, $user->showBoostsOfFollowing, + $user->showCommentsOfSubscribedHashtags, $user->featuredMagazines, $user->preferredLanguages, $user->customCss, @@ -56,6 +57,7 @@ public function update(User $user, UserSettingsDto $dto): void $user->frontDefaultSort = $dto->frontDefaultSort; $user->commentDefaultSort = $dto->commentDefaultSort; $user->showBoostsOfFollowing = $dto->showFollowingBoosts ?? false; + $user->showCommentsOfSubscribedHashtags = $dto->showCommentsOfSubscribedHashtags ?? false; $user->hideAdult = $dto->hideAdult; $user->showProfileSubscriptions = $dto->showProfileSubscriptions; $user->showProfileFollowings = $dto->showProfileFollowings; diff --git a/src/Twig/Components/HashtagSubComponent.php b/src/Twig/Components/HashtagSubComponent.php new file mode 100644 index 0000000000..82bd58b9da --- /dev/null +++ b/src/Twig/Components/HashtagSubComponent.php @@ -0,0 +1,41 @@ +security->getUser(); + if ($user instanceof User) { + $this->isHashtagSubscribed = $this->hashtag->isSubscribed($user); + $this->isHashtagBlocked = $user->isBlockedHashtag($this->hashtag); + } else { + $this->isHashtagSubscribed = false; + $this->isHashtagBlocked = false; + } + + $this->hashtagSubscriptionCount = $this->hashtag->subscriptions->count(); + } +} diff --git a/src/Utils/SqlHelpers.php b/src/Utils/SqlHelpers.php index cb0302c0c7..3ed50bf817 100644 --- a/src/Utils/SqlHelpers.php +++ b/src/Utils/SqlHelpers.php @@ -24,9 +24,11 @@ class SqlHelpers public const string USER_MAGAZINE_SUBSCRIPTION_KEY = 'cached_user_magazine_subscription_'; public const string USER_MAGAZINE_MODERATION_KEY = 'cached_user_magazine_moderation_'; public const string USER_DOMAIN_SUBSCRIPTION_KEY = 'cached_user_domain_subscription_'; + public const string USER_HASHTAG_SUBSCRIPTION_KEY = 'cached_user_hashtag_subscription_'; public const string USER_BLOCKS_KEY = 'cached_user_blocks_'; public const string USER_MAGAZINE_BLOCKS_KEY = 'cached_user_magazine_block_'; public const string USER_DOMAIN_BLOCKS_KEY = 'cached_user_domain_block_'; + public const string USER_HASHTAG_BLOCKS_KEY = 'cached_user_hashtag_block_'; public const string USER_INSTANCE_BLOCKS_KEY = 'cached_user_instance_block_'; public function __construct( @@ -165,7 +167,7 @@ public function getCachedUserFollows(User $user): array return $this->fetchSingleColumnAsArray($sql, $user); }); } catch (InvalidArgumentException|Exception $exception) { - $this->logger->error('There was an error getting the cached magazine blocks of user "{u}": {e} - {m}', ['u' => $user->username, 'e' => \get_class($exception), 'm' => $exception->getMessage()]); + $this->logger->error('There was an error getting the cached user follows of user "{u}": {e} - {m}', ['u' => $user->username, 'e' => \get_class($exception), 'm' => $exception->getMessage()]); return []; } @@ -196,7 +198,7 @@ public function getCachedUserSubscribedMagazines(User $user): array return $this->fetchSingleColumnAsArray($sql, $user); }); } catch (InvalidArgumentException|Exception $exception) { - $this->logger->error('There was an error getting the cached magazine blocks of user "{u}": {e} - {m}', ['u' => $user->username, 'e' => \get_class($exception), 'm' => $exception->getMessage()]); + $this->logger->error('There was an error getting the cached subscribed magazines of user "{u}": {e} - {m}', ['u' => $user->username, 'e' => \get_class($exception), 'm' => $exception->getMessage()]); return []; } @@ -208,7 +210,7 @@ public function clearCachedUserSubscribedMagazines(User $user): void try { $this->cache->delete(self::USER_MAGAZINE_SUBSCRIPTION_KEY.$user->getId()); } catch (InvalidArgumentException $exception) { - $this->logger->warning('There was an error clearing the cached subscribed Magazines of user "{u}": {m}', ['u' => $user->username, 'm' => $exception->getMessage()]); + $this->logger->warning('There was an error clearing the cached subscribed magazines of user "{u}": {m}', ['u' => $user->username, 'm' => $exception->getMessage()]); } } @@ -227,7 +229,7 @@ public function getCachedUserModeratedMagazines(User $user): array return $this->fetchSingleColumnAsArray($sql, $user); }); } catch (InvalidArgumentException|Exception $exception) { - $this->logger->error('There was an error getting the cached magazine blocks of user "{u}": {e} - {m}', ['u' => $user->username, 'e' => \get_class($exception), 'm' => $exception->getMessage()]); + $this->logger->error('There was an error getting the cached moderated magazines of user "{u}": {e} - {m}', ['u' => $user->username, 'e' => \get_class($exception), 'm' => $exception->getMessage()]); return []; } @@ -258,7 +260,7 @@ public function getCachedUserSubscribedDomains(User $user): array return $this->fetchSingleColumnAsArray($sql, $user); }); } catch (InvalidArgumentException|Exception $exception) { - $this->logger->error('There was an error getting the cached magazine blocks of user "{u}": {e} - {m}', ['u' => $user->username, 'e' => \get_class($exception), 'm' => $exception->getMessage()]); + $this->logger->error('There was an error getting the cached subscribed domains of user "{u}": {e} - {m}', ['u' => $user->username, 'e' => \get_class($exception), 'm' => $exception->getMessage()]); return []; } @@ -275,7 +277,38 @@ public function clearCachedUserSubscribedDomains(User $user): void } /** - * @return int[] the ids of the domains $user is subscribed to + * @return int[] the ids of the hashtags $user is subscribed to + */ + public function getCachedUserSubscribedHashtags(User $user): array + { + try { + $sql = 'SELECT hashtag_id FROM hashtag_subscription WHERE user_id = :uId'; + if ('test' === $this->kernel->getEnvironment()) { + return $this->fetchSingleColumnAsArray($sql, $user); + } + + return $this->cache->get(self::USER_HASHTAG_SUBSCRIPTION_KEY.$user->getId(), function (ItemInterface $item) use ($user, $sql) { + return $this->fetchSingleColumnAsArray($sql, $user); + }); + } catch (InvalidArgumentException|Exception $exception) { + $this->logger->error('There was an error getting the cached subscribed hashtags of user "{u}": {e} - {m}', ['u' => $user->username, 'e' => \get_class($exception), 'm' => $exception->getMessage()]); + + return []; + } + } + + public function clearCachedUserSubscribedHashtags(User $user): void + { + $this->logger->debug('Clearing cached hashtag subscriptions for user {u}', ['u' => $user->username]); + try { + $this->cache->delete(self::USER_HASHTAG_SUBSCRIPTION_KEY.$user->getId()); + } catch (InvalidArgumentException $exception) { + $this->logger->warning('There was an error clearing the cached subscribed hashtags of user "{u}": {m}', ['u' => $user->username, 'm' => $exception->getMessage()]); + } + } + + /** + * @return int[] the ids of the users $user has blocked */ public function getCachedUserBlocks(User $user): array { @@ -289,7 +322,7 @@ public function getCachedUserBlocks(User $user): array return $this->fetchSingleColumnAsArray($sql, $user); }); } catch (InvalidArgumentException|Exception $exception) { - $this->logger->error('There was an error getting the cached magazine blocks of user "{u}": {e} - {m}', ['u' => $user->username, 'e' => \get_class($exception), 'm' => $exception->getMessage()]); + $this->logger->error('There was an error getting the cached blocked users of user "{u}": {e} - {m}', ['u' => $user->username, 'e' => \get_class($exception), 'm' => $exception->getMessage()]); return []; } @@ -301,12 +334,12 @@ public function clearCachedUserBlocks(User $user): void try { $this->cache->delete(self::USER_BLOCKS_KEY.$user->getId()); } catch (InvalidArgumentException $exception) { - $this->logger->warning('There was an error clearing the cached blocked user of user "{u}": {m}', ['u' => $user->username, 'm' => $exception->getMessage()]); + $this->logger->warning('There was an error clearing the cached blocked users of user "{u}": {m}', ['u' => $user->username, 'm' => $exception->getMessage()]); } } /** - * @return int[] the ids of the domains $user is subscribed to + * @return int[] the ids of the magazines $user has blocked */ public function getCachedUserMagazineBlocks(User $user): array { @@ -320,7 +353,7 @@ public function getCachedUserMagazineBlocks(User $user): array return $this->fetchSingleColumnAsArray($sql, $user); }); } catch (InvalidArgumentException|Exception $exception) { - $this->logger->error('There was an error getting the cached magazine blocks of user "{u}": {e} - {m}', ['u' => $user->username, 'e' => \get_class($exception), 'm' => $exception->getMessage()]); + $this->logger->error('There was an error getting the cached blocked magazines of user "{u}": {e} - {m}', ['u' => $user->username, 'e' => \get_class($exception), 'm' => $exception->getMessage()]); return []; } @@ -337,7 +370,7 @@ public function clearCachedUserMagazineBlocks(User $user): void } /** - * @return int[] the ids of the domains $user is subscribed to + * @return int[] the ids of the domains $user has blocked */ public function getCachedUserDomainBlocks(User $user): array { @@ -351,7 +384,7 @@ public function getCachedUserDomainBlocks(User $user): array return $this->fetchSingleColumnAsArray($sql, $user); }); } catch (InvalidArgumentException|Exception $exception) { - $this->logger->error('There was an error getting the cached magazine blocks of user "{u}": {e} - {m}', ['u' => $user->username, 'e' => \get_class($exception), 'm' => $exception->getMessage()]); + $this->logger->error('There was an error getting the cached blocked domains of user "{u}": {e} - {m}', ['u' => $user->username, 'e' => \get_class($exception), 'm' => $exception->getMessage()]); return []; } @@ -367,6 +400,37 @@ public function clearCachedUserDomainBlocks(User $user): void } } + /** + * @return int[] the ids of the hashtags $user has blocked + */ + public function getCachedUserHashtagBlocks(User $user): array + { + try { + $sql = 'SELECT hashtag_id FROM hashtag_block WHERE user_id = :uId'; + if ('test' === $this->kernel->getEnvironment()) { + return $this->fetchSingleColumnAsArray($sql, $user); + } + + return $this->cache->get(self::USER_HASHTAG_BLOCKS_KEY.$user->getId(), function (ItemInterface $item) use ($user, $sql) { + return $this->fetchSingleColumnAsArray($sql, $user); + }); + } catch (InvalidArgumentException|Exception $exception) { + $this->logger->error('There was an error getting the cached blocked hashtags of user "{u}": {e} - {m}', ['u' => $user->username, 'e' => \get_class($exception), 'm' => $exception->getMessage()]); + + return []; + } + } + + public function clearCachedUserHashtagBlocks(User $user): void + { + $this->logger->debug('Clearing cached hashtag blocks for user {u}', ['u' => $user->username]); + try { + $this->cache->delete(self::USER_HASHTAG_BLOCKS_KEY.$user->getId()); + } catch (InvalidArgumentException $exception) { + $this->logger->warning('There was an error clearing the cached blocked hashtags of user "{u}": {m}', ['u' => $user->username, 'm' => $exception->getMessage()]); + } + } + /** * @return string[] the ids of the domains $user is subscribed to */ diff --git a/templates/components/hashtag_sub.html.twig b/templates/components/hashtag_sub.html.twig new file mode 100644 index 0000000000..47ffa528e1 --- /dev/null +++ b/templates/components/hashtag_sub.html.twig @@ -0,0 +1,26 @@ + +
+ {{ hashtagSubscriptionCount }} +
+
+ +
+ +
+ +
+ diff --git a/templates/layout/_hashtag_activity_list.html.twig b/templates/layout/_hashtag_activity_list.html.twig new file mode 100644 index 0000000000..df96b4dfb6 --- /dev/null +++ b/templates/layout/_hashtag_activity_list.html.twig @@ -0,0 +1,22 @@ +{% if list|length %} +
+
    + {% for subject in list %} +
  • +
    + {{ subject.hashtag.tag }} + {{ component('date', {date: subject.createdAt}) }} +
    +
  • + {% endfor %} +
+
+ {% if(list.haveToPaginate is defined and list.haveToPaginate) %} + {{ pagerfanta(list, null, {'pageParameter':'[p]'}) }} + {% endif %} +{% else %} + +{% endif %} \ No newline at end of file diff --git a/templates/tag/_panel.html.twig b/templates/tag/_panel.html.twig index 4fdb29f24d..b83c58031d 100644 --- a/templates/tag/_panel.html.twig +++ b/templates/tag/_panel.html.twig @@ -11,6 +11,7 @@ + {{ component('hashtag_sub', {hashtag: hashtag}) }} {{ component('tag_actions', {tag: tag}) }} {% if false %} diff --git a/templates/user/settings/block_hashtags.html.twig b/templates/user/settings/block_hashtags.html.twig new file mode 100644 index 0000000000..f0233478a3 --- /dev/null +++ b/templates/user/settings/block_hashtags.html.twig @@ -0,0 +1,21 @@ +{% extends 'base.html.twig' %} + +{%- block title -%} + {{- 'blocked'|trans }} - {{ app.user.username|username(false) }} - {{ parent() -}} +{%- endblock -%} + + +{% block mainClass %}page-settings page-settings-block-magazines{% endblock %} + +{% block header_nav %} +{% endblock %} + +{% block sidebar_top %} +{% endblock %} + +{% block body %} + {% include 'user/settings/_options.html.twig' %} + {% include('user/_visibility_info.html.twig') %} + {% include 'user/settings/block_pills.html.twig' %} + {% include 'layout/_hashtag_activity_list.html.twig' with {list: hashtags} %} +{% endblock %} diff --git a/templates/user/settings/block_pills.html.twig b/templates/user/settings/block_pills.html.twig index 15fdb20a24..b9be1a2326 100644 --- a/templates/user/settings/block_pills.html.twig +++ b/templates/user/settings/block_pills.html.twig @@ -12,6 +12,12 @@ {{ 'people'|trans }} +
  • + + {{ 'hashtags'|trans }} + +
  • diff --git a/templates/user/settings/general.html.twig b/templates/user/settings/general.html.twig index cb3d297d93..3ed90272c5 100644 --- a/templates/user/settings/general.html.twig +++ b/templates/user/settings/general.html.twig @@ -38,6 +38,13 @@
    {{ form_help(form.showFollowingBoosts) }}
    +
    + {{ form_label(form.showCommentsOfSubscribedHashtags, 'show_hashtag_sub_comments_label') }} + {{ form_widget(form.showCommentsOfSubscribedHashtags) }} +
    +
    + {{ form_help(form.showCommentsOfSubscribedHashtags) }} +

    {{ 'writing'|trans }}

    {{ form_row(form.addMentionsEntries, {label: 'add_mentions_entries', row_attr: {class: 'checkbox'}}) }} {{ form_row(form.addMentionsPosts, {label: 'add_mentions_posts', row_attr: {class: 'checkbox'}}) }} diff --git a/templates/user/settings/sub_hashtags.html.twig b/templates/user/settings/sub_hashtags.html.twig new file mode 100644 index 0000000000..9379d8f924 --- /dev/null +++ b/templates/user/settings/sub_hashtags.html.twig @@ -0,0 +1,21 @@ +{% extends 'base.html.twig' %} + +{%- block title -%} + {{- 'subscriptions'|trans }} - {{ app.user.username|username(false) }} - {{ parent() -}} +{%- endblock -%} + + +{% block mainClass %}page-settings page-settings-sub-magazines{% endblock %} + +{% block header_nav %} +{% endblock %} + +{% block sidebar_top %} +{% endblock %} + +{% block body %} + {% include 'user/settings/_options.html.twig' %} + {% include('user/_visibility_info.html.twig') %} + {% include 'user/settings/sub_pills.html.twig' %} + {% include 'layout/_hashtag_activity_list.html.twig' with {list: hashtags} %} +{% endblock %} diff --git a/templates/user/settings/sub_pills.html.twig b/templates/user/settings/sub_pills.html.twig index d5098cb68a..3ce34178f6 100644 --- a/templates/user/settings/sub_pills.html.twig +++ b/templates/user/settings/sub_pills.html.twig @@ -12,6 +12,12 @@ {{ 'people'|trans }}
  • +
  • + + {{ 'hashtags'|trans }} + +
  • diff --git a/tests/FactoryTrait.php b/tests/FactoryTrait.php index 16cb469795..85ff82e88e 100644 --- a/tests/FactoryTrait.php +++ b/tests/FactoryTrait.php @@ -18,6 +18,7 @@ use App\Entity\Contracts\VotableInterface; use App\Entity\Entry; use App\Entity\EntryComment; +use App\Entity\Hashtag; use App\Entity\Image; use App\Entity\Magazine; use App\Entity\Message; @@ -502,6 +503,30 @@ public function createImage(string $fileName): Image return $image; } + public function createHashtag(string $name): Hashtag + { + $tag = new Hashtag(); + $tag->tag = $name; + + $this->entityManager->persist($tag); + $this->entityManager->flush(); + + $this->hashtags[] = $tag; + + return $tag; + } + + public function getHashtag(string $name): Hashtag + { + $tag = $this->hashtags->filter(fn (Hashtag $tag) => $tag->tag === $name)->first(); + + if (!$tag) { + $tag = $this->createHashtag($name); + } + + return $tag; + } + public function createMessageNotification(?User $to = null, ?User $from = null): Notification { $messageManager = $this->messageManager; diff --git a/tests/Functional/Controller/Api/Tag/TagBlockApiTest.php b/tests/Functional/Controller/Api/Tag/TagBlockApiTest.php new file mode 100644 index 0000000000..400cc3c58c --- /dev/null +++ b/tests/Functional/Controller/Api/Tag/TagBlockApiTest.php @@ -0,0 +1,209 @@ +getEntryByTitle('TagBlockApiTest', body: 'some text with #someTag'); + + $this->client->request('PUT', '/api/tag/sometag/block'); + self::assertResponseStatusCodeSame(401); + } + + public function testApiCannotBlockHashtagWithoutScope() + { + $this->getEntryByTitle('TagBlockApiTest', body: 'some text with #someTag'); + + self::createOAuth2AuthCodeClient(); + $this->client->loginUser($this->getUserByUsername('JohnDoe')); + $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); + $token = $codes['token_type'].' '.$codes['access_token']; + + $this->client->request('PUT', '/api/tag/sometag/block', server: ['HTTP_AUTHORIZATION' => $token]); + self::assertResponseStatusCodeSame(403); + } + + #[Group(name: 'NonThreadSafe')] + public function testApiCanBlockHashtag() + { + $this->getEntryByTitle('TagBlockApiTest', body: 'some text with #someTag'); + + self::createOAuth2AuthCodeClient(); + $this->client->loginUser($this->getUserByUsername('JohnDoe')); + $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read hashtag:block'); + $token = $codes['token_type'].' '.$codes['access_token']; + + $this->client->request('PUT', '/api/tag/sometag/block', server: ['HTTP_AUTHORIZATION' => $token]); + self::assertResponseIsSuccessful(); + + $jsonData = self::getJsonResponse($this->client); + + self::assertIsArray($jsonData); + self::assertArrayKeysMatch(self::HASHTAG_RESPONSE_KEYS, $jsonData); + self::assertEquals('sometag', $jsonData['tag']); + self::assertSame(1, $jsonData['entryCount']); + self::assertSame(0, $jsonData['entryCommentCount']); + self::assertSame(0, $jsonData['postCount']); + self::assertSame(0, $jsonData['postCommentCount']); + self::assertNull($jsonData['isSubscribedByUser']); + self::assertTrue($jsonData['isBlockedByUser']); + + // Idempotent when called multiple times + $this->client->request('PUT', '/api/tag/sometag/block', server: ['HTTP_AUTHORIZATION' => $token]); + self::assertResponseIsSuccessful(); + + $jsonData = self::getJsonResponse($this->client); + + self::assertIsArray($jsonData); + self::assertArrayKeysMatch(self::HASHTAG_RESPONSE_KEYS, $jsonData); + self::assertEquals('sometag', $jsonData['tag']); + self::assertSame(1, $jsonData['entryCount']); + self::assertSame(0, $jsonData['entryCommentCount']); + self::assertSame(0, $jsonData['postCount']); + self::assertSame(0, $jsonData['postCommentCount']); + self::assertNull($jsonData['isSubscribedByUser']); + self::assertTrue($jsonData['isBlockedByUser']); + } + + public function testApiCannotUnblockHashtagAnonymous() + { + $this->getEntryByTitle('TagBlockApiTest', body: 'some text with #someTag'); + + $this->client->request('PUT', '/api/tag/sometag/unblock'); + self::assertResponseStatusCodeSame(401); + } + + public function testApiCannotUnblockHashtagWithoutScope() + { + $this->getEntryByTitle('TagBlockApiTest', body: 'some text with #someTag'); + + self::createOAuth2AuthCodeClient(); + $this->client->loginUser($this->getUserByUsername('JohnDoe')); + $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); + $token = $codes['token_type'].' '.$codes['access_token']; + + $this->client->request('PUT', '/api/tag/sometag/unblock', server: ['HTTP_AUTHORIZATION' => $token]); + self::assertResponseStatusCodeSame(403); + } + + #[Group(name: 'NonThreadSafe')] + public function testApiCanUnblockHashtag() + { + $user = $this->getUserByUsername('JohnDoe'); + $this->getEntryByTitle('TagBlockApiTest', body: 'some text with #someTag'); + + $this->tagManager->block($user, $this->tagRepository->findOneBy(['tag' => 'sometag'])); + + self::createOAuth2AuthCodeClient(); + $this->client->loginUser($user); + $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read hashtag:block'); + $token = $codes['token_type'].' '.$codes['access_token']; + + $this->client->request('PUT', '/api/tag/sometag/unblock', server: ['HTTP_AUTHORIZATION' => $token]); + self::assertResponseIsSuccessful(); + + $jsonData = self::getJsonResponse($this->client); + + self::assertIsArray($jsonData); + self::assertArrayKeysMatch(self::HASHTAG_RESPONSE_KEYS, $jsonData); + self::assertEquals('sometag', $jsonData['tag']); + self::assertSame(1, $jsonData['entryCount']); + self::assertSame(0, $jsonData['entryCommentCount']); + self::assertSame(0, $jsonData['postCount']); + self::assertSame(0, $jsonData['postCommentCount']); + self::assertNull($jsonData['isSubscribedByUser']); + self::assertFalse($jsonData['isBlockedByUser']); + + // Idempotent when called multiple times + $this->client->request('PUT', '/api/tag/sometag/unblock', server: ['HTTP_AUTHORIZATION' => $token]); + self::assertResponseIsSuccessful(); + + $jsonData = self::getJsonResponse($this->client); + + self::assertIsArray($jsonData); + self::assertArrayKeysMatch(self::HASHTAG_RESPONSE_KEYS, $jsonData); + self::assertEquals('sometag', $jsonData['tag']); + self::assertSame(1, $jsonData['entryCount']); + self::assertSame(0, $jsonData['entryCommentCount']); + self::assertSame(0, $jsonData['postCount']); + self::assertSame(0, $jsonData['postCommentCount']); + self::assertNull($jsonData['isSubscribedByUser']); + self::assertFalse($jsonData['isBlockedByUser']); + } + + public function testApiCannotRetrieveBlockedHashtagsAnonymous() + { + $this->client->request('GET', '/api/tags/blocked'); + self::assertResponseStatusCodeSame(401); + } + + public function testApiCannotRetrieveBlockedHashtagWithoutScope() + { + $this->getEntryByTitle('TagBlockApiTest', body: 'some text with #someTag'); + $user = $this->getUserByUsername('JohnDoe'); + $this->tagManager->block($user, $this->tagRepository->findOneBy(['tag' => 'sometag'])); + + self::createOAuth2AuthCodeClient(); + $this->client->loginUser($user); + $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); + $token = $codes['token_type'].' '.$codes['access_token']; + + $this->client->request('GET', '/api/tags/blocked', server: ['HTTP_AUTHORIZATION' => $token]); + self::assertResponseStatusCodeSame(403); + } + + public function testApiCanRetrieveBlockedHashtags() + { + $this->getEntryByTitle('testApiCanRetrieveBlockedHashtags', body: 'some text with #tag1 #tag2 #tag3'); + $user = $this->getUserByUsername('JohnDoe'); + + self::createOAuth2AuthCodeClient(); + $this->client->loginUser($user); + $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read hashtag:block'); + $token = $codes['token_type'].' '.$codes['access_token']; + + $this->client->request('PUT', '/api/tag/tag1/block', server: ['HTTP_AUTHORIZATION' => $token]); + self::assertResponseIsSuccessful(); + $this->client->request('PUT', '/api/tag/tag2/block', server: ['HTTP_AUTHORIZATION' => $token]); + self::assertResponseIsSuccessful(); + + $this->client->request('GET', '/api/tags/blocked', server: ['HTTP_AUTHORIZATION' => $token]); + self::assertResponseIsSuccessful(); + $jsonData = self::getJsonResponse($this->client); + + self::assertIsArray($jsonData); + self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); + + self::assertIsArray($jsonData['pagination']); + self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); + + $blocked = $jsonData['items']; + self::assertIsArray($blocked); + self::assertCount(2, $blocked); + + $tag1Found = false; + $tag2Found = false; + foreach ($blocked as $block) { + self::assertIsArray($block); + self::assertArrayKeysMatch(self::HASHTAG_RESPONSE_KEYS, $block); + self::assertSame(1, $block['entryCount']); + self::assertSame(0, $block['entryCommentCount']); + self::assertSame(0, $block['postCount']); + self::assertSame(0, $block['postCommentCount']); + self::assertNull($block['isSubscribedByUser']); + self::assertTrue($block['isBlockedByUser']); + + $tag1Found = ($tag1Found or 'tag1' === $block['tag']); + $tag2Found = ($tag2Found or 'tag2' === $block['tag']); + } + self::assertTrue($tag1Found); + self::assertTrue($tag2Found); + } +} diff --git a/tests/Functional/Controller/Api/Tag/TagSubscribeApiTest.php b/tests/Functional/Controller/Api/Tag/TagSubscribeApiTest.php new file mode 100644 index 0000000000..a4e502eaae --- /dev/null +++ b/tests/Functional/Controller/Api/Tag/TagSubscribeApiTest.php @@ -0,0 +1,209 @@ +getEntryByTitle('TagSubscribeApiTest', body: 'some text with #someTag'); + + $this->client->request('PUT', '/api/tag/sometag/subscribe'); + self::assertResponseStatusCodeSame(401); + } + + public function testApiCannotSubscribeHashtagWithoutScope() + { + $this->getEntryByTitle('TagSubscribeApiTest', body: 'some text with #someTag'); + + self::createOAuth2AuthCodeClient(); + $this->client->loginUser($this->getUserByUsername('JohnDoe')); + $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); + $token = $codes['token_type'].' '.$codes['access_token']; + + $this->client->request('PUT', '/api/tag/sometag/subscribe', server: ['HTTP_AUTHORIZATION' => $token]); + self::assertResponseStatusCodeSame(403); + } + + #[Group(name: 'NonThreadSafe')] + public function testApiCanSubscribeHashtag() + { + $this->getEntryByTitle('TagSubscribeApiTest', body: 'some text with #someTag'); + + self::createOAuth2AuthCodeClient(); + $this->client->loginUser($this->getUserByUsername('JohnDoe')); + $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read hashtag:subscribe'); + $token = $codes['token_type'].' '.$codes['access_token']; + + $this->client->request('PUT', '/api/tag/sometag/subscribe', server: ['HTTP_AUTHORIZATION' => $token]); + self::assertResponseIsSuccessful(); + + $jsonData = self::getJsonResponse($this->client); + + self::assertIsArray($jsonData); + self::assertArrayKeysMatch(self::HASHTAG_RESPONSE_KEYS, $jsonData); + self::assertEquals('sometag', $jsonData['tag']); + self::assertSame(1, $jsonData['entryCount']); + self::assertSame(0, $jsonData['entryCommentCount']); + self::assertSame(0, $jsonData['postCount']); + self::assertSame(0, $jsonData['postCommentCount']); + self::assertNull($jsonData['isBlockedByUser']); + self::assertTrue($jsonData['isSubscribedByUser']); + + // Idempotent when called multiple times + $this->client->request('PUT', '/api/tag/sometag/subscribe', server: ['HTTP_AUTHORIZATION' => $token]); + self::assertResponseIsSuccessful(); + + $jsonData = self::getJsonResponse($this->client); + + self::assertIsArray($jsonData); + self::assertArrayKeysMatch(self::HASHTAG_RESPONSE_KEYS, $jsonData); + self::assertEquals('sometag', $jsonData['tag']); + self::assertSame(1, $jsonData['entryCount']); + self::assertSame(0, $jsonData['entryCommentCount']); + self::assertSame(0, $jsonData['postCount']); + self::assertSame(0, $jsonData['postCommentCount']); + self::assertNull($jsonData['isBlockedByUser']); + self::assertTrue($jsonData['isSubscribedByUser']); + } + + public function testApiCannotUnsubscribeHashtagAnonymous() + { + $this->getEntryByTitle('TagSubscribeApiTest', body: 'some text with #someTag'); + + $this->client->request('PUT', '/api/tag/sometag/unsubscribe'); + self::assertResponseStatusCodeSame(401); + } + + public function testApiCannotUnsubscribeHashtagWithoutScope() + { + $this->getEntryByTitle('TagSubscribeApiTest', body: 'some text with #someTag'); + + self::createOAuth2AuthCodeClient(); + $this->client->loginUser($this->getUserByUsername('JohnDoe')); + $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); + $token = $codes['token_type'].' '.$codes['access_token']; + + $this->client->request('PUT', '/api/tag/sometag/unsubscribe', server: ['HTTP_AUTHORIZATION' => $token]); + self::assertResponseStatusCodeSame(403); + } + + #[Group(name: 'NonThreadSafe')] + public function testApiCanUnsubscribeHashtag() + { + $user = $this->getUserByUsername('JohnDoe'); + $this->getEntryByTitle('TagSubscribeApiTest', body: 'some text with #someTag'); + + $this->tagManager->subscribe($user, $this->tagRepository->findOneBy(['tag' => 'sometag'])); + + self::createOAuth2AuthCodeClient(); + $this->client->loginUser($user); + $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read hashtag:subscribe'); + $token = $codes['token_type'].' '.$codes['access_token']; + + $this->client->request('PUT', '/api/tag/sometag/unsubscribe', server: ['HTTP_AUTHORIZATION' => $token]); + self::assertResponseIsSuccessful(); + + $jsonData = self::getJsonResponse($this->client); + + self::assertIsArray($jsonData); + self::assertArrayKeysMatch(self::HASHTAG_RESPONSE_KEYS, $jsonData); + self::assertEquals('sometag', $jsonData['tag']); + self::assertSame(1, $jsonData['entryCount']); + self::assertSame(0, $jsonData['entryCommentCount']); + self::assertSame(0, $jsonData['postCount']); + self::assertSame(0, $jsonData['postCommentCount']); + self::assertNull($jsonData['isBlockedByUser']); + self::assertFalse($jsonData['isSubscribedByUser']); + + // Idempotent when called multiple times + $this->client->request('PUT', '/api/tag/sometag/unsubscribe', server: ['HTTP_AUTHORIZATION' => $token]); + self::assertResponseIsSuccessful(); + + $jsonData = self::getJsonResponse($this->client); + + self::assertIsArray($jsonData); + self::assertArrayKeysMatch(self::HASHTAG_RESPONSE_KEYS, $jsonData); + self::assertEquals('sometag', $jsonData['tag']); + self::assertSame(1, $jsonData['entryCount']); + self::assertSame(0, $jsonData['entryCommentCount']); + self::assertSame(0, $jsonData['postCount']); + self::assertSame(0, $jsonData['postCommentCount']); + self::assertNull($jsonData['isBlockedByUser']); + self::assertFalse($jsonData['isSubscribedByUser']); + } + + public function testApiCannotRetrieveSubscribedHashtagsAnonymous() + { + $this->client->request('GET', '/api/tags/subscribed'); + self::assertResponseStatusCodeSame(401); + } + + public function testApiCannotRetrieveSubscribedHashtagWithoutScope() + { + $this->getEntryByTitle('TagSubscribeApiTest', body: 'some text with #someTag'); + $user = $this->getUserByUsername('JohnDoe'); + $this->tagManager->subscribe($user, $this->tagRepository->findOneBy(['tag' => 'sometag'])); + + self::createOAuth2AuthCodeClient(); + $this->client->loginUser($user); + $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read'); + $token = $codes['token_type'].' '.$codes['access_token']; + + $this->client->request('GET', '/api/tags/subscribed', server: ['HTTP_AUTHORIZATION' => $token]); + self::assertResponseStatusCodeSame(403); + } + + public function testApiCanRetrieveSubscribedHashtags() + { + $this->getEntryByTitle('testApiCanRetrieveSubscribedHashtags', body: 'some text with #tag1 #tag2 #tag3'); + $user = $this->getUserByUsername('JohnDoe'); + + self::createOAuth2AuthCodeClient(); + $this->client->loginUser($user); + $codes = self::getAuthorizationCodeTokenResponse($this->client, scopes: 'read hashtag:subscribe'); + $token = $codes['token_type'].' '.$codes['access_token']; + + $this->client->request('PUT', '/api/tag/tag1/subscribe', server: ['HTTP_AUTHORIZATION' => $token]); + self::assertResponseIsSuccessful(); + $this->client->request('PUT', '/api/tag/tag2/subscribe', server: ['HTTP_AUTHORIZATION' => $token]); + self::assertResponseIsSuccessful(); + + $this->client->request('GET', '/api/tags/subscribed', server: ['HTTP_AUTHORIZATION' => $token]); + self::assertResponseIsSuccessful(); + $jsonData = self::getJsonResponse($this->client); + + self::assertIsArray($jsonData); + self::assertArrayKeysMatch(self::PAGINATED_KEYS, $jsonData); + + self::assertIsArray($jsonData['pagination']); + self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); + + $subscribed = $jsonData['items']; + self::assertIsArray($subscribed); + self::assertCount(2, $subscribed); + + $tag1Found = false; + $tag2Found = false; + foreach ($subscribed as $sub) { + self::assertIsArray($sub); + self::assertArrayKeysMatch(self::HASHTAG_RESPONSE_KEYS, $sub); + self::assertSame(1, $sub['entryCount']); + self::assertSame(0, $sub['entryCommentCount']); + self::assertSame(0, $sub['postCount']); + self::assertSame(0, $sub['postCommentCount']); + self::assertNull($sub['isBlockedByUser']); + self::assertTrue($sub['isSubscribedByUser']); + + $tag1Found = ($tag1Found or 'tag1' === $sub['tag']); + $tag2Found = ($tag2Found or 'tag2' === $sub['tag']); + } + self::assertTrue($tag1Found); + self::assertTrue($tag2Found); + } +} diff --git a/tests/Functional/Controller/Api/User/UserRetrieveApiTest.php b/tests/Functional/Controller/Api/User/UserRetrieveApiTest.php index 514b72eac0..895786c552 100644 --- a/tests/Functional/Controller/Api/User/UserRetrieveApiTest.php +++ b/tests/Functional/Controller/Api/User/UserRetrieveApiTest.php @@ -24,6 +24,8 @@ class UserRetrieveApiTest extends WebTestCase 'homepage', 'frontDefaultSort', 'commentDefaultSort', + 'showFollowingBoosts', + 'showCommentsOfSubscribedHashtags', 'featuredMagazines', 'preferredLanguages', 'customCss', diff --git a/tests/Functional/Controller/Api/User/UserUpdateApiTest.php b/tests/Functional/Controller/Api/User/UserUpdateApiTest.php index 9e109bb27f..d77998a120 100644 --- a/tests/Functional/Controller/Api/User/UserUpdateApiTest.php +++ b/tests/Functional/Controller/Api/User/UserUpdateApiTest.php @@ -207,6 +207,7 @@ public function testApiCannotUpdateCurrentUserSettingsWithoutScope(): void Criteria::SORT_HOT, Criteria::SORT_HOT, false, + false, ['test'], ['en'], directMessageSetting: EDirectMessageSettings::Everyone->value, @@ -244,6 +245,7 @@ public function testApiCanUpdateCurrentUserSettings(): void Criteria::SORT_NEW, Criteria::SORT_TOP, false, + false, ['test'], ['en'], directMessageSetting: EDirectMessageSettings::FollowersOnly->value, diff --git a/tests/Functional/Controller/User/Profile/UserBlockControllerTest.php b/tests/Functional/Controller/User/Profile/UserBlockControllerTest.php index ce4841aeee..96bc6129b1 100644 --- a/tests/Functional/Controller/User/Profile/UserBlockControllerTest.php +++ b/tests/Functional/Controller/User/Profile/UserBlockControllerTest.php @@ -49,4 +49,18 @@ public function testUserCanSeeBlockedDomains() $this->assertSelectorTextContains('#main .pills .active', 'Domains'); $this->assertSelectorTextContains('#main', 'kbin.pub'); } + + public function testUserCanSeeBlockedHashtags() + { + $this->client->loginUser($user = $this->getUserByUsername('JaneDoe')); + + $tag = $this->getHashtag('taghash'); + $this->tagManager->block($user, $tag); + + $crawler = $this->client->request('GET', '/settings/blocked/tags'); + $this->client->click($crawler->filter('#main .pills')->selectLink('Hashtags')->link()); + + $this->assertSelectorTextContains('#main .pills .active', 'Hashtags'); + $this->assertSelectorTextContains('#main', 'taghash'); + } } diff --git a/tests/Functional/Controller/User/Profile/UserSubControllerTest.php b/tests/Functional/Controller/User/Profile/UserSubControllerTest.php index fe14fdfe00..9c4e458cee 100644 --- a/tests/Functional/Controller/User/Profile/UserSubControllerTest.php +++ b/tests/Functional/Controller/User/Profile/UserSubControllerTest.php @@ -49,4 +49,19 @@ public function testUserCanSeeSubscribedDomains() $this->assertSelectorTextContains('#main .pills .active', 'Domains'); $this->assertSelectorTextContains('#main', 'kbin.pub'); } + + public function testUserCanSeeSubscribedHashtags() + { + $this->client->loginUser($user = $this->getUserByUsername('JaneDoe')); + + $entry = $this->getEntryByTitle('testUserCanSeeSubscribedHashtags', body: 'body with #sometag'); + + $this->tagManager->subscribe($user, $this->tagRepository->findOneBy(['tag' => 'sometag'])); + + $crawler = $this->client->request('GET', '/settings/subscriptions/hashtags'); + $this->client->click($crawler->filter('#main .pills')->selectLink('Hashtags')->link()); + + $this->assertSelectorTextContains('#main .pills .active', 'Hashtags'); + $this->assertSelectorTextContains('#main', 'sometag'); + } } diff --git a/tests/Functional/Misc/Entry/CrosspostDetectionTest.php b/tests/Functional/Misc/Entry/CrosspostDetectionTest.php index 6a77be6fc6..091781f6a8 100644 --- a/tests/Functional/Misc/Entry/CrosspostDetectionTest.php +++ b/tests/Functional/Misc/Entry/CrosspostDetectionTest.php @@ -59,16 +59,17 @@ public function testCrosspostsByUrl(): void { $user = $this->getUserByUsername('JohnDoe'); $magazine1 = $this->getMagazineByName('acme1'); - $entry1 = $this->createEntry('article 001', $magazine1, $user, url: 'https://duckduckgo.com'); + // make the URL invalid as else it would sometimes pull an image and sometimes it would not + $entry1 = $this->createEntry(' article 001', $magazine1, $user, url: 'https://duckduckgo-fake.com'); sleep(1); $magazine2 = $this->getMagazineByName('acme2'); - $entry2 = $this->createEntry('article 001', $magazine2, $user, url: 'https://duckduckgo.com'); + $entry2 = $this->createEntry('article 001', $magazine2, $user, url: 'https://duckduckgo-fake.com'); sleep(1); $magazine3 = $this->getMagazineByName('acme3'); - $entry3 = $this->createEntry('article with url', $magazine3, $user, url: 'https://duckduckgo.com'); + $entry3 = $this->createEntry('article with url', $magazine3, $user, url: 'https://duckduckgo-fake.com'); sleep(1); $magazine4 = $this->getMagazineByName('acme4'); - $entry4 = $this->createEntry('article 001', $magazine4, $user, url: 'https://google.com'); + $entry4 = $this->createEntry('article 001', $magazine4, $user, url: 'https://google-fake.com'); $this->entityManager->persist($entry1); $this->entityManager->persist($entry2); $this->entityManager->persist($entry3); diff --git a/tests/Functional/Service/Hashtag/TagBlockTest.php b/tests/Functional/Service/Hashtag/TagBlockTest.php new file mode 100644 index 0000000000..dc0edc4d0d --- /dev/null +++ b/tests/Functional/Service/Hashtag/TagBlockTest.php @@ -0,0 +1,193 @@ +getUserByUsername('John Doe'); + $user2 = $this->getUserByUsername('Jane Doe'); + $tagNeutral = $this->getHashtag('abc'); + $tagBlocked = $this->getHashtag('def'); + + $this->tagManager->block($user1, $tagBlocked); + + self::assertCount(1, $user1->blockedHashtags); + self::assertSame($tagBlocked->tag, $user1->blockedHashtags[0]->hashtag->tag); + self::assertCount(0, $user2->blockedHashtags); + } + + public function testUnblock() + { + $user1 = $this->getUserByUsername('John Doe'); + $user2 = $this->getUserByUsername('Jane Doe'); + $tag1 = $this->getHashtag('abc'); + $tag2 = $this->getHashtag('def'); + + $this->tagManager->block($user1, $tag1); + $this->tagManager->block($user1, $tag2); + $this->tagManager->block($user2, $tag1); + + $this->tagManager->unblock($user1, $tag1); + + self::assertCount(1, $user1->blockedHashtags); + self::assertSame($tag2->tag, $user1->blockedHashtags->first()->hashtag->tag); + self::assertCount(1, $user2->blockedHashtags); + self::assertSame($tag1->tag, $user2->blockedHashtags->first()->hashtag->tag); + } + + public function testBlockedHashtagIsHiddenInCombinedWithCache() + { + $user = $this->getUserByUsername('John Doe'); + $contentCreator = $this->getUserByUsername('poster'); + $tag = $this->getHashtag('notWanted'); + + $magazine = $this->getMagazineByName('testBlockedHashtagIsHiddenInCombinedWithCache'); + $entryShowing = $this->createEntry('showing', $magazine, $contentCreator, body: 'some text #wanted'); + $entryHidden = $this->createEntry('hidden', $magazine, $contentCreator, body: 'some text #notWanted'); + $entryCommentShowing = $this->createEntryComment('some text #wanted', $entryShowing, $contentCreator); + $entryCommentHidden = $this->createEntryComment('some text #notWanted', $entryShowing, $contentCreator); + $postShowing = $this->createPost('some text #wanted', $magazine, $contentCreator); + $postHidden = $this->createPost('some text #notWanted', $magazine, $contentCreator); + $postCommentShowing = $this->createPostComment('some text #wanted', $postShowing, $contentCreator); + $postCommentHidden = $this->createPostComment('some text #notWanted', $postShowing, $contentCreator); + $this->setContentTime($entryHidden, $entryShowing, 2); + $this->setContentTime($entryCommentShowing, $entryShowing, 4); + $this->setContentTime($entryCommentHidden, $entryShowing, 6); + $this->setContentTime($postShowing, $entryShowing, 8); + $this->setContentTime($postHidden, $entryShowing, 10); + $this->setContentTime($postCommentShowing, $entryShowing, 12); + $this->setContentTime($postCommentHidden, $entryShowing, 14); + + $user->follow($contentCreator); + $this->tagManager->block($user, $tag); + + $criteria = new EntryPageView(1, $this->security) + ->setContent(Criteria::CONTENT_COMBINED) + ->showSortOption(Criteria::SORT_OLD); + $criteria->magazine = $magazine; + $criteria->includeBoosts = true; + $criteria->perPage = 5; + $criteria->fetchCachedItems($this->sqlHelpers, $user); + + $fanta = $this->contentRepository->findByCriteria($criteria, $user); + $result = $fanta->getCurrentPageResults(); + + self::assertInstanceOf(Entry::class, $result[0]); + self::assertSame($entryShowing->getId(), $result[0]->getId()); + self::assertInstanceOf(EntryComment::class, $result[1]); + self::assertSame($entryCommentShowing->getId(), $result[1]->getId()); + self::assertInstanceOf(Post::class, $result[2]); + self::assertSame($postShowing->getId(), $result[2]->getId()); + self::assertInstanceOf(PostComment::class, $result[3]); + self::assertSame($postCommentShowing->getId(), $result[3]->getId()); + self::assertCount(4, $result); + } + + public function testBlockedHashtagIsHiddenInCombinedWithoutCache() + { + $user = $this->getUserByUsername('John Doe'); + $contentCreator = $this->getUserByUsername('poster'); + $tag = $this->getHashtag('notWanted'); + + $magazine = $this->getMagazineByName('testBlockedHashtagIsHiddenInCombinedWithoutCache'); + $entryShowing = $this->createEntry('showing', $magazine, $contentCreator, body: 'some text #wanted'); + $entryHidden = $this->createEntry('hidden', $magazine, $contentCreator, body: 'some text #notWanted'); + $entryCommentShowing = $this->createEntryComment('some text #wanted', $entryShowing, $contentCreator); + $entryCommentHidden = $this->createEntryComment('some text #notWanted', $entryShowing, $contentCreator); + $postShowing = $this->createPost('some text #wanted', $magazine, $contentCreator); + $postHidden = $this->createPost('some text #notWanted', $magazine, $contentCreator); + $postCommentShowing = $this->createPostComment('some text #wanted', $postShowing, $contentCreator); + $postCommentHidden = $this->createPostComment('some text #notWanted', $postShowing, $contentCreator); + $this->setContentTime($entryHidden, $entryShowing, 2); + $this->setContentTime($entryCommentShowing, $entryShowing, 4); + $this->setContentTime($entryCommentHidden, $entryShowing, 6); + $this->setContentTime($postShowing, $entryShowing, 8); + $this->setContentTime($postHidden, $entryShowing, 10); + $this->setContentTime($postCommentShowing, $entryShowing, 12); + $this->setContentTime($postCommentHidden, $entryShowing, 14); + + $user->follow($contentCreator); + $this->tagManager->block($user, $tag); + + $criteria = new EntryPageView(1, $this->security) + ->setContent(Criteria::CONTENT_COMBINED) + ->showSortOption(Criteria::SORT_OLD); + $criteria->magazine = $magazine; + $criteria->includeBoosts = true; + $criteria->perPage = 5; + + $fanta = $this->contentRepository->findByCriteria($criteria, $user); + $result = $fanta->getCurrentPageResults(); + + self::assertInstanceOf(Entry::class, $result[0]); + self::assertSame($entryShowing->getId(), $result[0]->getId()); + self::assertInstanceOf(EntryComment::class, $result[1]); + self::assertSame($entryCommentShowing->getId(), $result[1]->getId()); + self::assertInstanceOf(Post::class, $result[2]); + self::assertSame($postShowing->getId(), $result[2]->getId()); + self::assertInstanceOf(PostComment::class, $result[3]); + self::assertSame($postCommentShowing->getId(), $result[3]->getId()); + self::assertCount(4, $result); + } + + public function testBlockedHashtagIsHiddenInEntryComments() + { + $user = $this->getUserByUsername('John Doe'); + $contentCreator = $this->getUserByUsername('poster'); + $tag = $this->getHashtag('notWanted'); + + $magazine = $this->getMagazineByName('testBlockedHashtagIsHiddenInEntryComments'); + $entry = $this->createEntry('something', $magazine, $contentCreator, body: 'some text'); + $commentShowing = $this->createEntryComment('some text #wanted', $entry, $contentCreator); + $commentHidden = $this->createEntryComment('some text #notWanted', $entry, $contentCreator); + + $this->tagManager->block($user, $tag); + + $criteria = new EntryCommentPageView(1, $this->security); + $criteria->showSortOption(Criteria::SORT_OLD); + $criteria->entry = $entry; + + $fanta = $this->entryCommentRepository->findByCriteria($criteria, $user); + $result = $fanta->getCurrentPageResults(); + + self::assertSame($commentShowing->getId(), $result[0]->getId()); + self::assertCount(1, $result); + } + + public function testBlockedHashtagIsHiddenInPostComments() + { + $user = $this->getUserByUsername('John Doe'); + $contentCreator = $this->getUserByUsername('poster'); + $tag = $this->getHashtag('notWanted'); + + $magazine = $this->getMagazineByName('testBlockedHashtagIsHiddenInPostComments'); + $post = $this->createPost('something', $magazine, $contentCreator); + $commentShowing = $this->createPostComment('some text #wanted', $post, $contentCreator); + $commentHidden = $this->createPostComment('some text #notWanted', $post, $contentCreator); + + $this->tagManager->block($user, $tag); + + $criteria = new PostCommentPageView(1, $this->security); + $criteria->showSortOption(Criteria::SORT_OLD); + $criteria->post = $post; + + $fanta = $this->postCommentRepository->findByCriteria($criteria, $user); + $result = $fanta->getCurrentPageResults(); + + self::assertSame($commentShowing->getId(), $result[0]->getId()); + self::assertCount(1, $result); + } +} diff --git a/tests/Functional/Service/Hashtag/TagSubscriptionTest.php b/tests/Functional/Service/Hashtag/TagSubscriptionTest.php new file mode 100644 index 0000000000..ffb6537d0b --- /dev/null +++ b/tests/Functional/Service/Hashtag/TagSubscriptionTest.php @@ -0,0 +1,171 @@ +getUserByUsername('John Doe'); + $user2 = $this->getUserByUsername('Jane Doe'); + $tagNeutral = $this->getHashtag('abc'); + $tagBlocked = $this->getHashtag('def'); + + $this->tagManager->subscribe($user1, $tagBlocked); + + self::assertCount(1, $user1->subscribedHashtags); + self::assertSame($tagBlocked->tag, $user1->subscribedHashtags[0]->hashtag->tag); + self::assertCount(0, $user2->subscribedHashtags); + } + + public function testUnsubscribe() + { + $user1 = $this->getUserByUsername('John Doe'); + $user2 = $this->getUserByUsername('Jane Doe'); + $tag1 = $this->getHashtag('abc'); + $tag2 = $this->getHashtag('def'); + + $this->tagManager->subscribe($user1, $tag1); + $this->tagManager->subscribe($user1, $tag2); + $this->tagManager->subscribe($user2, $tag1); + + $this->tagManager->unsubscribe($user1, $tag1); + + self::assertCount(1, $user1->subscribedHashtags); + self::assertSame($tag2->tag, $user1->subscribedHashtags->first()->hashtag->tag); + self::assertCount(1, $user2->subscribedHashtags); + self::assertSame($tag1->tag, $user2->subscribedHashtags->first()->hashtag->tag); + } + + public function testSubscribedHashtagIsIncludedInCombinedWithCache() + { + $user = $this->getUserByUsername('John Doe'); + $contentCreator = $this->getUserByUsername('poster'); + $tag = $this->getHashtag('interesting'); + + $magazine = $this->getMagazineByName('testSubscribedHashtagIsIncludedInCombinedWithCache'); + $entryShowing = $this->createEntry('showing', $magazine, $contentCreator, body: 'some text #interesting'); + $entryHidden = $this->createEntry('hidden', $magazine, $contentCreator, body: 'some text #notInteresting'); + $entryCommentNotShowing = $this->createEntryComment('some text #interesting', $entryShowing, $contentCreator); + $entryCommentHidden = $this->createEntryComment('some text #notInteresting', $entryShowing, $contentCreator); + $postShowing = $this->createPost('some text #interesting', $magazine, $contentCreator); + $postHidden = $this->createPost('some text #notInteresting', $magazine, $contentCreator); + $postCommentNotShowing = $this->createPostComment('some text #interesting', $postShowing, $contentCreator); + $postCommentHidden = $this->createPostComment('some text #notInteresting', $postShowing, $contentCreator); + $this->setContentTime($entryHidden, $entryShowing, 2); + $this->setContentTime($entryCommentNotShowing, $entryShowing, 4); + $this->setContentTime($entryCommentHidden, $entryShowing, 6); + $this->setContentTime($postShowing, $entryShowing, 8); + $this->setContentTime($postHidden, $entryShowing, 10); + $this->setContentTime($postCommentNotShowing, $entryShowing, 12); + $this->setContentTime($postCommentHidden, $entryShowing, 14); + + $this->tagManager->subscribe($user, $tag); + + $criteria = new EntryPageView(1, $this->security) + ->setContent(Criteria::CONTENT_COMBINED) + ->showSortOption(Criteria::SORT_OLD); + $criteria->subscribed = true; + $criteria->includeBoosts = false; + $criteria->perPage = 5; + $criteria->fetchCachedItems($this->sqlHelpers, $user); + + $fanta = $this->contentRepository->findByCriteria($criteria, $user); + $result = $fanta->getCurrentPageResults(); + + self::assertInstanceOf(Entry::class, $result[0]); + self::assertSame($entryShowing->getId(), $result[0]->getId()); + self::assertInstanceOf(Post::class, $result[1]); + self::assertSame($postShowing->getId(), $result[1]->getId()); + self::assertCount(2, $result); + } + + public function testSubscribedHashtagIsIncludedInCombinedWithoutCache() + { + $user = $this->getUserByUsername('John Doe'); + $contentCreator = $this->getUserByUsername('poster'); + $tag = $this->getHashtag('interesting'); + + $magazine = $this->getMagazineByName('testSubscribedHashtagIsIncludedInCombinedWithoutCache'); + $entryShowing = $this->createEntry('showing', $magazine, $contentCreator, body: 'some text #interesting'); + $entryHidden = $this->createEntry('hidden', $magazine, $contentCreator, body: 'some text #notInteresting'); + $entryCommentNotShowing = $this->createEntryComment('some text #interesting', $entryShowing, $contentCreator); + $entryCommentHidden = $this->createEntryComment('some text #notInteresting', $entryShowing, $contentCreator); + $postShowing = $this->createPost('some text #interesting', $magazine, $contentCreator); + $postHidden = $this->createPost('some text #notInteresting', $magazine, $contentCreator); + $postCommentNotShowing = $this->createPostComment('some text #interesting', $postShowing, $contentCreator); + $postCommentHidden = $this->createPostComment('some text #notInteresting', $postShowing, $contentCreator); + $this->setContentTime($entryHidden, $entryShowing, 2); + $this->setContentTime($entryCommentNotShowing, $entryShowing, 4); + $this->setContentTime($entryCommentHidden, $entryShowing, 6); + $this->setContentTime($postShowing, $entryShowing, 8); + $this->setContentTime($postHidden, $entryShowing, 10); + $this->setContentTime($postCommentNotShowing, $entryShowing, 12); + $this->setContentTime($postCommentHidden, $entryShowing, 14); + + $this->tagManager->subscribe($user, $tag); + + $criteria = new EntryPageView(1, $this->security) + ->setContent(Criteria::CONTENT_COMBINED) + ->showSortOption(Criteria::SORT_OLD); + $criteria->subscribed = true; + $criteria->includeBoosts = false; + $criteria->perPage = 5; + + $fanta = $this->contentRepository->findByCriteria($criteria, $user); + $result = $fanta->getCurrentPageResults(); + + self::assertInstanceOf(Entry::class, $result[0]); + self::assertSame($entryShowing->getId(), $result[0]->getId()); + self::assertInstanceOf(Post::class, $result[1]); + self::assertSame($postShowing->getId(), $result[1]->getId()); + self::assertCount(2, $result); + } + + public function testSubscribedHashtagIsIncludedInCombinedComments() + { + $user = $this->getUserByUsername('John Doe'); + $contentCreator = $this->getUserByUsername('poster'); + $tag = $this->getHashtag('interesting'); + + $magazine = $this->getMagazineByName('testSubscribedHashtagIsIncludedInCombinedComments'); + $entry = $this->createEntry('parent', $magazine, $contentCreator, body: 'some text'); + $entryCommentShowing = $this->createEntryComment('some text #interesting', $entry, $contentCreator); + $entryCommentHidden = $this->createEntryComment('some text #notInteresting', $entry, $contentCreator); + $post = $this->createPost('parent', $magazine, $contentCreator); + $postCommentShowing = $this->createPostComment('some text #interesting', $post, $contentCreator); + $postCommentHidden = $this->createPostComment('some text #notInteresting', $post, $contentCreator); + $this->setContentTime($entryCommentShowing, $entry, 2); + $this->setContentTime($entryCommentHidden, $entry, 4); + $this->setContentTime($postCommentShowing, $entry, 6); + $this->setContentTime($postCommentHidden, $entry, 8); + + $this->tagManager->subscribe($user, $tag); + + $criteria = new EntryPageView(1, $this->security) + ->setContent(Criteria::CONTENT_COMBINED) + ->showSortOption(Criteria::SORT_OLD); + $criteria->subscribed = true; + $criteria->includeCommentsWithSubscribedHashtag = true; + $criteria->includeBoosts = true; + $criteria->perPage = 5; + + $fanta = $this->contentRepository->findByCriteria($criteria, $user); + $result = $fanta->getCurrentPageResults(); + + self::assertInstanceOf(EntryComment::class, $result[0]); + self::assertSame($entryCommentShowing->getId(), $result[0]->getId()); + self::assertInstanceOf(PostComment::class, $result[1]); + self::assertSame($postCommentShowing->getId(), $result[1]->getId()); + self::assertCount(2, $result); + } +} diff --git a/tests/WebTestCase.php b/tests/WebTestCase.php index 6fef1207da..e205d1f3b2 100644 --- a/tests/WebTestCase.php +++ b/tests/WebTestCase.php @@ -4,6 +4,10 @@ namespace App\Tests; +use App\Entity\Entry; +use App\Entity\EntryComment; +use App\Entity\Post; +use App\Entity\PostComment; use App\Factory\ActivityPub\EntryPageFactory; use App\Factory\ActivityPub\GroupFactory; use App\Factory\ActivityPub\PersonFactory; @@ -31,6 +35,7 @@ use App\Repository\SettingsRepository; use App\Repository\SiteRepository; use App\Repository\TagLinkRepository; +use App\Repository\TagRepository; use App\Repository\UserFollowRepository; use App\Repository\UserRepository; use App\Service\ActivityPub\ActivityJsonBuilder; @@ -55,11 +60,13 @@ use App\Service\ProjectInfoService; use App\Service\ReportManager; use App\Service\SettingsManager; +use App\Service\TagManager; use App\Service\UserManager; use App\Service\VoteManager; use App\Tests\Service\TestingApHttpClient; use App\Tests\Service\TestingImageManager; use App\Twig\Runtime\FormattingExtensionRuntime; +use App\Utils\SqlHelpers; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\ORM\EntityManagerInterface; use League\Flysystem\Filesystem; @@ -102,12 +109,14 @@ abstract class WebTestCase extends BaseWebTestCase protected const MAGAZINE_RESPONSE_KEYS = ['magazineId', 'owner', 'icon', 'banner', 'name', 'title', 'description', 'rules', 'subscriptionsCount', 'entryCount', 'entryCommentCount', 'postCount', 'postCommentCount', 'isAdult', 'isUserSubscribed', 'isBlockedByUser', 'tags', 'badges', 'moderators', 'apId', 'apProfileId', 'serverSoftware', 'serverSoftwareVersion', 'isPostingRestrictedToMods', 'localSubscribers', 'notificationStatus', 'discoverable', 'indexable']; protected const MAGAZINE_SMALL_RESPONSE_KEYS = ['magazineId', 'name', 'icon', 'banner', 'isUserSubscribed', 'isBlockedByUser', 'apId', 'apProfileId', 'discoverable', 'indexable']; protected const DOMAIN_RESPONSE_KEYS = ['domainId', 'name', 'entryCount', 'subscriptionsCount', 'isUserSubscribed', 'isBlockedByUser']; + protected const array HASHTAG_RESPONSE_KEYS = ['tag', 'entryCount', 'entryCommentCount', 'postCount', 'postCommentCount', 'isBlockedByUser', 'isSubscribedByUser']; protected const KIBBY_PNG_URL_RESULT = 'a8/1c/a81cc2fea35eeb232cd28fcb109b3eb5a4e52c71bce95af6650d71876c1bcbb7.png'; protected ArrayCollection $users; protected ArrayCollection $magazines; protected ArrayCollection $entries; + protected ArrayCollection $hashtags; protected EntityManagerInterface $entityManager; protected KernelBrowser $client; @@ -123,6 +132,7 @@ abstract class WebTestCase extends BaseWebTestCase protected VoteManager $voteManager; protected SettingsManager $settingsManager; protected DomainManager $domainManager; + protected TagManager $tagManager; protected ReportManager $reportManager; protected BadgeManager $badgeManager; protected NotificationManager $notificationManager; @@ -145,6 +155,7 @@ abstract class WebTestCase extends BaseWebTestCase protected SettingsRepository $settingsRepository; protected UserRepository $userRepository; protected TagLinkRepository $tagLinkRepository; + protected TagRepository $tagRepository; protected BookmarkRepository $bookmarkRepository; protected BookmarkListRepository $bookmarkListRepository; protected UserFollowRepository $userFollowRepository; @@ -177,6 +188,8 @@ abstract class WebTestCase extends BaseWebTestCase protected ActivityJsonBuilder $activityJsonBuilder; protected Security $security; + protected SqlHelpers $sqlHelpers; + protected DeliverHandler $deliverHandler; protected string $kibbyPath; @@ -187,6 +200,8 @@ public function setUp(): void $this->users = new ArrayCollection(); $this->magazines = new ArrayCollection(); $this->entries = new ArrayCollection(); + $this->hashtags = new ArrayCollection(); + $this->kibbyPath = \dirname(__FILE__).'/assets/kibby_emoji.png'; $this->imageUploadTmpDir = \dirname($this->kibbyPath).'/copy/'; $this->client = static::createClient(); @@ -222,6 +237,7 @@ public function setUp(): void $this->voteManager = $this->getService(VoteManager::class); $this->settingsManager = $this->getService(SettingsManager::class); $this->domainManager = $this->getService(DomainManager::class); + $this->tagManager = $this->getService(TagManager::class); $this->reportManager = $this->getService(ReportManager::class); $this->badgeManager = $this->getService(BadgeManager::class); $this->notificationManager = $this->getService(NotificationManager::class); @@ -246,6 +262,7 @@ public function setUp(): void $this->settingsRepository = $this->getService(SettingsRepository::class); $this->userRepository = $this->getService(UserRepository::class); $this->tagLinkRepository = $this->getService(TagLinkRepository::class); + $this->tagRepository = $this->getService(TagRepository::class); $this->bookmarkRepository = $this->getService(BookmarkRepository::class); $this->bookmarkListRepository = $this->getService(BookmarkListRepository::class); $this->userFollowRepository = $this->getService(UserFollowRepository::class); @@ -274,6 +291,8 @@ public function setUp(): void $this->projectInfoService = $this->getService(ProjectInfoService::class); $this->logger = $this->getService(LoggerInterface::class); + $this->sqlHelpers = $this->getService(SqlHelpers::class); + // clear all cache before every test $app = new Application($this->client->getKernel()); $command = $app->get('cache:pool:clear'); @@ -326,6 +345,14 @@ public static function removeTimeElements(string $content): string return preg_replace($pattern, '', $content); } + public function setContentTime(Entry|EntryComment|Post|PostComment $subject, Entry|EntryComment|Post|PostComment $reference, int $seconds): void + { + $subject->createdAt = $reference->getCreatedAt()->add(\DateInterval::createFromDateString($seconds.' seconds')); + $subject->lastBoostedAt = $subject->createdAt; + $this->entityManager->persist($subject); + $this->entityManager->flush(); + } + protected function tearDown(): void { parent::tearDown(); diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index 1e6b7d28bd..c9ef0b03c1 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -140,6 +140,8 @@ title: Title body: Body tags: Tags tag: Tag +hashtag: Hashtag +hashtags: Hashtags badges: Badges is_adult: 18+ / NSFW eng: ENG @@ -564,16 +566,22 @@ oauth2.grant.delete.general: Delete any of your threads, posts, or comments. oauth2.grant.report.general: Report threads, posts, or comments. oauth2.grant.vote.general: Upvote, downvote, or boost threads, posts, or comments. -oauth2.grant.subscribe.general: Subscribe or follow any magazine, domain, or +oauth2.grant.subscribe.general: Subscribe or follow any magazine, domain, hashtag, or user, and view the magazines, domains, and users you subscribe to. -oauth2.grant.block.general: Block or unblock any magazine, domain, or user, and +oauth2.grant.block.general: Block or unblock any magazine, domain, hashtag, or user, and view the magazines, domains, and users you have blocked. oauth2.grant.domain.all: Subscribe to or block domains, and view the domains you - subscribe to or block. + subscribed to or have blocked. oauth2.grant.domain.subscribe: Subscribe or unsubscribe to domains and view the - domains you subscribe to. + domains you subscribed to. oauth2.grant.domain.block: Block or unblock domains and view the domains you have blocked. +oauth2.grant.hashtag.all: Subscribe to or block hashtags, and view the hashtags you + subscribed to or have blocked. +oauth2.grant.hashtag.subscribe: Subscribe or unsubscribe to hashtags and view the + hashtags you subscribed to. +oauth2.grant.hashtag.block: Block or unblock hashtags and view the hashtags you + have blocked. oauth2.grant.entry.all: Create, edit, or delete your threads, and vote, boost, or report any thread. oauth2.grant.entry.create: Create new threads. @@ -590,9 +598,9 @@ oauth2.grant.entry_comment.vote: Upvote, boost, or downvote any comment in a thread. oauth2.grant.entry_comment.report: Report any comment in a thread. oauth2.grant.magazine.all: Subscribe to or block magazines, and view the - magazines you subscribe to or block. + magazines you subscribed to or have blocked. oauth2.grant.magazine.subscribe: Subscribe or unsubscribe to magazines and view - the magazines you subscribe to. + the magazines you subscribed to. oauth2.grant.magazine.block: Block or unblock magazines and view the magazines you have blocked. oauth2.grant.post.all: Create, edit, or delete your microblogs, and vote, boost, @@ -1078,6 +1086,8 @@ show_boost_following_label: Show boosted content in Microblog and Combined view show_boost_following_help: If this is enabled, threads, posts and comments boosted by you or users you follow will show up in the Combined view of your subscriptions and Microblog view. This will only have an effect when the sorting is set to 'Newest'. +show_hashtag_sub_comments_label: Show comments which contain a subscribed hashtag in Microblog and Combined view +show_hashtag_sub_comments_help: Requires inclusion of boosts to be enabled to be active in some views. delete_magazine_icon: Delete magazine icon flash_magazine_theme_icon_detached_success: Magazine icon deleted successfully delete_magazine_banner: Delete magazine banner