From 31c2f5dd69fd55d20297bf2d8f8df58d548e6e77 Mon Sep 17 00:00:00 2001 From: Javivi Date: Wed, 21 Aug 2024 21:59:43 +0200 Subject: [PATCH 01/19] related links --- .../form-related-social_controller.js | 64 +++++++++++++++++++ assets/styles/layout/_forms.scss | 6 ++ .../User/Profile/UserEditController.php | 19 ++++++ src/DTO/UserDto.php | 7 ++ src/Form/UserBasicType.php | 24 ++++++- src/Form/UserRelatedLinkType.php | 20 ++++++ templates/user/settings/profile.html.twig | 44 +++++++++++++ 7 files changed, 183 insertions(+), 1 deletion(-) create mode 100644 assets/controllers/form-related-social_controller.js create mode 100644 src/Form/UserRelatedLinkType.php diff --git a/assets/controllers/form-related-social_controller.js b/assets/controllers/form-related-social_controller.js new file mode 100644 index 000000000..30bfc5bff --- /dev/null +++ b/assets/controllers/form-related-social_controller.js @@ -0,0 +1,64 @@ +import { Controller } from '@hotwired/stimulus'; + +export default class extends Controller { + static targets = ['relatedContainer']; + + static values = { + index: Number, + name: String, + link: String, + }; + + connect() { + const container = this.element; + container + .querySelectorAll('.related-link-group') + .forEach((item) => { + this.#addButtonDeleteLink(item); + }); + } + + addRelatedElement() { + const item = document.createElement('span'); + item.className = 'flex related-link-group'; + + const nodeName = this.#htmlToNode(this.nameValue.replace( + /__name__/g, + this.indexValue, + )); + item.append(nodeName); + + const nodeLink = this.#htmlToNode(this.linkValue.replace( + /__name__/g, + this.indexValue, + )); + item.append(nodeLink); + + this.#addButtonDeleteLink(item); + + this.relatedContainerTarget.appendChild(item); + this.indexValue++; + } + + #addButtonDeleteLink(item) { + const div = document.createElement('div'); + + const removeFormButton = document.createElement('button'); + removeFormButton.innerText = 'Delete'; // TODO - Translation + removeFormButton.className = 'btn btn__secondry'; + + div.append(removeFormButton); + item.append(div); + + removeFormButton.addEventListener('click', (e) => { + e.preventDefault(); + item.remove(); + }); + } + + #htmlToNode(html) { + const template = document.createElement('div'); + template.innerHTML = html; + return template.firstChild; + } +} diff --git a/assets/styles/layout/_forms.scss b/assets/styles/layout/_forms.scss index 8d42c269d..1e246b7fe 100644 --- a/assets/styles/layout/_forms.scss +++ b/assets/styles/layout/_forms.scss @@ -525,3 +525,9 @@ div.input-box { border-radius: var(--kbin-rounded-edges-radius) !important; } } + +.related-link-fieldset { + border: 0; + margin: 0; + padding: 0; +} diff --git a/src/Controller/User/Profile/UserEditController.php b/src/Controller/User/Profile/UserEditController.php index 302110499..d54f2d1bc 100644 --- a/src/Controller/User/Profile/UserEditController.php +++ b/src/Controller/User/Profile/UserEditController.php @@ -10,6 +10,8 @@ use App\Form\UserEmailType; use App\Form\UserPasswordType; use App\Service\UserManager; +use Error; +use Exception; use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\Form\FormError; use Symfony\Component\Form\FormInterface; @@ -38,6 +40,20 @@ public function profile(Request $request): Response $dto = $this->manager->createDto($user); + $link1 = [ + 'relatedName' => 'name1', + 'relatedLink' => 'link1' + ]; + + $link2 = [ + 'relatedName' => 'name2', + 'relatedLink' => 'link2' + ]; + + $dto->relatedSocialLinks[] = $link1; + $dto->relatedSocialLinks[] = $link2; + + try { $form = $this->createForm(UserBasicType::class, $dto); $formHandler = $this->handleForm($form, $dto, $request); if (null === $formHandler) { @@ -59,6 +75,9 @@ public function profile(Request $request): Response $form->isSubmitted() && !$form->isValid() ? 422 : 200 ) ); + } catch (Error | Exception) { + $para = ''; + } } #[IsGranted('ROLE_USER')] diff --git a/src/DTO/UserDto.php b/src/DTO/UserDto.php index f55e41c56..a06a70d9f 100644 --- a/src/DTO/UserDto.php +++ b/src/DTO/UserDto.php @@ -8,6 +8,8 @@ use App\Entity\User; use App\Utils\RegPatterns; use App\Validator\Unique; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Context\ExecutionContextInterface; @@ -47,6 +49,11 @@ class UserDto implements UserDtoInterface public ?string $totpSecret = null; public ?string $serverSoftware = null; public ?string $serverSoftwareVersion = null; + public array $relatedSocialLinks = []; + + // public function __construct() { + // $this->relatedSocialLinks = new ArrayCollection(); + // } #[Assert\Callback] public function validate( diff --git a/src/Form/UserBasicType.php b/src/Form/UserBasicType.php index aad0f45e8..8dbc28268 100644 --- a/src/Form/UserBasicType.php +++ b/src/Form/UserBasicType.php @@ -10,6 +10,8 @@ use App\Form\EventListener\DisableFieldsOnUserEdit; use App\Form\EventListener\ImageListener; use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\CallbackTransformer; +use Symfony\Component\Form\Extension\Core\Type\CollectionType; use Symfony\Component\Form\Extension\Core\Type\SubmitType; use Symfony\Component\Form\Extension\Core\Type\TextareaType; use Symfony\Component\Form\Extension\Core\Type\TextType; @@ -31,7 +33,27 @@ public function buildForm(FormBuilderInterface $builder, array $options): void $builder ->add('username', TextType::class, ['required' => false]) ->add('about', TextareaType::class, ['required' => false]) - ->add('submit', SubmitType::class); + ->add('relatedSocialLinks', CollectionType::class , [ + 'entry_type' => UserRelatedLinkType::class, + 'entry_options' => ['label' => false], + 'label' => false, + 'allow_add' => true, + 'allow_delete' => true, + // 'by_reference' => false, + ]) + ->add('submit', SubmitType::class) + ; + + $builder->addModelTransformer(new CallbackTransformer( + function ($associativeArrayData) { + $para = ''; + return $associativeArrayData; + }, + function ($dtoData) { + $para = ''; + return $dtoData; + } + )); $builder->addEventSubscriber($this->disableUsernameFieldOnUserEdit); $builder->addEventSubscriber($this->addAvatarFieldOnUserEdit); diff --git a/src/Form/UserRelatedLinkType.php b/src/Form/UserRelatedLinkType.php new file mode 100644 index 000000000..fa2845698 --- /dev/null +++ b/src/Form/UserRelatedLinkType.php @@ -0,0 +1,20 @@ +add('relatedName', TextType::class) + ->add('relatedLink', TextType::class) + ; + } +} diff --git a/templates/user/settings/profile.html.twig b/templates/user/settings/profile.html.twig index b3f1f4114..5b875da91 100644 --- a/templates/user/settings/profile.html.twig +++ b/templates/user/settings/profile.html.twig @@ -43,6 +43,50 @@ }}) }} {{ form_row(form.avatar, {label: 'avatar'}) }} {{ form_row(form.cover, {label: 'cover'}) }} + + + +
{{ form_row(form.submit, {label: 'save', attr: {class: 'btn btn__primary'}}) }}
From 7debca33789d7a558f4c89336ad92ae8469e1139 Mon Sep 17 00:00:00 2001 From: Javivi Date: Thu, 22 Aug 2024 14:24:57 +0200 Subject: [PATCH 02/19] related links --- migrations/Version20240822112013.php | 27 +++++++++ .../User/Profile/UserEditController.php | 60 +++++++++---------- src/DTO/UserDto.php | 10 +--- src/Entity/User.php | 13 ++++ src/Factory/UserFactory.php | 3 +- src/Form/UserBasicType.php | 22 +++---- src/Service/UserManager.php | 2 + templates/user/settings/profile.html.twig | 8 +-- 8 files changed, 92 insertions(+), 53 deletions(-) create mode 100644 migrations/Version20240822112013.php diff --git a/migrations/Version20240822112013.php b/migrations/Version20240822112013.php new file mode 100644 index 000000000..3e986a6c4 --- /dev/null +++ b/migrations/Version20240822112013.php @@ -0,0 +1,27 @@ +addSql('ALTER TABLE "user" ADD related_links JSONB DEFAULT \'[]\' NOT NULL'); + + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE "user" DROP related_links'); + } +} diff --git a/src/Controller/User/Profile/UserEditController.php b/src/Controller/User/Profile/UserEditController.php index d54f2d1bc..fd5f3a9d8 100644 --- a/src/Controller/User/Profile/UserEditController.php +++ b/src/Controller/User/Profile/UserEditController.php @@ -40,42 +40,42 @@ public function profile(Request $request): Response $dto = $this->manager->createDto($user); - $link1 = [ - 'relatedName' => 'name1', - 'relatedLink' => 'link1' - ]; + // $link1 = [ + // 'relatedName' => 'name1', + // 'relatedLink' => 'link1' + // ]; - $link2 = [ - 'relatedName' => 'name2', - 'relatedLink' => 'link2' - ]; + // $link2 = [ + // 'relatedName' => 'name2', + // 'relatedLink' => 'link2' + // ]; - $dto->relatedSocialLinks[] = $link1; - $dto->relatedSocialLinks[] = $link2; + // $dto->relatedSocialLinks[] = $link1; + // $dto->relatedSocialLinks[] = $link2; try { - $form = $this->createForm(UserBasicType::class, $dto); - $formHandler = $this->handleForm($form, $dto, $request); - if (null === $formHandler) { - $this->addFlash('error', 'flash_user_edit_profile_error'); - } else { - if (!$formHandler instanceof FormInterface) { - return $formHandler; + $form = $this->createForm(UserBasicType::class, $dto); + $formHandler = $this->handleForm($form, $dto, $request); + if (null === $formHandler) { + $this->addFlash('error', 'flash_user_edit_profile_error'); + } else { + if (!$formHandler instanceof FormInterface) { + return $formHandler; + } } - } - return $this->render( - 'user/settings/profile.html.twig', - [ - 'user' => $user, - 'form' => $form->createView(), - ], - new Response( - null, - $form->isSubmitted() && !$form->isValid() ? 422 : 200 - ) - ); - } catch (Error | Exception) { + return $this->render( + 'user/settings/profile.html.twig', + [ + 'user' => $user, + 'form' => $form->createView(), + ], + new Response( + null, + $form->isSubmitted() && !$form->isValid() ? 422 : 200 + ) + ); + } catch (Error | Exception $e) { $para = ''; } } diff --git a/src/DTO/UserDto.php b/src/DTO/UserDto.php index a06a70d9f..eb7e35946 100644 --- a/src/DTO/UserDto.php +++ b/src/DTO/UserDto.php @@ -8,8 +8,6 @@ use App\Entity\User; use App\Utils\RegPatterns; use App\Validator\Unique; -use Doctrine\Common\Collections\ArrayCollection; -use Doctrine\Common\Collections\Collection; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Context\ExecutionContextInterface; @@ -49,11 +47,7 @@ class UserDto implements UserDtoInterface public ?string $totpSecret = null; public ?string $serverSoftware = null; public ?string $serverSoftwareVersion = null; - public array $relatedSocialLinks = []; - - // public function __construct() { - // $this->relatedSocialLinks = new ArrayCollection(); - // } + public array $relatedLinks = []; #[Assert\Callback] public function validate( @@ -94,6 +88,7 @@ public static function create( ?int $id = null, ?int $followersCount = 0, ?bool $isBot = null, + array $relatedLinks = [] ): self { $dto = new UserDto(); $dto->id = $id; @@ -108,6 +103,7 @@ public static function create( $dto->apProfileId = $apProfileId; $dto->followersCount = $followersCount; $dto->isBot = $isBot; + $dto->relatedLinks = $relatedLinks; return $dto; } diff --git a/src/Entity/User.php b/src/Entity/User.php index 8c39eca36..b2cbcffcf 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -237,6 +237,9 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, Visibil private Collection $oAuth2UserConsents; #[Column(type: 'string', nullable: false, options: ['default' => self::USER_TYPE_PERSON])] public string $type; + + #[Column(type: 'json', nullable: false, options: ['jsonb' => true, 'default' => '[]'])] + public array $relatedLinks = []; public function __construct( string $email, @@ -894,4 +897,14 @@ public function canUpdateUser(User $actor): bool return $this->apDomain === $actor->apDomain; } } + + public function getRelatedLinks(): array + { + return $this->relatedLinks; + } + + public function setRelatedLinks(array $relatedLinks): void + { + $this->relatedLinks = $relatedLinks; + } } diff --git a/src/Factory/UserFactory.php b/src/Factory/UserFactory.php index 021444560..d49bccb7a 100644 --- a/src/Factory/UserFactory.php +++ b/src/Factory/UserFactory.php @@ -33,7 +33,8 @@ public function createDto(User $user): UserDto $user->apProfileId, $user->getId(), $user->followersCount, - 'Service' === $user->type // setting isBot + 'Service' === $user->type, // setting isBot, + $user->relatedLinks ); /** @var User $currentUser */ diff --git a/src/Form/UserBasicType.php b/src/Form/UserBasicType.php index 8dbc28268..96042e6ba 100644 --- a/src/Form/UserBasicType.php +++ b/src/Form/UserBasicType.php @@ -33,7 +33,7 @@ public function buildForm(FormBuilderInterface $builder, array $options): void $builder ->add('username', TextType::class, ['required' => false]) ->add('about', TextareaType::class, ['required' => false]) - ->add('relatedSocialLinks', CollectionType::class , [ + ->add('relatedLinks', CollectionType::class , [ 'entry_type' => UserRelatedLinkType::class, 'entry_options' => ['label' => false], 'label' => false, @@ -44,16 +44,16 @@ public function buildForm(FormBuilderInterface $builder, array $options): void ->add('submit', SubmitType::class) ; - $builder->addModelTransformer(new CallbackTransformer( - function ($associativeArrayData) { - $para = ''; - return $associativeArrayData; - }, - function ($dtoData) { - $para = ''; - return $dtoData; - } - )); + // $builder->addModelTransformer(new CallbackTransformer( + // function ($associativeArrayData) { + // $para = ''; + // return $associativeArrayData; + // }, + // function ($dtoData) { + // $para = ''; + // return $dtoData; + // } + // )); $builder->addEventSubscriber($this->disableUsernameFieldOnUserEdit); $builder->addEventSubscriber($this->addAvatarFieldOnUserEdit); diff --git a/src/Service/UserManager.php b/src/Service/UserManager.php index 220024a72..39d22bf45 100644 --- a/src/Service/UserManager.php +++ b/src/Service/UserManager.php @@ -211,6 +211,8 @@ public function edit(User $user, UserDto $dto): User $user->setTotpSecret($dto->totpSecret); } + $user->relatedLinks = $dto->relatedLinks; + $user->lastActive = new \DateTime(); $this->entityManager->flush(); diff --git a/templates/user/settings/profile.html.twig b/templates/user/settings/profile.html.twig index 5b875da91..5b8fc62e0 100644 --- a/templates/user/settings/profile.html.twig +++ b/templates/user/settings/profile.html.twig @@ -49,15 +49,15 @@ Related links + {{ 'related_links'|trans }} + + - {{ form_row(item_form.relatedLink, { - label: false, - row_attr: { class: 'flex-grow-1' } - }) }} - - {% endfor %} - - - +
diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index 53aaab3a3..b64be7f72 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -890,3 +890,4 @@ admin_users_suspended: Suspended admin_users_banned: Banned user_verify: Activate account max_image_size: Maximum file size +related_links: Related links \ No newline at end of file diff --git a/translations/messages.es.yaml b/translations/messages.es.yaml index 99a04dbcd..ba3ac9ad9 100644 --- a/translations/messages.es.yaml +++ b/translations/messages.es.yaml @@ -532,3 +532,4 @@ marked_for_deletion_at: Marcado para eliminar el %date% sort_by: Ordenar por filter_by_subscription: Filtrar por suscripción filter_by_federation: Filtrar por estado de la federación +related_links: Enlaces relacionados \ No newline at end of file From 09a1a0d38bebb92f93d2b826bcea54aa91abf87f Mon Sep 17 00:00:00 2001 From: Javivi Date: Thu, 22 Aug 2024 18:48:20 +0200 Subject: [PATCH 04/19] php fixer --- compose.override.yml | 1 + migrations/Version20240822112013.php | 1 - .../User/Profile/UserEditController.php | 58 ++++++------------- src/Entity/User.php | 2 +- src/Form/UserBasicType.php | 4 +- 5 files changed, 23 insertions(+), 43 deletions(-) create mode 120000 compose.override.yml diff --git a/compose.override.yml b/compose.override.yml new file mode 120000 index 000000000..138e9abf7 --- /dev/null +++ b/compose.override.yml @@ -0,0 +1 @@ +compose.dev.yml \ No newline at end of file diff --git a/migrations/Version20240822112013.php b/migrations/Version20240822112013.php index 3e986a6c4..23299f38a 100644 --- a/migrations/Version20240822112013.php +++ b/migrations/Version20240822112013.php @@ -17,7 +17,6 @@ public function getDescription(): string public function up(Schema $schema): void { $this->addSql('ALTER TABLE "user" ADD related_links JSONB DEFAULT \'[]\' NOT NULL'); - } public function down(Schema $schema): void diff --git a/src/Controller/User/Profile/UserEditController.php b/src/Controller/User/Profile/UserEditController.php index 020a12c42..302110499 100644 --- a/src/Controller/User/Profile/UserEditController.php +++ b/src/Controller/User/Profile/UserEditController.php @@ -10,8 +10,6 @@ use App\Form\UserEmailType; use App\Form\UserPasswordType; use App\Service\UserManager; -use Error; -use Exception; use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\Form\FormError; use Symfony\Component\Form\FormInterface; @@ -40,45 +38,27 @@ public function profile(Request $request): Response $dto = $this->manager->createDto($user); - // $link1 = [ - // 'relatedName' => 'name1', - // 'relatedLink' => 'link1' - // ]; - - // $link2 = [ - // 'relatedName' => 'name2', - // 'relatedLink' => 'link2' - // ]; - - // $dto->relatedSocialLinks[] = $link1; - // $dto->relatedSocialLinks[] = $link2; - - try { - $form = $this->createForm(UserBasicType::class, $dto); - $formHandler = $this->handleForm($form, $dto, $request); - if (null === $formHandler) { - $this->addFlash('error', 'flash_user_edit_profile_error'); - } else { - if (!$formHandler instanceof FormInterface) { - return $formHandler; - } + $form = $this->createForm(UserBasicType::class, $dto); + $formHandler = $this->handleForm($form, $dto, $request); + if (null === $formHandler) { + $this->addFlash('error', 'flash_user_edit_profile_error'); + } else { + if (!$formHandler instanceof FormInterface) { + return $formHandler; } - - return $this->render( - 'user/settings/profile.html.twig', - [ - 'user' => $user, - 'form' => $form->createView(), - ], - new Response( - null, - $form->isSubmitted() && !$form->isValid() ? 422 : 200 - ) - ); - } catch (Error | Exception $e) { - $para = ''; - throw $e; } + + return $this->render( + 'user/settings/profile.html.twig', + [ + 'user' => $user, + 'form' => $form->createView(), + ], + new Response( + null, + $form->isSubmitted() && !$form->isValid() ? 422 : 200 + ) + ); } #[IsGranted('ROLE_USER')] diff --git a/src/Entity/User.php b/src/Entity/User.php index b2cbcffcf..b0b5e37c2 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -237,7 +237,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, Visibil private Collection $oAuth2UserConsents; #[Column(type: 'string', nullable: false, options: ['default' => self::USER_TYPE_PERSON])] public string $type; - + #[Column(type: 'json', nullable: false, options: ['jsonb' => true, 'default' => '[]'])] public array $relatedLinks = []; diff --git a/src/Form/UserBasicType.php b/src/Form/UserBasicType.php index 40a07ed98..c2062583c 100644 --- a/src/Form/UserBasicType.php +++ b/src/Form/UserBasicType.php @@ -33,12 +33,12 @@ public function buildForm(FormBuilderInterface $builder, array $options): void $builder ->add('username', TextType::class, ['required' => false]) ->add('about', TextareaType::class, ['required' => false]) - ->add('relatedLinks', CollectionType::class , [ + ->add('relatedLinks', CollectionType::class, [ 'entry_type' => UrlType::class, 'entry_options' => ['label' => false], 'label' => false, 'allow_add' => true, - 'allow_delete' => true + 'allow_delete' => true, ]) ->add('submit', SubmitType::class) ; From 8a3275bacdc13678f89d103431bc6be7856e8527 Mon Sep 17 00:00:00 2001 From: Javivi Date: Thu, 22 Aug 2024 18:58:03 +0200 Subject: [PATCH 05/19] cleaning --- compose.override.yml | 1 - 1 file changed, 1 deletion(-) delete mode 120000 compose.override.yml diff --git a/compose.override.yml b/compose.override.yml deleted file mode 120000 index 138e9abf7..000000000 --- a/compose.override.yml +++ /dev/null @@ -1 +0,0 @@ -compose.dev.yml \ No newline at end of file From 6c2ca557216b7083f9abb5083bb575e8be43345a Mon Sep 17 00:00:00 2001 From: Javivi Date: Mon, 2 Sep 2024 15:09:31 +0200 Subject: [PATCH 06/19] Required changes. --- .../form-related-links_controller.js | 10 ++----- assets/styles/layout/_forms.scss | 7 ++++- templates/user/settings/profile.html.twig | 30 +++++++++---------- 3 files changed, 22 insertions(+), 25 deletions(-) diff --git a/assets/controllers/form-related-links_controller.js b/assets/controllers/form-related-links_controller.js index c69c91d60..b3700eda9 100644 --- a/assets/controllers/form-related-links_controller.js +++ b/assets/controllers/form-related-links_controller.js @@ -18,20 +18,14 @@ export default class extends Controller { } addRelatedElement() { - console.log('addRelatedElement method'); - - const item = document.createElement('span'); - item.className = 'flex related-link-item'; - const nodeLink = this.#htmlToNode(this.linkValue.replace( /__name__/g, this.indexValue, )); - item.append(nodeLink); - this.#addButtonDeleteLink(item); + this.#addButtonDeleteLink(nodeLink); - this.relatedContainerTarget.appendChild(item); + this.relatedContainerTarget.appendChild(nodeLink); this.indexValue++; } diff --git a/assets/styles/layout/_forms.scss b/assets/styles/layout/_forms.scss index 3e77e1760..f55438afc 100644 --- a/assets/styles/layout/_forms.scss +++ b/assets/styles/layout/_forms.scss @@ -532,8 +532,13 @@ div.input-box { padding: 0; } +.related-link-fieldset legend { + margin-bottom: 0.5rem; +} + .related-link-item { align-items: center; + margin-bottom: 0.25rem; } .related-link-item button { @@ -543,4 +548,4 @@ div.input-box { border: 0; color: var(--kbin-input-text-color); -} \ No newline at end of file +} diff --git a/templates/user/settings/profile.html.twig b/templates/user/settings/profile.html.twig index c4ae08f85..601923274 100644 --- a/templates/user/settings/profile.html.twig +++ b/templates/user/settings/profile.html.twig @@ -29,14 +29,14 @@ {{ form_start(form) }} {{ component('editor_toolbar', {id: 'user_basic_about'}) }} {{ form_row(form.about, {label: false, attr: { - placeholder: 'about', - 'data-controller': 'input-length rich-textarea autogrow', + placeholder: 'about', + 'data-controller': 'input-length rich-textarea autogrow', 'data-entry-link-create-target': 'user_about', 'data-action' : 'input-length#updateDisplay', 'data-input-length-max-value' : constant('App\\DTO\\UserDto::MAX_ABOUT_LENGTH') }}) }} {{ form_row(form.username, {label: 'username', attr: { - 'data-controller': 'input-length autogrow', + 'data-controller': 'input-length autogrow', 'data-entry-link-create-target': 'user_about', 'data-action' : 'input-length#updateDisplay', 'data-input-length-max-value' : constant('App\\DTO\\UserDto::MAX_USERNAME_LENGTH') @@ -47,35 +47,33 @@
From 8174113bbc78a3bcf47e9dfc23b2096ce0519e92 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 2 Sep 2024 13:12:07 +0000 Subject: [PATCH 07/19] docs(contributor): contrib-readme-action has updated readme --- README.md | 45 +++++++++++++++++++++++++++------------------ 1 file changed, 27 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index c22538175..312fa71a9 100644 --- a/README.md +++ b/README.md @@ -209,47 +209,47 @@ For developers: - - lilfade + + ryanmonsen
- Bryson + ryanmonsen
- - vpzomtrrfrt + + drupol
- vpzomtrrfrt + Pol Dellaiera
- - cavebob + + jwr1
- cavebob + John Wesley
- - jwr1 + + cavebob
- John Wesley + cavebob
- - drupol + + vpzomtrrfrt
- Pol Dellaiera + vpzomtrrfrt
- - ryanmonsen + + lilfade
- ryanmonsen + Bryson
@@ -266,6 +266,13 @@ For developers: CSDUMMI + + + LoveIsGrief +
+ LoveIsGrief +
+ DismalShadowX @@ -273,6 +280,8 @@ For developers: Nathan Sparrow + + privacyguard From 3f043fde1a83162049616134f8877512af57fde4 Mon Sep 17 00:00:00 2001 From: JaviviJR Date: Wed, 4 Sep 2024 18:51:00 +0200 Subject: [PATCH 08/19] Related links Added new dynamic field in profile section witch let us to add related links. --- .gitignore | 2 + .../form-related-links_controller.js | 26 +++++-- assets/styles/layout/_forms.scss | 16 ++--- src/DTO/RelatedLinkDTO.php | 58 ++++++++++++++++ src/DTO/UserDto.php | 4 +- src/Entity/User.php | 5 -- src/Factory/UserFactory.php | 6 +- src/Form/UserBasicType.php | 4 +- src/Form/UserRelatedDataType.php | 67 +++++++++++++++++++ src/Service/UserManager.php | 7 +- templates/user/settings/profile.html.twig | 22 ++++-- translations/messages.en.yaml | 3 +- translations/messages.es.yaml | 57 +++++++++++++++- 13 files changed, 242 insertions(+), 35 deletions(-) create mode 100644 src/DTO/RelatedLinkDTO.php create mode 100644 src/Form/UserRelatedDataType.php diff --git a/.gitignore b/.gitignore index 0bce5491f..ea5626140 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ # IDEA/PhpStorm *.iml .idea/ +.run/ +.php-cs .DS_Store supervisord.log supervisord.pid diff --git a/assets/controllers/form-related-links_controller.js b/assets/controllers/form-related-links_controller.js index b3700eda9..ca0a27aaf 100644 --- a/assets/controllers/form-related-links_controller.js +++ b/assets/controllers/form-related-links_controller.js @@ -5,34 +5,46 @@ export default class extends Controller { static values = { index: Number, - link: String, + label: String, + value: String, + deleteIcon: String, }; connect() { const container = this.element; container - .querySelectorAll('.related-link-item') + .querySelectorAll('.related-link-row') .forEach((item) => { this.#addButtonDeleteLink(item); }); } addRelatedElement() { - const nodeLink = this.#htmlToNode(this.linkValue.replace( + const rowNode = document.createElement('div'); + rowNode.className = 'related-link-row'; + + const nodeLabel = this.#htmlToNode(this.labelValue.replace( + /__name__/g, + this.indexValue, + )); + rowNode.appendChild(nodeLabel); + + const nodeValue = this.#htmlToNode(this.valueValue.replace( /__name__/g, this.indexValue, )); + rowNode.appendChild(nodeValue); - this.#addButtonDeleteLink(nodeLink); + this.#addButtonDeleteLink(rowNode); - this.relatedContainerTarget.appendChild(nodeLink); + this.relatedContainerTarget.appendChild(rowNode); this.indexValue++; } #addButtonDeleteLink(item) { const removeFormButton = document.createElement('button'); - removeFormButton.innerText = '⌫'; - removeFormButton.className = 'btn'; + removeFormButton.innerHTML = this.deleteIconValue; + removeFormButton.className = 'btn btn__secondary'; item.append(removeFormButton); diff --git a/assets/styles/layout/_forms.scss b/assets/styles/layout/_forms.scss index f55438afc..a57b98ac6 100644 --- a/assets/styles/layout/_forms.scss +++ b/assets/styles/layout/_forms.scss @@ -536,16 +536,16 @@ div.input-box { margin-bottom: 0.5rem; } -.related-link-item { +.related-link-row { + display: flex; + flex-wrap: nowrap; + row-gap: 0.5rem; + column-gap: 0.2rem; align-items: center; margin-bottom: 0.25rem; } -.related-link-item button { - padding: 0; - background: none; - font-size: 1.5rem; - border: 0; - color: var(--kbin-input-text-color); - +.related-link-row div { + margin-bottom: 0; + flex-grow: 1 } diff --git a/src/DTO/RelatedLinkDTO.php b/src/DTO/RelatedLinkDTO.php new file mode 100644 index 000000000..0fbc7f138 --- /dev/null +++ b/src/DTO/RelatedLinkDTO.php @@ -0,0 +1,58 @@ +label; + } + + /** + * @param string $label + */ + public function setLabel(string $label): void + { + $this->label = $label; + } + + /** + * @return string + */ + public function getValue(): string + { + return $this->value; + } + + /** + * @param string $value + */ + public function setValue(string $value): void + { + $this->value = $value; + } + + /** + * @return bool + */ + public function isVerifiedLink(): bool + { + return $this->verifiedLink; + } + + /** + * @param bool $verifiedLink + */ + public function setVerifiedLink(bool $verifiedLink): void + { + $this->verifiedLink = $verifiedLink; + } +} diff --git a/src/DTO/UserDto.php b/src/DTO/UserDto.php index 6d3fa13c0..5767e92a3 100644 --- a/src/DTO/UserDto.php +++ b/src/DTO/UserDto.php @@ -49,6 +49,8 @@ class UserDto implements UserDtoInterface public ?string $totpSecret = null; public ?string $serverSoftware = null; public ?string $serverSoftwareVersion = null; + + /** @var RelatedLinkDTO[] */ public array $relatedLinks = []; #[Assert\Callback] @@ -92,7 +94,7 @@ public static function create( ?bool $isBot = null, ?bool $isAdmin = null, ?bool $isGlobalModerator = null, - array $relatedLinks = [] + array $relatedLinks = [], ): self { $dto = new UserDto(); $dto->id = $id; diff --git a/src/Entity/User.php b/src/Entity/User.php index b0b5e37c2..d8e9b6703 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -902,9 +902,4 @@ public function getRelatedLinks(): array { return $this->relatedLinks; } - - public function setRelatedLinks(array $relatedLinks): void - { - $this->relatedLinks = $relatedLinks; - } } diff --git a/src/Factory/UserFactory.php b/src/Factory/UserFactory.php index d49ec6703..f6e560954 100644 --- a/src/Factory/UserFactory.php +++ b/src/Factory/UserFactory.php @@ -4,11 +4,13 @@ namespace App\Factory; +use App\DTO\RelatedLinkDTO; use App\DTO\UserDto; use App\DTO\UserSmallResponseDto; use App\Entity\User; use App\Repository\InstanceRepository; use Symfony\Bundle\SecurityBundle\Security; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; class UserFactory { @@ -16,6 +18,7 @@ public function __construct( private readonly ImageFactory $imageFactory, private readonly InstanceRepository $instanceRepository, private readonly Security $security, + private readonly DenormalizerInterface $denormalizer, ) { } @@ -36,7 +39,8 @@ public function createDto(User $user): UserDto 'Service' === $user->type, // setting isBot $user->isAdmin(), $user->isModerator(), - $user->relatedLinks, +// $user->relatedLinks, + $this->denormalizer->denormalize($user->getRelatedLinks(), sprintf('%s[]', RelatedLinkDTO::class)), ); /** @var User $currentUser */ diff --git a/src/Form/UserBasicType.php b/src/Form/UserBasicType.php index c2062583c..8570c05db 100644 --- a/src/Form/UserBasicType.php +++ b/src/Form/UserBasicType.php @@ -33,8 +33,8 @@ public function buildForm(FormBuilderInterface $builder, array $options): void $builder ->add('username', TextType::class, ['required' => false]) ->add('about', TextareaType::class, ['required' => false]) - ->add('relatedLinks', CollectionType::class, [ - 'entry_type' => UrlType::class, + ->add('relatedLinks', CollectionType::class , [ + 'entry_type' => UserRelatedDataType::class, 'entry_options' => ['label' => false], 'label' => false, 'allow_add' => true, diff --git a/src/Form/UserRelatedDataType.php b/src/Form/UserRelatedDataType.php new file mode 100644 index 000000000..a96994468 --- /dev/null +++ b/src/Form/UserRelatedDataType.php @@ -0,0 +1,67 @@ +add('label', TextType::class) + ->add('value', UrlType::class) + ->setDataMapper($this); + ; + } + + /** + * @param RelatedLinkDTO|null $viewData + */ + public function mapDataToForms($viewData, \Traversable $forms): void + { + if (null === $viewData) { + return; + } + + if (!$viewData instanceof RelatedLinkDTO) { + throw new UnexpectedTypeException($viewData, RelatedLinkDTO::class); + } + + /** @var FormInterface[] $forms */ + $forms = iterator_to_array($forms); + + $forms['label']->setData($viewData->getLabel()); + $forms['value']->setData($viewData->getValue()); + } + + public function mapFormsToData(\Traversable $forms, &$viewData): void + { + /** @var FormInterface[] $forms */ + $forms = iterator_to_array($forms); + + // as data is passed by reference, overriding it will change it in + // the form object as well + // beware of type inconsistency, see caution below + $viewData = new RelatedLinkDTO(); + $viewData->setLabel($forms['label']->getData()); + $viewData->setValue($forms['value']->getData()); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults( + [ + 'data_class' => RelatedLinkDTO::class + ] + ); + } +} diff --git a/src/Service/UserManager.php b/src/Service/UserManager.php index 39d22bf45..c279cc93c 100644 --- a/src/Service/UserManager.php +++ b/src/Service/UserManager.php @@ -36,6 +36,8 @@ use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; use Symfony\Component\RateLimiter\RateLimiterFactory; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; +use Symfony\Component\Serializer\SerializerInterface; use Symfony\Contracts\Cache\CacheInterface; use Symfony\Contracts\Cache\ItemInterface; @@ -57,7 +59,8 @@ public function __construct( private ImageRepository $imageRepository, private Security $security, private CacheInterface $cache, - private ReputationRepository $reputationRepository + private ReputationRepository $reputationRepository, + private NormalizerInterface $normalizer, ) { } @@ -211,7 +214,7 @@ public function edit(User $user, UserDto $dto): User $user->setTotpSecret($dto->totpSecret); } - $user->relatedLinks = $dto->relatedLinks; + $user->relatedLinks = $this->normalizer->normalize($dto->relatedLinks); $user->lastActive = new \DateTime(); diff --git a/templates/user/settings/profile.html.twig b/templates/user/settings/profile.html.twig index 601923274..f0174b046 100644 --- a/templates/user/settings/profile.html.twig +++ b/templates/user/settings/profile.html.twig @@ -48,12 +48,16 @@ {{ stimulus_controller('form-related-links') }} data-form-related-links-index-value="{{ form.relatedLinks|length }}" data-form-related-links-link-value="{{ - form_row(form.relatedLinks.vars.prototype, { - label: false, - row_attr: { class: 'flex related-link-item' } + form_row(form.relatedLinks.vars.prototype.value, { + label: false })|e('html_attr') }}" - + data-form-related-links-label-value="{{ + form_row(form.relatedLinks.vars.prototype.label, { + label: false + })|e('html_attr') + }}" + data-form-related-links-delete-icon-value="" > {{ 'related_links'|trans }} @@ -62,10 +66,14 @@ data-form-related-links-target="relatedContainer" > {% for item_form in form.relatedLinks %} - {{ form_row(item_form, { - label: false, - row_attr: { class: 'flex related-link-item' } + {% endfor %}
diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index b64be7f72..a5a77dc4f 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -890,4 +890,5 @@ admin_users_suspended: Suspended admin_users_banned: Banned user_verify: Activate account max_image_size: Maximum file size -related_links: Related links \ No newline at end of file +comment_not_found: Comment not found +related_links: Related links diff --git a/translations/messages.es.yaml b/translations/messages.es.yaml index ba3ac9ad9..db8f0170a 100644 --- a/translations/messages.es.yaml +++ b/translations/messages.es.yaml @@ -532,4 +532,59 @@ marked_for_deletion_at: Marcado para eliminar el %date% sort_by: Ordenar por filter_by_subscription: Filtrar por suscripción filter_by_federation: Filtrar por estado de la federación -related_links: Enlaces relacionados \ No newline at end of file +hidden: Oculto +disabled: Desactivado +edit_entry: Editar hilo +oauth2.grant.entry_comment.create: Crear nuevos comentarios en hilos. +oauth2.grant.entry_comment.delete: Elimina todos tus comentarios de los hilos. +enabled: Activado +downvotes_mode: Modo de votos negativos +unban_hashtag_description: Si eliminas la prohibición de un hashtag, se podrán volver + a crear publicaciones con este hashtag. Las publicaciones existentes con este hashtag + ya no estarán ocultas. +change_downvotes_mode: Cambiar la forma de votar negativamente +oauth2.grant.magazine.all: Suscríbete o bloquea revistas y mira las revistas a las + que estás suscrito o bloqueadas. +oauth2.grant.magazine.subscribe: Suscríbete o cancela tu suscripción a una revista + y consulta las revistas a las que estás suscrito. +oauth2.grant.post.report: Reportar cualquier publicación. +unban_hashtag_btn: Eliminar la prohibición de hashtags +ban_hashtag_description: Prohibir un hashtag impedirá que se creen publicaciones con + ese hashtag, además de ocultar las publicaciones existentes con ese hashtag. +account_deletion_immediate: Eliminar inmediatamente +account_deletion_button: Eliminar cuenta +private_instance: Obligar a los usuarios a iniciar sesión antes de poder acceder a + cualquier contenido +oauth2.grant.entry.edit: Edite tus hilos. +oauth2.grant.entry.report: Reportar cualquier hilo. +oauth2.grant.entry_comment.all: Crea, edita o elimina tus comentarios en hilos, y + vota, impulsa o denuncia cualquier comentario en un hilo. +oauth2.grant.entry_comment.vote: Vota a favor, impulsa o rechaza cualquier comentario + en un hilo. +oauth2.grant.magazine.block: Bloquea o desbloquea revistas y visualiza las revistas + que has bloqueado. +oauth2.grant.post.all: Crea, edita o elimina tus microblogs y vota, impulsa o denuncia + cualquier microblog. +oauth2.grant.post.create: Crear nuevas publicaciones. +oauth2.grant.post_comment.create: Crear nuevos comentarios en las publicaciones. +oauth2.grant.post_comment.delete: Elimina todos tus comentarios en las publicaciones. +oauth2.grant.post_comment.edit: Edita todos tus comentarios en las publicaciones. +oauth2.grant.entry.create: Crear nuevos hilos. +oauth2.grant.entry.vote: Vota a favor, impulsa o rechaza cualquier hilo. +oauth2.grant.entry.delete: Elimina tus hilos. +oauth2.grant.entry_comment.edit: Edita todos tus comentarios en los hilos. +oauth2.grant.post.vote: Voto a favor o en contra, o promoción de cualquier publicación. +oauth2.grant.entry.all: Crea, edita o elimina tus hilos y vota, impulsa o denuncia + cualquier hilo. +account_deletion_title: Eliminar la cuenta +oauth2.grant.post.edit: Edita todas tus publicaciones. +oauth2.grant.post.delete: Elimina todas tus publicaciones. +oauth2.grant.post_comment.all: Crea, edita o elimina tus comentarios en las publicaciones + y vota, impulsa o denuncia cualquier comentario en una publicación. +oauth2.grant.entry_comment.report: Reportar cualquier comentario en un hilo. +federation_page_dead_description: Casos en los que no pudimos realizar al menos 10 + actividades seguidas y donde la última entrega exitosa fue hace más de una semana +account_deletion_description: Tu cuenta se eliminará en 30 días a menos que elijas + eliminarla inmediatamente. Para recuperar tu cuenta en un plazo de 30 días, inicia + sesión con las mismas credenciales de usuario o comunícate con un administrador. +related_links: Enlaces relacionados From 68d6d113a4e64b8d55ab3204485a9bb76496065a Mon Sep 17 00:00:00 2001 From: JaviviJR Date: Mon, 16 Sep 2024 19:56:42 +0200 Subject: [PATCH 09/19] Changes requested --- .gitignore | 1 - .../form-related-links_controller.js | 4 ++ assets/styles/layout/_forms.scss | 27 ++++++--- src/Factory/UserFactory.php | 1 - templates/components/user_box.html.twig | 17 ++++-- templates/user/settings/profile.html.twig | 17 +++--- translations/messages.en.yaml | 1 - translations/messages.es.yaml | 55 ------------------- 8 files changed, 45 insertions(+), 78 deletions(-) diff --git a/.gitignore b/.gitignore index ea5626140..6efd3f502 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,6 @@ *.iml .idea/ .run/ -.php-cs .DS_Store supervisord.log supervisord.pid diff --git a/assets/controllers/form-related-links_controller.js b/assets/controllers/form-related-links_controller.js index ca0a27aaf..b161e6285 100644 --- a/assets/controllers/form-related-links_controller.js +++ b/assets/controllers/form-related-links_controller.js @@ -20,6 +20,10 @@ export default class extends Controller { } addRelatedElement() { + console.log('entra'); + console.log(this.labelValue, 'label'); + console.log(this.valueValue, 'value'); + const rowNode = document.createElement('div'); rowNode.className = 'related-link-row'; diff --git a/assets/styles/layout/_forms.scss b/assets/styles/layout/_forms.scss index a57b98ac6..b78e61fd2 100644 --- a/assets/styles/layout/_forms.scss +++ b/assets/styles/layout/_forms.scss @@ -527,25 +527,34 @@ div.input-box { } .related-link-fieldset { - border: 0; - margin: 0; - padding: 0; + border: 0; + margin: 0; + padding: 0; } .related-link-fieldset legend { margin-bottom: 0.5rem; } +.related-links-group { + margin-bottom: 0.25rem; +} + .related-link-row { - display: flex; - flex-wrap: nowrap; - row-gap: 0.5rem; - column-gap: 0.2rem; - align-items: center; - margin-bottom: 0.25rem; + display: flex; + flex-wrap: nowrap; + row-gap: 0.5rem; + column-gap: 0.2rem; + align-items: center; + margin-bottom: 0.25rem; } .related-link-row div { margin-bottom: 0; flex-grow: 1 } + +.add-button-container { + display: flex; + justify-content: center; +} diff --git a/src/Factory/UserFactory.php b/src/Factory/UserFactory.php index f6e560954..bc2b78dd4 100644 --- a/src/Factory/UserFactory.php +++ b/src/Factory/UserFactory.php @@ -39,7 +39,6 @@ public function createDto(User $user): UserDto 'Service' === $user->type, // setting isBot $user->isAdmin(), $user->isModerator(), -// $user->relatedLinks, $this->denormalizer->denormalize($user->getRelatedLinks(), sprintf('%s[]', RelatedLinkDTO::class)), ); diff --git a/templates/components/user_box.html.twig b/templates/components/user_box.html.twig index c74c2b07b..d75b3acb2 100644 --- a/templates/components/user_box.html.twig +++ b/templates/components/user_box.html.twig @@ -78,11 +78,20 @@ {{ component('user_actions', {user: user}) }}
- {% if user.about|length %} + {% if user.about|length or user.relatedLinks|length %} {% endif %} diff --git a/templates/user/settings/profile.html.twig b/templates/user/settings/profile.html.twig index f0174b046..e56e1138b 100644 --- a/templates/user/settings/profile.html.twig +++ b/templates/user/settings/profile.html.twig @@ -47,7 +47,7 @@
diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index a5a77dc4f..64160d602 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -890,5 +890,4 @@ admin_users_suspended: Suspended admin_users_banned: Banned user_verify: Activate account max_image_size: Maximum file size -comment_not_found: Comment not found related_links: Related links diff --git a/translations/messages.es.yaml b/translations/messages.es.yaml index db8f0170a..0fb681f7b 100644 --- a/translations/messages.es.yaml +++ b/translations/messages.es.yaml @@ -532,59 +532,4 @@ marked_for_deletion_at: Marcado para eliminar el %date% sort_by: Ordenar por filter_by_subscription: Filtrar por suscripción filter_by_federation: Filtrar por estado de la federación -hidden: Oculto -disabled: Desactivado -edit_entry: Editar hilo -oauth2.grant.entry_comment.create: Crear nuevos comentarios en hilos. -oauth2.grant.entry_comment.delete: Elimina todos tus comentarios de los hilos. -enabled: Activado -downvotes_mode: Modo de votos negativos -unban_hashtag_description: Si eliminas la prohibición de un hashtag, se podrán volver - a crear publicaciones con este hashtag. Las publicaciones existentes con este hashtag - ya no estarán ocultas. -change_downvotes_mode: Cambiar la forma de votar negativamente -oauth2.grant.magazine.all: Suscríbete o bloquea revistas y mira las revistas a las - que estás suscrito o bloqueadas. -oauth2.grant.magazine.subscribe: Suscríbete o cancela tu suscripción a una revista - y consulta las revistas a las que estás suscrito. -oauth2.grant.post.report: Reportar cualquier publicación. -unban_hashtag_btn: Eliminar la prohibición de hashtags -ban_hashtag_description: Prohibir un hashtag impedirá que se creen publicaciones con - ese hashtag, además de ocultar las publicaciones existentes con ese hashtag. -account_deletion_immediate: Eliminar inmediatamente -account_deletion_button: Eliminar cuenta -private_instance: Obligar a los usuarios a iniciar sesión antes de poder acceder a - cualquier contenido -oauth2.grant.entry.edit: Edite tus hilos. -oauth2.grant.entry.report: Reportar cualquier hilo. -oauth2.grant.entry_comment.all: Crea, edita o elimina tus comentarios en hilos, y - vota, impulsa o denuncia cualquier comentario en un hilo. -oauth2.grant.entry_comment.vote: Vota a favor, impulsa o rechaza cualquier comentario - en un hilo. -oauth2.grant.magazine.block: Bloquea o desbloquea revistas y visualiza las revistas - que has bloqueado. -oauth2.grant.post.all: Crea, edita o elimina tus microblogs y vota, impulsa o denuncia - cualquier microblog. -oauth2.grant.post.create: Crear nuevas publicaciones. -oauth2.grant.post_comment.create: Crear nuevos comentarios en las publicaciones. -oauth2.grant.post_comment.delete: Elimina todos tus comentarios en las publicaciones. -oauth2.grant.post_comment.edit: Edita todos tus comentarios en las publicaciones. -oauth2.grant.entry.create: Crear nuevos hilos. -oauth2.grant.entry.vote: Vota a favor, impulsa o rechaza cualquier hilo. -oauth2.grant.entry.delete: Elimina tus hilos. -oauth2.grant.entry_comment.edit: Edita todos tus comentarios en los hilos. -oauth2.grant.post.vote: Voto a favor o en contra, o promoción de cualquier publicación. -oauth2.grant.entry.all: Crea, edita o elimina tus hilos y vota, impulsa o denuncia - cualquier hilo. -account_deletion_title: Eliminar la cuenta -oauth2.grant.post.edit: Edita todas tus publicaciones. -oauth2.grant.post.delete: Elimina todas tus publicaciones. -oauth2.grant.post_comment.all: Crea, edita o elimina tus comentarios en las publicaciones - y vota, impulsa o denuncia cualquier comentario en una publicación. -oauth2.grant.entry_comment.report: Reportar cualquier comentario en un hilo. -federation_page_dead_description: Casos en los que no pudimos realizar al menos 10 - actividades seguidas y donde la última entrega exitosa fue hace más de una semana -account_deletion_description: Tu cuenta se eliminará en 30 días a menos que elijas - eliminarla inmediatamente. Para recuperar tu cuenta en un plazo de 30 días, inicia - sesión con las mismas credenciales de usuario o comunícate con un administrador. related_links: Enlaces relacionados From a5b7f85cfa6a660baf7f5727c15f5f46eff9e878 Mon Sep 17 00:00:00 2001 From: JaviviJR Date: Mon, 16 Sep 2024 22:48:15 +0200 Subject: [PATCH 10/19] Changes requested --- assets/controllers/form-related-links_controller.js | 2 +- assets/styles/layout/_forms.scss | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/assets/controllers/form-related-links_controller.js b/assets/controllers/form-related-links_controller.js index b161e6285..8c4548681 100644 --- a/assets/controllers/form-related-links_controller.js +++ b/assets/controllers/form-related-links_controller.js @@ -48,7 +48,7 @@ export default class extends Controller { #addButtonDeleteLink(item) { const removeFormButton = document.createElement('button'); removeFormButton.innerHTML = this.deleteIconValue; - removeFormButton.className = 'btn btn__secondary'; + removeFormButton.className = 'btn btn__secondary delete-button'; item.append(removeFormButton); diff --git a/assets/styles/layout/_forms.scss b/assets/styles/layout/_forms.scss index b78e61fd2..92dc2b66b 100644 --- a/assets/styles/layout/_forms.scss +++ b/assets/styles/layout/_forms.scss @@ -554,7 +554,13 @@ div.input-box { flex-grow: 1 } +.delete-button { + padding: 0; + border: 0; +} + .add-button-container { display: flex; + margin-right: 13.9px; justify-content: center; } From 1d0f707ef859a5a17d981169b5a4199c75ead8a8 Mon Sep 17 00:00:00 2001 From: JaviviJR Date: Mon, 16 Sep 2024 23:52:05 +0200 Subject: [PATCH 11/19] Changes requested --- src/Entity/User.php | 10 ++++++++++ src/Service/UserManager.php | 1 - templates/components/user_box.html.twig | 12 +++++++----- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/src/Entity/User.php b/src/Entity/User.php index d8e9b6703..c63d2342b 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -902,4 +902,14 @@ public function getRelatedLinks(): array { return $this->relatedLinks; } + + public function isVerifiedRelatedLinkExists(): bool + { + foreach ($this->relatedLinks as $relatedLink) { + if ($relatedLink['verifiedLink'] === true) { + return true; + } + } + return false; + } } diff --git a/src/Service/UserManager.php b/src/Service/UserManager.php index c279cc93c..1835b597d 100644 --- a/src/Service/UserManager.php +++ b/src/Service/UserManager.php @@ -37,7 +37,6 @@ use Symfony\Component\RateLimiter\RateLimiterFactory; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; -use Symfony\Component\Serializer\SerializerInterface; use Symfony\Contracts\Cache\CacheInterface; use Symfony\Contracts\Cache\ItemInterface; diff --git a/templates/components/user_box.html.twig b/templates/components/user_box.html.twig index d75b3acb2..18983245d 100644 --- a/templates/components/user_box.html.twig +++ b/templates/components/user_box.html.twig @@ -78,18 +78,20 @@ {{ component('user_actions', {user: user}) }}
- {% if user.about|length or user.relatedLinks|length %} + {% if user.about|length or user.isVerifiedRelatedLinkExists %}
{% if user.about|length %}
{{ user.about|markdown|raw }}
{% endif %} - {% if user.relatedLinks|length %} + {% if user.isVerifiedRelatedLinkExists %} {% for rel in user.relatedLinks %} - + {% if rel.verifiedLink %} + + {% endif %} {% endfor %} {% endif %}
From 09a90b484dd0802e3ca1e1730921310d2f8a787a Mon Sep 17 00:00:00 2001 From: BentiGorlich Date: Sun, 6 Oct 2024 14:16:11 +0200 Subject: [PATCH 12/19] Introducing bookmarks (#1095) Co-authored-by: Melroy van den Berg --- assets/controllers/subject_controller.js | 40 ++ assets/styles/app.scss | 3 + assets/styles/layout/_icons.scss | 3 + assets/styles/pages/page_bookmarks.scss | 6 + config/kbin_routes/bookmark.yaml | 71 ++++ config/kbin_routes/bookmark_api.yaml | 61 +++ config/packages/league_oauth2_server.yaml | 7 + config/packages/security.yaml | 11 + config/services.yaml | 1 + migrations/Version20240831151328.php | 56 +++ src/Controller/Api/BaseApi.php | 10 +- .../Api/Bookmark/BookmarkApiController.php | 265 ++++++++++++ .../Bookmark/BookmarkListApiController.php | 378 ++++++++++++++++++ src/Controller/BookmarkController.php | 145 +++++++ src/Controller/BookmarkListController.php | 180 +++++++++ src/DTO/BookmarkListDto.php | 42 ++ src/DTO/OAuth2ClientDto.php | 7 + src/Entity/Bookmark.php | 70 ++++ src/Entity/BookmarkList.php | 51 +++ src/Entity/User.php | 2 + src/Form/BookmarkListType.php | 35 ++ src/Pagination/NativeQueryAdapter.php | 17 +- .../ContentPopulationTransformer.php | 69 +++- src/Repository/BookmarkListRepository.php | 83 ++++ src/Repository/BookmarkRepository.php | 180 +++++++++ src/Schema/PaginationSchema.php | 4 +- src/Service/BookmarkManager.php | 90 +++++ src/Twig/Components/BookmarkListComponent.php | 20 + .../Components/BookmarkMenuListComponent.php | 23 ++ .../Components/BookmarkStandardComponent.php | 18 + .../EntryCommentsNestedComponent.php | 40 +- .../PostCommentsNestedComponent.php | 43 +- src/Twig/Extension/BookmarkExtension.php | 22 + src/Twig/Extension/FrontExtension.php | 2 + src/Twig/Runtime/BookmarkExtensionRuntime.php | 47 +++ src/Twig/Runtime/FrontExtensionRuntime.php | 24 ++ templates/bookmark/_form_edit.html.twig | 14 + templates/bookmark/_options.html.twig | 231 +++++++++++ templates/bookmark/edit.html.twig | 24 ++ templates/bookmark/front.html.twig | 25 ++ templates/bookmark/overview.html.twig | 77 ++++ templates/components/bookmark_list.html.twig | 19 + .../components/bookmark_menu_list.html.twig | 5 + .../components/bookmark_standard.html.twig | 17 + templates/components/entry.html.twig | 3 + templates/components/entry_comment.html.twig | 3 + templates/components/post.html.twig | 3 + templates/components/post_comment.html.twig | 3 + templates/entry/_menu.html.twig | 9 + templates/entry/comment/_menu.html.twig | 11 + templates/layout/_header.html.twig | 6 + templates/post/_menu.html.twig | 11 + templates/post/comment/_menu.html.twig | 11 + translations/messages.en.yaml | 17 + 54 files changed, 2524 insertions(+), 91 deletions(-) create mode 100644 assets/styles/layout/_icons.scss create mode 100644 assets/styles/pages/page_bookmarks.scss create mode 100644 config/kbin_routes/bookmark.yaml create mode 100644 config/kbin_routes/bookmark_api.yaml create mode 100644 migrations/Version20240831151328.php create mode 100644 src/Controller/Api/Bookmark/BookmarkApiController.php create mode 100644 src/Controller/Api/Bookmark/BookmarkListApiController.php create mode 100644 src/Controller/BookmarkController.php create mode 100644 src/Controller/BookmarkListController.php create mode 100644 src/DTO/BookmarkListDto.php create mode 100644 src/Entity/Bookmark.php create mode 100644 src/Entity/BookmarkList.php create mode 100644 src/Form/BookmarkListType.php create mode 100644 src/Repository/BookmarkListRepository.php create mode 100644 src/Repository/BookmarkRepository.php create mode 100644 src/Service/BookmarkManager.php create mode 100644 src/Twig/Components/BookmarkListComponent.php create mode 100644 src/Twig/Components/BookmarkMenuListComponent.php create mode 100644 src/Twig/Components/BookmarkStandardComponent.php create mode 100644 src/Twig/Extension/BookmarkExtension.php create mode 100644 src/Twig/Runtime/BookmarkExtensionRuntime.php create mode 100644 templates/bookmark/_form_edit.html.twig create mode 100644 templates/bookmark/_options.html.twig create mode 100644 templates/bookmark/edit.html.twig create mode 100644 templates/bookmark/front.html.twig create mode 100644 templates/bookmark/overview.html.twig create mode 100644 templates/components/bookmark_list.html.twig create mode 100644 templates/components/bookmark_menu_list.html.twig create mode 100644 templates/components/bookmark_standard.html.twig diff --git a/assets/controllers/subject_controller.js b/assets/controllers/subject_controller.js index 95331ad07..cd38b0eb0 100644 --- a/assets/controllers/subject_controller.js +++ b/assets/controllers/subject_controller.js @@ -199,6 +199,46 @@ 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 7f60d159a..57fb23d5d 100644 --- a/assets/styles/app.scss +++ b/assets/styles/app.scss @@ -1,5 +1,6 @@ @import '@fortawesome/fontawesome-free/scss/fontawesome'; @import '@fortawesome/fontawesome-free/scss/solid'; +@import '@fortawesome/fontawesome-free/scss/regular'; @import '@fortawesome/fontawesome-free/scss/brands'; @import 'simple-icons-font/font/simple-icons'; @import 'glightbox/dist/css/glightbox.min.css'; @@ -21,6 +22,7 @@ @import 'layout/alerts'; @import 'layout/forms'; @import 'layout/images'; +@import 'layout/icons'; @import 'components/announcement'; @import 'components/topbar'; @import 'components/header'; @@ -51,6 +53,7 @@ @import 'components/settings_row'; @import 'pages/post_single'; @import 'pages/post_front'; +@import 'pages/page_bookmarks'; @import 'themes/kbin'; @import 'themes/default'; @import 'themes/solarized'; diff --git a/assets/styles/layout/_icons.scss b/assets/styles/layout/_icons.scss new file mode 100644 index 000000000..0f94dbfc0 --- /dev/null +++ b/assets/styles/layout/_icons.scss @@ -0,0 +1,3 @@ +i.active { + color: var(--kbin-color-icon-active, orange); +} diff --git a/assets/styles/pages/page_bookmarks.scss b/assets/styles/pages/page_bookmarks.scss new file mode 100644 index 000000000..557f314dd --- /dev/null +++ b/assets/styles/pages/page_bookmarks.scss @@ -0,0 +1,6 @@ +.page-bookmarks { + .entry, .entry-comment, .post, .post-comment, .comment { + margin-top: 0!important; + margin-bottom: .5em!important; + } +} diff --git a/config/kbin_routes/bookmark.yaml b/config/kbin_routes/bookmark.yaml new file mode 100644 index 000000000..487c8f307 --- /dev/null +++ b/config/kbin_routes/bookmark.yaml @@ -0,0 +1,71 @@ +bookmark_front: + controller: App\Controller\BookmarkListController::front + defaults: { sortBy: hot, time: '∞', federation: all } + path: /bookmark-lists/show/{list}/{sortBy}/{time}/{federation} + methods: [GET] + requirements: &front_requirement + sortBy: "%default_sort_options%" + time: "%default_time_options%" + federation: "%default_federation_options%" + +bookmark_lists: + controller: App\Controller\BookmarkListController::list + path: /bookmark-lists + methods: [GET, POST] + +bookmark_lists_menu_refresh_status: + controller: App\Controller\BookmarkListController::subjectBookmarkMenuListRefresh + path: /blr/{subject_id}/{subject_type} + requirements: + subject_type: "%default_subject_type_options%" + methods: [ GET ] + +bookmark_lists_make_default: + controller: App\Controller\BookmarkListController::makeDefault + path: /bookmark-lists/makeDefault + methods: [GET] + +bookmark_lists_edit_list: + controller: App\Controller\BookmarkListController::editList + path: /bookmark-lists/editList/{list} + methods: [GET, POST] + +bookmark_lists_delete_list: + controller: App\Controller\BookmarkListController::deleteList + path: /bookmark-lists/deleteList/{list} + methods: [GET] + +subject_bookmark_standard: + controller: App\Controller\BookmarkController::subjectBookmarkStandard + path: /bos/{subject_id}/{subject_type} + requirements: + subject_type: "%default_subject_type_options%" + methods: [ GET ] + +subject_bookmark_refresh_status: + controller: App\Controller\BookmarkController::subjectBookmarkRefresh + path: /bor/{subject_id}/{subject_type} + requirements: + subject_type: "%default_subject_type_options%" + methods: [ GET ] + +subject_bookmark_to_list: + controller: App\Controller\BookmarkController::subjectBookmarkToList + path: /bol/{subject_id}/{subject_type}/{list} + requirements: + subject_type: "%default_subject_type_options%" + methods: [ GET ] + +subject_remove_bookmarks: + controller: App\Controller\BookmarkController::subjectRemoveBookmarks + path: /rbo/{subject_id}/{subject_type} + requirements: + subject_type: "%default_subject_type_options%" + methods: [ GET ] + +subject_remove_bookmark_from_list: + controller: App\Controller\BookmarkController::subjectRemoveBookmarkFromList + path: /rbol/{subject_id}/{subject_type}/{list} + requirements: + subject_type: "%default_subject_type_options%" + methods: [ GET ] diff --git a/config/kbin_routes/bookmark_api.yaml b/config/kbin_routes/bookmark_api.yaml new file mode 100644 index 000000000..f69ad45eb --- /dev/null +++ b/config/kbin_routes/bookmark_api.yaml @@ -0,0 +1,61 @@ +api_bookmark_front: + controller: App\Controller\Api\Bookmark\BookmarkListApiController::front + path: /api/bookmark-lists/show + methods: [GET] + format: json + +api_bookmark_lists: + controller: App\Controller\Api\Bookmark\BookmarkListApiController::list + path: /api/bookmark-lists + methods: [GET] + format: json + +api_bookmark_lists_make_default: + controller: App\Controller\Api\Bookmark\BookmarkListApiController::makeDefault + path: /api/bookmark-lists/{list_name}/makeDefault + methods: [GET] + format: json + +api_bookmark_lists_edit_list: + controller: App\Controller\Api\Bookmark\BookmarkListApiController::editList + path: /api/bookmark-lists/{list_name} + methods: [POST] + format: json + +api_bookmark_lists_delete_list: + controller: App\Controller\Api\Bookmark\BookmarkListApiController::deleteList + path: /api/bookmark-lists/{list_name} + methods: [DELETE] + format: json + +api_subject_bookmark_standard: + controller: App\Controller\Api\Bookmark\BookmarkApiController::subjectBookmarkStandard + path: /api/bos/{subject_id}/{subject_type} + requirements: + subject_type: "%default_subject_type_options%" + methods: [ GET ] + format: json + +api_subject_bookmark_to_list: + controller: App\Controller\Api\Bookmark\BookmarkApiController::subjectBookmarkToList + path: /api/bol/{subject_id}/{subject_type}/{list_name} + requirements: + subject_type: "%default_subject_type_options%" + methods: [ GET ] + format: json + +api_subject_remove_bookmarks: + controller: App\Controller\Api\Bookmark\BookmarkApiController::subjectRemoveBookmarks + path: /api/rbo/{subject_id}/{subject_type} + requirements: + subject_type: "%default_subject_type_options%" + methods: [ GET ] + format: json + +api_subject_remove_bookmark_from_list: + controller: App\Controller\Api\Bookmark\BookmarkApiController::subjectRemoveBookmarkFromList + path: /api/rbol/{subject_id}/{subject_type}/{list_name} + requirements: + subject_type: "%default_subject_type_options%" + methods: [ GET ] + format: json diff --git a/config/packages/league_oauth2_server.yaml b/config/packages/league_oauth2_server.yaml index 714d49007..221fde7b4 100644 --- a/config/packages/league_oauth2_server.yaml +++ b/config/packages/league_oauth2_server.yaml @@ -59,6 +59,13 @@ league_oauth2_server: "user:profile", "user:profile:read", "user:profile:edit", + "user:bookmark", + "user:bookmark:add", + "user:bookmark:remove", + "user:bookmark:list", + "user:bookmark:list:read", + "user:bookmark:list:edit", + "user:bookmark:list:delete", "user:message", "user:message:read", "user:message:create", diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 626baa730..c3dee042c 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -230,6 +230,17 @@ security: 'ROLE_OAUTH2_USER:OAUTH_CLIENTS:READ', 'ROLE_OAUTH2_USER:OAUTH_CLIENTS:EDIT', ] + 'ROLE_OAUTH2_USER:BOOKMARK': + [ + 'ROLE_OAUTH2_USER:BOOKMARK:ADD', + 'ROLE_OAUTH2_USER:BOOKMARK:REMOVE', + ] + 'ROLE_OAUTH2_USER:BOOKMARK_LIST': + [ + 'ROLE_OAUTH2_USER:BOOKMARK_LIST:READ', + 'ROLE_OAUTH2_USER:BOOKMARK_LIST:EDIT', + 'ROLE_OAUTH2_USER:BOOKMARK_LIST:DELETE', + ] 'ROLE_OAUTH2_MODERATE': [ 'ROLE_OAUTH2_MODERATE:ENTRY', diff --git a/config/services.yaml b/config/services.yaml index b5924f19a..7d622e14f 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -82,6 +82,7 @@ parameters: default_subscription_options: sub|fav|mod|all|home default_federation_options: local|all default_content_options: threads|microblog + default_subject_type_options: entry|entry_comment|post|post_comment comment_sort_options: top|hot|active|newest|oldest diff --git a/migrations/Version20240831151328.php b/migrations/Version20240831151328.php new file mode 100644 index 000000000..d680579ca --- /dev/null +++ b/migrations/Version20240831151328.php @@ -0,0 +1,56 @@ +addSql('CREATE SEQUENCE bookmark_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE SEQUENCE bookmark_list_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE TABLE bookmark (id INT NOT NULL, list_id INT NOT NULL, user_id INT NOT NULL, entry_id INT DEFAULT NULL, entry_comment_id INT DEFAULT NULL, post_id INT DEFAULT NULL, post_comment_id INT DEFAULT NULL, created_at TIMESTAMP(0) WITH TIME ZONE NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX IDX_DA62921D3DAE168B ON bookmark (list_id)'); + $this->addSql('CREATE INDEX IDX_DA62921DA76ED395 ON bookmark (user_id)'); + $this->addSql('CREATE INDEX IDX_DA62921DBA364942 ON bookmark (entry_id)'); + $this->addSql('CREATE INDEX IDX_DA62921D60C33421 ON bookmark (entry_comment_id)'); + $this->addSql('CREATE INDEX IDX_DA62921D4B89032C ON bookmark (post_id)'); + $this->addSql('CREATE INDEX IDX_DA62921DDB1174D2 ON bookmark (post_comment_id)'); + $this->addSql('CREATE UNIQUE INDEX bookmark_list_entry_entryComment_post_postComment_idx ON bookmark (list_id, entry_id, entry_comment_id, post_id, post_comment_id)'); + $this->addSql('COMMENT ON COLUMN bookmark.created_at IS \'(DC2Type:datetimetz_immutable)\''); + $this->addSql('CREATE TABLE bookmark_list (id INT NOT NULL, user_id INT NOT NULL, name VARCHAR(255) NOT NULL, is_default BOOLEAN NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX IDX_A650C0C4A76ED395 ON bookmark_list (user_id)'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_A650C0C4A76ED3955E237E06 ON bookmark_list (user_id, name)'); + $this->addSql('ALTER TABLE bookmark ADD CONSTRAINT FK_DA62921D3DAE168B FOREIGN KEY (list_id) REFERENCES bookmark_list (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE bookmark ADD CONSTRAINT FK_DA62921DA76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE bookmark ADD CONSTRAINT FK_DA62921DBA364942 FOREIGN KEY (entry_id) REFERENCES entry (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE bookmark ADD CONSTRAINT FK_DA62921D60C33421 FOREIGN KEY (entry_comment_id) REFERENCES entry_comment (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE bookmark ADD CONSTRAINT FK_DA62921D4B89032C FOREIGN KEY (post_id) REFERENCES post (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE bookmark ADD CONSTRAINT FK_DA62921DDB1174D2 FOREIGN KEY (post_comment_id) REFERENCES post_comment (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE bookmark_list ADD CONSTRAINT FK_A650C0C4A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP SEQUENCE bookmark_id_seq CASCADE'); + $this->addSql('DROP SEQUENCE bookmark_list_id_seq CASCADE'); + $this->addSql('ALTER TABLE bookmark DROP CONSTRAINT FK_DA62921D3DAE168B'); + $this->addSql('ALTER TABLE bookmark DROP CONSTRAINT FK_DA62921DA76ED395'); + $this->addSql('ALTER TABLE bookmark DROP CONSTRAINT FK_DA62921DBA364942'); + $this->addSql('ALTER TABLE bookmark DROP CONSTRAINT FK_DA62921D60C33421'); + $this->addSql('ALTER TABLE bookmark DROP CONSTRAINT FK_DA62921D4B89032C'); + $this->addSql('ALTER TABLE bookmark DROP CONSTRAINT FK_DA62921DDB1174D2'); + $this->addSql('ALTER TABLE bookmark_list DROP CONSTRAINT FK_A650C0C4A76ED395'); + $this->addSql('DROP TABLE bookmark'); + $this->addSql('DROP TABLE bookmark_list'); + } +} diff --git a/src/Controller/Api/BaseApi.php b/src/Controller/Api/BaseApi.php index 470391322..4485c7143 100644 --- a/src/Controller/Api/BaseApi.php +++ b/src/Controller/Api/BaseApi.php @@ -30,6 +30,8 @@ use App\Factory\PostCommentFactory; use App\Factory\PostFactory; use App\Form\Constraint\ImageConstraint; +use App\Repository\BookmarkListRepository; +use App\Repository\BookmarkRepository; use App\Repository\Criteria; use App\Repository\EntryCommentRepository; use App\Repository\EntryRepository; @@ -39,12 +41,13 @@ use App\Repository\PostRepository; use App\Repository\TagLinkRepository; use App\Schema\PaginationSchema; +use App\Service\BookmarkManager; use App\Service\IpResolver; use App\Service\ReportManager; use Doctrine\ORM\EntityManagerInterface; use League\Bundle\OAuth2ServerBundle\Model\AccessToken; use League\Bundle\OAuth2ServerBundle\Security\Authentication\Token\OAuth2Token; -use Pagerfanta\Pagerfanta; +use Pagerfanta\PagerfantaInterface; use Psr\Container\ContainerExceptionInterface; use Psr\Container\NotFoundExceptionInterface; use Psr\Log\LoggerInterface; @@ -85,6 +88,9 @@ public function __construct( protected readonly EntryCommentRepository $entryCommentRepository, protected readonly PostRepository $postRepository, protected readonly PostCommentRepository $postCommentRepository, + protected readonly BookmarkListRepository $bookmarkListRepository, + protected readonly BookmarkRepository $bookmarkRepository, + protected readonly BookmarkManager $bookmarkManager, private readonly ImageRepository $imageRepository, private readonly ReportManager $reportManager, private readonly OAuth2ClientAccessRepository $clientAccessRepository, @@ -189,7 +195,7 @@ public function getAccessToken(?OAuth2Token $oAuth2Token): ?AccessToken ->findOneBy(['identifier' => $oAuth2Token->getAttribute('access_token_id')]); } - public function serializePaginated(array $serializedItems, Pagerfanta $pagerfanta): array + public function serializePaginated(array $serializedItems, PagerfantaInterface $pagerfanta): array { return [ 'items' => $serializedItems, diff --git a/src/Controller/Api/Bookmark/BookmarkApiController.php b/src/Controller/Api/Bookmark/BookmarkApiController.php new file mode 100644 index 000000000..e7a79f9f5 --- /dev/null +++ b/src/Controller/Api/Bookmark/BookmarkApiController.php @@ -0,0 +1,265 @@ +getUserOrThrow(); + $headers = $this->rateLimit($apiUpdateLimiter); + $subjectClass = BookmarkManager::GetClassFromSubjectType($subject_type); + $subject = $this->entityManager->getRepository($subjectClass)->find($subject_id); + if (null === $subject) { + throw new NotFoundHttpException(code: 404, headers: $headers); + } + $this->bookmarkManager->addBookmarkToDefaultList($user, $subject); + + return new JsonResponse(status: 200, headers: $headers); + } + + #[OA\Response( + response: 200, + description: 'Add a bookmark for the subject in the specified list', + headers: [ + new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), + ], + content: null + )] + #[OA\Response( + response: 401, + description: 'Permission denied due to missing or expired token', + content: new OA\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class)) + )] + #[OA\Response( + response: 404, + description: 'The specified subject or list does not exist', + content: new OA\JsonContent(ref: new Model(type: NotFoundErrorSchema::class)) + )] + #[OA\Response( + response: 429, + description: 'You are being rate limited', + headers: [ + new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), + ], + content: new OA\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class)) + )] + #[OA\Parameter( + name: 'subject_id', + description: 'The id of the subject to be added to the specified list', + in: 'path', + schema: new OA\Schema(type: 'integer') + )] + #[OA\Parameter( + name: 'subject_type', + description: 'the type of the subject', + in: 'path', + schema: new OA\Schema(type: 'string', enum: ['entry', 'entry_comment', 'post', 'post_comment']) + )] + #[OA\Tag(name: 'bookmark:list')] + #[Security(name: 'oauth2', scopes: ['user:bookmark:add'])] + #[IsGranted('ROLE_OAUTH2_USER:BOOKMARK:ADD')] + public function subjectBookmarkToList(string $list_name, int $subject_id, string $subject_type, RateLimiterFactory $apiUpdateLimiter): JsonResponse + { + $user = $this->getUserOrThrow(); + $headers = $this->rateLimit($apiUpdateLimiter); + $subjectClass = BookmarkManager::GetClassFromSubjectType($subject_type); + $subject = $this->entityManager->getRepository($subjectClass)->find($subject_id); + if (null === $subject) { + throw new NotFoundHttpException(code: 404, headers: $headers); + } + $list = $this->bookmarkListRepository->findOneByUserAndName($user, $list_name); + if (null === $list) { + throw new NotFoundHttpException(code: 404, headers: $headers); + } + $this->bookmarkManager->addBookmark($user, $list, $subject); + + return new JsonResponse(status: 200, headers: $headers); + } + + #[OA\Response( + response: 200, + description: 'Remove bookmark for the subject from the specified list', + headers: [ + new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), + ], + content: null + )] + #[OA\Response( + response: 401, + description: 'Permission denied due to missing or expired token', + content: new OA\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class)) + )] + #[OA\Response( + response: 404, + description: 'The specified subject or list does not exist', + content: new OA\JsonContent(ref: new Model(type: NotFoundErrorSchema::class)) + )] + #[OA\Response( + response: 429, + description: 'You are being rate limited', + headers: [ + new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), + ], + content: new OA\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class)) + )] + #[OA\Parameter( + name: 'subject_id', + description: 'The id of the subject to be removed', + in: 'path', + schema: new OA\Schema(type: 'integer') + )] + #[OA\Parameter( + name: 'subject_type', + description: 'the type of the subject', + in: 'path', + schema: new OA\Schema(type: 'string', enum: ['entry', 'entry_comment', 'post', 'post_comment']) + )] + #[OA\Tag(name: 'bookmark:list')] + #[Security(name: 'oauth2', scopes: ['user:bookmark:remove'])] + #[IsGranted('ROLE_OAUTH2_USER:BOOKMARK:REMOVE')] + public function subjectRemoveBookmarkFromList(string $list_name, int $subject_id, string $subject_type, RateLimiterFactory $apiUpdateLimiter): JsonResponse + { + $user = $this->getUserOrThrow(); + $headers = $this->rateLimit($apiUpdateLimiter); + $subjectClass = BookmarkManager::GetClassFromSubjectType($subject_type); + $subject = $this->entityManager->getRepository($subjectClass)->find($subject_id); + if (null === $subject) { + throw new NotFoundHttpException(code: 404, headers: $headers); + } + $list = $this->bookmarkListRepository->findOneByUserAndName($user, $list_name); + if (null === $list) { + throw new NotFoundHttpException(code: 404, headers: $headers); + } + $this->bookmarkRepository->removeBookmarkFromList($user, $list, $subject); + + return new JsonResponse(status: 200, headers: $headers); + } + + #[OA\Response( + response: 200, + description: 'Remove all bookmarks for the subject', + headers: [ + new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), + ], + content: null + )] + #[OA\Response( + response: 401, + description: 'Permission denied due to missing or expired token', + content: new OA\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class)) + )] + #[OA\Response( + response: 404, + description: 'The specified subject does not exist', + content: new OA\JsonContent(ref: new Model(type: NotFoundErrorSchema::class)) + )] + #[OA\Response( + response: 429, + description: 'You are being rate limited', + headers: [ + new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), + ], + content: new OA\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class)) + )] + #[OA\Parameter( + name: 'subject_id', + description: 'The id of the subject to be removed', + in: 'path', + schema: new OA\Schema(type: 'integer') + )] + #[OA\Parameter( + name: 'subject_type', + description: 'the type of the subject', + in: 'path', + schema: new OA\Schema(type: 'string', enum: ['entry', 'entry_comment', 'post', 'post_comment']) + )] + #[OA\Tag(name: 'bookmark:list')] + #[Security(name: 'oauth2', scopes: ['user:bookmark:remove'])] + #[IsGranted('ROLE_OAUTH2_USER:BOOKMARK:REMOVE')] + public function subjectRemoveBookmarks(int $subject_id, string $subject_type, RateLimiterFactory $apiUpdateLimiter): JsonResponse + { + $user = $this->getUserOrThrow(); + $headers = $this->rateLimit($apiUpdateLimiter); + $subjectClass = BookmarkManager::GetClassFromSubjectType($subject_type); + $subject = $this->entityManager->getRepository($subjectClass)->find($subject_id); + if (null === $subject) { + throw new NotFoundHttpException(code: 404, headers: $headers); + } + $this->bookmarkRepository->removeAllBookmarksForContent($user, $subject); + + return new JsonResponse(status: 200, headers: $headers); + } +} diff --git a/src/Controller/Api/Bookmark/BookmarkListApiController.php b/src/Controller/Api/Bookmark/BookmarkListApiController.php new file mode 100644 index 000000000..d7e5c10bc --- /dev/null +++ b/src/Controller/Api/Bookmark/BookmarkListApiController.php @@ -0,0 +1,378 @@ +getUserOrThrow(); + $headers = $this->rateLimit($apiReadLimiter); + $criteria = new EntryPageView($p ?? 1); + $criteria->setTime($criteria->resolveTime($time ?? Criteria::TIME_ALL)); + $criteria->setType($criteria->resolveType($type ?? 'all')); + $criteria->showSortOption($criteria->resolveSort($sort ?? Criteria::SORT_NEW)); + $criteria->setFederation($federation ?? Criteria::AP_ALL); + + if (null !== $list_id) { + $bookmarkList = $this->bookmarkListRepository->findOneBy(['id' => $list_id, 'user' => $user]); + if (null === $bookmarkList) { + return new JsonResponse(status: 404, headers: $headers); + } + } else { + $bookmarkList = $this->bookmarkListRepository->findOneByUserDefault($user); + } + $pagerfanta = $this->bookmarkRepository->findPopulatedByList($bookmarkList, $criteria, $perPage); + $objects = $pagerfanta->getCurrentPageResults(); + $items = array_map(fn (ContentInterface $item) => $this->serializeContentInterface($item), $objects); + $result = $this->serializePaginated($items, $pagerfanta); + + return new JsonResponse($result, status: 200, headers: $headers); + } + + #[OA\Response( + response: 200, + description: 'Returns all bookmark lists from the user', + headers: [ + new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), + ], + content: new OA\JsonContent( + properties: [ + new OA\Property( + property: 'items', + type: 'array', + items: new OA\Items(ref: new Model(type: BookmarkListDto::class)) + ), + ], + type: 'object' + ) + )] + #[OA\Response( + response: 401, + description: 'Permission denied due to missing or expired token', + content: new OA\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class)) + )] + #[OA\Response( + response: 429, + description: 'You are being rate limited', + headers: [ + new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), + ], + content: new OA\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class)) + )] + #[OA\Tag(name: 'bookmark:list')] + #[Security(name: 'oauth2', scopes: ['user:bookmark:list:read'])] + #[IsGranted('ROLE_OAUTH2_USER:BOOKMARK_LIST:READ')] + public function list(RateLimiterFactory $apiReadLimiter): JsonResponse + { + $user = $this->getUserOrThrow(); + $headers = $this->rateLimit($apiReadLimiter); + $items = array_map(fn (BookmarkList $list) => BookmarkListDto::fromList($list), $this->bookmarkListRepository->findByUser($user)); + $response = [ + 'items' => $items, + ]; + + return new JsonResponse($response, status: 200, headers: $headers); + } + + #[OA\Response( + response: 200, + description: 'Sets the provided list as the default', + headers: [ + new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), + ], + content: null + )] + #[OA\Response( + response: 401, + description: 'Permission denied due to missing or expired token', + content: new OA\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class)) + )] + #[OA\Response( + response: 404, + description: 'The requested list does not exist', + content: new OA\JsonContent(ref: new Model(type: NotFoundErrorSchema::class)) + )] + #[OA\Response( + response: 429, + description: 'You are being rate limited', + headers: [ + new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), + ], + content: new OA\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class)) + )] + #[OA\Parameter( + name: 'list_name', + description: 'The name of the list to be made the default', + in: 'path', + schema: new OA\Schema(type: 'string') + )] + #[OA\Tag(name: 'bookmark:list')] + #[Security(name: 'oauth2', scopes: ['user:bookmark:list:edit'])] + #[IsGranted('ROLE_OAUTH2_USER:BOOKMARK_LIST:EDIT')] + public function makeDefault(string $list_name, RateLimiterFactory $apiUpdateLimiter): JsonResponse + { + $user = $this->getUserOrThrow(); + $headers = $this->rateLimit($apiUpdateLimiter); + $list = $this->bookmarkListRepository->findOneByUserAndName($user, $list_name); + if (null === $list) { + throw new NotFoundHttpException(headers: $headers); + } + $this->bookmarkListRepository->makeListDefault($user, $list); + + return new JsonResponse(status: 200, headers: $headers); + } + + #[OA\Response( + response: 200, + description: 'Edits the supplied list', + headers: [ + new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), + ], + content: new Model(type: BookmarkListDto::class), + )] + #[OA\Response( + response: 401, + description: 'Permission denied due to missing or expired token', + content: new OA\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class)) + )] + #[OA\Response( + response: 404, + description: 'The requested list does not exist', + content: new OA\JsonContent(ref: new Model(type: NotFoundErrorSchema::class)) + )] + #[OA\Response( + response: 429, + description: 'You are being rate limited', + headers: [ + new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), + ], + content: new OA\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class)) + )] + #[OA\Parameter( + name: 'list_name', + description: 'The name of the list to be edited', + in: 'path', + schema: new OA\Schema(type: 'string') + )] + #[OA\RequestBody(content: new Model( + type: BookmarkListDto::class, + groups: ['common'] + ))] + #[OA\Tag(name: 'bookmark:list')] + #[Security(name: 'oauth2', scopes: ['user:bookmark:list:edit'])] + #[IsGranted('ROLE_OAUTH2_USER:BOOKMARK_LIST:EDIT')] + public function editList(string $list_name, #[MapRequestPayload] BookmarkListDto $dto, RateLimiterFactory $apiUpdateLimiter): JsonResponse + { + $user = $this->getUserOrThrow(); + $headers = $this->rateLimit($apiUpdateLimiter); + $list = $this->bookmarkListRepository->findOneByUserAndName($user, $list_name); + if (null === $list) { + throw new NotFoundHttpException(headers: $headers); + } + $this->bookmarkListRepository->editList($user, $list, $dto); + $list = $this->bookmarkListRepository->findOneBy(['id' => $list->getId()]); + + return new JsonResponse(BookmarkListDto::fromList($list), status: 200, headers: $headers); + } + + #[OA\Response( + response: 200, + description: 'Deletes the provided list', + headers: [ + new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), + ], + content: null + )] + #[OA\Response( + response: 401, + description: 'Permission denied due to missing or expired token', + content: new OA\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class)) + )] + #[OA\Response( + response: 404, + description: 'The requested list does not exist', + content: new OA\JsonContent(ref: new Model(type: NotFoundErrorSchema::class)) + )] + #[OA\Response( + response: 429, + description: 'You are being rate limited', + headers: [ + new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')), + new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')), + ], + content: new OA\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class)) + )] + #[OA\Parameter( + name: 'list_name', + description: 'The name of the list to be deleted', + in: 'path', + schema: new OA\Schema(type: 'string') + )] + #[OA\Tag(name: 'bookmark:list')] + #[Security(name: 'oauth2', scopes: ['user:bookmark:list:delete'])] + #[IsGranted('ROLE_OAUTH2_USER:BOOKMARK_LIST:DELETE')] + public function deleteList(string $list_name, RateLimiterFactory $apiDeleteLimiter): JsonResponse + { + $user = $this->getUserOrThrow(); + $headers = $this->rateLimit($apiDeleteLimiter); + $list = $this->bookmarkListRepository->findOneByUserAndName($user, $list_name); + if (null === $list) { + throw new NotFoundHttpException(headers: $headers); + } + $this->bookmarkListRepository->deleteList($list); + + return new JsonResponse(status: 200, headers: $headers); + } +} diff --git a/src/Controller/BookmarkController.php b/src/Controller/BookmarkController.php new file mode 100644 index 000000000..55f4c1b18 --- /dev/null +++ b/src/Controller/BookmarkController.php @@ -0,0 +1,145 @@ +entityManager->getRepository($subjectClass)->findOneBy(['id' => $subject_id]); + $this->bookmarkManager->addBookmarkToDefaultList($this->getUserOrThrow(), $subjectEntity); + if ($request->isXmlHttpRequest()) { + return new JsonResponse([ + 'html' => $this->renderView('components/_ajax.html.twig', [ + 'component' => 'bookmark_standard', + 'attributes' => [ + 'subject' => $subjectEntity, + 'subjectClass' => $subjectClass, + ], + ] + ), + ]); + } + + return $this->redirect($request->headers->get('Referer')); + } + + #[IsGranted('ROLE_USER')] + public function subjectBookmarkRefresh(int $subject_id, string $subject_type, Request $request): Response + { + $subjectClass = BookmarkManager::GetClassFromSubjectType($subject_type); + $subjectEntity = $this->entityManager->getRepository($subjectClass)->findOneBy(['id' => $subject_id]); + if ($request->isXmlHttpRequest()) { + return new JsonResponse([ + 'html' => $this->renderView('components/_ajax.html.twig', [ + 'component' => 'bookmark_standard', + 'attributes' => [ + 'subject' => $subjectEntity, + 'subjectClass' => $subjectClass, + ], + ] + ), + ]); + } + + return $this->redirect($request->headers->get('Referer')); + } + + #[IsGranted('ROLE_USER')] + public function subjectBookmarkToList(int $subject_id, string $subject_type, #[MapEntity] BookmarkList $list, Request $request): Response + { + $subjectClass = BookmarkManager::GetClassFromSubjectType($subject_type); + $subjectEntity = $this->entityManager->getRepository($subjectClass)->findOneBy(['id' => $subject_id]); + $user = $this->getUserOrThrow(); + if ($user->getId() !== $list->user->getId()) { + throw new AccessDeniedHttpException(); + } + $this->bookmarkManager->addBookmark($user, $list, $subjectEntity); + if ($request->isXmlHttpRequest()) { + return new JsonResponse([ + 'html' => $this->renderView('components/_ajax.html.twig', [ + 'component' => 'bookmark_list', + 'attributes' => [ + 'subject' => $subjectEntity, + 'subjectClass' => $subjectClass, + 'list' => $list, + ], + ] + ), + ]); + } + + return $this->redirect($request->headers->get('Referer')); + } + + #[IsGranted('ROLE_USER')] + public function subjectRemoveBookmarks(int $subject_id, string $subject_type, Request $request): Response + { + $subjectClass = BookmarkManager::GetClassFromSubjectType($subject_type); + $subjectEntity = $this->entityManager->getRepository($subjectClass)->findOneBy(['id' => $subject_id]); + $this->bookmarkRepository->removeAllBookmarksForContent($this->getUserOrThrow(), $subjectEntity); + if ($request->isXmlHttpRequest()) { + return new JsonResponse([ + 'html' => $this->renderView('components/_ajax.html.twig', [ + 'component' => 'bookmark_standard', + 'attributes' => [ + 'subject' => $subjectEntity, + 'subjectClass' => $subjectClass, + ], + ] + ), + ]); + } + + return $this->redirect($request->headers->get('Referer')); + } + + #[IsGranted('ROLE_USER')] + public function subjectRemoveBookmarkFromList(int $subject_id, string $subject_type, #[MapEntity] BookmarkList $list, Request $request): Response + { + $subjectClass = BookmarkManager::GetClassFromSubjectType($subject_type); + $subjectEntity = $this->entityManager->getRepository($subjectClass)->findOneBy(['id' => $subject_id]); + $user = $this->getUserOrThrow(); + if ($user->getId() !== $list->user->getId()) { + throw new AccessDeniedHttpException(); + } + $this->bookmarkRepository->removeBookmarkFromList($user, $list, $subjectEntity); + if ($request->isXmlHttpRequest()) { + return new JsonResponse([ + 'html' => $this->renderView('components/_ajax.html.twig', [ + 'component' => 'bookmark_list', + 'attributes' => [ + 'subject' => $subjectEntity, + 'subjectClass' => $subjectClass, + 'list' => $list, + ], + ] + ), + ]); + } + + return $this->redirect($request->headers->get('Referer')); + } +} diff --git a/src/Controller/BookmarkListController.php b/src/Controller/BookmarkListController.php new file mode 100644 index 000000000..098f5bcbe --- /dev/null +++ b/src/Controller/BookmarkListController.php @@ -0,0 +1,180 @@ +getPageNb($request); + $user = $this->getUserOrThrow(); + $criteria = new EntryPageView($page); + $criteria->setTime($criteria->resolveTime($time)); + $criteria->setType($criteria->resolveType($type)); + $criteria->showSortOption($criteria->resolveSort($sortBy ?? Criteria::SORT_NEW)); + $criteria->setFederation($federation); + + if (null !== $list) { + $bookmarkList = $this->bookmarkListRepository->findOneByUserAndName($user, $list); + } else { + $bookmarkList = $this->bookmarkListRepository->findOneByUserDefault($user); + } + $res = $this->bookmarkRepository->findPopulatedByList($bookmarkList, $criteria); + $objects = $res->getCurrentPageResults(); + $lists = $this->bookmarkListRepository->findByUser($user); + + $this->logger->info('got results in list {l}: {r}', ['l' => $list, 'r' => $objects]); + + if ($request->isXmlHttpRequest()) { + return new JsonResponse([ + 'html' => $this->renderView('layout/_subject_list.html.twig', [ + 'results' => $objects, + 'pagination' => $res, + ]), + ]); + } + + return $this->render( + 'bookmark/front.html.twig', + [ + 'criteria' => $criteria, + 'list' => $bookmarkList, + 'lists' => $lists, + 'results' => $objects, + 'pagination' => $res, + ] + ); + } + + #[IsGranted('ROLE_USER')] + public function list(Request $request): Response + { + $user = $this->getUserOrThrow(); + $dto = new BookmarkListDto(); + $form = $this->createForm(BookmarkListType::class, $dto); + $form->handleRequest($request); + if ($form->isSubmitted() && $form->isValid()) { + /** @var BookmarkListDto $dto */ + $dto = $form->getData(); + $list = $this->bookmarkManager->createList($user, $dto->name); + if ($dto->isDefault) { + $this->bookmarkListRepository->makeListDefault($user, $list); + } + + return $this->redirectToRoute('bookmark_lists'); + } + + return $this->render('bookmark/overview.html.twig', [ + 'lists' => $this->bookmarkListRepository->findByUser($user), + 'form' => $form->createView(), + ], + new Response(null, $form->isSubmitted() && !$form->isValid() ? 422 : 200) + ); + } + + #[IsGranted('ROLE_USER')] + public function subjectBookmarkMenuListRefresh(int $subject_id, string $subject_type, Request $request): Response + { + $user = $this->getUserOrThrow(); + $bookmarkLists = $this->bookmarkListRepository->findByUser($user); + $subjectClass = BookmarkManager::GetClassFromSubjectType($subject_type); + $subjectEntity = $this->entityManager->getRepository($subjectClass)->findOneBy(['id' => $subject_id]); + if ($request->isXmlHttpRequest()) { + return new JsonResponse([ + 'html' => $this->renderView('components/_ajax.html.twig', [ + 'component' => 'bookmark_menu_list', + 'attributes' => [ + 'subject' => $subjectEntity, + 'subjectClass' => $subjectClass, + 'bookmarkLists' => $bookmarkLists, + ], + ] + ), + ]); + } + + return $this->redirect($request->headers->get('Referer')); + } + + #[IsGranted('ROLE_USER')] + public function makeDefault(#[MapQueryParameter] ?int $makeDefault): Response + { + $user = $this->getUserOrThrow(); + $this->logger->info('making list id {id} default for user {u}', ['user' => $user->username, 'id' => $makeDefault]); + if (null !== $makeDefault) { + $list = $this->bookmarkListRepository->findOneBy(['id' => $makeDefault]); + $this->bookmarkListRepository->makeListDefault($user, $list); + } + + return $this->redirectToRoute('bookmark_lists'); + } + + #[IsGranted('ROLE_USER')] + public function editList(#[MapEntity] BookmarkList $list, Request $request): Response + { + $user = $this->getUserOrThrow(); + $dto = BookmarkListDto::fromList($list); + $form = $this->createForm(BookmarkListType::class, $dto); + $form->handleRequest($request); + if ($form->isSubmitted() && $form->isValid()) { + $dto = $form->getData(); + $this->bookmarkListRepository->editList($user, $list, $dto); + + return $this->redirectToRoute('bookmark_lists'); + } + + return $this->render('bookmark/edit.html.twig', [ + 'list' => $list, + 'form' => $form->createView(), + ]); + } + + #[IsGranted('ROLE_USER')] + public function deleteList(#[MapEntity] BookmarkList $list): Response + { + $user = $this->getUserOrThrow(); + if ($user->getId() !== $list->user->getId()) { + $this->logger->error('user {u} tried to delete a list that is not his own: {l}', ['u' => $user->username, 'l' => "$list->name ({$list->getId()})"]); + throw new AccessDeniedHttpException(); + } + $this->bookmarkListRepository->deleteList($list); + + return $this->redirectToRoute('bookmark_lists'); + } +} diff --git a/src/DTO/BookmarkListDto.php b/src/DTO/BookmarkListDto.php new file mode 100644 index 000000000..39bc3d8e2 --- /dev/null +++ b/src/DTO/BookmarkListDto.php @@ -0,0 +1,42 @@ +name = $list->name; + $dto->isDefault = $list->isDefault; + $dto->count = $list->entities->count(); + + return $dto; + } + + public function jsonSerialize(): array + { + return [ + 'name' => $this->name, + 'isDefault' => $this->isDefault, + 'count' => $this->count, + ]; + } +} diff --git a/src/DTO/OAuth2ClientDto.php b/src/DTO/OAuth2ClientDto.php index a35598d40..4d4eed9af 100644 --- a/src/DTO/OAuth2ClientDto.php +++ b/src/DTO/OAuth2ClientDto.php @@ -62,6 +62,13 @@ class OAuth2ClientDto extends ImageUploadDto implements \JsonSerializable 'user:profile', 'user:profile:read', 'user:profile:edit', + 'user:bookmark', + 'user:bookmark:add', + 'user:bookmark:remove', + 'user:bookmark:list', + 'user:bookmark:list:read', + 'user:bookmark:list:edit', + 'user:bookmark:list:delete', 'user:message', 'user:message:read', 'user:message:create', diff --git a/src/Entity/Bookmark.php b/src/Entity/Bookmark.php new file mode 100644 index 000000000..3902e3948 --- /dev/null +++ b/src/Entity/Bookmark.php @@ -0,0 +1,70 @@ +user = $user; + $this->list = $list; + $this->createdAtTraitConstruct(); + } + + public function setContent(Post|EntryComment|PostComment|Entry $content): void + { + if ($content instanceof Entry) { + $this->entry = $content; + } elseif ($content instanceof EntryComment) { + $this->entryComment = $content; + } elseif ($content instanceof Post) { + $this->post = $content; + } elseif ($content instanceof PostComment) { + $this->postComment = $content; + } + } + + public function getContent(): Entry|EntryComment|Post|PostComment + { + return $this->entry ?? $this->entryComment ?? $this->post ?? $this->postComment; + } +} diff --git a/src/Entity/BookmarkList.php b/src/Entity/BookmarkList.php new file mode 100644 index 000000000..52673e1cf --- /dev/null +++ b/src/Entity/BookmarkList.php @@ -0,0 +1,51 @@ +user = $user; + $this->name = $name; + $this->isDefault = $isDefault; + $this->entities = new ArrayCollection(); + } + + public function getId(): int + { + return $this->id; + } +} diff --git a/src/Entity/User.php b/src/Entity/User.php index 8c39eca36..8095a2300 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -223,6 +223,8 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, Visibil public Collection $notifications; #[OneToMany(mappedBy: 'user', targetEntity: UserPushSubscription::class, fetch: 'EXTRA_LAZY')] public Collection $pushSubscriptions; + #[OneToMany(mappedBy: 'user', targetEntity: BookmarkList::class, fetch: 'EXTRA_LAZY')] + public Collection $bookmarkLists; #[Id] #[GeneratedValue] #[Column(type: 'integer')] diff --git a/src/Form/BookmarkListType.php b/src/Form/BookmarkListType.php new file mode 100644 index 000000000..f10ba5cf1 --- /dev/null +++ b/src/Form/BookmarkListType.php @@ -0,0 +1,35 @@ +add('name', TextType::class) + ->add('isDefault', CheckboxType::class, [ + 'required' => false, + ]) + ->add('submit', SubmitType::class); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults( + [ + 'data_class' => BookmarkListDto::class, + ] + ); + } +} diff --git a/src/Pagination/NativeQueryAdapter.php b/src/Pagination/NativeQueryAdapter.php index 959d47b60..4888ca604 100644 --- a/src/Pagination/NativeQueryAdapter.php +++ b/src/Pagination/NativeQueryAdapter.php @@ -8,7 +8,9 @@ use App\Pagination\Transformation\VoidTransformer; use Doctrine\DBAL\Connection; use Doctrine\DBAL\Exception; +use Doctrine\DBAL\ParameterType; use Doctrine\DBAL\Statement; +use Doctrine\DBAL\Types\Types; use Pagerfanta\Adapter\AdapterInterface; /** @@ -35,7 +37,7 @@ public function __construct( $sql2 = 'SELECT COUNT(*) as cnt FROM ('.$sql.') sub'; $stmt2 = $this->conn->prepare($sql2); foreach ($this->parameters as $key => $value) { - $stmt2->bindValue($key, $value); + $stmt2->bindValue($key, $value, $this->getSqlType($value)); } $result = $stmt2->executeQuery()->fetchAllAssociative(); $this->numOfResults = $result[0]['cnt']; @@ -43,7 +45,7 @@ public function __construct( $this->statement = $this->conn->prepare($sql.' LIMIT :limit OFFSET :offset'); foreach ($this->parameters as $key => $value) { - $this->statement->bindValue($key, $value); + $this->statement->bindValue($key, $value, $this->getSqlType($value)); } } @@ -59,4 +61,15 @@ public function getSlice(int $offset, int $length): iterable return $this->transformer->transform($this->statement->executeQuery()->fetchAllAssociative()); } + + private function getSqlType(mixed $value): mixed + { + if ($value instanceof \DateTimeImmutable) { + return Types::DATETIMETZ_IMMUTABLE; + } elseif ($value instanceof \DateTime) { + return Types::DATETIMETZ_MUTABLE; + } + + return ParameterType::STRING; + } } diff --git a/src/Pagination/Transformation/ContentPopulationTransformer.php b/src/Pagination/Transformation/ContentPopulationTransformer.php index 522e98d97..41b005136 100644 --- a/src/Pagination/Transformation/ContentPopulationTransformer.php +++ b/src/Pagination/Transformation/ContentPopulationTransformer.php @@ -19,6 +19,7 @@ public function __construct( public function transform(iterable $input): iterable { + $positionsArray = $this->buildPositionArray($input); $entries = $this->entityManager->getRepository(Entry::class)->findBy( ['id' => $this->getOverviewIds((array) $input, 'entry')] ); @@ -32,10 +33,7 @@ public function transform(iterable $input): iterable ['id' => $this->getOverviewIds((array) $input, 'post_comment')] ); - $result = array_merge($entries, $entryComments, $post, $postComment); - uasort($result, fn ($a, $b) => $a->getCreatedAt() > $b->getCreatedAt() ? -1 : 1); - - return $result; + return $this->applyPositions($positionsArray, $entries, $entryComments, $post, $postComment); } private function getOverviewIds(array $result, string $type): array @@ -44,4 +42,67 @@ private function getOverviewIds(array $result, string $type): array return array_map(fn ($subject) => $subject['id'], $result); } + + /** + * @return int[][] + */ + private function buildPositionArray(iterable $input): array + { + $entryPositions = []; + $entryCommentPositions = []; + $postPositions = []; + $postCommentPositions = []; + $i = 0; + foreach ($input as $current) { + switch ($current['type']) { + case 'entry': + $entryPositions[$current['id']] = $i; + break; + case 'entry_comment': + $entryCommentPositions[$current['id']] = $i; + break; + case 'post': + $postPositions[$current['id']] = $i; + break; + case 'post_comment': + $postCommentPositions[$current['id']] = $i; + break; + } + ++$i; + } + + return [ + 'entry' => $entryPositions, + 'entry_comment' => $entryCommentPositions, + 'post' => $postPositions, + 'post_comment' => $postCommentPositions, + ]; + } + + /** + * @param int[][] $positionsArray + * @param Entry[] $entries + * @param EntryComment[] $entryComments + * @param Post[] $posts + * @param PostComment[] $postComments + */ + private function applyPositions(array $positionsArray, array $entries, array $entryComments, array $posts, array $postComments): array + { + $result = []; + foreach ($entries as $entry) { + $result[$positionsArray['entry'][$entry->getId()]] = $entry; + } + foreach ($entryComments as $entryComment) { + $result[$positionsArray['entry_comment'][$entryComment->getId()]] = $entryComment; + } + foreach ($posts as $post) { + $result[$positionsArray['post'][$post->getId()]] = $post; + } + foreach ($postComments as $postComment) { + $result[$positionsArray['post_comment'][$postComment->getId()]] = $postComment; + } + ksort($result, SORT_NUMERIC); + + return $result; + } } diff --git a/src/Repository/BookmarkListRepository.php b/src/Repository/BookmarkListRepository.php new file mode 100644 index 000000000..c942dc500 --- /dev/null +++ b/src/Repository/BookmarkListRepository.php @@ -0,0 +1,83 @@ +findBy(['user' => $user]); + } + + public function findOneByUserAndName(User $user, string $name): ?BookmarkList + { + return $this->findOneBy(['user' => $user, 'name' => $name]); + } + + public function findOneByUserDefault(User $user): BookmarkList + { + $list = $this->findOneBy(['user' => $user, 'isDefault' => true]); + if (null === $list) { + $list = new BookmarkList($user, 'Default', true); + $this->entityManager->persist($list); + $this->entityManager->flush(); + } + + return $list; + } + + public function makeListDefault(User $user, BookmarkList $list): void + { + $sql = 'UPDATE bookmark_list SET is_default = false WHERE user_id = :user'; + $conn = $this->entityManager->getConnection(); + $stmt = $conn->prepare($sql); + $stmt->executeStatement(['user' => $user->getId()]); + + $sql = 'UPDATE bookmark_list SET is_default = true WHERE user_id = :user AND id = :id'; + $stmt = $conn->prepare($sql); + $stmt->executeStatement(['user' => $user->getId(), 'id' => $list->getId()]); + } + + public function deleteList(BookmarkList $list): void + { + $sql = 'DELETE FROM bookmark_list WHERE id = :id'; + $conn = $this->entityManager->getConnection(); + $stmt = $conn->prepare($sql); + $stmt->executeStatement(['id' => $list->getId()]); + } + + public function editList(User $user, BookmarkList $list, BookmarkListDto $dto): void + { + $sql = 'UPDATE bookmark_list SET name = :name WHERE id = :id'; + $conn = $this->entityManager->getConnection(); + $stmt = $conn->prepare($sql); + $stmt->executeStatement(['id' => $list->getId(), 'name' => $dto->name]); + + if ($dto->isDefault) { + $this->makeListDefault($user, $list); + } + } +} diff --git a/src/Repository/BookmarkRepository.php b/src/Repository/BookmarkRepository.php new file mode 100644 index 000000000..39cd79c1c --- /dev/null +++ b/src/Repository/BookmarkRepository.php @@ -0,0 +1,180 @@ +createQueryBuilder('b') + ->where('b.user = :user') + ->andWhere('b.list = :list') + ->setParameter('user', $user) + ->setParameter('list', $list) + ->getQuery() + ->getResult(); + } + + public function removeAllBookmarksForContent(User $user, Entry|EntryComment|Post|PostComment $content): void + { + if ($content instanceof Entry) { + $contentWhere = 'entry_id = :id'; + } elseif ($content instanceof EntryComment) { + $contentWhere = 'entry_comment_id = :id'; + } elseif ($content instanceof Post) { + $contentWhere = 'post_id = :id'; + } elseif ($content instanceof PostComment) { + $contentWhere = 'post_comment_id = :id'; + } else { + throw new \LogicException(); + } + + $sql = "DELETE FROM bookmark WHERE user_id = :u AND $contentWhere"; + $conn = $this->entityManager->getConnection(); + $stmt = $conn->prepare($sql); + $stmt->executeStatement(['u' => $user->getId(), 'id' => $content->getId()]); + } + + public function removeBookmarkFromList(User $user, BookmarkList $list, Entry|EntryComment|Post|PostComment $content): void + { + if ($content instanceof Entry) { + $contentWhere = 'entry_id = :id'; + } elseif ($content instanceof EntryComment) { + $contentWhere = 'entry_comment_id = :id'; + } elseif ($content instanceof Post) { + $contentWhere = 'post_id = :id'; + } elseif ($content instanceof PostComment) { + $contentWhere = 'post_comment_id = :id'; + } else { + throw new \LogicException(); + } + + $sql = "DELETE FROM bookmark WHERE user_id = :u AND list_id = :l AND $contentWhere"; + $conn = $this->entityManager->getConnection(); + $stmt = $conn->prepare($sql); + $stmt->executeStatement(['u' => $user->getId(), 'l' => $list->getId(), 'id' => $content->getId()]); + } + + public function findPopulatedByList(BookmarkList $list, Criteria $criteria, ?int $perPage = null): PagerfantaInterface + { + $entryWhereArr = ['b.list_id = :list']; + $entryCommentWhereArr = ['b.list_id = :list']; + $postWhereArr = ['b.list_id = :list']; + $postCommentWhereArr = ['b.list_id = :list']; + $parameters = [ + 'list' => $list->getId(), + ]; + + $orderBy = match ($criteria->sortOption) { + Criteria::SORT_OLD => 'ORDER BY i.created_at ASC', + Criteria::SORT_TOP => 'ORDER BY i.score DESC, i.created_at DESC', + Criteria::SORT_HOT => 'ORDER BY i.ranking DESC, i.created_at DESC', + default => 'ORDER BY created_at DESC', + }; + + if (Criteria::AP_LOCAL === $criteria->federation) { + $entryWhereArr[] = 'e.ap_id IS NULL'; + $entryCommentWhereArr[] = 'ec.ap_id IS NULL'; + $postWhereArr[] = 'p.ap_id IS NULL'; + $postCommentWhereArr[] = 'pc.ap_id IS NULL'; + } + + if ('all' !== $criteria->type) { + $entryWhereArr[] = 'e.type = :type'; + $entryCommentWhereArr[] = 'false'; + $postWhereArr[] = 'false'; + $postCommentWhereArr[] = 'false'; + + $parameters['type'] = $criteria->type; + } + + if (Criteria::TIME_ALL !== $criteria->time) { + $entryWhereArr[] = 'b.created_at > :time'; + $entryCommentWhereArr[] = 'b.created_at > :time'; + $postWhereArr[] = 'b.created_at > :time'; + $postCommentWhereArr[] = 'b.created_at > :time'; + + $parameters['time'] = $criteria->getSince(); + } + + $entryWhere = $this->makeWhereString($entryWhereArr); + $entryCommentWhere = $this->makeWhereString($entryCommentWhereArr); + $postWhere = $this->makeWhereString($postWhereArr); + $postCommentWhere = $this->makeWhereString($postCommentWhereArr); + + $sql = " + SELECT * FROM ( + SELECT e.id AS id, e.ap_id AS ap_id, e.score AS score, e.ranking AS ranking, b.created_at AS created_at, 'entry' AS type FROM bookmark b + INNER JOIN entry e ON b.entry_id = e.id $entryWhere + UNION + SELECT ec.id AS id, ec.ap_id AS ap_id, (ec.up_votes + ec.favourite_count - ec.down_votes) AS score, ec.up_votes AS ranking, b.created_at AS created_at, 'entry_comment' AS type FROM bookmark b + INNER JOIN entry_comment ec ON b.entry_comment_id = ec.id $entryCommentWhere + UNION + SELECT p.id AS id, p.ap_id AS ap_id, p.score AS score, p.ranking AS ranking, b.created_at AS created_at, 'post' AS type FROM bookmark b + INNER JOIN post p ON b.post_id = p.id $postWhere + UNION + SELECT pc.id AS id, pc.ap_id AS ap_id, (pc.up_votes + pc.favourite_count - pc.down_votes) AS score, pc.up_votes AS ranking, b.created_at AS created_at, 'post_comment' AS type FROM bookmark b + INNER JOIN post_comment pc ON b.post_comment_id = pc.id $postCommentWhere + ) i $orderBy + "; + + $this->logger->info('bookmark list sql: {sql}', ['sql' => $sql]); + + $conn = $this->entityManager->getConnection(); + $adapter = new NativeQueryAdapter($conn, $sql, $parameters, transformer: $this->transformer); + + return Pagerfanta::createForCurrentPageWithMaxPerPage($adapter, $criteria->page, $perPage ?? EntryRepository::PER_PAGE); + } + + private function makeWhereString(array $whereClauses): string + { + if (empty($whereClauses)) { + return ''; + } + + $where = 'WHERE '; + $i = 0; + foreach ($whereClauses as $whereClause) { + if ($i > 0) { + $where .= ' AND '; + } + $where .= $whereClause; + ++$i; + } + + return $where; + } +} diff --git a/src/Schema/PaginationSchema.php b/src/Schema/PaginationSchema.php index a311d533d..15b1c9021 100644 --- a/src/Schema/PaginationSchema.php +++ b/src/Schema/PaginationSchema.php @@ -5,7 +5,7 @@ namespace App\Schema; use OpenApi\Attributes as OA; -use Pagerfanta\Pagerfanta; +use Pagerfanta\PagerfantaInterface; #[OA\Schema()] class PaginationSchema implements \JsonSerializable @@ -19,7 +19,7 @@ class PaginationSchema implements \JsonSerializable #[OA\Property(description: 'Max number of items per page')] public int $perPage = 0; - public function __construct(Pagerfanta $pagerfanta) + public function __construct(PagerfantaInterface $pagerfanta) { $this->count = $pagerfanta->count(); $this->currentPage = $pagerfanta->getCurrentPage(); diff --git a/src/Service/BookmarkManager.php b/src/Service/BookmarkManager.php new file mode 100644 index 000000000..11529a06b --- /dev/null +++ b/src/Service/BookmarkManager.php @@ -0,0 +1,90 @@ +entityManager->persist($list); + $this->entityManager->flush(); + + return $list; + } + + public function isBookmarked(User $user, Entry|EntryComment|Post|PostComment $content): bool + { + if ($content instanceof Entry) { + return !empty($this->bookmarkRepository->findBy(['user' => $user, 'entry' => $content])); + } elseif ($content instanceof EntryComment) { + return !empty($this->bookmarkRepository->findBy(['user' => $user, 'entryComment' => $content])); + } elseif ($content instanceof Post) { + return !empty($this->bookmarkRepository->findBy(['user' => $user, 'post' => $content])); + } elseif ($content instanceof PostComment) { + return !empty($this->bookmarkRepository->findBy(['user' => $user, 'postComment' => $content])); + } + + return false; + } + + public function isBookmarkedInList(User $user, BookmarkList $list, Entry|EntryComment|Post|PostComment $content): bool + { + if ($content instanceof Entry) { + return null !== $this->bookmarkRepository->findOneBy(['user' => $user, 'list' => $list, 'entry' => $content]); + } elseif ($content instanceof EntryComment) { + return null !== $this->bookmarkRepository->findOneBy(['user' => $user, 'list' => $list, 'entryComment' => $content]); + } elseif ($content instanceof Post) { + return null !== $this->bookmarkRepository->findOneBy(['user' => $user, 'list' => $list, 'post' => $content]); + } elseif ($content instanceof PostComment) { + return null !== $this->bookmarkRepository->findOneBy(['user' => $user, 'list' => $list, 'postComment' => $content]); + } + + return false; + } + + public function addBookmarkToDefaultList(User $user, Entry|EntryComment|Post|PostComment $content): void + { + $list = $this->bookmarkListRepository->findOneByUserDefault($user); + $this->addBookmark($user, $list, $content); + } + + public function addBookmark(User $user, BookmarkList $list, Entry|EntryComment|Post|PostComment $content): void + { + $bookmark = new Bookmark($user, $list); + $bookmark->setContent($content); + $this->entityManager->persist($bookmark); + $this->entityManager->flush(); + } + + public static function GetClassFromSubjectType(string $subjectType): string + { + return match ($subjectType) { + 'entry' => Entry::class, + 'entry_comment' => EntryComment::class, + 'post' => Post::class, + 'post_comment' => PostComment::class, + default => throw new \LogicException("cannot match type $subjectType") + }; + } +} diff --git a/src/Twig/Components/BookmarkListComponent.php b/src/Twig/Components/BookmarkListComponent.php new file mode 100644 index 000000000..95ac59965 --- /dev/null +++ b/src/Twig/Components/BookmarkListComponent.php @@ -0,0 +1,20 @@ +comment->root?->getId() ?? $this->comment->getId(); - $userId = $this->security->getUser()?->getId(); - - return $this->cache->get( - "entry_comments_nested_{$commentId}_{$userId}_{$this->view}_{$this->requestStack->getCurrentRequest()?->getLocale()}", - function (ItemInterface $item) use ($commentId, $userId) { - $item->expiresAfter(3600); - $item->tag(['entry_comments_user_'.$userId]); - $item->tag(['entry_comment_'.$commentId]); - - return $this->twig->render( - 'components/entry_comments_nested.html.twig', - [ - 'comment' => $this->comment, - 'level' => $this->level, - 'view' => $this->view, - ] - ); - } - ); - } } diff --git a/src/Twig/Components/PostCommentsNestedComponent.php b/src/Twig/Components/PostCommentsNestedComponent.php index 97035da45..f812856e2 100644 --- a/src/Twig/Components/PostCommentsNestedComponent.php +++ b/src/Twig/Components/PostCommentsNestedComponent.php @@ -6,53 +6,12 @@ use App\Controller\User\ThemeSettingsController; use App\Entity\PostComment; -use Symfony\Bundle\SecurityBundle\Security; -use Symfony\Component\HttpFoundation\RequestStack; -use Symfony\Contracts\Cache\CacheInterface; -use Symfony\Contracts\Cache\ItemInterface; use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; -use Symfony\UX\TwigComponent\ComponentAttributes; -use Twig\Environment; -#[AsTwigComponent('post_comments_nested', template: 'components/_cached.html.twig')] +#[AsTwigComponent('post_comments_nested')] final class PostCommentsNestedComponent { public PostComment $comment; public int $level; public string $view = ThemeSettingsController::TREE; - - public function __construct( - private readonly Environment $twig, - private readonly Security $security, - private readonly CacheInterface $cache, - private readonly RequestStack $requestStack - ) { - } - - public function getHtml(ComponentAttributes $attributes): string - { - $comment = $this->comment->root ?? $this->comment; - $commentId = $comment->getId(); - $postId = $comment->post->getId(); - $userId = $this->security->getUser()?->getId(); - - return $this->cache->get( - "post_comments_nested_{$commentId}_{$userId}_{$this->view}_{$this->requestStack->getCurrentRequest()?->getLocale()}", - function (ItemInterface $item) use ($commentId, $userId, $postId) { - $item->expiresAfter(3600); - $item->tag(['post_comments_user_'.$userId]); - $item->tag(['post_comment_'.$commentId]); - $item->tag(['post_'.$postId]); - - return $this->twig->render( - 'components/post_comments_nested.html.twig', - [ - 'comment' => $this->comment, - 'level' => $this->level, - 'view' => $this->view, - ] - ); - } - ); - } } diff --git a/src/Twig/Extension/BookmarkExtension.php b/src/Twig/Extension/BookmarkExtension.php new file mode 100644 index 000000000..e3ac3367c --- /dev/null +++ b/src/Twig/Extension/BookmarkExtension.php @@ -0,0 +1,22 @@ +bookmarkListRepository->findByUser($user); + } + + public function getBookmarkListEntryCount(BookmarkList $list): int + { + return $list->entities->count(); + } + + public function isContentBookmarked(User $user, Entry|EntryComment|Post|PostComment $content): bool + { + return $this->bookmarkManager->isBookmarked($user, $content); + } + + public function isContentBookmarkedInList(User $user, BookmarkList $list, Entry|EntryComment|Post|PostComment $content): bool + { + return $this->bookmarkManager->isBookmarkedInList($user, $list, $content); + } +} diff --git a/src/Twig/Runtime/FrontExtensionRuntime.php b/src/Twig/Runtime/FrontExtensionRuntime.php index 5bb43294c..b52735041 100644 --- a/src/Twig/Runtime/FrontExtensionRuntime.php +++ b/src/Twig/Runtime/FrontExtensionRuntime.php @@ -4,6 +4,10 @@ namespace App\Twig\Runtime; +use App\Entity\Entry; +use App\Entity\EntryComment; +use App\Entity\Post; +use App\Entity\PostComment; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Twig\Extension\RuntimeExtensionInterface; @@ -63,4 +67,24 @@ private function getFrontRoute(string $currentRoute, array $params): string return 'front_short'; } } + + public function getClass(mixed $object): string + { + return \get_class($object); + } + + public function getSubjectType(mixed $object): string + { + if ($object instanceof Entry) { + return 'entry'; + } elseif ($object instanceof EntryComment) { + return 'entry_comment'; + } elseif ($object instanceof Post) { + return 'post'; + } elseif ($object instanceof PostComment) { + return 'post_comment'; + } else { + throw new \LogicException('unknown class '.\get_class($object)); + } + } } diff --git a/templates/bookmark/_form_edit.html.twig b/templates/bookmark/_form_edit.html.twig new file mode 100644 index 000000000..ca58c553f --- /dev/null +++ b/templates/bookmark/_form_edit.html.twig @@ -0,0 +1,14 @@ +{{ form_start(form, {attr: {class: 'bookmark_edit'}}) }} + +{{ form_row(form.name, {label: 'bookmark_list_create_label'}) }} + +
+ {{ form_row(form.isDefault, {label: 'bookmark_list_make_default', row_attr: {class: 'checkbox'}}) }} +
+ +
+ {% set btn_label = is_create ? 'bookmark_list_create' : 'bookmark_list_edit' %} + {{ form_row(form.submit, {label: btn_label, attr: {class: 'btn btn__primary'}}) }} +
+ +{{ form_end(form) }} diff --git a/templates/bookmark/_options.html.twig b/templates/bookmark/_options.html.twig new file mode 100644 index 000000000..d57833c0f --- /dev/null +++ b/templates/bookmark/_options.html.twig @@ -0,0 +1,231 @@ +{% set showFilterLabels = app.request.cookies.get('kbin_general_filter_labels')|default('on') %} + + diff --git a/templates/bookmark/edit.html.twig b/templates/bookmark/edit.html.twig new file mode 100644 index 000000000..4aec0c0b8 --- /dev/null +++ b/templates/bookmark/edit.html.twig @@ -0,0 +1,24 @@ +{% extends 'base.html.twig' %} + +{%- block title -%} + {{- 'bookmarks_list'|trans({'%list%': list.name}) }} - {{ parent() -}} +{%- endblock -%} + +{% block mainClass %}page-bookmarks{% endblock %} + +{% block header_nav %} +{% endblock %} + +{% block sidebar_top %} +{% endblock %} + +{% block body %} +

{{ 'bookmarks_list_edit'|trans }}

+ +
+
+ {% include 'bookmark/_form_edit.html.twig' with {is_create: false} %} +
+
+ +{% endblock %} diff --git a/templates/bookmark/front.html.twig b/templates/bookmark/front.html.twig new file mode 100644 index 000000000..627370b74 --- /dev/null +++ b/templates/bookmark/front.html.twig @@ -0,0 +1,25 @@ +{% extends 'base.html.twig' %} + +{%- block title -%} + {{- 'bookmarks_list'|trans({'%list%': list.name}) }} - {{ parent() -}} +{%- endblock -%} + +{% block mainClass %}page-bookmarks{% endblock %} + +{% block header_nav %} +{% endblock %} + +{% block sidebar_top %} +{% endblock %} + +{% block body %} +

{{ 'bookmarks'|trans }}

+ + {% include 'bookmark/_options.html.twig' %} +
+ {% include 'layout/_subject_list.html.twig' %} +
+{% endblock %} diff --git a/templates/bookmark/overview.html.twig b/templates/bookmark/overview.html.twig new file mode 100644 index 000000000..4972ea4e6 --- /dev/null +++ b/templates/bookmark/overview.html.twig @@ -0,0 +1,77 @@ +{% extends 'base.html.twig' %} + +{%- block title -%} + {{- 'bookmark_lists'|trans }} - {{ parent() -}} +{%- endblock -%} + +{% block mainClass %}page-bookmark-lists{% endblock %} + +{% block header_nav %} +{% endblock %} + +{% block sidebar_top %} +{% endblock %} + +{% block body %} +

{{ 'bookmark_lists'|trans }}

+ +
+
+ {% include('bookmark/_form_edit.html.twig') with {is_create: true} %} +
+
+ + {% if lists|length %} +
+
+ + + + + + + + + + + {% for list in lists %} + + + + + + + {% endfor %} + +
{{ 'name'|trans }}{{ 'count'|trans }}
+ {% if list.isDefault %} + + {% endif %} + {{ list.name }}{{ get_bookmark_list_entry_count(list) }} + {% if not list.isDefault %} +
+ + +
+ {% endif %} + + + + + + +
+
+
+ {% else %} + + {% endif %} +{% endblock %} diff --git a/templates/components/bookmark_list.html.twig b/templates/components/bookmark_list.html.twig new file mode 100644 index 000000000..be65c8849 --- /dev/null +++ b/templates/components/bookmark_list.html.twig @@ -0,0 +1,19 @@ +
  • + {% if is_bookmarked_in_list(app.user, list, subject) %} + + + {{ 'bookmark_remove_from_list'|trans({'%list%': list.name}) }} + + {% else %} + + + {{ 'bookmark_add_to_list'|trans({'%list%': list.name}) }} + + {% endif %} +
  • diff --git a/templates/components/bookmark_menu_list.html.twig b/templates/components/bookmark_menu_list.html.twig new file mode 100644 index 000000000..3a6126616 --- /dev/null +++ b/templates/components/bookmark_menu_list.html.twig @@ -0,0 +1,5 @@ +
    + {% for list in bookmarkLists %} + {{ component('bookmark_list', { subject: subject, list: list }) }} + {% endfor %} +
    diff --git a/templates/components/bookmark_standard.html.twig b/templates/components/bookmark_standard.html.twig new file mode 100644 index 000000000..d8d502a9e --- /dev/null +++ b/templates/components/bookmark_standard.html.twig @@ -0,0 +1,17 @@ +
  • + {% if is_bookmarked(app.user, subject) %} + + + + {% else %} + + + + {% endif %} +
  • diff --git a/templates/components/entry.html.twig b/templates/components/entry.html.twig index 59cd3242f..a4a402432 100644 --- a/templates/components/entry.html.twig +++ b/templates/components/entry.html.twig @@ -171,6 +171,9 @@ subject: entry }) }} + {% if app.user is defined and app.user is not same as null %} + {{ component('bookmark_standard', { subject: entry }) }} + {% endif %} {% include 'entry/_menu.html.twig' %}
  • diff --git a/templates/components/entry_comment.html.twig b/templates/components/entry_comment.html.twig index 2bbec6930..1f87f77b5 100644 --- a/templates/components/entry_comment.html.twig +++ b/templates/components/entry_comment.html.twig @@ -102,6 +102,9 @@
  • {{ component('boost', {subject: comment}) }}
  • + {% if app.user is defined and app.user is not same as null %} + {{ component('bookmark_standard', { subject: comment }) }} + {% endif %} {% include 'entry/comment/_menu.html.twig' %}
  • diff --git a/templates/components/post.html.twig b/templates/components/post.html.twig index 04a79d7e6..d03e769ca 100644 --- a/templates/components/post.html.twig +++ b/templates/components/post.html.twig @@ -110,6 +110,9 @@ subject: post }) }}
  • + {% if app.user is defined and app.user is not same as null %} + {{ component('bookmark_standard', { subject: post }) }} + {% endif %} {% include 'post/_menu.html.twig' %}
  • diff --git a/templates/components/post_comment.html.twig b/templates/components/post_comment.html.twig index b91eb650a..7755aceac 100644 --- a/templates/components/post_comment.html.twig +++ b/templates/components/post_comment.html.twig @@ -102,6 +102,9 @@ subject: comment }) }}
  • + {% if app.user is defined and app.user is not same as null %} + {{ component('bookmark_standard', { subject: comment }) }} + {% endif %} {% include 'post/comment/_menu.html.twig' %}
  • diff --git a/templates/entry/_menu.html.twig b/templates/entry/_menu.html.twig index e94186bfd..5dae6bf39 100644 --- a/templates/entry/_menu.html.twig +++ b/templates/entry/_menu.html.twig @@ -20,6 +20,14 @@
  • {% endif %} + {% if app.user is defined and app.user is not same as null %} + {% set bookmarkLists = get_bookmark_lists(app.user) %} + {% if bookmarkLists|length %} + + {{ component('bookmark_menu_list', { bookmarkLists: bookmarkLists, subject: entry }) }} + {% endif %} + {% endif %} +
  • {{ 'copy_url'|trans }}
  • + {% if is_granted('edit', entry) or (app.user and entry.isAuthor(app.user)) or is_granted('moderate', entry) %} {% endif %} diff --git a/templates/entry/comment/_menu.html.twig b/templates/entry/comment/_menu.html.twig index 7b1fc2c80..6c250689d 100644 --- a/templates/entry/comment/_menu.html.twig +++ b/templates/entry/comment/_menu.html.twig @@ -14,6 +14,17 @@ {{ 'activity'|trans }} + + {% if app.user is defined and app.user is not same as null %} + {% set bookmarkLists = get_bookmark_lists(app.user) %} + {% if bookmarkLists|length %} + + {% for list in bookmarkLists %} + {{ component('bookmark_list', { subject: comment, subjectType: 'entry_comment', list: list }) }} + {% endfor %} + {% endif %} + {% endif %} +
  • {{ app.user.countNewNotifications }}
  • +
  • + + {{ 'bookmark_lists'|trans }} + +
  • {% if is_granted('ROLE_ADMIN') %}
  • + + {% if app.user is defined and app.user is not same as null %} + {% set bookmarkLists = get_bookmark_lists(app.user) %} + {% if bookmarkLists|length %} + + {% for list in bookmarkLists %} + {{ component('bookmark_list', { subject: post, subjectType: 'post', list: list }) }} + {% endfor %} + {% endif %} + {% endif %} +
  • + + {% if app.user is defined and app.user is not same as null %} + {% set bookmarkLists = get_bookmark_lists(app.user) %} + {% if bookmarkLists|length %} + + {% for list in bookmarkLists %} + {{ component('bookmark_list', { subject: comment, subjectType: 'post_comment', list: list }) }} + {% endfor %} + {% endif %} + {% endif %} +
  • Date: Sun, 13 Oct 2024 17:36:04 +0200 Subject: [PATCH 13/19] Make related panels respect blocks (#1183) --- src/Repository/BookmarkRepository.php | 28 ++------- src/Repository/EntryRepository.php | 44 ++++++++++---- src/Repository/MagazineRepository.php | 39 +++++++----- src/Repository/PostRepository.php | 48 +++++++++++---- .../Components/RelatedEntriesComponent.php | 19 ++++-- .../Components/RelatedMagazinesComponent.php | 15 +++-- src/Twig/Components/RelatedPostsComponent.php | 19 ++++-- src/Utils/SqlHelpers.php | 59 +++++++++++++++++++ 8 files changed, 193 insertions(+), 78 deletions(-) create mode 100644 src/Utils/SqlHelpers.php diff --git a/src/Repository/BookmarkRepository.php b/src/Repository/BookmarkRepository.php index 39cd79c1c..e8982f4c6 100644 --- a/src/Repository/BookmarkRepository.php +++ b/src/Repository/BookmarkRepository.php @@ -14,6 +14,7 @@ use App\Pagination\NativeQueryAdapter; use App\Pagination\Pagerfanta; use App\Pagination\Transformation\ContentPopulationTransformer; +use App\Utils\SqlHelpers; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\ORM\EntityManagerInterface; use Doctrine\Persistence\ManagerRegistry; @@ -130,10 +131,10 @@ public function findPopulatedByList(BookmarkList $list, Criteria $criteria, ?int $parameters['time'] = $criteria->getSince(); } - $entryWhere = $this->makeWhereString($entryWhereArr); - $entryCommentWhere = $this->makeWhereString($entryCommentWhereArr); - $postWhere = $this->makeWhereString($postWhereArr); - $postCommentWhere = $this->makeWhereString($postCommentWhereArr); + $entryWhere = SqlHelpers::makeWhereString($entryWhereArr); + $entryCommentWhere = SqlHelpers::makeWhereString($entryCommentWhereArr); + $postWhere = SqlHelpers::makeWhereString($postWhereArr); + $postCommentWhere = SqlHelpers::makeWhereString($postCommentWhereArr); $sql = " SELECT * FROM ( @@ -158,23 +159,4 @@ public function findPopulatedByList(BookmarkList $list, Criteria $criteria, ?int return Pagerfanta::createForCurrentPageWithMaxPerPage($adapter, $criteria->page, $perPage ?? EntryRepository::PER_PAGE); } - - private function makeWhereString(array $whereClauses): string - { - if (empty($whereClauses)) { - return ''; - } - - $where = 'WHERE '; - $i = 0; - foreach ($whereClauses as $whereClause) { - if ($i > 0) { - $where .= ' AND '; - } - $where .= $whereClause; - ++$i; - } - - return $where; - } } diff --git a/src/Repository/EntryRepository.php b/src/Repository/EntryRepository.php index 2dfcbe7e8..cc7540e2f 100644 --- a/src/Repository/EntryRepository.php +++ b/src/Repository/EntryRepository.php @@ -24,6 +24,7 @@ use App\PageView\EntryPageView; use App\Pagination\AdapterFactory; use App\Service\SettingsManager; +use App\Utils\SqlHelpers; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\DBAL\ArrayParameterType; use Doctrine\DBAL\Types\Types; @@ -56,6 +57,7 @@ public function __construct( private readonly CacheInterface $cache, private readonly AdapterFactory $adapterFactory, private readonly SettingsManager $settingsManager, + private readonly SqlHelpers $sqlHelpers ) { parent::__construct($registry, Entry::class); } @@ -151,6 +153,7 @@ private function addBannedHashtagClause(QueryBuilder $qb): void private function filter(QueryBuilder $qb, EntryPageView $criteria): QueryBuilder { + /** @var User $user */ $user = $this->security->getUser(); if (Criteria::AP_LOCAL === $criteria->federation) { @@ -339,12 +342,11 @@ public function findToDelete(User $user, int $limit): array ->getResult(); } - public function findRelatedByTag(string $tag, ?int $limit = 1): array + public function findRelatedByTag(string $tag, ?int $limit = 1, ?User $user = null): array { $qb = $this->createQueryBuilder('e'); - return $qb - ->andWhere('e.visibility = :visibility') + $qb->andWhere('e.visibility = :visibility') ->andWhere('m.visibility = :visibility') ->andWhere('u.visibility = :visibility') ->andWhere('u.isDeleted = false') @@ -360,16 +362,23 @@ public function findRelatedByTag(string $tag, ?int $limit = 1): array 'visibility' => VisibilityInterface::VISIBILITY_VISIBLE, 'tag' => $tag, ]) - ->setMaxResults($limit) - ->getQuery() + ->setMaxResults($limit); + + if (null !== $user) { + $qb->andWhere($qb->expr()->not($qb->expr()->exists($this->sqlHelpers->getBlockedMagazinesDql($user)))) + ->andWhere($qb->expr()->not($qb->expr()->exists($this->sqlHelpers->getBlockedUsersDql($user)))); + $qb->setParameter('user', $user); + } + + return $qb->getQuery() ->getResult(); } - public function findRelatedByMagazine(string $name, ?int $limit = 1): array + public function findRelatedByMagazine(string $name, ?int $limit = 1, ?User $user = null): array { $qb = $this->createQueryBuilder('e'); - return $qb->where('m.name LIKE :name OR m.title LIKE :title') + $qb->where('m.name LIKE :name OR m.title LIKE :title') ->andWhere('e.visibility = :visibility') ->andWhere('m.visibility = :visibility') ->andWhere('u.visibility = :visibility') @@ -382,12 +391,19 @@ public function findRelatedByMagazine(string $name, ?int $limit = 1): array ->setParameters( ['name' => "%{$name}%", 'title' => "%{$name}%", 'visibility' => VisibilityInterface::VISIBILITY_VISIBLE] ) - ->setMaxResults($limit) - ->getQuery() + ->setMaxResults($limit); + + if (null !== $user) { + $qb->andWhere($qb->expr()->not($qb->expr()->exists($this->sqlHelpers->getBlockedMagazinesDql($user)))) + ->andWhere($qb->expr()->not($qb->expr()->exists($this->sqlHelpers->getBlockedUsersDql($user)))); + $qb->setParameter('user', $user); + } + + return $qb->getQuery() ->getResult(); } - public function findLast(int $limit): array + public function findLast(int $limit, ?User $user = null): array { $qb = $this->createQueryBuilder('e'); @@ -401,10 +417,16 @@ public function findLast(int $limit): array $qb = $qb->andWhere('m.apId IS NULL'); } + if (null !== $user) { + $qb->andWhere($qb->expr()->not($qb->expr()->exists($this->sqlHelpers->getBlockedMagazinesDql($user)))) + ->andWhere($qb->expr()->not($qb->expr()->exists($this->sqlHelpers->getBlockedUsersDql($user)))); + $qb->setParameter('user', $user); + } + return $qb->join('e.magazine', 'm') ->join('e.user', 'u') ->orderBy('e.createdAt', 'DESC') - ->setParameters(['visibility' => VisibilityInterface::VISIBILITY_VISIBLE]) + ->setParameter('visibility', VisibilityInterface::VISIBILITY_VISIBLE) ->setMaxResults($limit) ->getQuery() ->getResult(); diff --git a/src/Repository/MagazineRepository.php b/src/Repository/MagazineRepository.php index 0f42034f4..ebd92131e 100644 --- a/src/Repository/MagazineRepository.php +++ b/src/Repository/MagazineRepository.php @@ -16,6 +16,7 @@ use App\Entity\User; use App\PageView\MagazinePageView; use App\Service\SettingsManager; +use App\Utils\SqlHelpers; use App\Utils\SubscriptionSort; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Common\Collections\Collection; @@ -49,7 +50,7 @@ class MagazineRepository extends ServiceEntityRepository self::SORT_NEWEST, ]; - public function __construct(ManagerRegistry $registry, private readonly SettingsManager $settingsManager) + public function __construct(ManagerRegistry $registry, private readonly SettingsManager $settingsManager, private readonly SqlHelpers $sqlHelpers) { parent::__construct($registry, Magazine::class); } @@ -478,21 +479,23 @@ public function search(string $magazine, int $page, int $perPage = self::PER_PAG return $pagerfanta; } - public function findRandom(): array + public function findRandom(?User $user = null): array { $conn = $this->getEntityManager()->getConnection(); - $sql = ' - SELECT id FROM magazine - '; + $whereClauses = []; + $parameters = []; if ($this->settingsManager->get('MBIN_SIDEBAR_SECTIONS_LOCAL_ONLY')) { - $sql .= 'WHERE ap_id IS NULL'; + $whereClauses[] = 'm.ap_id IS NULL'; + } + if (null !== $user) { + $subSql = 'SELECT * FROM magazine_block mb WHERE mb.magazine_id = m.id AND mb.user_id = :user'; + $whereClauses[] = "NOT EXISTS($subSql)"; + $parameters['user'] = $user->getId(); } - $sql .= ' - ORDER BY random() - LIMIT 5 - '; + $whereString = SqlHelpers::makeWhereString($whereClauses); + $sql = "SELECT m.id FROM magazine m $whereString ORDER BY random() LIMIT 5"; $stmt = $conn->prepare($sql); - $stmt = $stmt->executeQuery(); + $stmt = $stmt->executeQuery($parameters); $ids = $stmt->fetchAllAssociative(); return $this->createQueryBuilder('m') @@ -505,17 +508,23 @@ public function findRandom(): array ->getResult(); } - public function findRelated(string $magazine): array + public function findRelated(string $magazine, ?User $user = null): array { - return $this->createQueryBuilder('m') + $qb = $this->createQueryBuilder('m') ->where('m.entryCount > 0 OR m.postCount > 0') ->andWhere('m.title LIKE :magazine OR m.description LIKE :magazine OR m.name LIKE :magazine') ->andWhere('m.isAdult = false') ->andWhere('m.visibility = :visibility') ->setParameter('visibility', VisibilityInterface::VISIBILITY_VISIBLE) ->setParameter('magazine', "%{$magazine}%") - ->setMaxResults(5) - ->getQuery() + ->setMaxResults(5); + + if (null !== $user) { + $qb->andWhere($qb->expr()->not($qb->expr()->exists($this->sqlHelpers->getBlockedMagazinesDql($user)))); + $qb->setParameter('user', $user); + } + + return $qb->getQuery() ->getResult(); } diff --git a/src/Repository/PostRepository.php b/src/Repository/PostRepository.php index 870b20490..380e7470d 100644 --- a/src/Repository/PostRepository.php +++ b/src/Repository/PostRepository.php @@ -23,6 +23,7 @@ use App\PageView\PostPageView; use App\Pagination\AdapterFactory; use App\Service\SettingsManager; +use App\Utils\SqlHelpers; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\DBAL\ArrayParameterType; use Doctrine\DBAL\Types\Types; @@ -54,6 +55,7 @@ public function __construct( private readonly CacheInterface $cache, private readonly AdapterFactory $adapterFactory, private readonly SettingsManager $settingsManager, + private readonly SqlHelpers $sqlHelpers, ) { parent::__construct($registry, Post::class); } @@ -143,6 +145,7 @@ private function addBannedHashtagClause(QueryBuilder $qb): void private function filter(QueryBuilder $qb, Criteria $criteria): QueryBuilder { + /** @var User|null $user */ $user = $this->security->getUser(); if (Criteria::AP_LOCAL === $criteria->federation) { @@ -168,8 +171,8 @@ private function filter(QueryBuilder $qb, Criteria $criteria): QueryBuilder if ($criteria->subscribed) { $qb->andWhere( - 'EXISTS (SELECT IDENTITY(ms.magazine) FROM '.MagazineSubscription::class.' ms WHERE ms.user = :user AND ms.magazine = p.magazine) - OR + 'EXISTS (SELECT IDENTITY(ms.magazine) FROM '.MagazineSubscription::class.' ms WHERE ms.user = :user AND ms.magazine = p.magazine) + OR EXISTS (SELECT IDENTITY(uf.following) FROM '.UserFollow::class.' uf WHERE uf.follower = :user AND uf.following = p.user) OR p.user = :user' @@ -307,11 +310,11 @@ public function findToDelete(User $user, int $limit): array ->getResult(); } - public function findRelatedByTag(string $tag, ?int $limit = 1): array + public function findRelatedByTag(string $tag, ?int $limit = 1, ?User $user = null): array { $qb = $this->createQueryBuilder('p'); - return $qb + $qb = $qb ->andWhere('p.visibility = :visibility') ->andWhere('m.visibility = :visibility') ->andWhere('u.visibility = :visibility') @@ -328,16 +331,23 @@ public function findRelatedByTag(string $tag, ?int $limit = 1): array 'visibility' => VisibilityInterface::VISIBILITY_VISIBLE, 'name' => $tag, ]) - ->setMaxResults($limit) - ->getQuery() + ->setMaxResults($limit); + + if (null !== $user) { + $qb->andWhere($qb->expr()->not($qb->expr()->exists($this->sqlHelpers->getBlockedMagazinesDql($user)))) + ->andWhere($qb->expr()->not($qb->expr()->exists($this->sqlHelpers->getBlockedUsersDql($user)))); + $qb->setParameter('user', $user); + } + + return $qb->getQuery() ->getResult(); } - public function findRelatedByMagazine(string $name, ?int $limit = 1): array + public function findRelatedByMagazine(string $name, ?int $limit = 1, ?User $user = null): array { $qb = $this->createQueryBuilder('p'); - return $qb->where('m.name LIKE :name OR m.title LIKE :title') + $qb = $qb->where('m.name LIKE :name OR m.title LIKE :title') ->andWhere('p.visibility = :visibility') ->andWhere('m.visibility = :visibility') ->andWhere('u.visibility = :visibility') @@ -349,12 +359,19 @@ public function findRelatedByMagazine(string $name, ?int $limit = 1): array ->setParameters( ['name' => "%{$name}%", 'title' => "%{$name}%", 'visibility' => VisibilityInterface::VISIBILITY_VISIBLE] ) - ->setMaxResults($limit) - ->getQuery() + ->setMaxResults($limit); + + if (null !== $user) { + $qb->andWhere($qb->expr()->not($qb->expr()->exists($this->sqlHelpers->getBlockedMagazinesDql($user)))) + ->andWhere($qb->expr()->not($qb->expr()->exists($this->sqlHelpers->getBlockedUsersDql($user)))); + $qb->setParameter('user', $user); + } + + return $qb->getQuery() ->getResult(); } - public function findLast(int $limit = 1): array + public function findLast(int $limit = 1, ?User $user = null): array { $qb = $this->createQueryBuilder('p'); @@ -365,9 +382,16 @@ public function findLast(int $limit = 1): array $qb = $qb->andWhere('m.apId IS NULL'); } + if (null !== $user) { + $qb->andWhere($qb->expr()->not($qb->expr()->exists($this->sqlHelpers->getBlockedMagazinesDql($user)))) + ->andWhere($qb->expr()->not($qb->expr()->exists($this->sqlHelpers->getBlockedUsersDql($user)))); + $qb->setParameter('user', $user); + } + return $qb->join('p.magazine', 'm') + ->join('p.user', 'u') ->orderBy('p.createdAt', 'DESC') - ->setParameters(['visibility' => VisibilityInterface::VISIBILITY_VISIBLE]) + ->setParameter('visibility', VisibilityInterface::VISIBILITY_VISIBLE) ->setMaxResults($limit) ->getQuery() ->getResult(); diff --git a/src/Twig/Components/RelatedEntriesComponent.php b/src/Twig/Components/RelatedEntriesComponent.php index febe94156..4615006c3 100644 --- a/src/Twig/Components/RelatedEntriesComponent.php +++ b/src/Twig/Components/RelatedEntriesComponent.php @@ -5,9 +5,11 @@ namespace App\Twig\Components; use App\Entity\Entry; +use App\Entity\User; use App\Repository\EntryRepository; use App\Service\MentionManager; use App\Service\SettingsManager; +use Symfony\Bundle\SecurityBundle\Security; use Symfony\Contracts\Cache\CacheInterface; use Symfony\Contracts\Cache\ItemInterface; use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; @@ -31,7 +33,8 @@ public function __construct( private readonly EntryRepository $repository, private readonly CacheInterface $cache, private readonly SettingsManager $settingsManager, - private readonly MentionManager $mentionManager + private readonly MentionManager $mentionManager, + private readonly Security $security, ) { } @@ -49,19 +52,23 @@ public function mount(?string $magazine, ?string $tag): void $entryId = $this->entry?->getId(); $magazine = str_replace('@', '', $magazine ?? ''); + /** @var User|null $user */ + $user = $this->security->getUser(); + $cacheKey = "related_entries_{$magazine}_{$tag}_{$entryId}_{$this->type}_{$this->settingsManager->getLocale()}_{$user?->getId()}"; $entryIds = $this->cache->get( - "related_entries_{$magazine}_{$tag}_{$entryId}_{$this->type}_{$this->settingsManager->getLocale()}", - function (ItemInterface $item) use ($magazine, $tag) { + $cacheKey, + function (ItemInterface $item) use ($magazine, $tag, $user) { $item->expiresAfter(60 * 5); // 5 minutes $entries = match ($this->type) { - self::TYPE_TAG => $this->repository->findRelatedByMagazine($tag, $this->limit + 20), + self::TYPE_TAG => $this->repository->findRelatedByMagazine($tag, $this->limit + 20, user: $user), self::TYPE_MAGAZINE => $this->repository->findRelatedByTag( $this->mentionManager->getUsername($magazine), - $this->limit + 20 + $this->limit + 20, + user: $user, ), - default => $this->repository->findLast($this->limit + 150), + default => $this->repository->findLast($this->limit + 150, user: $user), }; $entries = array_filter($entries, fn (Entry $e) => !$e->isAdult && !$e->magazine->isAdult); diff --git a/src/Twig/Components/RelatedMagazinesComponent.php b/src/Twig/Components/RelatedMagazinesComponent.php index 741dea67f..0871cda57 100644 --- a/src/Twig/Components/RelatedMagazinesComponent.php +++ b/src/Twig/Components/RelatedMagazinesComponent.php @@ -5,8 +5,10 @@ namespace App\Twig\Components; use App\Entity\Magazine; +use App\Entity\User; use App\Repository\MagazineRepository; use App\Service\SettingsManager; +use Symfony\Bundle\SecurityBundle\Security; use Symfony\Contracts\Cache\CacheInterface; use Symfony\Contracts\Cache\ItemInterface; use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; @@ -28,6 +30,7 @@ public function __construct( private readonly MagazineRepository $repository, private readonly CacheInterface $cache, private readonly SettingsManager $settingsManager, + private readonly Security $security, ) { } @@ -44,16 +47,18 @@ public function mount(?string $magazine, ?string $tag): void } $magazine = str_replace('@', '', $magazine ?? ''); + /** @var User|null $user */ + $user = $this->security->getUser(); $magazineIds = $this->cache->get( - "related_magazines_{$magazine}_{$tag}_{$this->type}_{$this->settingsManager->getLocale()}", - function (ItemInterface $item) use ($magazine, $tag) { + "related_magazines_{$magazine}_{$tag}_{$this->type}_{$this->settingsManager->getLocale()}_{$user?->getId()}", + function (ItemInterface $item) use ($magazine, $tag, $user) { $item->expiresAfter(60 * 5); // 5 minutes $magazines = match ($this->type) { - self::TYPE_TAG => $this->repository->findRelated($tag), - self::TYPE_MAGAZINE => $this->repository->findRelated($magazine), - default => $this->repository->findRandom(), + self::TYPE_TAG => $this->repository->findRelated($tag, user: $user), + self::TYPE_MAGAZINE => $this->repository->findRelated($magazine, user: $user), + default => $this->repository->findRandom(user: $user), }; $magazines = array_filter($magazines, fn ($m) => $m->name !== $magazine); diff --git a/src/Twig/Components/RelatedPostsComponent.php b/src/Twig/Components/RelatedPostsComponent.php index bfc1d944d..a5a033950 100644 --- a/src/Twig/Components/RelatedPostsComponent.php +++ b/src/Twig/Components/RelatedPostsComponent.php @@ -5,9 +5,11 @@ namespace App\Twig\Components; use App\Entity\Post; +use App\Entity\User; use App\Repository\PostRepository; use App\Service\MentionManager; use App\Service\SettingsManager; +use Symfony\Bundle\SecurityBundle\Security; use Symfony\Contracts\Cache\CacheInterface; use Symfony\Contracts\Cache\ItemInterface; use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; @@ -30,7 +32,8 @@ public function __construct( private readonly PostRepository $repository, private readonly CacheInterface $cache, private readonly SettingsManager $settingsManager, - private readonly MentionManager $mentionManager + private readonly MentionManager $mentionManager, + private readonly Security $security, ) { } @@ -46,21 +49,25 @@ public function mount(?string $magazine, ?string $tag): void $this->type = self::TYPE_MAGAZINE; } + /** @var User|null $user */ + $user = $this->security->getUser(); + $postId = $this->post?->getId(); $magazine = str_replace('@', '', $magazine ?? ''); $postIds = $this->cache->get( - "related_posts_{$magazine}_{$tag}_{$postId}_{$this->type}_{$this->settingsManager->getLocale()}", - function (ItemInterface $item) use ($magazine, $tag) { + "related_posts_{$magazine}_{$tag}_{$postId}_{$this->type}_{$this->settingsManager->getLocale()}_{$user?->getId()}", + function (ItemInterface $item) use ($magazine, $tag, $user) { $item->expiresAfter(60 * 5); // 5 minutes $posts = match ($this->type) { - self::TYPE_TAG => $this->repository->findRelatedByMagazine($tag, $this->limit + 20), + self::TYPE_TAG => $this->repository->findRelatedByMagazine($tag, $this->limit + 20, user: $user), self::TYPE_MAGAZINE => $this->repository->findRelatedByTag( $this->mentionManager->getUsername($magazine), - $this->limit + 20 + $this->limit + 20, + user: $user ), - default => $this->repository->findLast($this->limit + 150), + default => $this->repository->findLast($this->limit + 150, user: $user), }; $posts = array_filter($posts, fn (Post $p) => !$p->isAdult && !$p->magazine->isAdult); diff --git a/src/Utils/SqlHelpers.php b/src/Utils/SqlHelpers.php new file mode 100644 index 000000000..39e9de186 --- /dev/null +++ b/src/Utils/SqlHelpers.php @@ -0,0 +1,59 @@ + 0) { + $where .= ' AND '; + } + $where .= $whereClause; + ++$i; + } + + return $where; + } + + public function getBlockedMagazinesDql(User $user): string + { + return $this->entityManager->createQueryBuilder() + ->select('bm') + ->from(MagazineBlock::class, 'bm') + ->where('bm.magazine = m') + ->andWhere('bm.user = :user') + ->setParameter('user', $user) + ->getDQL(); + } + + public function getBlockedUsersDql(User $user): string + { + return $this->entityManager->createQueryBuilder() + ->select('ub') + ->from(UserBlock::class, 'ub') + ->where('ub.blocker = :user') + ->andWhere('ub.blocked = u') + ->setParameter('user', $user) + ->getDql(); + } +} From 55cc85ab50603ab3236733610fce629eda730c38 Mon Sep 17 00:00:00 2001 From: BentiGorlich Date: Wed, 16 Oct 2024 13:11:40 +0200 Subject: [PATCH 14/19] Improve search (#1167) --- assets/styles/app.scss | 1 + assets/styles/components/_search.scss | 24 +++++ assets/styles/layout/_forms.scss | 6 ++ assets/styles/layout/_layout.scss | 10 ++- assets/styles/layout/_section.scss | 8 -- .../Api/Search/SearchRetrieveApi.php | 31 ++++++- src/Controller/Api/User/UserRetrieveApi.php | 20 ++++- src/Controller/SearchController.php | 89 +++++++++++-------- src/DTO/SearchDto.php | 8 +- src/Form/SearchType.php | 36 ++++++++ src/Form/Type/UserAutocompleteType.php | 59 ++++++++++++ src/Repository/SearchRepository.php | 50 +++++++++-- src/Repository/UserRepository.php | 25 +++--- src/Service/SearchManager.php | 4 +- templates/magazine/list_all.html.twig | 6 +- templates/search/form.html.twig | 19 ++++ templates/search/front.html.twig | 14 +-- translations/messages.en.yaml | 4 + 18 files changed, 329 insertions(+), 85 deletions(-) create mode 100644 assets/styles/components/_search.scss create mode 100644 src/Form/SearchType.php create mode 100644 src/Form/Type/UserAutocompleteType.php create mode 100644 templates/search/form.html.twig diff --git a/assets/styles/app.scss b/assets/styles/app.scss index 57fb23d5d..e9f9b0dc9 100644 --- a/assets/styles/app.scss +++ b/assets/styles/app.scss @@ -37,6 +37,7 @@ @import 'components/figure_image'; @import 'components/figure_lightbox'; @import 'components/post'; +@import 'components/search'; @import 'components/subject'; @import 'components/login'; @import 'components/modlog'; diff --git a/assets/styles/components/_search.scss b/assets/styles/components/_search.scss new file mode 100644 index 000000000..d179dd372 --- /dev/null +++ b/assets/styles/components/_search.scss @@ -0,0 +1,24 @@ +.search-container { + background: var(--kbin-input-bg); + border: var(--kbin-input-border); + border-radius: var(--kbin-rounded-edges-radius) !important; + + input.form-control { + border-radius: 0 !important; + border: none; + background: transparent; + margin: 0 .5em; + padding: .5rem .25rem; + } + + button { + border-radius: 0 var(--kbin-rounded-edges-radius) var(--kbin-rounded-edges-radius) 0 !important; + border: 0; + padding: 1rem 0.5rem; + + &:not(:hover) { + background: var(--kbin-input-bg); + color: var(--kbin-input-text-color) !important; + } + } +} diff --git a/assets/styles/layout/_forms.scss b/assets/styles/layout/_forms.scss index 8d42c269d..e0e606d7a 100644 --- a/assets/styles/layout/_forms.scss +++ b/assets/styles/layout/_forms.scss @@ -525,3 +525,9 @@ div.input-box { border-radius: var(--kbin-rounded-edges-radius) !important; } } + +.form-control { + display: block; + width: 100%; + +} diff --git a/assets/styles/layout/_layout.scss b/assets/styles/layout/_layout.scss index 789c8f20c..c7b387c12 100644 --- a/assets/styles/layout/_layout.scss +++ b/assets/styles/layout/_layout.scss @@ -214,7 +214,9 @@ figure { code, .ts-control > [data-value].item, .image-preview-container { - border-radius: var(--kbin-rounded-edges-radius) !important; + &:not(.ignore-edges) { + border-radius: var(--kbin-rounded-edges-radius) !important; + } } .ts-wrapper { @@ -361,6 +363,12 @@ figure { gap: .25rem; } +@include media-breakpoint-down(lg) { + .flex.mobile { + display: block; + } +} + .flex-wrap { flex-wrap: wrap; } diff --git a/assets/styles/layout/_section.scss b/assets/styles/layout/_section.scss index 7f44e8166..47e75a8eb 100644 --- a/assets/styles/layout/_section.scss +++ b/assets/styles/layout/_section.scss @@ -68,11 +68,3 @@ color: var(--kbin-alert-danger-text-color); } } - -.page-search { - .section--top { - button { - padding: 1rem 1.5rem; - } - } -} \ No newline at end of file diff --git a/src/Controller/Api/Search/SearchRetrieveApi.php b/src/Controller/Api/Search/SearchRetrieveApi.php index c92b4a0ec..f2499fcad 100644 --- a/src/Controller/Api/Search/SearchRetrieveApi.php +++ b/src/Controller/Api/Search/SearchRetrieveApi.php @@ -103,6 +103,27 @@ class SearchRetrieveApi extends BaseApi required: true, schema: new OA\Schema(type: 'string') )] + #[OA\Parameter( + name: 'authorId', + description: 'User id of the author', + in: 'query', + required: false, + schema: new OA\Schema(type: 'integer') + )] + #[OA\Parameter( + name: 'magazineId', + description: 'Id of the magazine', + in: 'query', + required: false, + schema: new OA\Schema(type: 'integer') + )] + #[OA\Parameter( + name: 'type', + description: 'The type of content', + in: 'query', + required: false, + schema: new OA\Schema(type: 'string', enum: ['', 'entry', 'post']) + )] #[OA\Tag(name: 'search')] public function __invoke( SearchManager $manager, @@ -122,8 +143,16 @@ public function __invoke( $page = $this->getPageNb($request); $perPage = self::constrainPerPage($request->get('perPage', SearchRepository::PER_PAGE)); + $authorIdRaw = $request->get('authorId'); + $authorId = null === $authorIdRaw ? null : \intval($authorIdRaw); + $magazineIdRaw = $request->get('magazineId'); + $magazineId = null === $magazineIdRaw ? null : \intval($magazineIdRaw); + $type = $request->get('type'); + if ('entry' !== $type && 'post' !== $type && null !== $type) { + throw new BadRequestHttpException(); + } - $items = $manager->findPaginated($this->getUser(), $q, $page, $perPage); + $items = $manager->findPaginated($this->getUser(), $q, $page, $perPage, authorId: $authorId, magazineId: $magazineId, specificType: $type); $dtos = []; foreach ($items->getCurrentPageResults() as $value) { \assert($value instanceof ContentInterface); diff --git a/src/Controller/Api/User/UserRetrieveApi.php b/src/Controller/Api/User/UserRetrieveApi.php index 97ce14fef..ab95009af 100644 --- a/src/Controller/Api/User/UserRetrieveApi.php +++ b/src/Controller/Api/User/UserRetrieveApi.php @@ -275,6 +275,18 @@ public function settings( in: 'query', schema: new OA\Schema(type: 'string', default: UserRepository::USERS_ALL, enum: UserRepository::USERS_OPTIONS) )] + #[OA\Parameter( + name: 'q', + description: 'The term to search for', + in: 'query', + schema: new OA\Schema(type: 'string') + )] + #[OA\Parameter( + name: 'withAbout', + description: 'Only include users with a filled in profile', + in: 'query', + schema: new OA\Schema(type: 'boolean') + )] #[OA\Tag(name: 'user')] public function collection( UserRepository $userRepository, @@ -286,11 +298,15 @@ public function collection( $request = $this->request->getCurrentRequest(); $group = $request->get('group', UserRepository::USERS_ALL); + $withAboutRaw = $request->get('withAbout'); + $withAbout = null === $withAboutRaw ? false : \boolval($withAboutRaw); - $users = $userRepository->findWithAboutPaginated( + $users = $userRepository->findPaginated( $this->getPageNb($request), + $withAbout, $group, - $this->constrainPerPage($request->get('perPage', UserRepository::PER_PAGE)) + $this->constrainPerPage($request->get('perPage', UserRepository::PER_PAGE)), + $request->get('q'), ); $dtos = []; diff --git a/src/Controller/SearchController.php b/src/Controller/SearchController.php index c7451b83e..1bbe8ea38 100644 --- a/src/Controller/SearchController.php +++ b/src/Controller/SearchController.php @@ -5,8 +5,10 @@ namespace App\Controller; use App\ActivityPub\ActorHandle; +use App\DTO\SearchDto; use App\Entity\Magazine; use App\Entity\User; +use App\Form\SearchType; use App\Message\ActivityPub\Inbox\ActivityMessage; use App\Service\ActivityPub\ApHttpClient; use App\Service\ActivityPubManager; @@ -33,52 +35,63 @@ public function __construct( public function __invoke(Request $request): Response { - $query = $request->query->get('q') ? trim($request->query->get('q')) : null; - - if (!$query) { - return $this->render( - 'search/front.html.twig', - [ - 'objects' => [], - 'results' => [], - 'q' => '', - ] - ); - } - - $this->logger->debug('searching for {query}', ['query' => $query]); - - $objects = []; + $dto = new SearchDto(); + $form = $this->createForm(SearchType::class, $dto, ['csrf_protection' => false]); + try { + $form = $form->handleRequest($request); + if ($form->isSubmitted() && $form->isValid()) { + /** @var SearchDto $dto */ + $dto = $form->getData(); + $query = $dto->q; + $this->logger->debug('searching for {query}', ['query' => $query]); + + $objects = []; + + // looking up handles (users and mags) + if (str_contains($query, '@') && $this->federatedSearchAllowed()) { + if ($handle = ActorHandle::parse($query)) { + $this->logger->debug('searching for a matched webfinger {query}', ['query' => $query]); + $objects = array_merge($objects, $this->lookupHandle($handle)); + } else { + $this->logger->debug("query doesn't look like a valid handle...", ['query' => $query]); + } + } - // looking up handles (users and mags) - if (str_contains($query, '@') && $this->federatedSearchAllowed()) { - if ($handle = ActorHandle::parse($query)) { - $this->logger->debug('searching for a matched webfinger {query}', ['query' => $query]); - $objects = array_merge($objects, $this->lookupHandle($handle)); - } else { - $this->logger->debug("query doesn't look like a valid handle...", ['query' => $query]); - } - } + // looking up object by AP id (i.e. urls) + if (false !== filter_var($query, FILTER_VALIDATE_URL)) { + $objects = $this->manager->findByApId($query); + if (!$objects) { + $body = $this->apHttpClient->getActivityObject($query, false); + $this->bus->dispatch(new ActivityMessage($body)); + } + } - // looking up object by AP id (i.e. urls) - if (false !== filter_var($query, FILTER_VALIDATE_URL)) { - $objects = $this->manager->findByApId($query); - if (!$objects) { - $body = $this->apHttpClient->getActivityObject($query, false); - $this->bus->dispatch(new ActivityMessage($body)); + $user = $this->getUser(); + $res = $this->manager->findPaginated($user, $query, $this->getPageNb($request), authorId: $dto->user?->getId(), magazineId: $dto->magazine?->getId(), specificType: $dto->type); + + $this->logger->debug('results: {num}', ['num' => $res->count()]); + + return $this->render( + 'search/front.html.twig', + [ + 'objects' => $objects, + 'results' => $this->overviewManager->buildList($res), + 'pagination' => $res, + 'form' => $form->createView(), + 'q' => $query, + ] + ); } + } catch (\Exception $e) { + $this->logger->error($e); } - $user = $this->getUser(); - $res = $this->manager->findPaginated($user, $query, $this->getPageNb($request)); - return $this->render( 'search/front.html.twig', [ - 'objects' => $objects, - 'results' => $this->overviewManager->buildList($res), - 'pagination' => $res, - 'q' => $request->query->get('q'), + 'objects' => [], + 'results' => [], + 'form' => $form->createView(), ] ); } diff --git a/src/DTO/SearchDto.php b/src/DTO/SearchDto.php index c9070f0df..de26533c6 100644 --- a/src/DTO/SearchDto.php +++ b/src/DTO/SearchDto.php @@ -4,7 +4,13 @@ namespace App\DTO; +use App\Entity\Magazine; +use App\Entity\User; + class SearchDto { - public string $val; + public string $q; + public ?string $type = null; + public ?User $user = null; + public ?Magazine $magazine = null; } diff --git a/src/Form/SearchType.php b/src/Form/SearchType.php new file mode 100644 index 000000000..5d7410e50 --- /dev/null +++ b/src/Form/SearchType.php @@ -0,0 +1,36 @@ +setMethod('GET') + ->add('q', TextType::class, [ + 'required' => true, + 'attr' => [ + 'placeholder' => 'type_search_term', + ], + ]) + ->add('magazine', MagazineAutocompleteType::class, ['required' => false]) + ->add('user', UserAutocompleteType::class, ['required' => false]) + ->add('type', ChoiceType::class, [ + 'choices' => [ + 'search_type_all' => null, + 'search_type_entry' => 'entry', + 'search_type_post' => 'post', + ], + ]); + } +} diff --git a/src/Form/Type/UserAutocompleteType.php b/src/Form/Type/UserAutocompleteType.php new file mode 100644 index 000000000..d1cc01909 --- /dev/null +++ b/src/Form/Type/UserAutocompleteType.php @@ -0,0 +1,59 @@ +setDefaults([ + 'class' => User::class, + 'choice_label' => 'username', + 'placeholder' => 'select_user', + 'filter_query' => function (QueryBuilder $qb, string $query) { + if ($currentUser = $this->security->getUser()) { + $qb + ->andWhere( + \sprintf( + 'entity.id NOT IN (SELECT IDENTITY(ub.blocked) FROM %s ub WHERE ub.blocker = :user)', + UserBlock::class, + ) + ) + ->setParameter('user', $currentUser); + } + + if (!$query) { + return; + } + + $qb->andWhere('entity.username LIKE :filter') + ->andWhere('entity.visibility = :visibility') + ->setParameter('filter', '%'.$query.'%') + ->setParameter('visibility', VisibilityInterface::VISIBILITY_VISIBLE) + ; + }, + ]); + } + + public function getParent(): string + { + return BaseEntityAutocompleteType::class; + } +} diff --git a/src/Repository/SearchRepository.php b/src/Repository/SearchRepository.php index a5d89e5eb..84a412133 100644 --- a/src/Repository/SearchRepository.php +++ b/src/Repository/SearchRepository.php @@ -13,6 +13,7 @@ use Doctrine\ORM\EntityManagerInterface; use Pagerfanta\Pagerfanta; use Pagerfanta\PagerfantaInterface; +use Psr\Log\LoggerInterface; class SearchRepository { @@ -21,6 +22,7 @@ class SearchRepository public function __construct( private readonly EntityManagerInterface $entityManager, private readonly ContentPopulationTransformer $transformer, + private readonly LoggerInterface $logger, ) { } @@ -79,10 +81,15 @@ public function findBoosts(int $page, User $user): PagerfantaInterface return $pagerfanta; } - public function search(?User $searchingUser, string $query, int $page = 1): PagerfantaInterface + /** + * @param 'entry'|'post'|null $specificType + */ + public function search(?User $searchingUser, string $query, int $page = 1, ?int $authorId = null, ?int $magazineId = null, ?string $specificType = null): PagerfantaInterface { + $authorWhere = null !== $authorId ? 'AND e.user_id = :authorId' : ''; + $magazineWhere = null !== $magazineId ? 'AND e.magazine_id = :magazineId' : ''; $conn = $this->entityManager->getConnection(); - $sql = "SELECT e.id, e.created_at, e.visibility, 'entry' AS type FROM entry e + $sqlEntry = "SELECT e.id, e.created_at, e.visibility, 'entry' AS type FROM entry e INNER JOIN public.user u ON u.id = user_id INNER JOIN magazine m ON e.magazine_id = m.id WHERE (body_ts @@ plainto_tsquery( :query ) = true OR title_ts @@ plainto_tsquery( :query ) = true) @@ -91,6 +98,7 @@ public function search(?User $searchingUser, string $query, int $page = 1): Page AND NOT EXISTS (SELECT id FROM user_block ub WHERE ub.blocked_id = u.id AND ub.blocker_id = :queryingUser) AND NOT EXISTS (SELECT id FROM magazine_block mb WHERE mb.magazine_id = m.id AND mb.user_id = :queryingUser) AND NOT EXISTS (SELECT hl.id FROM hashtag_link hl INNER JOIN hashtag h ON h.id = hl.hashtag_id AND h.banned = true WHERE hl.entry_id = e.id) + $authorWhere $magazineWhere UNION ALL SELECT e.id, e.created_at, e.visibility, 'entry_comment' AS type FROM entry_comment e INNER JOIN public.user u ON u.id = user_id @@ -101,8 +109,9 @@ public function search(?User $searchingUser, string $query, int $page = 1): Page AND NOT EXISTS (SELECT id FROM user_block ub WHERE ub.blocked_id = u.id AND ub.blocker_id = :queryingUser) AND NOT EXISTS (SELECT id FROM magazine_block mb WHERE mb.magazine_id = m.id AND mb.user_id = :queryingUser) AND NOT EXISTS (SELECT hl.id FROM hashtag_link hl INNER JOIN hashtag h ON h.id = hl.hashtag_id AND h.banned = true WHERE hl.entry_comment_id = e.id) - UNION ALL - SELECT e.id, e.created_at, e.visibility, 'post' AS type FROM post e + $authorWhere $magazineWhere + "; + $sqlPost = "SELECT e.id, e.created_at, e.visibility, 'post' AS type FROM post e INNER JOIN public.user u ON u.id = user_id INNER JOIN magazine m ON e.magazine_id = m.id WHERE body_ts @@ plainto_tsquery( :query ) = true @@ -111,6 +120,7 @@ public function search(?User $searchingUser, string $query, int $page = 1): Page AND NOT EXISTS (SELECT id FROM user_block ub WHERE ub.blocked_id = u.id AND ub.blocker_id = :queryingUser) AND NOT EXISTS (SELECT id FROM magazine_block mb WHERE mb.magazine_id = m.id AND mb.user_id = :queryingUser) AND NOT EXISTS (SELECT hl.id FROM hashtag_link hl INNER JOIN hashtag h ON h.id = hl.hashtag_id AND h.banned = true WHERE hl.post_id = e.id) + $authorWhere $magazineWhere UNION ALL SELECT e.id, e.created_at, e.visibility, 'post_comment' AS type FROM post_comment e INNER JOIN public.user u ON u.id = user_id @@ -121,12 +131,38 @@ public function search(?User $searchingUser, string $query, int $page = 1): Page AND NOT EXISTS (SELECT id FROM user_block ub WHERE ub.blocked_id = u.id AND ub.blocker_id = :queryingUser) AND NOT EXISTS (SELECT id FROM magazine_block mb WHERE mb.magazine_id = m.id AND mb.user_id = :queryingUser) AND NOT EXISTS (SELECT hl.id FROM hashtag_link hl INNER JOIN hashtag h ON h.id = hl.hashtag_id AND h.banned = true WHERE hl.post_comment_id = e.id) - ORDER BY created_at DESC"; - $adapter = new NativeQueryAdapter($conn, $sql, [ + $authorWhere $magazineWhere + "; + + if (null === $specificType) { + $sql = "$sqlEntry UNION ALL $sqlPost ORDER BY created_at DESC"; + } else { + if ('entry' === $specificType) { + $sql = "$sqlEntry ORDER BY created_at DESC"; + } elseif ('post' === $specificType) { + $sql = "$sqlPost ORDER BY created_at DESC"; + } else { + throw new \LogicException($specificType.' is not supported'); + } + } + + $this->logger->debug('Search query: {sql}', ['sql' => $sql]); + + $parameters = [ 'query' => $query, 'visibility' => VisibilityInterface::VISIBILITY_VISIBLE, 'queryingUser' => $searchingUser?->getId() ?? -1, - ], transformer: $this->transformer); + ]; + + if (null !== $authorId) { + $parameters['authorId'] = $authorId; + } + + if (null !== $magazineId) { + $parameters['magazineId'] = $magazineId; + } + + $adapter = new NativeQueryAdapter($conn, $sql, $parameters, transformer: $this->transformer); $pagerfanta = new Pagerfanta($adapter); $pagerfanta->setCurrentPage($page); diff --git a/src/Repository/UserRepository.php b/src/Repository/UserRepository.php index 2c7c91dda..2b714b151 100644 --- a/src/Repository/UserRepository.php +++ b/src/Repository/UserRepository.php @@ -477,12 +477,9 @@ private function findUsersQueryBuilder(string $group, ?bool $recentlyActive = tr ->orderBy('u.lastActive', 'DESC'); } - public function findWithAboutPaginated( - int $page, - string $group = self::USERS_ALL, - int $perPage = self::PER_PAGE - ): PagerfantaInterface { - $query = $this->findWithAboutQueryBuilder($group)->getQuery(); + public function findPaginated(int $page, bool $needsAbout, string $group = self::USERS_ALL, int $perPage = self::PER_PAGE, ?string $query = null): PagerfantaInterface + { + $query = $this->findQueryBuilder($group, $query, $needsAbout)->getQuery(); $pagerfanta = new Pagerfanta( new QueryAdapter( @@ -500,11 +497,19 @@ public function findWithAboutPaginated( return $pagerfanta; } - private function findWithAboutQueryBuilder(string $group): QueryBuilder + private function findQueryBuilder(string $group, ?string $query, bool $needsAbout): QueryBuilder { - $qb = $this->createQueryBuilder('u') - ->andWhere('u.about != \'\'') - ->andWhere('u.about IS NOT NULL'); + $qb = $this->createQueryBuilder('u'); + + if ($needsAbout) { + $qb->andWhere('u.about != \'\'') + ->andWhere('u.about IS NOT NULL'); + } + + if (null !== $query) { + $qb->andWhere('u.username LIKE :query') + ->setParameter('query', '%'.$query.'%'); + } switch ($group) { case self::USERS_LOCAL: diff --git a/src/Service/SearchManager.php b/src/Service/SearchManager.php index 8b62a943f..fbdae9916 100644 --- a/src/Service/SearchManager.php +++ b/src/Service/SearchManager.php @@ -47,9 +47,9 @@ public function findDomainsPaginated(string $domain, int $page = 1, int $perPage return $this->domainRepository->search($domain, $page, $perPage); } - public function findPaginated(?User $queryingUser, string $val, int $page = 1, int $perPage = SearchRepository::PER_PAGE): PagerfantaInterface + public function findPaginated(?User $queryingUser, string $val, int $page = 1, int $perPage = SearchRepository::PER_PAGE, ?int $authorId = null, ?int $magazineId = null, ?string $specificType = null): PagerfantaInterface { - return $this->repository->search($queryingUser, $val, $page, $perPage); + return $this->repository->search($queryingUser, $val, $page, authorId: $authorId, magazineId: $magazineId, specificType: $specificType); } public function findByApId(string $url): array diff --git a/templates/magazine/list_all.html.twig b/templates/magazine/list_all.html.twig index 5a9f45cbe..452c2349e 100644 --- a/templates/magazine/list_all.html.twig +++ b/templates/magazine/list_all.html.twig @@ -21,9 +21,9 @@
    {{ form_start(form) }} -
    - {{ form_widget(form.query) }} -
    diff --git a/templates/search/form.html.twig b/templates/search/form.html.twig new file mode 100644 index 000000000..fcec8301b --- /dev/null +++ b/templates/search/form.html.twig @@ -0,0 +1,19 @@ +{{ form_start(form, {'attr': {'class': 'search-form'}}) }} + +
    + {{ form_widget(form.q, {label: false, 'attr': {'class': 'form-control'}}) }} + + +
    + +
    + {{ form_widget(form.magazine, {label: false, 'attr': {'class': 'form-control'}}) }} + {{ form_widget(form.user, {label: false, 'attr': {'class': 'form-control'}}) }} +
    + {{ form_widget(form.type, {label: false, 'attr': {'class': 'form-control', 'style': 'padding: 1rem .5rem;'}}) }} +
    +
    + +{{ form_end(form) }} diff --git a/templates/search/front.html.twig b/templates/search/front.html.twig index feac7ebf2..0b72851d0 100644 --- a/templates/search/front.html.twig +++ b/templates/search/front.html.twig @@ -15,17 +15,7 @@ {% block body %}

    {{ 'search'|trans }}

    -
    -
    -
    - - -
    -
    -
    + {% include 'search/form.html.twig' %}
  • + {% if is_bookmarked_in_list(app.user, list, subject) %} + + + {{ 'bookmark_remove_from_list'|trans({'%list%': list.name}) }} + + {% else %} + + + {{ 'bookmark_add_to_list'|trans({'%list%': list.name}) }} + + {% endif %} +
  • diff --git a/templates/components/bookmark_menu_list.html.twig b/templates/components/bookmark_menu_list.html.twig new file mode 100644 index 000000000..3a6126616 --- /dev/null +++ b/templates/components/bookmark_menu_list.html.twig @@ -0,0 +1,5 @@ +
    + {% for list in bookmarkLists %} + {{ component('bookmark_list', { subject: subject, list: list }) }} + {% endfor %} +
    diff --git a/templates/components/bookmark_standard.html.twig b/templates/components/bookmark_standard.html.twig new file mode 100644 index 000000000..d8d502a9e --- /dev/null +++ b/templates/components/bookmark_standard.html.twig @@ -0,0 +1,17 @@ +
  • + {% if is_bookmarked(app.user, subject) %} + + + + {% else %} + + + + {% endif %} +
  • diff --git a/templates/components/entry.html.twig b/templates/components/entry.html.twig index 59cd3242f..a4a402432 100644 --- a/templates/components/entry.html.twig +++ b/templates/components/entry.html.twig @@ -171,6 +171,9 @@ subject: entry }) }} + {% if app.user is defined and app.user is not same as null %} + {{ component('bookmark_standard', { subject: entry }) }} + {% endif %} {% include 'entry/_menu.html.twig' %}
  • diff --git a/templates/components/entry_comment.html.twig b/templates/components/entry_comment.html.twig index 2bbec6930..1f87f77b5 100644 --- a/templates/components/entry_comment.html.twig +++ b/templates/components/entry_comment.html.twig @@ -102,6 +102,9 @@
  • {{ component('boost', {subject: comment}) }}
  • + {% if app.user is defined and app.user is not same as null %} + {{ component('bookmark_standard', { subject: comment }) }} + {% endif %} {% include 'entry/comment/_menu.html.twig' %}
  • diff --git a/templates/components/post.html.twig b/templates/components/post.html.twig index 04a79d7e6..d03e769ca 100644 --- a/templates/components/post.html.twig +++ b/templates/components/post.html.twig @@ -110,6 +110,9 @@ subject: post }) }}
  • + {% if app.user is defined and app.user is not same as null %} + {{ component('bookmark_standard', { subject: post }) }} + {% endif %} {% include 'post/_menu.html.twig' %}
  • diff --git a/templates/components/post_comment.html.twig b/templates/components/post_comment.html.twig index b91eb650a..7755aceac 100644 --- a/templates/components/post_comment.html.twig +++ b/templates/components/post_comment.html.twig @@ -102,6 +102,9 @@ subject: comment }) }}
  • + {% if app.user is defined and app.user is not same as null %} + {{ component('bookmark_standard', { subject: comment }) }} + {% endif %} {% include 'post/comment/_menu.html.twig' %}
  • diff --git a/templates/entry/_menu.html.twig b/templates/entry/_menu.html.twig index e94186bfd..5dae6bf39 100644 --- a/templates/entry/_menu.html.twig +++ b/templates/entry/_menu.html.twig @@ -20,6 +20,14 @@
  • {% endif %} + {% if app.user is defined and app.user is not same as null %} + {% set bookmarkLists = get_bookmark_lists(app.user) %} + {% if bookmarkLists|length %} + + {{ component('bookmark_menu_list', { bookmarkLists: bookmarkLists, subject: entry }) }} + {% endif %} + {% endif %} +
  • {{ 'copy_url'|trans }}
  • + {% if is_granted('edit', entry) or (app.user and entry.isAuthor(app.user)) or is_granted('moderate', entry) %} {% endif %} diff --git a/templates/entry/comment/_menu.html.twig b/templates/entry/comment/_menu.html.twig index 7b1fc2c80..6c250689d 100644 --- a/templates/entry/comment/_menu.html.twig +++ b/templates/entry/comment/_menu.html.twig @@ -14,6 +14,17 @@ {{ 'activity'|trans }} + + {% if app.user is defined and app.user is not same as null %} + {% set bookmarkLists = get_bookmark_lists(app.user) %} + {% if bookmarkLists|length %} + + {% for list in bookmarkLists %} + {{ component('bookmark_list', { subject: comment, subjectType: 'entry_comment', list: list }) }} + {% endfor %} + {% endif %} + {% endif %} +
  • {{ app.user.countNewNotifications }}
  • +
  • + + {{ 'bookmark_lists'|trans }} + +
  • {% if is_granted('ROLE_ADMIN') %}
  • + + {% if app.user is defined and app.user is not same as null %} + {% set bookmarkLists = get_bookmark_lists(app.user) %} + {% if bookmarkLists|length %} + + {% for list in bookmarkLists %} + {{ component('bookmark_list', { subject: post, subjectType: 'post', list: list }) }} + {% endfor %} + {% endif %} + {% endif %} +
  • + + {% if app.user is defined and app.user is not same as null %} + {% set bookmarkLists = get_bookmark_lists(app.user) %} + {% if bookmarkLists|length %} + + {% for list in bookmarkLists %} + {{ component('bookmark_list', { subject: comment, subjectType: 'post_comment', list: list }) }} + {% endfor %} + {% endif %} + {% endif %} +
  • Date: Sun, 13 Oct 2024 17:36:04 +0200 Subject: [PATCH 17/19] Make related panels respect blocks (#1183) --- src/Repository/BookmarkRepository.php | 28 ++------- src/Repository/EntryRepository.php | 44 ++++++++++---- src/Repository/MagazineRepository.php | 39 +++++++----- src/Repository/PostRepository.php | 48 +++++++++++---- .../Components/RelatedEntriesComponent.php | 19 ++++-- .../Components/RelatedMagazinesComponent.php | 15 +++-- src/Twig/Components/RelatedPostsComponent.php | 19 ++++-- src/Utils/SqlHelpers.php | 59 +++++++++++++++++++ 8 files changed, 193 insertions(+), 78 deletions(-) create mode 100644 src/Utils/SqlHelpers.php diff --git a/src/Repository/BookmarkRepository.php b/src/Repository/BookmarkRepository.php index 39cd79c1c..e8982f4c6 100644 --- a/src/Repository/BookmarkRepository.php +++ b/src/Repository/BookmarkRepository.php @@ -14,6 +14,7 @@ use App\Pagination\NativeQueryAdapter; use App\Pagination\Pagerfanta; use App\Pagination\Transformation\ContentPopulationTransformer; +use App\Utils\SqlHelpers; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\ORM\EntityManagerInterface; use Doctrine\Persistence\ManagerRegistry; @@ -130,10 +131,10 @@ public function findPopulatedByList(BookmarkList $list, Criteria $criteria, ?int $parameters['time'] = $criteria->getSince(); } - $entryWhere = $this->makeWhereString($entryWhereArr); - $entryCommentWhere = $this->makeWhereString($entryCommentWhereArr); - $postWhere = $this->makeWhereString($postWhereArr); - $postCommentWhere = $this->makeWhereString($postCommentWhereArr); + $entryWhere = SqlHelpers::makeWhereString($entryWhereArr); + $entryCommentWhere = SqlHelpers::makeWhereString($entryCommentWhereArr); + $postWhere = SqlHelpers::makeWhereString($postWhereArr); + $postCommentWhere = SqlHelpers::makeWhereString($postCommentWhereArr); $sql = " SELECT * FROM ( @@ -158,23 +159,4 @@ public function findPopulatedByList(BookmarkList $list, Criteria $criteria, ?int return Pagerfanta::createForCurrentPageWithMaxPerPage($adapter, $criteria->page, $perPage ?? EntryRepository::PER_PAGE); } - - private function makeWhereString(array $whereClauses): string - { - if (empty($whereClauses)) { - return ''; - } - - $where = 'WHERE '; - $i = 0; - foreach ($whereClauses as $whereClause) { - if ($i > 0) { - $where .= ' AND '; - } - $where .= $whereClause; - ++$i; - } - - return $where; - } } diff --git a/src/Repository/EntryRepository.php b/src/Repository/EntryRepository.php index 2dfcbe7e8..cc7540e2f 100644 --- a/src/Repository/EntryRepository.php +++ b/src/Repository/EntryRepository.php @@ -24,6 +24,7 @@ use App\PageView\EntryPageView; use App\Pagination\AdapterFactory; use App\Service\SettingsManager; +use App\Utils\SqlHelpers; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\DBAL\ArrayParameterType; use Doctrine\DBAL\Types\Types; @@ -56,6 +57,7 @@ public function __construct( private readonly CacheInterface $cache, private readonly AdapterFactory $adapterFactory, private readonly SettingsManager $settingsManager, + private readonly SqlHelpers $sqlHelpers ) { parent::__construct($registry, Entry::class); } @@ -151,6 +153,7 @@ private function addBannedHashtagClause(QueryBuilder $qb): void private function filter(QueryBuilder $qb, EntryPageView $criteria): QueryBuilder { + /** @var User $user */ $user = $this->security->getUser(); if (Criteria::AP_LOCAL === $criteria->federation) { @@ -339,12 +342,11 @@ public function findToDelete(User $user, int $limit): array ->getResult(); } - public function findRelatedByTag(string $tag, ?int $limit = 1): array + public function findRelatedByTag(string $tag, ?int $limit = 1, ?User $user = null): array { $qb = $this->createQueryBuilder('e'); - return $qb - ->andWhere('e.visibility = :visibility') + $qb->andWhere('e.visibility = :visibility') ->andWhere('m.visibility = :visibility') ->andWhere('u.visibility = :visibility') ->andWhere('u.isDeleted = false') @@ -360,16 +362,23 @@ public function findRelatedByTag(string $tag, ?int $limit = 1): array 'visibility' => VisibilityInterface::VISIBILITY_VISIBLE, 'tag' => $tag, ]) - ->setMaxResults($limit) - ->getQuery() + ->setMaxResults($limit); + + if (null !== $user) { + $qb->andWhere($qb->expr()->not($qb->expr()->exists($this->sqlHelpers->getBlockedMagazinesDql($user)))) + ->andWhere($qb->expr()->not($qb->expr()->exists($this->sqlHelpers->getBlockedUsersDql($user)))); + $qb->setParameter('user', $user); + } + + return $qb->getQuery() ->getResult(); } - public function findRelatedByMagazine(string $name, ?int $limit = 1): array + public function findRelatedByMagazine(string $name, ?int $limit = 1, ?User $user = null): array { $qb = $this->createQueryBuilder('e'); - return $qb->where('m.name LIKE :name OR m.title LIKE :title') + $qb->where('m.name LIKE :name OR m.title LIKE :title') ->andWhere('e.visibility = :visibility') ->andWhere('m.visibility = :visibility') ->andWhere('u.visibility = :visibility') @@ -382,12 +391,19 @@ public function findRelatedByMagazine(string $name, ?int $limit = 1): array ->setParameters( ['name' => "%{$name}%", 'title' => "%{$name}%", 'visibility' => VisibilityInterface::VISIBILITY_VISIBLE] ) - ->setMaxResults($limit) - ->getQuery() + ->setMaxResults($limit); + + if (null !== $user) { + $qb->andWhere($qb->expr()->not($qb->expr()->exists($this->sqlHelpers->getBlockedMagazinesDql($user)))) + ->andWhere($qb->expr()->not($qb->expr()->exists($this->sqlHelpers->getBlockedUsersDql($user)))); + $qb->setParameter('user', $user); + } + + return $qb->getQuery() ->getResult(); } - public function findLast(int $limit): array + public function findLast(int $limit, ?User $user = null): array { $qb = $this->createQueryBuilder('e'); @@ -401,10 +417,16 @@ public function findLast(int $limit): array $qb = $qb->andWhere('m.apId IS NULL'); } + if (null !== $user) { + $qb->andWhere($qb->expr()->not($qb->expr()->exists($this->sqlHelpers->getBlockedMagazinesDql($user)))) + ->andWhere($qb->expr()->not($qb->expr()->exists($this->sqlHelpers->getBlockedUsersDql($user)))); + $qb->setParameter('user', $user); + } + return $qb->join('e.magazine', 'm') ->join('e.user', 'u') ->orderBy('e.createdAt', 'DESC') - ->setParameters(['visibility' => VisibilityInterface::VISIBILITY_VISIBLE]) + ->setParameter('visibility', VisibilityInterface::VISIBILITY_VISIBLE) ->setMaxResults($limit) ->getQuery() ->getResult(); diff --git a/src/Repository/MagazineRepository.php b/src/Repository/MagazineRepository.php index 0f42034f4..ebd92131e 100644 --- a/src/Repository/MagazineRepository.php +++ b/src/Repository/MagazineRepository.php @@ -16,6 +16,7 @@ use App\Entity\User; use App\PageView\MagazinePageView; use App\Service\SettingsManager; +use App\Utils\SqlHelpers; use App\Utils\SubscriptionSort; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Common\Collections\Collection; @@ -49,7 +50,7 @@ class MagazineRepository extends ServiceEntityRepository self::SORT_NEWEST, ]; - public function __construct(ManagerRegistry $registry, private readonly SettingsManager $settingsManager) + public function __construct(ManagerRegistry $registry, private readonly SettingsManager $settingsManager, private readonly SqlHelpers $sqlHelpers) { parent::__construct($registry, Magazine::class); } @@ -478,21 +479,23 @@ public function search(string $magazine, int $page, int $perPage = self::PER_PAG return $pagerfanta; } - public function findRandom(): array + public function findRandom(?User $user = null): array { $conn = $this->getEntityManager()->getConnection(); - $sql = ' - SELECT id FROM magazine - '; + $whereClauses = []; + $parameters = []; if ($this->settingsManager->get('MBIN_SIDEBAR_SECTIONS_LOCAL_ONLY')) { - $sql .= 'WHERE ap_id IS NULL'; + $whereClauses[] = 'm.ap_id IS NULL'; + } + if (null !== $user) { + $subSql = 'SELECT * FROM magazine_block mb WHERE mb.magazine_id = m.id AND mb.user_id = :user'; + $whereClauses[] = "NOT EXISTS($subSql)"; + $parameters['user'] = $user->getId(); } - $sql .= ' - ORDER BY random() - LIMIT 5 - '; + $whereString = SqlHelpers::makeWhereString($whereClauses); + $sql = "SELECT m.id FROM magazine m $whereString ORDER BY random() LIMIT 5"; $stmt = $conn->prepare($sql); - $stmt = $stmt->executeQuery(); + $stmt = $stmt->executeQuery($parameters); $ids = $stmt->fetchAllAssociative(); return $this->createQueryBuilder('m') @@ -505,17 +508,23 @@ public function findRandom(): array ->getResult(); } - public function findRelated(string $magazine): array + public function findRelated(string $magazine, ?User $user = null): array { - return $this->createQueryBuilder('m') + $qb = $this->createQueryBuilder('m') ->where('m.entryCount > 0 OR m.postCount > 0') ->andWhere('m.title LIKE :magazine OR m.description LIKE :magazine OR m.name LIKE :magazine') ->andWhere('m.isAdult = false') ->andWhere('m.visibility = :visibility') ->setParameter('visibility', VisibilityInterface::VISIBILITY_VISIBLE) ->setParameter('magazine', "%{$magazine}%") - ->setMaxResults(5) - ->getQuery() + ->setMaxResults(5); + + if (null !== $user) { + $qb->andWhere($qb->expr()->not($qb->expr()->exists($this->sqlHelpers->getBlockedMagazinesDql($user)))); + $qb->setParameter('user', $user); + } + + return $qb->getQuery() ->getResult(); } diff --git a/src/Repository/PostRepository.php b/src/Repository/PostRepository.php index 870b20490..380e7470d 100644 --- a/src/Repository/PostRepository.php +++ b/src/Repository/PostRepository.php @@ -23,6 +23,7 @@ use App\PageView\PostPageView; use App\Pagination\AdapterFactory; use App\Service\SettingsManager; +use App\Utils\SqlHelpers; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\DBAL\ArrayParameterType; use Doctrine\DBAL\Types\Types; @@ -54,6 +55,7 @@ public function __construct( private readonly CacheInterface $cache, private readonly AdapterFactory $adapterFactory, private readonly SettingsManager $settingsManager, + private readonly SqlHelpers $sqlHelpers, ) { parent::__construct($registry, Post::class); } @@ -143,6 +145,7 @@ private function addBannedHashtagClause(QueryBuilder $qb): void private function filter(QueryBuilder $qb, Criteria $criteria): QueryBuilder { + /** @var User|null $user */ $user = $this->security->getUser(); if (Criteria::AP_LOCAL === $criteria->federation) { @@ -168,8 +171,8 @@ private function filter(QueryBuilder $qb, Criteria $criteria): QueryBuilder if ($criteria->subscribed) { $qb->andWhere( - 'EXISTS (SELECT IDENTITY(ms.magazine) FROM '.MagazineSubscription::class.' ms WHERE ms.user = :user AND ms.magazine = p.magazine) - OR + 'EXISTS (SELECT IDENTITY(ms.magazine) FROM '.MagazineSubscription::class.' ms WHERE ms.user = :user AND ms.magazine = p.magazine) + OR EXISTS (SELECT IDENTITY(uf.following) FROM '.UserFollow::class.' uf WHERE uf.follower = :user AND uf.following = p.user) OR p.user = :user' @@ -307,11 +310,11 @@ public function findToDelete(User $user, int $limit): array ->getResult(); } - public function findRelatedByTag(string $tag, ?int $limit = 1): array + public function findRelatedByTag(string $tag, ?int $limit = 1, ?User $user = null): array { $qb = $this->createQueryBuilder('p'); - return $qb + $qb = $qb ->andWhere('p.visibility = :visibility') ->andWhere('m.visibility = :visibility') ->andWhere('u.visibility = :visibility') @@ -328,16 +331,23 @@ public function findRelatedByTag(string $tag, ?int $limit = 1): array 'visibility' => VisibilityInterface::VISIBILITY_VISIBLE, 'name' => $tag, ]) - ->setMaxResults($limit) - ->getQuery() + ->setMaxResults($limit); + + if (null !== $user) { + $qb->andWhere($qb->expr()->not($qb->expr()->exists($this->sqlHelpers->getBlockedMagazinesDql($user)))) + ->andWhere($qb->expr()->not($qb->expr()->exists($this->sqlHelpers->getBlockedUsersDql($user)))); + $qb->setParameter('user', $user); + } + + return $qb->getQuery() ->getResult(); } - public function findRelatedByMagazine(string $name, ?int $limit = 1): array + public function findRelatedByMagazine(string $name, ?int $limit = 1, ?User $user = null): array { $qb = $this->createQueryBuilder('p'); - return $qb->where('m.name LIKE :name OR m.title LIKE :title') + $qb = $qb->where('m.name LIKE :name OR m.title LIKE :title') ->andWhere('p.visibility = :visibility') ->andWhere('m.visibility = :visibility') ->andWhere('u.visibility = :visibility') @@ -349,12 +359,19 @@ public function findRelatedByMagazine(string $name, ?int $limit = 1): array ->setParameters( ['name' => "%{$name}%", 'title' => "%{$name}%", 'visibility' => VisibilityInterface::VISIBILITY_VISIBLE] ) - ->setMaxResults($limit) - ->getQuery() + ->setMaxResults($limit); + + if (null !== $user) { + $qb->andWhere($qb->expr()->not($qb->expr()->exists($this->sqlHelpers->getBlockedMagazinesDql($user)))) + ->andWhere($qb->expr()->not($qb->expr()->exists($this->sqlHelpers->getBlockedUsersDql($user)))); + $qb->setParameter('user', $user); + } + + return $qb->getQuery() ->getResult(); } - public function findLast(int $limit = 1): array + public function findLast(int $limit = 1, ?User $user = null): array { $qb = $this->createQueryBuilder('p'); @@ -365,9 +382,16 @@ public function findLast(int $limit = 1): array $qb = $qb->andWhere('m.apId IS NULL'); } + if (null !== $user) { + $qb->andWhere($qb->expr()->not($qb->expr()->exists($this->sqlHelpers->getBlockedMagazinesDql($user)))) + ->andWhere($qb->expr()->not($qb->expr()->exists($this->sqlHelpers->getBlockedUsersDql($user)))); + $qb->setParameter('user', $user); + } + return $qb->join('p.magazine', 'm') + ->join('p.user', 'u') ->orderBy('p.createdAt', 'DESC') - ->setParameters(['visibility' => VisibilityInterface::VISIBILITY_VISIBLE]) + ->setParameter('visibility', VisibilityInterface::VISIBILITY_VISIBLE) ->setMaxResults($limit) ->getQuery() ->getResult(); diff --git a/src/Twig/Components/RelatedEntriesComponent.php b/src/Twig/Components/RelatedEntriesComponent.php index febe94156..4615006c3 100644 --- a/src/Twig/Components/RelatedEntriesComponent.php +++ b/src/Twig/Components/RelatedEntriesComponent.php @@ -5,9 +5,11 @@ namespace App\Twig\Components; use App\Entity\Entry; +use App\Entity\User; use App\Repository\EntryRepository; use App\Service\MentionManager; use App\Service\SettingsManager; +use Symfony\Bundle\SecurityBundle\Security; use Symfony\Contracts\Cache\CacheInterface; use Symfony\Contracts\Cache\ItemInterface; use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; @@ -31,7 +33,8 @@ public function __construct( private readonly EntryRepository $repository, private readonly CacheInterface $cache, private readonly SettingsManager $settingsManager, - private readonly MentionManager $mentionManager + private readonly MentionManager $mentionManager, + private readonly Security $security, ) { } @@ -49,19 +52,23 @@ public function mount(?string $magazine, ?string $tag): void $entryId = $this->entry?->getId(); $magazine = str_replace('@', '', $magazine ?? ''); + /** @var User|null $user */ + $user = $this->security->getUser(); + $cacheKey = "related_entries_{$magazine}_{$tag}_{$entryId}_{$this->type}_{$this->settingsManager->getLocale()}_{$user?->getId()}"; $entryIds = $this->cache->get( - "related_entries_{$magazine}_{$tag}_{$entryId}_{$this->type}_{$this->settingsManager->getLocale()}", - function (ItemInterface $item) use ($magazine, $tag) { + $cacheKey, + function (ItemInterface $item) use ($magazine, $tag, $user) { $item->expiresAfter(60 * 5); // 5 minutes $entries = match ($this->type) { - self::TYPE_TAG => $this->repository->findRelatedByMagazine($tag, $this->limit + 20), + self::TYPE_TAG => $this->repository->findRelatedByMagazine($tag, $this->limit + 20, user: $user), self::TYPE_MAGAZINE => $this->repository->findRelatedByTag( $this->mentionManager->getUsername($magazine), - $this->limit + 20 + $this->limit + 20, + user: $user, ), - default => $this->repository->findLast($this->limit + 150), + default => $this->repository->findLast($this->limit + 150, user: $user), }; $entries = array_filter($entries, fn (Entry $e) => !$e->isAdult && !$e->magazine->isAdult); diff --git a/src/Twig/Components/RelatedMagazinesComponent.php b/src/Twig/Components/RelatedMagazinesComponent.php index 741dea67f..0871cda57 100644 --- a/src/Twig/Components/RelatedMagazinesComponent.php +++ b/src/Twig/Components/RelatedMagazinesComponent.php @@ -5,8 +5,10 @@ namespace App\Twig\Components; use App\Entity\Magazine; +use App\Entity\User; use App\Repository\MagazineRepository; use App\Service\SettingsManager; +use Symfony\Bundle\SecurityBundle\Security; use Symfony\Contracts\Cache\CacheInterface; use Symfony\Contracts\Cache\ItemInterface; use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; @@ -28,6 +30,7 @@ public function __construct( private readonly MagazineRepository $repository, private readonly CacheInterface $cache, private readonly SettingsManager $settingsManager, + private readonly Security $security, ) { } @@ -44,16 +47,18 @@ public function mount(?string $magazine, ?string $tag): void } $magazine = str_replace('@', '', $magazine ?? ''); + /** @var User|null $user */ + $user = $this->security->getUser(); $magazineIds = $this->cache->get( - "related_magazines_{$magazine}_{$tag}_{$this->type}_{$this->settingsManager->getLocale()}", - function (ItemInterface $item) use ($magazine, $tag) { + "related_magazines_{$magazine}_{$tag}_{$this->type}_{$this->settingsManager->getLocale()}_{$user?->getId()}", + function (ItemInterface $item) use ($magazine, $tag, $user) { $item->expiresAfter(60 * 5); // 5 minutes $magazines = match ($this->type) { - self::TYPE_TAG => $this->repository->findRelated($tag), - self::TYPE_MAGAZINE => $this->repository->findRelated($magazine), - default => $this->repository->findRandom(), + self::TYPE_TAG => $this->repository->findRelated($tag, user: $user), + self::TYPE_MAGAZINE => $this->repository->findRelated($magazine, user: $user), + default => $this->repository->findRandom(user: $user), }; $magazines = array_filter($magazines, fn ($m) => $m->name !== $magazine); diff --git a/src/Twig/Components/RelatedPostsComponent.php b/src/Twig/Components/RelatedPostsComponent.php index bfc1d944d..a5a033950 100644 --- a/src/Twig/Components/RelatedPostsComponent.php +++ b/src/Twig/Components/RelatedPostsComponent.php @@ -5,9 +5,11 @@ namespace App\Twig\Components; use App\Entity\Post; +use App\Entity\User; use App\Repository\PostRepository; use App\Service\MentionManager; use App\Service\SettingsManager; +use Symfony\Bundle\SecurityBundle\Security; use Symfony\Contracts\Cache\CacheInterface; use Symfony\Contracts\Cache\ItemInterface; use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; @@ -30,7 +32,8 @@ public function __construct( private readonly PostRepository $repository, private readonly CacheInterface $cache, private readonly SettingsManager $settingsManager, - private readonly MentionManager $mentionManager + private readonly MentionManager $mentionManager, + private readonly Security $security, ) { } @@ -46,21 +49,25 @@ public function mount(?string $magazine, ?string $tag): void $this->type = self::TYPE_MAGAZINE; } + /** @var User|null $user */ + $user = $this->security->getUser(); + $postId = $this->post?->getId(); $magazine = str_replace('@', '', $magazine ?? ''); $postIds = $this->cache->get( - "related_posts_{$magazine}_{$tag}_{$postId}_{$this->type}_{$this->settingsManager->getLocale()}", - function (ItemInterface $item) use ($magazine, $tag) { + "related_posts_{$magazine}_{$tag}_{$postId}_{$this->type}_{$this->settingsManager->getLocale()}_{$user?->getId()}", + function (ItemInterface $item) use ($magazine, $tag, $user) { $item->expiresAfter(60 * 5); // 5 minutes $posts = match ($this->type) { - self::TYPE_TAG => $this->repository->findRelatedByMagazine($tag, $this->limit + 20), + self::TYPE_TAG => $this->repository->findRelatedByMagazine($tag, $this->limit + 20, user: $user), self::TYPE_MAGAZINE => $this->repository->findRelatedByTag( $this->mentionManager->getUsername($magazine), - $this->limit + 20 + $this->limit + 20, + user: $user ), - default => $this->repository->findLast($this->limit + 150), + default => $this->repository->findLast($this->limit + 150, user: $user), }; $posts = array_filter($posts, fn (Post $p) => !$p->isAdult && !$p->magazine->isAdult); diff --git a/src/Utils/SqlHelpers.php b/src/Utils/SqlHelpers.php new file mode 100644 index 000000000..39e9de186 --- /dev/null +++ b/src/Utils/SqlHelpers.php @@ -0,0 +1,59 @@ + 0) { + $where .= ' AND '; + } + $where .= $whereClause; + ++$i; + } + + return $where; + } + + public function getBlockedMagazinesDql(User $user): string + { + return $this->entityManager->createQueryBuilder() + ->select('bm') + ->from(MagazineBlock::class, 'bm') + ->where('bm.magazine = m') + ->andWhere('bm.user = :user') + ->setParameter('user', $user) + ->getDQL(); + } + + public function getBlockedUsersDql(User $user): string + { + return $this->entityManager->createQueryBuilder() + ->select('ub') + ->from(UserBlock::class, 'ub') + ->where('ub.blocker = :user') + ->andWhere('ub.blocked = u') + ->setParameter('user', $user) + ->getDql(); + } +} From 5fec24b43262db15bcd148b2463f6eaf08a126d4 Mon Sep 17 00:00:00 2001 From: BentiGorlich Date: Wed, 16 Oct 2024 13:11:40 +0200 Subject: [PATCH 18/19] Improve search (#1167) --- assets/styles/app.scss | 1 + assets/styles/components/_search.scss | 24 +++++ assets/styles/layout/_forms.scss | 6 ++ assets/styles/layout/_layout.scss | 10 ++- assets/styles/layout/_section.scss | 8 -- .../Api/Search/SearchRetrieveApi.php | 31 ++++++- src/Controller/Api/User/UserRetrieveApi.php | 20 ++++- src/Controller/SearchController.php | 89 +++++++++++-------- src/DTO/SearchDto.php | 8 +- src/Form/SearchType.php | 36 ++++++++ src/Form/Type/UserAutocompleteType.php | 59 ++++++++++++ src/Repository/SearchRepository.php | 50 +++++++++-- src/Repository/UserRepository.php | 25 +++--- src/Service/SearchManager.php | 4 +- templates/magazine/list_all.html.twig | 6 +- templates/search/form.html.twig | 19 ++++ templates/search/front.html.twig | 14 +-- translations/messages.en.yaml | 4 + 18 files changed, 329 insertions(+), 85 deletions(-) create mode 100644 assets/styles/components/_search.scss create mode 100644 src/Form/SearchType.php create mode 100644 src/Form/Type/UserAutocompleteType.php create mode 100644 templates/search/form.html.twig diff --git a/assets/styles/app.scss b/assets/styles/app.scss index 57fb23d5d..e9f9b0dc9 100644 --- a/assets/styles/app.scss +++ b/assets/styles/app.scss @@ -37,6 +37,7 @@ @import 'components/figure_image'; @import 'components/figure_lightbox'; @import 'components/post'; +@import 'components/search'; @import 'components/subject'; @import 'components/login'; @import 'components/modlog'; diff --git a/assets/styles/components/_search.scss b/assets/styles/components/_search.scss new file mode 100644 index 000000000..d179dd372 --- /dev/null +++ b/assets/styles/components/_search.scss @@ -0,0 +1,24 @@ +.search-container { + background: var(--kbin-input-bg); + border: var(--kbin-input-border); + border-radius: var(--kbin-rounded-edges-radius) !important; + + input.form-control { + border-radius: 0 !important; + border: none; + background: transparent; + margin: 0 .5em; + padding: .5rem .25rem; + } + + button { + border-radius: 0 var(--kbin-rounded-edges-radius) var(--kbin-rounded-edges-radius) 0 !important; + border: 0; + padding: 1rem 0.5rem; + + &:not(:hover) { + background: var(--kbin-input-bg); + color: var(--kbin-input-text-color) !important; + } + } +} diff --git a/assets/styles/layout/_forms.scss b/assets/styles/layout/_forms.scss index 8d42c269d..e0e606d7a 100644 --- a/assets/styles/layout/_forms.scss +++ b/assets/styles/layout/_forms.scss @@ -525,3 +525,9 @@ div.input-box { border-radius: var(--kbin-rounded-edges-radius) !important; } } + +.form-control { + display: block; + width: 100%; + +} diff --git a/assets/styles/layout/_layout.scss b/assets/styles/layout/_layout.scss index 789c8f20c..c7b387c12 100644 --- a/assets/styles/layout/_layout.scss +++ b/assets/styles/layout/_layout.scss @@ -214,7 +214,9 @@ figure { code, .ts-control > [data-value].item, .image-preview-container { - border-radius: var(--kbin-rounded-edges-radius) !important; + &:not(.ignore-edges) { + border-radius: var(--kbin-rounded-edges-radius) !important; + } } .ts-wrapper { @@ -361,6 +363,12 @@ figure { gap: .25rem; } +@include media-breakpoint-down(lg) { + .flex.mobile { + display: block; + } +} + .flex-wrap { flex-wrap: wrap; } diff --git a/assets/styles/layout/_section.scss b/assets/styles/layout/_section.scss index 7f44e8166..47e75a8eb 100644 --- a/assets/styles/layout/_section.scss +++ b/assets/styles/layout/_section.scss @@ -68,11 +68,3 @@ color: var(--kbin-alert-danger-text-color); } } - -.page-search { - .section--top { - button { - padding: 1rem 1.5rem; - } - } -} \ No newline at end of file diff --git a/src/Controller/Api/Search/SearchRetrieveApi.php b/src/Controller/Api/Search/SearchRetrieveApi.php index c92b4a0ec..f2499fcad 100644 --- a/src/Controller/Api/Search/SearchRetrieveApi.php +++ b/src/Controller/Api/Search/SearchRetrieveApi.php @@ -103,6 +103,27 @@ class SearchRetrieveApi extends BaseApi required: true, schema: new OA\Schema(type: 'string') )] + #[OA\Parameter( + name: 'authorId', + description: 'User id of the author', + in: 'query', + required: false, + schema: new OA\Schema(type: 'integer') + )] + #[OA\Parameter( + name: 'magazineId', + description: 'Id of the magazine', + in: 'query', + required: false, + schema: new OA\Schema(type: 'integer') + )] + #[OA\Parameter( + name: 'type', + description: 'The type of content', + in: 'query', + required: false, + schema: new OA\Schema(type: 'string', enum: ['', 'entry', 'post']) + )] #[OA\Tag(name: 'search')] public function __invoke( SearchManager $manager, @@ -122,8 +143,16 @@ public function __invoke( $page = $this->getPageNb($request); $perPage = self::constrainPerPage($request->get('perPage', SearchRepository::PER_PAGE)); + $authorIdRaw = $request->get('authorId'); + $authorId = null === $authorIdRaw ? null : \intval($authorIdRaw); + $magazineIdRaw = $request->get('magazineId'); + $magazineId = null === $magazineIdRaw ? null : \intval($magazineIdRaw); + $type = $request->get('type'); + if ('entry' !== $type && 'post' !== $type && null !== $type) { + throw new BadRequestHttpException(); + } - $items = $manager->findPaginated($this->getUser(), $q, $page, $perPage); + $items = $manager->findPaginated($this->getUser(), $q, $page, $perPage, authorId: $authorId, magazineId: $magazineId, specificType: $type); $dtos = []; foreach ($items->getCurrentPageResults() as $value) { \assert($value instanceof ContentInterface); diff --git a/src/Controller/Api/User/UserRetrieveApi.php b/src/Controller/Api/User/UserRetrieveApi.php index 97ce14fef..ab95009af 100644 --- a/src/Controller/Api/User/UserRetrieveApi.php +++ b/src/Controller/Api/User/UserRetrieveApi.php @@ -275,6 +275,18 @@ public function settings( in: 'query', schema: new OA\Schema(type: 'string', default: UserRepository::USERS_ALL, enum: UserRepository::USERS_OPTIONS) )] + #[OA\Parameter( + name: 'q', + description: 'The term to search for', + in: 'query', + schema: new OA\Schema(type: 'string') + )] + #[OA\Parameter( + name: 'withAbout', + description: 'Only include users with a filled in profile', + in: 'query', + schema: new OA\Schema(type: 'boolean') + )] #[OA\Tag(name: 'user')] public function collection( UserRepository $userRepository, @@ -286,11 +298,15 @@ public function collection( $request = $this->request->getCurrentRequest(); $group = $request->get('group', UserRepository::USERS_ALL); + $withAboutRaw = $request->get('withAbout'); + $withAbout = null === $withAboutRaw ? false : \boolval($withAboutRaw); - $users = $userRepository->findWithAboutPaginated( + $users = $userRepository->findPaginated( $this->getPageNb($request), + $withAbout, $group, - $this->constrainPerPage($request->get('perPage', UserRepository::PER_PAGE)) + $this->constrainPerPage($request->get('perPage', UserRepository::PER_PAGE)), + $request->get('q'), ); $dtos = []; diff --git a/src/Controller/SearchController.php b/src/Controller/SearchController.php index c7451b83e..1bbe8ea38 100644 --- a/src/Controller/SearchController.php +++ b/src/Controller/SearchController.php @@ -5,8 +5,10 @@ namespace App\Controller; use App\ActivityPub\ActorHandle; +use App\DTO\SearchDto; use App\Entity\Magazine; use App\Entity\User; +use App\Form\SearchType; use App\Message\ActivityPub\Inbox\ActivityMessage; use App\Service\ActivityPub\ApHttpClient; use App\Service\ActivityPubManager; @@ -33,52 +35,63 @@ public function __construct( public function __invoke(Request $request): Response { - $query = $request->query->get('q') ? trim($request->query->get('q')) : null; - - if (!$query) { - return $this->render( - 'search/front.html.twig', - [ - 'objects' => [], - 'results' => [], - 'q' => '', - ] - ); - } - - $this->logger->debug('searching for {query}', ['query' => $query]); - - $objects = []; + $dto = new SearchDto(); + $form = $this->createForm(SearchType::class, $dto, ['csrf_protection' => false]); + try { + $form = $form->handleRequest($request); + if ($form->isSubmitted() && $form->isValid()) { + /** @var SearchDto $dto */ + $dto = $form->getData(); + $query = $dto->q; + $this->logger->debug('searching for {query}', ['query' => $query]); + + $objects = []; + + // looking up handles (users and mags) + if (str_contains($query, '@') && $this->federatedSearchAllowed()) { + if ($handle = ActorHandle::parse($query)) { + $this->logger->debug('searching for a matched webfinger {query}', ['query' => $query]); + $objects = array_merge($objects, $this->lookupHandle($handle)); + } else { + $this->logger->debug("query doesn't look like a valid handle...", ['query' => $query]); + } + } - // looking up handles (users and mags) - if (str_contains($query, '@') && $this->federatedSearchAllowed()) { - if ($handle = ActorHandle::parse($query)) { - $this->logger->debug('searching for a matched webfinger {query}', ['query' => $query]); - $objects = array_merge($objects, $this->lookupHandle($handle)); - } else { - $this->logger->debug("query doesn't look like a valid handle...", ['query' => $query]); - } - } + // looking up object by AP id (i.e. urls) + if (false !== filter_var($query, FILTER_VALIDATE_URL)) { + $objects = $this->manager->findByApId($query); + if (!$objects) { + $body = $this->apHttpClient->getActivityObject($query, false); + $this->bus->dispatch(new ActivityMessage($body)); + } + } - // looking up object by AP id (i.e. urls) - if (false !== filter_var($query, FILTER_VALIDATE_URL)) { - $objects = $this->manager->findByApId($query); - if (!$objects) { - $body = $this->apHttpClient->getActivityObject($query, false); - $this->bus->dispatch(new ActivityMessage($body)); + $user = $this->getUser(); + $res = $this->manager->findPaginated($user, $query, $this->getPageNb($request), authorId: $dto->user?->getId(), magazineId: $dto->magazine?->getId(), specificType: $dto->type); + + $this->logger->debug('results: {num}', ['num' => $res->count()]); + + return $this->render( + 'search/front.html.twig', + [ + 'objects' => $objects, + 'results' => $this->overviewManager->buildList($res), + 'pagination' => $res, + 'form' => $form->createView(), + 'q' => $query, + ] + ); } + } catch (\Exception $e) { + $this->logger->error($e); } - $user = $this->getUser(); - $res = $this->manager->findPaginated($user, $query, $this->getPageNb($request)); - return $this->render( 'search/front.html.twig', [ - 'objects' => $objects, - 'results' => $this->overviewManager->buildList($res), - 'pagination' => $res, - 'q' => $request->query->get('q'), + 'objects' => [], + 'results' => [], + 'form' => $form->createView(), ] ); } diff --git a/src/DTO/SearchDto.php b/src/DTO/SearchDto.php index c9070f0df..de26533c6 100644 --- a/src/DTO/SearchDto.php +++ b/src/DTO/SearchDto.php @@ -4,7 +4,13 @@ namespace App\DTO; +use App\Entity\Magazine; +use App\Entity\User; + class SearchDto { - public string $val; + public string $q; + public ?string $type = null; + public ?User $user = null; + public ?Magazine $magazine = null; } diff --git a/src/Form/SearchType.php b/src/Form/SearchType.php new file mode 100644 index 000000000..5d7410e50 --- /dev/null +++ b/src/Form/SearchType.php @@ -0,0 +1,36 @@ +setMethod('GET') + ->add('q', TextType::class, [ + 'required' => true, + 'attr' => [ + 'placeholder' => 'type_search_term', + ], + ]) + ->add('magazine', MagazineAutocompleteType::class, ['required' => false]) + ->add('user', UserAutocompleteType::class, ['required' => false]) + ->add('type', ChoiceType::class, [ + 'choices' => [ + 'search_type_all' => null, + 'search_type_entry' => 'entry', + 'search_type_post' => 'post', + ], + ]); + } +} diff --git a/src/Form/Type/UserAutocompleteType.php b/src/Form/Type/UserAutocompleteType.php new file mode 100644 index 000000000..d1cc01909 --- /dev/null +++ b/src/Form/Type/UserAutocompleteType.php @@ -0,0 +1,59 @@ +setDefaults([ + 'class' => User::class, + 'choice_label' => 'username', + 'placeholder' => 'select_user', + 'filter_query' => function (QueryBuilder $qb, string $query) { + if ($currentUser = $this->security->getUser()) { + $qb + ->andWhere( + \sprintf( + 'entity.id NOT IN (SELECT IDENTITY(ub.blocked) FROM %s ub WHERE ub.blocker = :user)', + UserBlock::class, + ) + ) + ->setParameter('user', $currentUser); + } + + if (!$query) { + return; + } + + $qb->andWhere('entity.username LIKE :filter') + ->andWhere('entity.visibility = :visibility') + ->setParameter('filter', '%'.$query.'%') + ->setParameter('visibility', VisibilityInterface::VISIBILITY_VISIBLE) + ; + }, + ]); + } + + public function getParent(): string + { + return BaseEntityAutocompleteType::class; + } +} diff --git a/src/Repository/SearchRepository.php b/src/Repository/SearchRepository.php index a5d89e5eb..84a412133 100644 --- a/src/Repository/SearchRepository.php +++ b/src/Repository/SearchRepository.php @@ -13,6 +13,7 @@ use Doctrine\ORM\EntityManagerInterface; use Pagerfanta\Pagerfanta; use Pagerfanta\PagerfantaInterface; +use Psr\Log\LoggerInterface; class SearchRepository { @@ -21,6 +22,7 @@ class SearchRepository public function __construct( private readonly EntityManagerInterface $entityManager, private readonly ContentPopulationTransformer $transformer, + private readonly LoggerInterface $logger, ) { } @@ -79,10 +81,15 @@ public function findBoosts(int $page, User $user): PagerfantaInterface return $pagerfanta; } - public function search(?User $searchingUser, string $query, int $page = 1): PagerfantaInterface + /** + * @param 'entry'|'post'|null $specificType + */ + public function search(?User $searchingUser, string $query, int $page = 1, ?int $authorId = null, ?int $magazineId = null, ?string $specificType = null): PagerfantaInterface { + $authorWhere = null !== $authorId ? 'AND e.user_id = :authorId' : ''; + $magazineWhere = null !== $magazineId ? 'AND e.magazine_id = :magazineId' : ''; $conn = $this->entityManager->getConnection(); - $sql = "SELECT e.id, e.created_at, e.visibility, 'entry' AS type FROM entry e + $sqlEntry = "SELECT e.id, e.created_at, e.visibility, 'entry' AS type FROM entry e INNER JOIN public.user u ON u.id = user_id INNER JOIN magazine m ON e.magazine_id = m.id WHERE (body_ts @@ plainto_tsquery( :query ) = true OR title_ts @@ plainto_tsquery( :query ) = true) @@ -91,6 +98,7 @@ public function search(?User $searchingUser, string $query, int $page = 1): Page AND NOT EXISTS (SELECT id FROM user_block ub WHERE ub.blocked_id = u.id AND ub.blocker_id = :queryingUser) AND NOT EXISTS (SELECT id FROM magazine_block mb WHERE mb.magazine_id = m.id AND mb.user_id = :queryingUser) AND NOT EXISTS (SELECT hl.id FROM hashtag_link hl INNER JOIN hashtag h ON h.id = hl.hashtag_id AND h.banned = true WHERE hl.entry_id = e.id) + $authorWhere $magazineWhere UNION ALL SELECT e.id, e.created_at, e.visibility, 'entry_comment' AS type FROM entry_comment e INNER JOIN public.user u ON u.id = user_id @@ -101,8 +109,9 @@ public function search(?User $searchingUser, string $query, int $page = 1): Page AND NOT EXISTS (SELECT id FROM user_block ub WHERE ub.blocked_id = u.id AND ub.blocker_id = :queryingUser) AND NOT EXISTS (SELECT id FROM magazine_block mb WHERE mb.magazine_id = m.id AND mb.user_id = :queryingUser) AND NOT EXISTS (SELECT hl.id FROM hashtag_link hl INNER JOIN hashtag h ON h.id = hl.hashtag_id AND h.banned = true WHERE hl.entry_comment_id = e.id) - UNION ALL - SELECT e.id, e.created_at, e.visibility, 'post' AS type FROM post e + $authorWhere $magazineWhere + "; + $sqlPost = "SELECT e.id, e.created_at, e.visibility, 'post' AS type FROM post e INNER JOIN public.user u ON u.id = user_id INNER JOIN magazine m ON e.magazine_id = m.id WHERE body_ts @@ plainto_tsquery( :query ) = true @@ -111,6 +120,7 @@ public function search(?User $searchingUser, string $query, int $page = 1): Page AND NOT EXISTS (SELECT id FROM user_block ub WHERE ub.blocked_id = u.id AND ub.blocker_id = :queryingUser) AND NOT EXISTS (SELECT id FROM magazine_block mb WHERE mb.magazine_id = m.id AND mb.user_id = :queryingUser) AND NOT EXISTS (SELECT hl.id FROM hashtag_link hl INNER JOIN hashtag h ON h.id = hl.hashtag_id AND h.banned = true WHERE hl.post_id = e.id) + $authorWhere $magazineWhere UNION ALL SELECT e.id, e.created_at, e.visibility, 'post_comment' AS type FROM post_comment e INNER JOIN public.user u ON u.id = user_id @@ -121,12 +131,38 @@ public function search(?User $searchingUser, string $query, int $page = 1): Page AND NOT EXISTS (SELECT id FROM user_block ub WHERE ub.blocked_id = u.id AND ub.blocker_id = :queryingUser) AND NOT EXISTS (SELECT id FROM magazine_block mb WHERE mb.magazine_id = m.id AND mb.user_id = :queryingUser) AND NOT EXISTS (SELECT hl.id FROM hashtag_link hl INNER JOIN hashtag h ON h.id = hl.hashtag_id AND h.banned = true WHERE hl.post_comment_id = e.id) - ORDER BY created_at DESC"; - $adapter = new NativeQueryAdapter($conn, $sql, [ + $authorWhere $magazineWhere + "; + + if (null === $specificType) { + $sql = "$sqlEntry UNION ALL $sqlPost ORDER BY created_at DESC"; + } else { + if ('entry' === $specificType) { + $sql = "$sqlEntry ORDER BY created_at DESC"; + } elseif ('post' === $specificType) { + $sql = "$sqlPost ORDER BY created_at DESC"; + } else { + throw new \LogicException($specificType.' is not supported'); + } + } + + $this->logger->debug('Search query: {sql}', ['sql' => $sql]); + + $parameters = [ 'query' => $query, 'visibility' => VisibilityInterface::VISIBILITY_VISIBLE, 'queryingUser' => $searchingUser?->getId() ?? -1, - ], transformer: $this->transformer); + ]; + + if (null !== $authorId) { + $parameters['authorId'] = $authorId; + } + + if (null !== $magazineId) { + $parameters['magazineId'] = $magazineId; + } + + $adapter = new NativeQueryAdapter($conn, $sql, $parameters, transformer: $this->transformer); $pagerfanta = new Pagerfanta($adapter); $pagerfanta->setCurrentPage($page); diff --git a/src/Repository/UserRepository.php b/src/Repository/UserRepository.php index 2c7c91dda..2b714b151 100644 --- a/src/Repository/UserRepository.php +++ b/src/Repository/UserRepository.php @@ -477,12 +477,9 @@ private function findUsersQueryBuilder(string $group, ?bool $recentlyActive = tr ->orderBy('u.lastActive', 'DESC'); } - public function findWithAboutPaginated( - int $page, - string $group = self::USERS_ALL, - int $perPage = self::PER_PAGE - ): PagerfantaInterface { - $query = $this->findWithAboutQueryBuilder($group)->getQuery(); + public function findPaginated(int $page, bool $needsAbout, string $group = self::USERS_ALL, int $perPage = self::PER_PAGE, ?string $query = null): PagerfantaInterface + { + $query = $this->findQueryBuilder($group, $query, $needsAbout)->getQuery(); $pagerfanta = new Pagerfanta( new QueryAdapter( @@ -500,11 +497,19 @@ public function findWithAboutPaginated( return $pagerfanta; } - private function findWithAboutQueryBuilder(string $group): QueryBuilder + private function findQueryBuilder(string $group, ?string $query, bool $needsAbout): QueryBuilder { - $qb = $this->createQueryBuilder('u') - ->andWhere('u.about != \'\'') - ->andWhere('u.about IS NOT NULL'); + $qb = $this->createQueryBuilder('u'); + + if ($needsAbout) { + $qb->andWhere('u.about != \'\'') + ->andWhere('u.about IS NOT NULL'); + } + + if (null !== $query) { + $qb->andWhere('u.username LIKE :query') + ->setParameter('query', '%'.$query.'%'); + } switch ($group) { case self::USERS_LOCAL: diff --git a/src/Service/SearchManager.php b/src/Service/SearchManager.php index 8b62a943f..fbdae9916 100644 --- a/src/Service/SearchManager.php +++ b/src/Service/SearchManager.php @@ -47,9 +47,9 @@ public function findDomainsPaginated(string $domain, int $page = 1, int $perPage return $this->domainRepository->search($domain, $page, $perPage); } - public function findPaginated(?User $queryingUser, string $val, int $page = 1, int $perPage = SearchRepository::PER_PAGE): PagerfantaInterface + public function findPaginated(?User $queryingUser, string $val, int $page = 1, int $perPage = SearchRepository::PER_PAGE, ?int $authorId = null, ?int $magazineId = null, ?string $specificType = null): PagerfantaInterface { - return $this->repository->search($queryingUser, $val, $page, $perPage); + return $this->repository->search($queryingUser, $val, $page, authorId: $authorId, magazineId: $magazineId, specificType: $specificType); } public function findByApId(string $url): array diff --git a/templates/magazine/list_all.html.twig b/templates/magazine/list_all.html.twig index 5a9f45cbe..452c2349e 100644 --- a/templates/magazine/list_all.html.twig +++ b/templates/magazine/list_all.html.twig @@ -21,9 +21,9 @@
    {{ form_start(form) }} -
    - {{ form_widget(form.query) }} -
    diff --git a/templates/search/form.html.twig b/templates/search/form.html.twig new file mode 100644 index 000000000..fcec8301b --- /dev/null +++ b/templates/search/form.html.twig @@ -0,0 +1,19 @@ +{{ form_start(form, {'attr': {'class': 'search-form'}}) }} + +
    + {{ form_widget(form.q, {label: false, 'attr': {'class': 'form-control'}}) }} + + +
    + +
    + {{ form_widget(form.magazine, {label: false, 'attr': {'class': 'form-control'}}) }} + {{ form_widget(form.user, {label: false, 'attr': {'class': 'form-control'}}) }} +
    + {{ form_widget(form.type, {label: false, 'attr': {'class': 'form-control', 'style': 'padding: 1rem .5rem;'}}) }} +
    +
    + +{{ form_end(form) }} diff --git a/templates/search/front.html.twig b/templates/search/front.html.twig index feac7ebf2..0b72851d0 100644 --- a/templates/search/front.html.twig +++ b/templates/search/front.html.twig @@ -15,17 +15,7 @@ {% block body %}

    {{ 'search'|trans }}

    -
    -
    -
    - - -
    -
    -
    + {% include 'search/form.html.twig' %}