From 07863d2a7294fb8ef12b4ff2cb84235d8f450e4e Mon Sep 17 00:00:00 2001 From: BentiGorlich Date: Tue, 26 Nov 2024 10:42:32 +0100 Subject: [PATCH] Implement custom notifications You can now set custom notification settings for users, magazine, entries and posts: `Loud`, `Default` or `Muted`. Move the logic of "fetch an endpoint and get html. Replace html node with selector x with the result of the fetch" to another controller, so it can be used outside of subjects (`Entry`, `EntryComment`, `Post` and `PostComment`) # Conflicts: # config/packages/doctrine.yaml --- assets/controllers/html_refresh_controller.js | 46 +++++ assets/controllers/subject_controller.js | 40 ---- assets/styles/app.scss | 1 + assets/styles/components/_entry.scss | 2 +- assets/styles/components/_magazine.scss | 5 +- .../components/_notification_switch.scss | 58 ++++++ assets/styles/components/_popover.scss | 4 +- assets/styles/components/_post.scss | 10 +- assets/styles/components/_sidebar.scss | 6 +- assets/styles/components/_user.scss | 1 - assets/styles/layout/_section.scss | 2 +- config/mbin_routes/notification_settings.yaml | 6 + .../notification_settings_api.yaml | 8 + config/packages/doctrine.yaml | 2 + migrations/Version20241125210454.php | 47 +++++ src/Controller/Api/BaseApi.php | 15 +- src/Controller/Api/Entry/EntriesBaseApi.php | 1 + .../Notification/NotificationSettingApi.php | 107 ++++++++++ src/Controller/Api/Post/PostsBaseApi.php | 1 + .../NotificationSettingsController.php | 59 ++++++ src/DTO/EntryResponseDto.php | 3 + src/DTO/MagazineResponseDto.php | 3 + src/DTO/PostResponseDto.php | 3 + src/DTO/UserResponseDto.php | 3 + .../DBAL/Types/EnumNotificationStatus.php | 20 ++ src/Entity/NotificationSettings.php | 70 +++++++ src/Enums/ENotificationStatus.php | 36 ++++ .../NotificationSettingsRepository.php | 188 ++++++++++++++++++ .../EntryCommentNotificationManager.php | 93 +++------ .../Notification/EntryNotificationManager.php | 26 +-- .../PostCommentNotificationManager.php | 91 +++------ .../Notification/PostNotificationManager.php | 26 +-- src/Twig/Components/NotificationSwitch.php | 38 ++++ src/Twig/Extension/FrontExtension.php | 1 + src/Twig/Runtime/FrontExtensionRuntime.php | 17 ++ templates/components/bookmark_list.html.twig | 4 +- .../components/bookmark_standard.html.twig | 12 +- templates/components/entry.html.twig | 7 +- templates/components/entry_comment.html.twig | 2 +- templates/components/magazine_box.html.twig | 7 + .../components/notification_switch.html.twig | 32 +++ templates/components/post.html.twig | 5 +- templates/components/post_comment.html.twig | 2 +- templates/components/user_box.html.twig | 5 + templates/entry/_info.html.twig | 5 + templates/post/_info.html.twig | 5 + templates/user/_user_popover.html.twig | 3 + 47 files changed, 907 insertions(+), 221 deletions(-) create mode 100644 assets/controllers/html_refresh_controller.js create mode 100644 assets/styles/components/_notification_switch.scss create mode 100644 config/mbin_routes/notification_settings.yaml create mode 100644 config/mbin_routes/notification_settings_api.yaml create mode 100644 migrations/Version20241125210454.php create mode 100644 src/Controller/Api/Notification/NotificationSettingApi.php create mode 100644 src/Controller/NotificationSettingsController.php create mode 100644 src/DoctrineExtensions/DBAL/Types/EnumNotificationStatus.php create mode 100644 src/Entity/NotificationSettings.php create mode 100644 src/Enums/ENotificationStatus.php create mode 100644 src/Repository/NotificationSettingsRepository.php create mode 100644 src/Twig/Components/NotificationSwitch.php create mode 100644 templates/components/notification_switch.html.twig diff --git a/assets/controllers/html_refresh_controller.js b/assets/controllers/html_refresh_controller.js new file mode 100644 index 000000000..fe49d11fe --- /dev/null +++ b/assets/controllers/html_refresh_controller.js @@ -0,0 +1,46 @@ +import { Controller } from "@hotwired/stimulus"; +import { fetch, ok } from "../utils/http"; + +/* stimulusFetch: 'lazy' */ +export default class extends Controller { + /** + * Calls the address attached to the nearest link node. Replaces the outer html of the nearest `cssclass` parameter + * with the response from the link + */ + async linkCallback(event) { + event.preventDefault(); + const { cssclass: cssClass, refreshlink: refreshLink, refreshselector: refreshSelector } = event.params + + const a = event.target.closest('a'); + let subjectController = this.application.getControllerForElementAndIdentifier(this.element, 'subject') + + try { + if (subjectController) { + subjectController.loadingValue = true; + } + + let response = await fetch(a.href); + + response = await ok(response); + response = await response.json(); + + event.target.closest(`.${cssClass}`).outerHTML = response.html; + + const refreshElement = this.element.querySelector(refreshSelector) + + if (!!refreshLink && refreshLink !== "" && !!refreshElement) { + let response = await fetch(refreshLink); + + response = await ok(response); + response = await response.json(); + refreshElement.outerHTML = response.html; + } + } catch (e) { + console.error(e) + } finally { + if (subjectController) { + subjectController.loadingValue = false; + } + } + } +} diff --git a/assets/controllers/subject_controller.js b/assets/controllers/subject_controller.js index 093944d60..3b180aff8 100644 --- a/assets/controllers/subject_controller.js +++ b/assets/controllers/subject_controller.js @@ -199,46 +199,6 @@ export default class extends Controller { } } - /** - * Calls the address attached to the nearest link node. Replaces the outer html of the nearest `cssclass` parameter - * with the response from the link - */ - async linkCallback(event) { - const { cssclass: cssClass, refreshlink: refreshLink, refreshselector: refreshSelector } = event.params - event.preventDefault(); - - const a = event.target.closest('a'); - - try { - this.loadingValue = true; - - let response = await fetch(a.href, { - method: 'GET', - }); - - response = await ok(response); - response = await response.json(); - - event.target.closest(`.${cssClass}`).outerHTML = response.html; - - const refreshElement = this.element.querySelector(refreshSelector) - console.log("linkCallback refresh stuff", refreshLink, refreshSelector, refreshElement) - - if (!!refreshLink && refreshLink !== "" && !!refreshElement) { - let response = await fetch(refreshLink, { - method: 'GET', - }); - - response = await ok(response); - response = await response.json(); - refreshElement.outerHTML = response.html; - } - } catch (e) { - } finally { - this.loadingValue = false; - } - } - loadingValueChanged(val) { const submitButton = this.containerTarget.querySelector('form button[type="submit"]'); diff --git a/assets/styles/app.scss b/assets/styles/app.scss index 2cc77c74b..164dc71af 100644 --- a/assets/styles/app.scss +++ b/assets/styles/app.scss @@ -52,6 +52,7 @@ @import 'components/infinite_scroll'; @import 'components/sidebar-subscriptions'; @import 'components/settings_row'; +@import 'components/notification_switch'; @import 'pages/post_single'; @import 'pages/post_front'; @import 'pages/page_bookmarks'; diff --git a/assets/styles/components/_entry.scss b/assets/styles/components/_entry.scss index aacbcabfb..0fd921fb8 100644 --- a/assets/styles/components/_entry.scss +++ b/assets/styles/components/_entry.scss @@ -331,7 +331,7 @@ text-decoration: underline; } - button, input[type='submit'], a { + button, input[type='submit'], a:not(.notification-setting) { @include mbin-btn-link; } } diff --git a/assets/styles/components/_magazine.scss b/assets/styles/components/_magazine.scss index bbdf91a07..c17ba6afe 100644 --- a/assets/styles/components/_magazine.scss +++ b/assets/styles/components/_magazine.scss @@ -51,11 +51,14 @@ } } + &__description { + margin-top: 2.5rem; + } + &__subscribe { display: flex; flex-direction: row; justify-content: center; - margin-bottom: 2.5rem; flex-wrap: wrap; div { diff --git a/assets/styles/components/_notification_switch.scss b/assets/styles/components/_notification_switch.scss new file mode 100644 index 000000000..e140bd085 --- /dev/null +++ b/assets/styles/components/_notification_switch.scss @@ -0,0 +1,58 @@ +.notification-switch-container .notification-switch { + align-items: center; + justify-content: center; +} + +.entry-info, +.user-main { + .notification-switch > * { + opacity: .75; + } +} + +footer .notification-switch { + padding: 0.25em 0; + margin-top: 0; + line-height: 1.25em; +} + +.notification-switch { + display: flex; + flex-direction: row; + margin-top: .25em; + line-height: 1.5; + + >* { + cursor: pointer; + padding: .25em .375em; + border: var(--kbin-button-secondary-border); + background: var(--kbin-button-secondary-bg); + color: var(--kbin-button-secondary-text-color); + + &:hover:not(.active) { + background: var(--kbin-button-secondary-hover-bg); + color: var(--kbin-button-secondary-text-hover-color); + } + + &.active { + cursor: unset; + background: var(--kbin-button-primary-bg); + color: var(--kbin-button-primary-text-color); + + &:hover { + background: var(--kbin-button-primary-hover-bg); + color: var(--kbin-button-primary-text-hover-color); + } + } + + &:last-child { + border-radius: 0 1em 1em 0; + padding-right: .75em; + } + + &:first-child { + border-radius: 1em 0 0 1em; + padding-left: .75em; + } + } +} diff --git a/assets/styles/components/_popover.scss b/assets/styles/components/_popover.scss index c97cf0cd5..d5352dbbe 100644 --- a/assets/styles/components/_popover.scss +++ b/assets/styles/components/_popover.scss @@ -20,12 +20,12 @@ z-index: var(--z-index-popover, 25); a { - color: var(--kbin-meta-link-color) !important; + color: var(--kbin-meta-link-color); line-height: normal; display: inline-block; &:hover { - color: var(--kbin-meta-link-color-hover) !important; + color: var(--kbin-meta-link-color-hover); } } } diff --git a/assets/styles/components/_post.scss b/assets/styles/components/_post.scss index 533e5f3cb..267a15508 100644 --- a/assets/styles/components/_post.scss +++ b/assets/styles/components/_post.scss @@ -56,7 +56,7 @@ margin-bottom: 0; opacity: .75; - a { + a:not(.notification-setting) { color: var(--kbin-meta-link-color); font-weight: bold; @@ -72,7 +72,7 @@ } } - aside { + aside:not(.notification-switch) { grid-area: vote; } @@ -131,13 +131,13 @@ line-height: 1rem; } - & > a.active, + & > a:not(.notification-setting).active, & > li button.active { text-decoration: underline; } button, - a { + a:not(.notification-setting) { font-size: .8rem; @include mbin-btn-link; } @@ -147,7 +147,7 @@ } } - a { + a:not(.notification-setting) { @include mbin-btn-link; } diff --git a/assets/styles/components/_sidebar.scss b/assets/styles/components/_sidebar.scss index 0a5d92c89..21d7a87ae 100644 --- a/assets/styles/components/_sidebar.scss +++ b/assets/styles/components/_sidebar.scss @@ -244,7 +244,7 @@ } } - a { + a:not(.notification-setting) { color: var(--kbin-meta-link-color); } @@ -257,6 +257,10 @@ } } + .entry-info ul.info { + margin-top: 2.5rem; + } + .settings { display: flex; gap: 1rem; diff --git a/assets/styles/components/_user.scss b/assets/styles/components/_user.scss index 42e05f252..ff0990eb1 100644 --- a/assets/styles/components/_user.scss +++ b/assets/styles/components/_user.scss @@ -3,7 +3,6 @@ display: flex; flex-direction: row; justify-content: center; - margin-bottom: 2.5rem; opacity: .75; div { diff --git a/assets/styles/layout/_section.scss b/assets/styles/layout/_section.scss index 47e75a8eb..6e3e77199 100644 --- a/assets/styles/layout/_section.scss +++ b/assets/styles/layout/_section.scss @@ -5,7 +5,7 @@ margin-bottom: .5rem; padding: 2rem 1rem; - a { + a:not(.notification-setting) { color: var(--kbin-section-title-link-color); overflow-wrap: anywhere; diff --git a/config/mbin_routes/notification_settings.yaml b/config/mbin_routes/notification_settings.yaml new file mode 100644 index 000000000..ed4b6a457 --- /dev/null +++ b/config/mbin_routes/notification_settings.yaml @@ -0,0 +1,6 @@ +change_notification_setting: + controller: App\Controller\NotificationSettingsController::changeSetting + path: /cns/{subject_type}/{subject_id}/{status} + requirements: + subject_type: user|magazine|entry|post + status: Default|Loud|Muted diff --git a/config/mbin_routes/notification_settings_api.yaml b/config/mbin_routes/notification_settings_api.yaml new file mode 100644 index 000000000..5107b6c3d --- /dev/null +++ b/config/mbin_routes/notification_settings_api.yaml @@ -0,0 +1,8 @@ +api_notification_settings_update: + controller: App\Controller\Api\Notification\NotificationSettingApi::update + path: /api/notification/update/{targetType}/{targetId}/{setting} + requirements: + targetType: entry|post|magazine|user + setting: Default|Loud|Muted + methods: [ GET ] + format: json diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml index 809607c02..8564532dc 100644 --- a/config/packages/doctrine.yaml +++ b/config/packages/doctrine.yaml @@ -4,10 +4,12 @@ doctrine: types: citext: App\DoctrineExtensions\DBAL\Types\Citext enumApplicationStatus: App\DoctrineExtensions\DBAL\Types\EnumApplicationStatus + enumNotificationStatus: App\DoctrineExtensions\DBAL\Types\EnumNotificationStatus mapping_types: user_type: string citext: citext enumApplicationStatus: string + enumNotificationStatus: string # IMPORTANT: You MUST configure your server version, # either here or in the DATABASE_URL env var (see .env file) diff --git a/migrations/Version20241125210454.php b/migrations/Version20241125210454.php new file mode 100644 index 000000000..0a7ab28e2 --- /dev/null +++ b/migrations/Version20241125210454.php @@ -0,0 +1,47 @@ +addSql('CREATE TYPE enumNotificationStatus AS ENUM(\'Default\', \'Muted\', \'Loud\')'); + $this->addSql('CREATE SEQUENCE notification_settings_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE TABLE notification_settings (id INT NOT NULL, user_id INT NOT NULL, entry_id INT DEFAULT NULL, post_id INT DEFAULT NULL, magazine_id INT DEFAULT NULL, target_user_id INT DEFAULT NULL, notification_status enumNotificationStatus DEFAULT \'Default\' NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX IDX_B0559860A76ED395 ON notification_settings (user_id)'); + $this->addSql('CREATE INDEX IDX_B0559860BA364942 ON notification_settings (entry_id)'); + $this->addSql('CREATE INDEX IDX_B05598604B89032C ON notification_settings (post_id)'); + $this->addSql('CREATE INDEX IDX_B05598603EB84A1D ON notification_settings (magazine_id)'); + $this->addSql('CREATE INDEX IDX_B05598606C066AFE ON notification_settings (target_user_id)'); + $this->addSql('CREATE UNIQUE INDEX notification_settings_user_target ON notification_settings (user_id, entry_id, post_id, magazine_id, target_user_id)'); + $this->addSql('COMMENT ON COLUMN notification_settings.notification_status IS \'(DC2Type:EnumNotificationStatus)\''); + $this->addSql('ALTER TABLE notification_settings ADD CONSTRAINT FK_B0559860A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE notification_settings ADD CONSTRAINT FK_B0559860BA364942 FOREIGN KEY (entry_id) REFERENCES entry (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE notification_settings ADD CONSTRAINT FK_B05598604B89032C FOREIGN KEY (post_id) REFERENCES post (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE notification_settings ADD CONSTRAINT FK_B05598603EB84A1D FOREIGN KEY (magazine_id) REFERENCES magazine (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE notification_settings ADD CONSTRAINT FK_B05598606C066AFE FOREIGN KEY (target_user_id) REFERENCES "user" (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP SEQUENCE notification_settings_id_seq CASCADE'); + $this->addSql('ALTER TABLE notification_settings DROP CONSTRAINT FK_B0559860A76ED395'); + $this->addSql('ALTER TABLE notification_settings DROP CONSTRAINT FK_B0559860BA364942'); + $this->addSql('ALTER TABLE notification_settings DROP CONSTRAINT FK_B05598604B89032C'); + $this->addSql('ALTER TABLE notification_settings DROP CONSTRAINT FK_B05598603EB84A1D'); + $this->addSql('ALTER TABLE notification_settings DROP CONSTRAINT FK_B05598606C066AFE'); + $this->addSql('DROP TABLE notification_settings'); + $this->addSql('DROP TYPE enumNotificationStatus'); + } +} diff --git a/src/Controller/Api/BaseApi.php b/src/Controller/Api/BaseApi.php index fbc63903d..fe8c515ac 100644 --- a/src/Controller/Api/BaseApi.php +++ b/src/Controller/Api/BaseApi.php @@ -6,6 +6,7 @@ use App\Controller\AbstractController; use App\DTO\MagazineDto; +use App\DTO\MagazineResponseDto; use App\DTO\ReportDto; use App\DTO\ReportRequestDto; use App\DTO\UserDto; @@ -36,6 +37,7 @@ use App\Repository\EntryCommentRepository; use App\Repository\EntryRepository; use App\Repository\ImageRepository; +use App\Repository\NotificationSettingsRepository; use App\Repository\OAuth2ClientAccessRepository; use App\Repository\PostCommentRepository; use App\Repository\PostRepository; @@ -98,6 +100,7 @@ public function __construct( private readonly ImageRepository $imageRepository, private readonly ReportManager $reportManager, private readonly OAuth2ClientAccessRepository $clientAccessRepository, + protected readonly NotificationSettingsRepository $notificationSettingsRepository, ) { } @@ -297,12 +300,16 @@ protected function serializeLogItem(MagazineLog $log): array * * @param MagazineDto $dto The MagazineDto to serialize * - * @return array An associative array representation of the entry's safe fields, to be used as JSON + * @return MagazineResponseDto An associative array representation of the entry's safe fields, to be used as JSON */ - protected function serializeMagazine(MagazineDto $dto) + protected function serializeMagazine(MagazineDto $dto): MagazineResponseDto { $response = $this->magazineFactory->createResponseDto($dto); + if ($user = $this->getUser()) { + $response->notificationStatus = $this->notificationSettingsRepository->findOneByTarget($user, $dto); + } + return $response; } @@ -317,6 +324,10 @@ protected function serializeUser(UserDto $dto): UserResponseDto { $response = new UserResponseDto($dto); + if ($user = $this->getUser()) { + $response->notificationStatus = $this->notificationSettingsRepository->findOneByTarget($user, $dto); + } + return $response; } diff --git a/src/Controller/Api/Entry/EntriesBaseApi.php b/src/Controller/Api/Entry/EntriesBaseApi.php index 8b34c280f..16de488fe 100644 --- a/src/Controller/Api/Entry/EntriesBaseApi.php +++ b/src/Controller/Api/Entry/EntriesBaseApi.php @@ -46,6 +46,7 @@ protected function serializeEntry(EntryDto|Entry $dto, array $tags): EntryRespon if ($user = $this->getUser()) { $response->canAuthUserModerate = $dto->getMagazine()->userIsModerator($user) || $user->isModerator() || $user->isAdmin(); + $response->notificationStatus = $this->notificationSettingsRepository->findOneByTarget($user, $dto); } return $response; diff --git a/src/Controller/Api/Notification/NotificationSettingApi.php b/src/Controller/Api/Notification/NotificationSettingApi.php new file mode 100644 index 000000000..f2ce3d8b7 --- /dev/null +++ b/src/Controller/Api/Notification/NotificationSettingApi.php @@ -0,0 +1,107 @@ +rateLimit($apiUpdateLimiter); + $user = $this->getUserOrThrow(); + $notificationSetting = ENotificationStatus::getFromString($setting); + if ('entry' === $targetType) { + $repo = $this->entityManager->getRepository(Entry::class); + } elseif ('post' === $targetType) { + $repo = $this->entityManager->getRepository(Post::class); + } elseif ('magazine' === $targetType) { + $repo = $this->entityManager->getRepository(Magazine::class); + } elseif ('user' === $targetType) { + $repo = $this->entityManager->getRepository(User::class); + } else { + throw new \LogicException(); + } + $target = $repo->find($targetId); + if (null === $target) { + throw $this->createNotFoundException(); + } + $this->notificationSettingsRepository->setStatusByTarget($user, $target, $notificationSetting); + + return new JsonResponse(); + } +} diff --git a/src/Controller/Api/Post/PostsBaseApi.php b/src/Controller/Api/Post/PostsBaseApi.php index d1df4625b..90a0c677d 100644 --- a/src/Controller/Api/Post/PostsBaseApi.php +++ b/src/Controller/Api/Post/PostsBaseApi.php @@ -32,6 +32,7 @@ protected function serializePost(PostDto $dto, array $tags): PostResponseDto if ($user = $this->getUser()) { $response->canAuthUserModerate = $dto->getMagazine()->userIsModerator($user) || $user->isModerator() || $user->isAdmin(); + $response->notificationStatus = $this->notificationSettingsRepository->findOneByTarget($user, $dto); } return $response; diff --git a/src/Controller/NotificationSettingsController.php b/src/Controller/NotificationSettingsController.php new file mode 100644 index 000000000..fefecdc0f --- /dev/null +++ b/src/Controller/NotificationSettingsController.php @@ -0,0 +1,59 @@ +entityManager->getRepository(self::GetClassFromSubjectType($subject_type))->findOneBy(['id' => $subject_id]); + $user = $this->getUserOrThrow(); + $this->repository->setStatusByTarget($user, $subject, $status); + if ($request->isXmlHttpRequest()) { + return new JsonResponse([ + 'html' => $this->renderView('components/_ajax.html.twig', [ + 'component' => 'notification_switch', + 'attributes' => [ + 'target' => $subject, + ], + ] + ), + ]); + } + + return $this->redirect($request->headers->get('Referer')); + } + + protected static function GetClassFromSubjectType(string $subjectType): string + { + return match ($subjectType) { + 'entry' => Entry::class, + 'post' => Post::class, + 'user' => User::class, + 'magazine' => Magazine::class, + default => throw new \LogicException("cannot match type $subjectType") + }; + } +} diff --git a/src/DTO/EntryResponseDto.php b/src/DTO/EntryResponseDto.php index 8d761a624..7d548899d 100644 --- a/src/DTO/EntryResponseDto.php +++ b/src/DTO/EntryResponseDto.php @@ -6,6 +6,7 @@ use App\DTO\Contracts\VisibilityAwareDtoTrait; use App\Entity\Entry; +use App\Enums\ENotificationStatus; use Nelmio\ApiDocBundle\Annotation\Model; use OpenApi\Attributes as OA; @@ -45,6 +46,7 @@ class EntryResponseDto implements \JsonSerializable public ?string $slug = null; public ?string $apId = null; public ?bool $canAuthUserModerate = null; + public ?ENotificationStatus $notificationStatus = null; public static function create( ?int $id = null, @@ -154,6 +156,7 @@ public function jsonSerialize(): mixed 'slug' => $this->slug, 'apId' => $this->apId, 'canAuthUserModerate' => $this->canAuthUserModerate, + 'notificationStatus' => $this->notificationStatus, ]); } } diff --git a/src/DTO/MagazineResponseDto.php b/src/DTO/MagazineResponseDto.php index fcfa57a83..a1f80ad1b 100644 --- a/src/DTO/MagazineResponseDto.php +++ b/src/DTO/MagazineResponseDto.php @@ -4,6 +4,7 @@ namespace App\DTO; +use App\Enums\ENotificationStatus; use Nelmio\ApiDocBundle\Annotation\Model; use OpenApi\Attributes as OA; @@ -37,6 +38,7 @@ class MagazineResponseDto implements \JsonSerializable public ?string $serverSoftwareVersion = null; public bool $isPostingRestrictedToMods = false; public ?int $localSubscribers = null; + public ?ENotificationStatus $notificationStatus = null; public static function create( ?ModeratorResponseDto $owner = null, @@ -120,6 +122,7 @@ public function jsonSerialize(): mixed 'serverSoftwareVersion' => $this->serverSoftwareVersion, 'isPostingRestrictedToMods' => $this->isPostingRestrictedToMods, 'localSubscribers' => $this->localSubscribers, + 'notificationStatus' => $this->notificationStatus, ]; } } diff --git a/src/DTO/PostResponseDto.php b/src/DTO/PostResponseDto.php index c17d86a0a..330af2afe 100644 --- a/src/DTO/PostResponseDto.php +++ b/src/DTO/PostResponseDto.php @@ -5,6 +5,7 @@ namespace App\DTO; use App\DTO\Contracts\VisibilityAwareDtoTrait; +use App\Enums\ENotificationStatus; use OpenApi\Attributes as OA; #[OA\Schema()] @@ -37,6 +38,7 @@ class PostResponseDto implements \JsonSerializable public ?\DateTimeImmutable $editedAt = null; public ?\DateTime $lastActive = null; public ?bool $canAuthUserModerate = null; + public ?ENotificationStatus $notificationStatus = null; public static function create( int $id, @@ -128,6 +130,7 @@ public function jsonSerialize(): mixed 'lastActive' => $this->lastActive?->format(\DateTimeInterface::ATOM), 'slug' => $this->slug, 'canAuthUserModerate' => $this->canAuthUserModerate, + 'notificationStatus' => $this->notificationStatus, ]); } } diff --git a/src/DTO/UserResponseDto.php b/src/DTO/UserResponseDto.php index a9f96308d..3f7efa44d 100644 --- a/src/DTO/UserResponseDto.php +++ b/src/DTO/UserResponseDto.php @@ -4,6 +4,7 @@ namespace App\DTO; +use App\Enums\ENotificationStatus; use OpenApi\Attributes as OA; #[OA\Schema()] @@ -26,6 +27,7 @@ class UserResponseDto implements \JsonSerializable public ?int $userId = null; public ?string $serverSoftware = null; public ?string $serverSoftwareVersion = null; + public ?ENotificationStatus $notificationStatus = null; public function __construct(UserDto $dto) { @@ -68,6 +70,7 @@ public function jsonSerialize(): mixed 'isBlockedByUser' => $this->isBlockedByUser, 'serverSoftware' => $this->serverSoftware, 'serverSoftwareVersion' => $this->serverSoftwareVersion, + 'notificationStatus' => $this->notificationStatus, ]; } } diff --git a/src/DoctrineExtensions/DBAL/Types/EnumNotificationStatus.php b/src/DoctrineExtensions/DBAL/Types/EnumNotificationStatus.php new file mode 100644 index 000000000..25723ae82 --- /dev/null +++ b/src/DoctrineExtensions/DBAL/Types/EnumNotificationStatus.php @@ -0,0 +1,20 @@ + ENotificationStatus::Default->value])] + private string $notificationStatus = ENotificationStatus::Default->value; + + public function __construct(User $user, Entry|Post|User|Magazine $target, ENotificationStatus $status) + { + $this->user = $user; + $this->setStatus($status); + if ($target instanceof User) { + $this->targetUser = $target; + } elseif ($target instanceof Magazine) { + $this->magazine = $target; + } elseif ($target instanceof Entry) { + $this->entry = $target; + } elseif ($target instanceof Post) { + $this->post = $target; + } + } + + public function setStatus(ENotificationStatus $status): void + { + $this->notificationStatus = $status->value; + } + + public function getStatus(): ENotificationStatus + { + return ENotificationStatus::getFromString($this->notificationStatus); + } +} diff --git a/src/Enums/ENotificationStatus.php b/src/Enums/ENotificationStatus.php new file mode 100644 index 000000000..18f177d48 --- /dev/null +++ b/src/Enums/ENotificationStatus.php @@ -0,0 +1,36 @@ +value => self::Default, + self::Muted->value => self::Muted, + self::Loud->value => self::Loud, + default => null, + }; + } + + public const Values = [ + ENotificationStatus::Default->value, + ENotificationStatus::Muted->value, + ENotificationStatus::Loud->value, + ]; + + /** + * @return string[] + */ + public static function getValues(): array + { + return self::Values; + } +} diff --git a/src/Repository/NotificationSettingsRepository.php b/src/Repository/NotificationSettingsRepository.php new file mode 100644 index 000000000..b8541dcb9 --- /dev/null +++ b/src/Repository/NotificationSettingsRepository.php @@ -0,0 +1,188 @@ +createQueryBuilder('ns') + ->where('ns.user = :user'); + + if ($target instanceof User || $target instanceof UserDto) { + $qb->andWhere('ns.targetUser = :target'); + } elseif ($target instanceof Magazine || $target instanceof MagazineDto) { + $qb->andWhere('ns.magazine = :target'); + } elseif ($target instanceof Entry || $target instanceof EntryDto) { + $qb->andWhere('ns.entry = :target'); + } elseif ($target instanceof Post || $target instanceof PostDto) { + $qb->andWhere('ns.post = :target'); + } + $qb->setParameter('target', $target->getId()); + $qb->setParameter('user', $user); + + return $qb->getQuery() + ->getOneOrNullResult(); + } + + public function setStatusByTarget(User $user, Entry|Post|User|Magazine $target, ENotificationStatus $status): void + { + $setting = $this->findOneByTarget($user, $target); + if (null === $setting) { + $setting = new NotificationSettings($user, $target, $status); + } else { + $setting->setStatus($status); + } + $this->entityManager->persist($setting); + $this->entityManager->flush(); + } + + /** + * gets the users that should be notified about the created of $target. This respects user and magazine blocks + * as well as custom notification settings and the users default notification settings. + * + * @return int[] + * + * @throws Exception + */ + public function findNotificationSubscribersByTarget(Entry|EntryComment|Post|PostComment $target): array + { + if ($target instanceof Entry || $target instanceof EntryComment) { + $targetCol = 'entry_id'; + if ($target instanceof Entry) { + $targetId = $target->getId(); + $notifyCol = 'notify_on_new_entry'; + $activateMagazineNotifications = true; + $dontNeedSubscription = false; + $dontNeedToBeAuthor = true; + $targetParentUserId = null; + } else { + $targetId = $target->entry->getId(); + if (null === $target->parent) { + $notifyCol = 'notify_on_new_entry_reply'; + $targetParentUserId = $target->entry->user->getId(); + } else { + $notifyCol = 'notify_on_new_entry_comment_reply'; + $targetParentUserId = $target->parent->user->getId(); + } + $activateMagazineNotifications = false; + $dontNeedSubscription = true; + $dontNeedToBeAuthor = false; + } + } else { + $targetCol = 'post_id'; + if ($target instanceof Post) { + $targetId = $target->getId(); + $notifyCol = 'notify_on_new_post'; + $activateMagazineNotifications = true; + $dontNeedSubscription = false; + $dontNeedToBeAuthor = true; + $targetParentUserId = null; + } else { + $targetId = $target->post->getId(); + if (null === $target->parent) { + $notifyCol = 'notify_on_new_post_reply'; + $targetParentUserId = $target->post->user->getId(); + } else { + $notifyCol = 'notify_on_new_post_comment_reply'; + $targetParentUserId = $target->parent->user->getId(); + } + $activateMagazineNotifications = false; + $dontNeedSubscription = true; + $dontNeedToBeAuthor = false; + } + } + + $activateMagazineNotificationsString = $activateMagazineNotifications ? 'true' : 'false'; + $dontNeedSubscriptionString = $dontNeedSubscription ? 'true' : 'false'; + $dontNeedToBeAuthorString = $dontNeedToBeAuthor ? 'true' : 'false'; + + $sql = "SELECT u.id FROM \"user\" u + LEFT JOIN notification_settings ns_user ON ns_user.user_id = u.id AND ns_user.target_user_id = :targetUserId + LEFT JOIN notification_settings ns_post ON ns_post.user_id = u.id AND ns_post.$targetCol = :targetId + LEFT JOIN notification_settings ns_mag ON ns_mag.user_id = u.id AND ns_mag.magazine_id = :magId + WHERE + u.ap_id IS NULL + AND u.id <> :targetUserId + AND ( + COALESCE(ns_user.notification_status, :normal) = :loud + OR ( + COALESCE(ns_user.notification_status, :normal) = :normal + AND COALESCE(ns_post.notification_status, :normal) = :loud + ) + OR ( + COALESCE(ns_user.notification_status, :normal) = :normal + AND COALESCE(ns_post.notification_status, :normal) = :normal + AND COALESCE(ns_mag.notification_status, :normal) = :loud + -- deactivate loud magazine notifications for comments + AND $activateMagazineNotificationsString + ) + OR ( + COALESCE(ns_user.notification_status, :normal) = :normal + AND COALESCE(ns_post.notification_status, :normal) = :normal + AND COALESCE(ns_mag.notification_status, :normal) = :normal + AND u.$notifyCol = true + AND ( + -- deactivate magazine subscription need for comments + $dontNeedSubscriptionString + OR EXISTS (SELECT * FROM magazine_subscription ms WHERE ms.user_id = u.id AND ms.magazine_id = :magId) + ) + AND ( + -- deactivate the need to be the author of the parent to receive notifications + $dontNeedToBeAuthorString + OR u.id = :targetParentUserId + ) + ) + ) + AND NOT EXISTS (SELECT * FROM user_block ub WHERE ub.blocker_id = u.id AND ub.blocked_id = :targetUserId) + "; + $conn = $this->getEntityManager()->getConnection(); + $stmt = $conn->prepare($sql); + $result = $stmt->executeQuery([ + 'normal' => ENotificationStatus::Default->value, + 'loud' => ENotificationStatus::Loud->value, + 'targetUserId' => $target->user->getId(), + 'targetId' => $targetId, + 'magId' => $target->magazine->getId(), + 'targetParentUserId' => $targetParentUserId, + ]); + $rows = $result->fetchAllAssociative(); + $this->logger->debug('got subscribers for target {c} id {id}: {subs}', ['c' => \get_class($target), 'id' => $target->getId(), 'subs' => $rows]); + + return array_map(fn (array $row) => $row['id'], $rows); + } +} diff --git a/src/Service/Notification/EntryCommentNotificationManager.php b/src/Service/Notification/EntryCommentNotificationManager.php index 55ca0b736..5a6c2e9f2 100644 --- a/src/Service/Notification/EntryCommentNotificationManager.php +++ b/src/Service/Notification/EntryCommentNotificationManager.php @@ -18,6 +18,8 @@ use App\Repository\MagazineLogRepository; use App\Repository\MagazineSubscriptionRepository; use App\Repository\NotificationRepository; +use App\Repository\NotificationSettingsRepository; +use App\Repository\UserRepository; use App\Service\Contracts\ContentNotificationManagerInterface; use App\Service\GenerateHtmlClassService; use App\Service\ImageManager; @@ -49,11 +51,12 @@ public function __construct( private readonly EntityManagerInterface $entityManager, private readonly ImageManager $imageManager, private readonly GenerateHtmlClassService $classService, - private readonly SettingsManager $settingsManager + private readonly SettingsManager $settingsManager, + private readonly NotificationSettingsRepository $notificationSettingsRepository, + private readonly UserRepository $userRepository, ) { } - // @todo check if author is on the block list public function sendCreated(ContentInterface $subject): void { if ($subject->user->isBanned || $subject->user->isDeleted || $subject->user->isTrashed() || $subject->user->isSoftDeleted()) { @@ -62,10 +65,30 @@ public function sendCreated(ContentInterface $subject): void if (!$subject instanceof EntryComment) { throw new \LogicException(); } + $comment = $subject; - $users = $this->sendMentionedNotification($subject); - $users = $this->sendUserReplyNotification($subject, $users); - $this->sendMagazineSubscribersNotification($subject, $users); + $mentioned = $this->sendMentionedNotification($comment); + + $this->notifyMagazine(new EntryCommentCreatedNotification($comment->user, $comment)); + + $userIdsToNotify = $this->notificationSettingsRepository->findNotificationSubscribersByTarget($comment); + $usersToNotify = $this->userRepository->findBy(['id' => $userIdsToNotify]); + + if (\count($mentioned)) { + $usersToNotify = array_filter($usersToNotify, fn ($user) => !\in_array($user, $mentioned)); + } + + foreach ($usersToNotify as $subscriber) { + if (null !== $comment->parent && $comment->parent->isAuthor($subscriber)) { + $notification = new EntryCommentReplyNotification($subscriber, $comment); + } else { + $notification = new EntryCommentCreatedNotification($subscriber, $comment); + } + $this->entityManager->persist($notification); + $this->eventDispatcher->dispatch(new NotificationCreatedEvent($notification)); + } + + $this->entityManager->flush(); } private function sendMentionedNotification(EntryComment $subject): array @@ -86,41 +109,6 @@ private function sendMentionedNotification(EntryComment $subject): array return $users; } - private function sendUserReplyNotification(EntryComment $comment, array $exclude): array - { - if (!$comment->parent || $comment->parent->isAuthor($comment->user)) { - return $exclude; - } - - if (!$comment->parent->user->notifyOnNewEntryCommentReply) { - return $exclude; - } - - if (\in_array($comment->parent->user, $exclude)) { - return $exclude; - } - - if ($comment->parent->user->apId) { - // @todo activtypub - $exclude[] = $comment->parent->user; - - return $exclude; - } - - if (!$comment->parent->user->isBlocked($comment->user)) { - $notification = new EntryCommentReplyNotification($comment->parent->user, $comment); - $this->notifyUser($notification); - - $this->entityManager->persist($notification); - $this->entityManager->flush(); - $this->eventDispatcher->dispatch(new NotificationCreatedEvent($notification)); - - $exclude[] = $notification->user; - } - - return $exclude; - } - private function notifyUser(EntryCommentReplyNotification $notification): void { if (false === $this->settingsManager->get('KBIN_MERCURE_ENABLED')) { @@ -177,31 +165,6 @@ private function getResponse(Notification $notification): string ); } - private function sendMagazineSubscribersNotification(EntryComment $comment, array $exclude): void - { - $this->notifyMagazine(new EntryCommentCreatedNotification($comment->user, $comment)); - - $usersToNotify = []; // @todo user followers - if ($comment->entry->user->notifyOnNewEntryReply && !$comment->isAuthor($comment->entry->user)) { - $usersToNotify = $this->merge( - $usersToNotify, - [$comment->entry->user] - ); - } - - if (\count($exclude)) { - $usersToNotify = array_filter($usersToNotify, fn ($user) => !\in_array($user, $exclude)); - } - - foreach ($usersToNotify as $subscriber) { - $notification = new EntryCommentCreatedNotification($subscriber, $comment); - $this->eventDispatcher->dispatch(new NotificationCreatedEvent($notification)); - $this->entityManager->persist($notification); - } - - $this->entityManager->flush(); - } - private function notifyMagazine(Notification $notification): void { if (false === $this->settingsManager->get('KBIN_MERCURE_ENABLED')) { diff --git a/src/Service/Notification/EntryNotificationManager.php b/src/Service/Notification/EntryNotificationManager.php index a7f97bb24..174a4c463 100644 --- a/src/Service/Notification/EntryNotificationManager.php +++ b/src/Service/Notification/EntryNotificationManager.php @@ -12,12 +12,13 @@ use App\Entity\EntryMentionedNotification; use App\Entity\Magazine; use App\Entity\Notification; -use App\Entity\User; use App\Event\NotificationCreatedEvent; use App\Factory\MagazineFactory; use App\Repository\MagazineLogRepository; use App\Repository\MagazineSubscriptionRepository; use App\Repository\NotificationRepository; +use App\Repository\NotificationSettingsRepository; +use App\Repository\UserRepository; use App\Service\Contracts\ContentNotificationManagerInterface; use App\Service\GenerateHtmlClassService; use App\Service\ImageManager; @@ -50,7 +51,9 @@ public function __construct( private readonly ImageManager $imageManager, private readonly GenerateHtmlClassService $classService, private readonly UserManager $userManager, - private readonly SettingsManager $settingsManager + private readonly SettingsManager $settingsManager, + private readonly NotificationSettingsRepository $notificationSettingsRepository, + private readonly UserRepository $userRepository, ) { } @@ -80,20 +83,17 @@ public function sendCreated(ContentInterface $subject): void } // Notify subscribers - /** @var User[] $subscribers */ - $subscribers = $this->merge( - $this->getUsersToNotify($this->magazineRepository->findNewEntrySubscribers($subject)), - [] // @todo user followers - ); + $subscriberIds = $this->notificationSettingsRepository->findNotificationSubscribersByTarget($subject); + $subscribers = $this->userRepository->findBy(['id' => $subscriberIds]); - $subscribers = array_filter($subscribers, fn ($s) => !\in_array($s->username, $mentions ?? [])); + if (\count($mentions)) { + $subscribers = array_filter($subscribers, fn ($s) => !\in_array($s->username, $mentions ?? [])); + } foreach ($subscribers as $subscriber) { - if (!$subscriber->isBlocked($subject->user)) { - $notification = new EntryCreatedNotification($subscriber, $subject); - $this->entityManager->persist($notification); - $this->eventDispatcher->dispatch(new NotificationCreatedEvent($notification)); - } + $notification = new EntryCreatedNotification($subscriber, $subject); + $this->entityManager->persist($notification); + $this->eventDispatcher->dispatch(new NotificationCreatedEvent($notification)); } $this->entityManager->flush(); diff --git a/src/Service/Notification/PostCommentNotificationManager.php b/src/Service/Notification/PostCommentNotificationManager.php index d7a8241a1..212488ead 100644 --- a/src/Service/Notification/PostCommentNotificationManager.php +++ b/src/Service/Notification/PostCommentNotificationManager.php @@ -18,6 +18,8 @@ use App\Repository\MagazineLogRepository; use App\Repository\MagazineSubscriptionRepository; use App\Repository\NotificationRepository; +use App\Repository\NotificationSettingsRepository; +use App\Repository\UserRepository; use App\Service\Contracts\ContentNotificationManagerInterface; use App\Service\GenerateHtmlClassService; use App\Service\ImageManager; @@ -49,7 +51,9 @@ public function __construct( private readonly EntityManagerInterface $entityManager, private readonly ImageManager $imageManager, private readonly GenerateHtmlClassService $classService, - private readonly SettingsManager $settingsManager + private readonly SettingsManager $settingsManager, + private readonly NotificationSettingsRepository $notificationSettingsRepository, + private readonly UserRepository $userRepository, ) { } @@ -61,10 +65,29 @@ public function sendCreated(ContentInterface $subject): void if (!$subject instanceof PostComment) { throw new \LogicException(); } + $comment = $subject; - $users = $this->sendMentionedNotification($subject); - $users = $this->sendUserReplyNotification($subject, $users); - $this->sendMagazineSubscribersNotification($subject, $users); + $mentions = $this->sendMentionedNotification($subject); + $this->notifyMagazine(new PostCommentCreatedNotification($comment->user, $comment)); + + $userIdsToNotify = $this->notificationSettingsRepository->findNotificationSubscribersByTarget($comment); + $usersToNotify = $this->userRepository->findBy(['id' => $userIdsToNotify]); + + if (\count($mentions)) { + $usersToNotify = array_filter($usersToNotify, fn ($user) => !\in_array($user, $mentions)); + } + + foreach ($usersToNotify as $subscriber) { + if (null !== $comment->parent && $comment->parent->isAuthor($subscriber)) { + $notification = new PostCommentReplyNotification($subscriber, $comment); + } else { + $notification = new PostCommentCreatedNotification($subscriber, $comment); + } + $this->entityManager->persist($notification); + $this->eventDispatcher->dispatch(new NotificationCreatedEvent($notification)); + } + + $this->entityManager->flush(); } private function sendMentionedNotification(PostComment $subject): array @@ -85,41 +108,6 @@ private function sendMentionedNotification(PostComment $subject): array return $users; } - private function sendUserReplyNotification(PostComment $comment, array $exclude): array - { - if (!$comment->parent || $comment->parent->isAuthor($comment->user)) { - return $exclude; - } - - if (!$comment->parent->user->notifyOnNewPostCommentReply) { - return $exclude; - } - - if (\in_array($comment->parent->user, $exclude)) { - return $exclude; - } - - if ($comment->parent->user->apId) { - // @todo activtypub - $exclude[] = $comment->parent->user; - - return $exclude; - } - - if (!$comment->parent->user->isBlocked($comment->user)) { - $notification = new PostCommentReplyNotification($comment->parent->user, $comment); - $this->notifyUser($notification); - - $this->entityManager->persist($notification); - $this->entityManager->flush(); - - $this->eventDispatcher->dispatch(new NotificationCreatedEvent($notification)); - $exclude[] = $notification->user; - } - - return $exclude; - } - private function notifyUser(PostCommentReplyNotification $notification): void { if (false === $this->settingsManager->get('KBIN_MERCURE_ENABLED')) { @@ -175,31 +163,6 @@ private function getResponse(Notification $notification): string ); } - public function sendMagazineSubscribersNotification(PostComment $comment, array $exclude): void - { - $this->notifyMagazine(new PostCommentCreatedNotification($comment->user, $comment)); - - $usersToNotify = []; // @todo user followers - if ($comment->user->notifyOnNewPostReply && !$comment->isAuthor($comment->post->user)) { - $usersToNotify = $this->merge( - $usersToNotify, - [$comment->post->user] - ); - } - - if (\count($exclude)) { - $usersToNotify = array_filter($usersToNotify, fn ($user) => !\in_array($user, $exclude)); - } - - foreach ($usersToNotify as $subscriber) { - $notification = new PostCommentCreatedNotification($subscriber, $comment); - $this->entityManager->persist($notification); - $this->eventDispatcher->dispatch(new NotificationCreatedEvent($notification)); - } - - $this->entityManager->flush(); - } - private function notifyMagazine(Notification $notification): void { if (false === $this->settingsManager->get('KBIN_MERCURE_ENABLED')) { diff --git a/src/Service/Notification/PostNotificationManager.php b/src/Service/Notification/PostNotificationManager.php index 662e569c3..ff8f35767 100644 --- a/src/Service/Notification/PostNotificationManager.php +++ b/src/Service/Notification/PostNotificationManager.php @@ -11,12 +11,13 @@ use App\Entity\PostDeletedNotification; use App\Entity\PostEditedNotification; use App\Entity\PostMentionedNotification; -use App\Entity\User; use App\Event\NotificationCreatedEvent; use App\Factory\MagazineFactory; use App\Repository\MagazineLogRepository; use App\Repository\MagazineSubscriptionRepository; use App\Repository\NotificationRepository; +use App\Repository\NotificationSettingsRepository; +use App\Repository\UserRepository; use App\Service\Contracts\ContentNotificationManagerInterface; use App\Service\GenerateHtmlClassService; use App\Service\ImageManager; @@ -47,7 +48,9 @@ public function __construct( private readonly EntityManagerInterface $entityManager, private readonly ImageManager $imageManager, private readonly GenerateHtmlClassService $classService, - private readonly SettingsManager $settingsManager + private readonly SettingsManager $settingsManager, + private readonly NotificationSettingsRepository $notificationSettingsRepository, + private readonly UserRepository $userRepository, ) { } @@ -73,20 +76,17 @@ public function sendCreated(ContentInterface $subject): void } // Notify subscribers - /** @var User[] $subscribers */ - $subscribers = $this->merge( - $this->getUsersToNotify($this->magazineRepository->findNewPostSubscribers($subject)), - [] // @todo user followers - ); + $subscriberIds = $this->notificationSettingsRepository->findNotificationSubscribersByTarget($subject); + $subscribers = $this->userRepository->findBy(['id' => $subscriberIds]); - $subscribers = array_filter($subscribers, fn ($s) => !\in_array($s->username, $mentions ?? [])); + if (\count($mentions)) { + $subscribers = array_filter($subscribers, fn ($s) => !\in_array($s->username, $mentions)); + } foreach ($subscribers as $subscriber) { - if (!$subscriber->isBlocked($subject->user)) { - $notification2 = new PostCreatedNotification($subscriber, $subject); - $this->entityManager->persist($notification2); - $this->eventDispatcher->dispatch(new NotificationCreatedEvent($notification2)); - } + $notification2 = new PostCreatedNotification($subscriber, $subject); + $this->entityManager->persist($notification2); + $this->eventDispatcher->dispatch(new NotificationCreatedEvent($notification2)); } $this->entityManager->flush(); diff --git a/src/Twig/Components/NotificationSwitch.php b/src/Twig/Components/NotificationSwitch.php new file mode 100644 index 000000000..2be49287f --- /dev/null +++ b/src/Twig/Components/NotificationSwitch.php @@ -0,0 +1,38 @@ +security->getUser(); + if ($user instanceof User) { + $this->status = $this->repository->findOneByTarget($user, $this->target)?->getStatus() ?? ENotificationStatus::Default; + } + } +} diff --git a/src/Twig/Extension/FrontExtension.php b/src/Twig/Extension/FrontExtension.php index 8e8d81de6..c472e90e7 100644 --- a/src/Twig/Extension/FrontExtension.php +++ b/src/Twig/Extension/FrontExtension.php @@ -16,6 +16,7 @@ public function getFunctions(): array new TwigFunction('front_options_url', [FrontExtensionRuntime::class, 'frontOptionsUrl']), new TwigFunction('get_class', [FrontExtensionRuntime::class, 'getClass']), new TwigFunction('get_subject_type', [FrontExtensionRuntime::class, 'getSubjectType']), + new TwigFunction('get_notification_settings_subject_type', [FrontExtensionRuntime::class, 'getNotificationSettingSubjectType']), ]; } } diff --git a/src/Twig/Runtime/FrontExtensionRuntime.php b/src/Twig/Runtime/FrontExtensionRuntime.php index b52735041..7bdde2624 100644 --- a/src/Twig/Runtime/FrontExtensionRuntime.php +++ b/src/Twig/Runtime/FrontExtensionRuntime.php @@ -6,8 +6,10 @@ use App\Entity\Entry; use App\Entity\EntryComment; +use App\Entity\Magazine; use App\Entity\Post; use App\Entity\PostComment; +use App\Entity\User; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Twig\Extension\RuntimeExtensionInterface; @@ -87,4 +89,19 @@ public function getSubjectType(mixed $object): string throw new \LogicException('unknown class '.\get_class($object)); } } + + public function getNotificationSettingSubjectType(mixed $object): string + { + if ($object instanceof Entry) { + return 'entry'; + } elseif ($object instanceof Post) { + return 'post'; + } elseif ($object instanceof User) { + return 'user'; + } elseif ($object instanceof Magazine) { + return 'magazine'; + } else { + throw new \LogicException('unknown class '.\get_class($object)); + } + } } diff --git a/templates/components/bookmark_list.html.twig b/templates/components/bookmark_list.html.twig index be65c8849..2e075df3d 100644 --- a/templates/components/bookmark_list.html.twig +++ b/templates/components/bookmark_list.html.twig @@ -3,7 +3,7 @@ + data-action="html-refresh#linkCallback"> {{ 'bookmark_remove_from_list'|trans({'%list%': list.name}) }} @@ -11,7 +11,7 @@ + data-action="html-refresh#linkCallback"> {{ 'bookmark_add_to_list'|trans({'%list%': list.name}) }} diff --git a/templates/components/bookmark_standard.html.twig b/templates/components/bookmark_standard.html.twig index d8d502a9e..dd74443df 100644 --- a/templates/components/bookmark_standard.html.twig +++ b/templates/components/bookmark_standard.html.twig @@ -1,16 +1,16 @@
  • {% if is_bookmarked(app.user, subject) %} + data-html-refresh-cssclass-param="bookmark-standard" data-html-refresh-refreshselector-param=".bookmark-menu-list" + data-html-refresh-refreshlink-param="{{ path('bookmark_lists_menu_refresh_status', { 'subject_id': subject.id, 'subject_type': get_subject_type(subject) }) }}" + data-action="html-refresh#linkCallback"> {% else %} + data-html-refresh-cssclass-param="bookmark-standard" data-html-refresh-refreshselector-param=".bookmark-menu-list" + data-html-refresh-refreshlink-param="{{ path('bookmark_lists_menu_refresh_status', { 'subject_id': subject.id, 'subject_type': get_subject_type(subject) }) }}" + data-action="html-refresh#linkCallback"> {% endif %} diff --git a/templates/components/entry.html.twig b/templates/components/entry.html.twig index a4a402432..f26c4a0a1 100644 --- a/templates/components/entry.html.twig +++ b/templates/components/entry.html.twig @@ -22,7 +22,7 @@ 'show-preview': SHOW_PREVIEW is same as V_TRUE and not entry.isAdult })}).without('id') }} id="entry-{{ entry.id }}" - data-controller="subject preview mentions" + data-controller="subject preview mentions html-refresh" data-action="notifications:Notification@window->subject#notification">
    {% if entry.visibility in ['visible', 'private'] or (entry.visibility is same as 'trashed' and this.canSeeTrashed) %} @@ -175,6 +175,11 @@ {{ component('bookmark_standard', { subject: entry }) }} {% endif %} {% include 'entry/_menu.html.twig' %} + + {% if app.user is defined and app.user is not same as null and not showShortSentence %} + {{ component('notification_switch', {target: entry}) }} + {% endif %} +
  • Loading... diff --git a/templates/components/entry_comment.html.twig b/templates/components/entry_comment.html.twig index 1f87f77b5..4bac7c4ac 100644 --- a/templates/components/entry_comment.html.twig +++ b/templates/components/entry_comment.html.twig @@ -19,7 +19,7 @@ 'show-preview': SHOW_PREVIEW is same as V_TRUE and not comment.isAdult, })}).without('id') }} id="entry-comment-{{ comment.id }}" - data-controller="comment subject mentions comment-collapse" + data-controller="comment subject mentions comment-collapse html-refresh" data-comment-collapse-depth-value="{{ level }}" data-subject-parent-value="{{ comment.parent ? comment.parent.id : '' }}" data-action="{{- DYNAMIC_LISTS is same as V_TRUE ? 'notifications:Notification@window->subject#notification' : '' -}}"> diff --git a/templates/components/magazine_box.html.twig b/templates/components/magazine_box.html.twig index d84849a53..19bc5fff9 100644 --- a/templates/components/magazine_box.html.twig +++ b/templates/components/magazine_box.html.twig @@ -43,6 +43,13 @@
  • {{ component('magazine_sub', {magazine: magazine}) }} + + {% if app.user is defined and app.user is not same as null %} +
    + {{ component('notification_switch', {target: magazine}) }} +
    + {% endif %} + {% if computed.magazine.description and showDescription %}
    {{ computed.magazine.description|markdown|raw }}
    {% endif %} diff --git a/templates/components/notification_switch.html.twig b/templates/components/notification_switch.html.twig new file mode 100644 index 000000000..8d0109898 --- /dev/null +++ b/templates/components/notification_switch.html.twig @@ -0,0 +1,32 @@ + diff --git a/templates/components/post.html.twig b/templates/components/post.html.twig index d03e769ca..0f2fe0e07 100644 --- a/templates/components/post.html.twig +++ b/templates/components/post.html.twig @@ -15,7 +15,7 @@ 'show-preview': SHOW_PREVIEW is same as V_TRUE and not post.isAdult })}).without('id') }} id="post-{{ post.id }}" - data-controller="post subject mentions" + data-controller="post subject mentions html-refresh" data-action="notifications:Notification@window->subject#notification">
    {% if post.isAdult %}18+{% endif %} @@ -114,6 +114,9 @@ {{ component('bookmark_standard', { subject: post }) }} {% endif %} {% include 'post/_menu.html.twig' %} + {% if app.user is defined and app.user is not same as null %} + {{ component('notification_switch', {target: post}) }} + {% endif %}
  • Loading... diff --git a/templates/components/post_comment.html.twig b/templates/components/post_comment.html.twig index 7755aceac..487c0f8a3 100644 --- a/templates/components/post_comment.html.twig +++ b/templates/components/post_comment.html.twig @@ -21,7 +21,7 @@ 'show-preview': SHOW_PREVIEW is same as V_TRUE and not comment.isAdult, })}).without('id') }} id="post-comment-{{ comment.id }}" - data-controller="comment subject mentions comment-collapse" + data-controller="comment subject mentions comment-collapse html-refresh" data-comment-collapse-depth-value="{{ level }}" data-subject-parent-value="{{ comment.parent ? comment.parent.id : '' }}" data-action="notifications:Notification@window->subject#notification"> diff --git a/templates/components/user_box.html.twig b/templates/components/user_box.html.twig index c74c2b07b..177e47dc3 100644 --- a/templates/components/user_box.html.twig +++ b/templates/components/user_box.html.twig @@ -76,6 +76,11 @@
    {{ component('user_actions', {user: user}) }} + {% if app.user is defined and app.user is not same as null and app.user is not same as user %} +
    + {{ component('notification_switch', {target: user}) }} +
    + {% endif %} {% if user.about|length %} diff --git a/templates/entry/_info.html.twig b/templates/entry/_info.html.twig index c4af6389e..2da5abebc 100644 --- a/templates/entry/_info.html.twig +++ b/templates/entry/_info.html.twig @@ -25,6 +25,11 @@

    {{ component('user_actions', {user: entry.user}) }} + {% if app.user is defined and app.user is not same as null and app.user is not same as entry.user %} +
    + {{ component('notification_switch', {target: entry.user}) }} +
    + {% endif %}
    • {{ 'added'|trans }}: {{ component('date', {date: entry.createdAt}) }}
    • {% if entry.editedAt %} diff --git a/templates/post/_info.html.twig b/templates/post/_info.html.twig index f2565e554..2fc2e9d4c 100644 --- a/templates/post/_info.html.twig +++ b/templates/post/_info.html.twig @@ -25,6 +25,11 @@

      {{ component('user_actions', {user: post.user}) }} + {% if app.user is defined and app.user is not same as null and app.user is not same as post.user %} +
      + {{ component('notification_switch', {target: post.user}) }} +
      + {% endif %}
      • {{ 'added'|trans }}: {{ component('date', {date: post.createdAt}) }}
      • {{ 'up_votes'|trans }}: diff --git a/templates/user/_user_popover.html.twig b/templates/user/_user_popover.html.twig index c7900044b..21a53f126 100644 --- a/templates/user/_user_popover.html.twig +++ b/templates/user/_user_popover.html.twig @@ -44,6 +44,9 @@
      {{ component('user_actions', {user: user}) }} + {% if app.user is defined and app.user is not same as null and app.user is not same as user %} + {{ component('notification_switch', {target: user}) }} + {% endif %}