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 888caeef9..cbdedb985 100644 --- a/assets/controllers/subject_controller.js +++ b/assets/controllers/subject_controller.js @@ -217,46 +217,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 6f65b2c8f..233240b64 100644 --- a/assets/styles/app.scss +++ b/assets/styles/app.scss @@ -34,6 +34,7 @@ @use 'components/subject'; @use 'components/login'; @use 'components/modlog'; +@use 'components/notification_switch'; @use 'components/notifications'; @use 'components/messages'; @use 'components/dropdown'; diff --git a/assets/styles/components/_entry.scss b/assets/styles/components/_entry.scss index 9d3e5ccfa..67699190d 100644 --- a/assets/styles/components/_entry.scss +++ b/assets/styles/components/_entry.scss @@ -335,7 +335,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 e51970261..17cd0d230 100644 --- a/assets/styles/components/_magazine.scss +++ b/assets/styles/components/_magazine.scss @@ -53,11 +53,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 792f66482..b11445a5a 100644 --- a/assets/styles/components/_post.scss +++ b/assets/styles/components/_post.scss @@ -60,7 +60,7 @@ margin-bottom: 0; opacity: .75; - a { + a:not(.notification-setting) { color: var(--kbin-meta-link-color); font-weight: bold; @@ -76,7 +76,7 @@ } } - aside { + aside:not(.notification-switch) { grid-area: vote; } @@ -135,13 +135,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; } @@ -151,7 +151,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 0694660e0..861397e83 100644 --- a/assets/styles/components/_sidebar.scss +++ b/assets/styles/components/_sidebar.scss @@ -247,7 +247,7 @@ } } - a { + a:not(.notification-setting) { color: var(--kbin-meta-link-color); } @@ -260,6 +260,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 10a9ebe64..dd2ba87ab 100644 --- a/assets/styles/components/_user.scss +++ b/assets/styles/components/_user.scss @@ -5,7 +5,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 8818f5071..03f87bfc7 100644 --- a/src/Controller/Api/BaseApi.php +++ b/src/Controller/Api/BaseApi.php @@ -37,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; @@ -101,6 +102,7 @@ public function __construct( private readonly ImageRepository $imageRepository, private readonly ReportManager $reportManager, private readonly OAuth2ClientAccessRepository $clientAccessRepository, + protected readonly NotificationSettingsRepository $notificationSettingsRepository, ) { } @@ -302,10 +304,14 @@ protected function serializeLogItem(MagazineLog $log): array * * @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; } @@ -320,6 +326,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 d955cc260..718fd9e47 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..a5f814c3d --- /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 7b09a7522..eaebb5681 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\Attribute\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 7381f18a8..8d7cb9575 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\Attribute\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..e30d00752 --- /dev/null +++ b/src/Repository/NotificationSettingsRepository.php @@ -0,0 +1,223 @@ +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 + { + $nestedCommentPostAuthor = 'false'; + if ($target instanceof Entry || $target instanceof EntryComment) { + $targetCol = 'entry_id'; + if ($target instanceof Entry) { + $targetId = $target->getId(); + $notifyCol = 'notify_on_new_entry'; + $isMagazineLevel = 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(); + + $nestedCommentPostAuthor = 'u.notify_on_new_entry_reply = true + AND u.id = :targetParent2UserId'; + $targetParent2UserId = $target->entry->user->getId(); + } + $isMagazineLevel = false; + $dontNeedSubscription = true; + $dontNeedToBeAuthor = false; + } + } else { + $targetCol = 'post_id'; + if ($target instanceof Post) { + $targetId = $target->getId(); + $notifyCol = 'notify_on_new_post'; + $isMagazineLevel = 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(); + + $nestedCommentPostAuthor = 'u.notify_on_new_post_reply = true + AND u.id = :targetParent2UserId'; + $targetParent2UserId = $target->post->user->getId(); + } + $isMagazineLevel = false; + $dontNeedSubscription = true; + $dontNeedToBeAuthor = false; + } + } + + $isMagazineLevelString = $isMagazineLevel ? 'true' : 'false'; + $isNotMagazineLevelString = !$isMagazineLevel ? '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 $isMagazineLevelString + ) + OR ( + COALESCE(ns_user.notification_status, :normal) = :normal + AND COALESCE(ns_post.notification_status, :normal) = :normal + AND ( + -- ignore the magazine level settings for comments + COALESCE(ns_mag.notification_status, :normal) = :normal + OR $isNotMagazineLevelString + ) + 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 + ) + ) OR ( + $nestedCommentPostAuthor + ) + ) + ) + ) + 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); + + $stmt->bindValue('normal', ENotificationStatus::Default->value); + $stmt->bindValue('loud', ENotificationStatus::Loud->value); + $stmt->bindValue('targetUserId', $target->user->getId()); + $stmt->bindValue('targetId', $targetId); + $stmt->bindValue('magId', $target->magazine->getId()); + $stmt->bindValue('targetParentUserId', $targetParentUserId); + + if (isset($targetParent2UserId)) { + $stmt->bindValue('targetParent2UserId', $targetParent2UserId); + } + $result = $stmt->executeQuery(); + $rows = $result->fetchAllAssociative(); + $this->logger->debug('got subscribers for target {c} id {id}: {subs}, (magLevel: {ml}, notMagLevel: {nml}, targetCol: {tc}, notifyCol: {nc}, dontNeedSubs: {dns}, doneNeedAuthor: {dna}, nestedComment extra condition: {nested})', [ + 'c' => \get_class($target), + 'id' => $target->getId(), + 'subs' => $rows, + 'ml' => $isMagazineLevelString, + 'nml' => $isNotMagazineLevelString, + 'tc' => $targetCol, + 'nc' => $notifyCol, + 'dns' => $dontNeedSubscriptionString, + 'dna' => $dontNeedToBeAuthorString, + 'nested' => $nestedCommentPostAuthor, + ]); + + return array_map(fn (array $row) => $row['id'], $rows); + } +} diff --git a/src/Service/Notification/EntryCommentNotificationManager.php b/src/Service/Notification/EntryCommentNotificationManager.php index 3b524af34..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; @@ -50,10 +52,11 @@ public function __construct( private readonly ImageManager $imageManager, private readonly GenerateHtmlClassService $classService, 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 c52c415c4..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; @@ -51,6 +52,8 @@ public function __construct( private readonly GenerateHtmlClassService $classService, private readonly UserManager $userManager, 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 70945cfb4..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; @@ -50,6 +52,8 @@ public function __construct( private readonly ImageManager $imageManager, private readonly GenerateHtmlClassService $classService, 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 760efe697..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; @@ -48,6 +49,8 @@ public function __construct( private readonly ImageManager $imageManager, private readonly GenerateHtmlClassService $classService, 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..447c3fa11 100644 --- a/templates/components/bookmark_list.html.twig +++ b/templates/components/bookmark_list.html.twig @@ -1,17 +1,17 @@
  • {% if is_bookmarked_in_list(app.user, list, subject) %} + data-html-refresh-cssclass-param="bookmark-list" data-html-refresh-refreshselector-param=".bookmark-standard" + data-html-refresh-refreshlink-param="{{ path('subject_bookmark_refresh_status', { subject_id: subject.id, subject_type: get_subject_type(subject) }) }}" + data-action="html-refresh#linkCallback"> {{ 'bookmark_remove_from_list'|trans({'%list%': list.name}) }} {% else %} + data-html-refresh-cssclass-param="bookmark-list" data-html-refresh-refreshselector-param=".bookmark-standard" + data-html-refresh-refreshlink-param="{{ path('subject_bookmark_refresh_status', { subject_id: subject.id, subject_type: get_subject_type(subject) }) }}" + 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 b7aecca1a..dba9013e1 100644 --- a/templates/components/entry.html.twig +++ b/templates/components/entry.html.twig @@ -23,7 +23,7 @@ 'isSingle': isSingle is same as true })}).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) %} @@ -176,6 +176,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 80a31cf13..6f9fe32ec 100644 --- a/templates/components/post.html.twig +++ b/templates/components/post.html.twig @@ -16,7 +16,7 @@ 'isSingle': isSingle is same as true })}).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 %} @@ -115,6 +115,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 and isSingle is defined and isSingle %} + {{ 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 %}
  • diff --git a/tests/WebTestCase.php b/tests/WebTestCase.php index 3456c4da1..b740577d2 100644 --- a/tests/WebTestCase.php +++ b/tests/WebTestCase.php @@ -69,15 +69,15 @@ abstract class WebTestCase extends BaseWebTestCase protected const PAGINATION_KEYS = ['count', 'currentPage', 'maxPage', 'perPage']; protected const IMAGE_KEYS = ['filePath', 'sourceUrl', 'storageUrl', 'altText', 'width', 'height', 'blurHash']; protected const MESSAGE_RESPONSE_KEYS = ['messageId', 'threadId', 'sender', 'body', 'status', 'createdAt']; - protected const USER_RESPONSE_KEYS = ['userId', 'username', 'about', 'avatar', 'cover', 'createdAt', 'followersCount', 'apId', 'apProfileId', 'isBot', 'isFollowedByUser', 'isFollowerOfUser', 'isBlockedByUser', 'isAdmin', 'isGlobalModerator', 'serverSoftware', 'serverSoftwareVersion']; + protected const USER_RESPONSE_KEYS = ['userId', 'username', 'about', 'avatar', 'cover', 'createdAt', 'followersCount', 'apId', 'apProfileId', 'isBot', 'isFollowedByUser', 'isFollowerOfUser', 'isBlockedByUser', 'isAdmin', 'isGlobalModerator', 'serverSoftware', 'serverSoftwareVersion', 'notificationStatus']; protected const USER_SMALL_RESPONSE_KEYS = ['userId', 'username', 'isBot', 'isFollowedByUser', 'isFollowerOfUser', 'isBlockedByUser', 'avatar', 'apId', 'apProfileId', 'createdAt', 'isAdmin', 'isGlobalModerator']; - protected const ENTRY_RESPONSE_KEYS = ['entryId', 'magazine', 'user', 'domain', 'title', 'url', 'image', 'body', 'lang', 'tags', 'badges', 'numComments', 'uv', 'dv', 'favourites', 'isFavourited', 'userVote', 'isOc', 'isAdult', 'isPinned', 'createdAt', 'editedAt', 'lastActive', 'visibility', 'type', 'slug', 'apId', 'canAuthUserModerate']; + protected const ENTRY_RESPONSE_KEYS = ['entryId', 'magazine', 'user', 'domain', 'title', 'url', 'image', 'body', 'lang', 'tags', 'badges', 'numComments', 'uv', 'dv', 'favourites', 'isFavourited', 'userVote', 'isOc', 'isAdult', 'isPinned', 'createdAt', 'editedAt', 'lastActive', 'visibility', 'type', 'slug', 'apId', 'canAuthUserModerate', 'notificationStatus']; protected const ENTRY_COMMENT_RESPONSE_KEYS = ['commentId', 'magazine', 'user', 'entryId', 'parentId', 'rootId', 'image', 'body', 'lang', 'isAdult', 'uv', 'dv', 'favourites', 'isFavourited', 'userVote', 'visibility', 'apId', 'mentions', 'tags', 'createdAt', 'editedAt', 'lastActive', 'childCount', 'children', 'canAuthUserModerate']; - protected const POST_RESPONSE_KEYS = ['postId', 'user', 'magazine', 'image', 'body', 'lang', 'isAdult', 'isPinned', 'comments', 'uv', 'dv', 'favourites', 'isFavourited', 'userVote', 'visibility', 'apId', 'tags', 'mentions', 'createdAt', 'editedAt', 'lastActive', 'slug', 'canAuthUserModerate']; + protected const POST_RESPONSE_KEYS = ['postId', 'user', 'magazine', 'image', 'body', 'lang', 'isAdult', 'isPinned', 'comments', 'uv', 'dv', 'favourites', 'isFavourited', 'userVote', 'visibility', 'apId', 'tags', 'mentions', 'createdAt', 'editedAt', 'lastActive', 'slug', 'canAuthUserModerate', 'notificationStatus']; protected const POST_COMMENT_RESPONSE_KEYS = ['commentId', 'user', 'magazine', 'postId', 'parentId', 'rootId', 'image', 'body', 'lang', 'isAdult', 'uv', 'dv', 'favourites', 'isFavourited', 'userVote', 'visibility', 'apId', 'mentions', 'tags', 'createdAt', 'editedAt', 'lastActive', 'childCount', 'children', 'canAuthUserModerate']; protected const BAN_RESPONSE_KEYS = ['banId', 'reason', 'expired', 'expiredAt', 'bannedUser', 'bannedBy', 'magazine']; protected const LOG_ENTRY_KEYS = ['type', 'createdAt', 'magazine', 'moderator', 'subject']; - protected const MAGAZINE_RESPONSE_KEYS = ['magazineId', 'owner', 'icon', 'name', 'title', 'description', 'rules', 'subscriptionsCount', 'entryCount', 'entryCommentCount', 'postCount', 'postCommentCount', 'isAdult', 'isUserSubscribed', 'isBlockedByUser', 'tags', 'badges', 'moderators', 'apId', 'apProfileId', 'serverSoftware', 'serverSoftwareVersion', 'isPostingRestrictedToMods', 'localSubscribers']; + protected const MAGAZINE_RESPONSE_KEYS = ['magazineId', 'owner', 'icon', 'name', 'title', 'description', 'rules', 'subscriptionsCount', 'entryCount', 'entryCommentCount', 'postCount', 'postCommentCount', 'isAdult', 'isUserSubscribed', 'isBlockedByUser', 'tags', 'badges', 'moderators', 'apId', 'apProfileId', 'serverSoftware', 'serverSoftwareVersion', 'isPostingRestrictedToMods', 'localSubscribers', 'notificationStatus']; protected const MAGAZINE_SMALL_RESPONSE_KEYS = ['magazineId', 'name', 'icon', 'isUserSubscribed', 'isBlockedByUser', 'apId', 'apProfileId']; protected const DOMAIN_RESPONSE_KEYS = ['domainId', 'name', 'entryCount', 'subscriptionsCount', 'isUserSubscribed', 'isBlockedByUser'];