diff --git a/assets/styles/app.scss b/assets/styles/app.scss index 2ff55834c2..8d7a8a4a6f 100644 --- a/assets/styles/app.scss +++ b/assets/styles/app.scss @@ -62,5 +62,6 @@ @use 'themes/solarized'; @use 'themes/tokyo-night'; @use 'components/tag'; +@use 'widgets/boosted_by'; @import 'glightbox/dist/css/glightbox.min.css'; diff --git a/assets/styles/widgets/boosted_by.scss b/assets/styles/widgets/boosted_by.scss new file mode 100644 index 0000000000..1c4730cd64 --- /dev/null +++ b/assets/styles/widgets/boosted_by.scss @@ -0,0 +1,5 @@ +.boosted-by { + font-size: 0.8rem; + max-height: 2lh; + overflow: hidden; +} diff --git a/src/Controller/Api/Combined/CombinedRetrieveApi.php b/src/Controller/Api/Combined/CombinedRetrieveApi.php index 67a004d296..f7755c42b6 100644 --- a/src/Controller/Api/Combined/CombinedRetrieveApi.php +++ b/src/Controller/Api/Combined/CombinedRetrieveApi.php @@ -6,6 +6,7 @@ use App\Controller\Api\BaseApi; use App\Controller\Traits\PrivateContentTrait; +use App\DTO\ContentBoostResponseDto; use App\DTO\ContentResponseDto; use App\Entity\Entry; use App\Entity\EntryComment; @@ -811,19 +812,7 @@ private function serializeContent(PagerfantaInterface $content, array $headers): { $result = []; foreach ($content as $item) { - if ($item instanceof Entry) { - $this->handlePrivateContent($item); - $result[] = new ContentResponseDto(entry: $this->serializeEntry($this->entryFactory->createDto($item), $this->tagLinkRepository->getTagsOfContent($item))); - } elseif ($item instanceof Post) { - $this->handlePrivateContent($item); - $result[] = new ContentResponseDto(post: $this->serializePost($this->postFactory->createDto($item), $this->tagLinkRepository->getTagsOfContent($item))); - } elseif ($item instanceof EntryComment) { - $this->handlePrivateContent($item); - $result[] = new ContentResponseDto(entryComment: $this->serializeEntryComment($this->entryCommentFactory->createDto($item), $this->tagLinkRepository->getTagsOfContent($item))); - } elseif ($item instanceof PostComment) { - $this->handlePrivateContent($item); - $result[] = new ContentResponseDto(postComment: $this->serializePostComment($this->postCommentFactory->createDto($item), $this->tagLinkRepository->getTagsOfContent($item))); - } + $result[] = $this->createContentResponse($item); } return new JsonResponse($this->serializePaginated($result, $content), headers: $headers); @@ -833,18 +822,46 @@ private function serializeContentCursored(CursorPaginationInterface $content, ar { $result = []; foreach ($content as $item) { - if ($item instanceof Entry) { - $this->handlePrivateContent($item); - $result[] = new ContentResponseDto(entry: $this->serializeEntry($this->entryFactory->createDto($item), $this->tagLinkRepository->getTagsOfContent($item))); - } elseif ($item instanceof Post) { - $this->handlePrivateContent($item); - $result[] = new ContentResponseDto(post: $this->serializePost($this->postFactory->createDto($item), $this->tagLinkRepository->getTagsOfContent($item))); - } + $result[] = $this->createContentResponse($item); } return new JsonResponse($this->serializeCursorPaginated($result, $content), headers: $headers); } + private function createContentResponse(Entry|EntryComment|Post|PostComment $item): ContentResponseDto + { + $this->handlePrivateContent($item); + $boostedBy = null; + if (isset($item->extendedContentProperties['boostUsers'])) { + $boostedBy = array_map( + fn (array $boost): ContentBoostResponseDto => new ContentBoostResponseDto( + $this->userFactory->createSmallDto($boost['user']), + $boost['time'], + ), + $item->extendedContentProperties['boostUsers'], + ); + } + + return match (true) { + $item instanceof Entry => new ContentResponseDto( + entry: $this->serializeEntry($this->entryFactory->createDto($item), $this->tagLinkRepository->getTagsOfContent($item)), + boostedBy: $boostedBy, + ), + $item instanceof Post => new ContentResponseDto( + post: $this->serializePost($this->postFactory->createDto($item), $this->tagLinkRepository->getTagsOfContent($item)), + boostedBy: $boostedBy, + ), + $item instanceof EntryComment => new ContentResponseDto( + entryComment: $this->serializeEntryComment($this->entryCommentFactory->createDto($item), $this->tagLinkRepository->getTagsOfContent($item)), + boostedBy: $boostedBy, + ), + $item instanceof PostComment => new ContentResponseDto( + postComment: $this->serializePostComment($this->postCommentFactory->createDto($item), $this->tagLinkRepository->getTagsOfContent($item)), + boostedBy: $boostedBy, + ), + }; + } + private function getCursor(ContentRepository $contentRepository, string $sortOption, ?string $cursor): int|\DateTime|\DateTimeImmutable { $initialCursor = $contentRepository->guessInitialCursor($sortOption); diff --git a/src/DTO/ContentBoostResponseDto.php b/src/DTO/ContentBoostResponseDto.php new file mode 100644 index 0000000000..1767aa865f --- /dev/null +++ b/src/DTO/ContentBoostResponseDto.php @@ -0,0 +1,27 @@ + $this->user, + 'boostedAt' => $this->boostedAt->format(\DateTimeInterface::ATOM), + ]; + } +} diff --git a/src/DTO/ContentResponseDto.php b/src/DTO/ContentResponseDto.php index a3f59e936f..1574db3cc1 100644 --- a/src/DTO/ContentResponseDto.php +++ b/src/DTO/ContentResponseDto.php @@ -4,6 +4,7 @@ namespace App\DTO; +use Nelmio\ApiDocBundle\Attribute\Model; use OpenApi\Attributes as OA; /** @@ -17,6 +18,9 @@ public function __construct( public ?PostResponseDto $post = null, public ?EntryCommentResponseDto $entryComment = null, public ?PostCommentResponseDto $postComment = null, + /** @var ContentBoostResponseDto[]|null */ + #[OA\Property(type: 'array', nullable: true, items: new OA\Items(ref: new Model(type: ContentBoostResponseDto::class)))] + public ?array $boostedBy = null, ) { } } diff --git a/src/DTO/SettingsDto.php b/src/DTO/SettingsDto.php index 76257197e5..8024d17cdc 100644 --- a/src/DTO/SettingsDto.php +++ b/src/DTO/SettingsDto.php @@ -38,6 +38,8 @@ public function __construct( public string $MBIN_DOWNVOTES_MODE, public bool $MBIN_NEW_USERS_NEED_APPROVAL, public bool $MBIN_USE_FEDERATION_ALLOW_LIST, + public bool $MBIN_FEED_ALLOW_ENTRY_COMMENTS, + public bool $MBIN_FEED_ALLOW_POST_COMMENTS, ) { } @@ -71,6 +73,8 @@ public function mergeIntoDto(SettingsDto $dto): SettingsDto $dto->MBIN_DOWNVOTES_MODE = $this->MBIN_DOWNVOTES_MODE ?? $dto->MBIN_DOWNVOTES_MODE; $dto->MBIN_NEW_USERS_NEED_APPROVAL = $this->MBIN_NEW_USERS_NEED_APPROVAL ?? $dto->MBIN_NEW_USERS_NEED_APPROVAL; $dto->MBIN_USE_FEDERATION_ALLOW_LIST = $this->MBIN_USE_FEDERATION_ALLOW_LIST ?? $dto->MBIN_USE_FEDERATION_ALLOW_LIST; + $dto->MBIN_FEED_ALLOW_ENTRY_COMMENTS = $this->MBIN_FEED_ALLOW_ENTRY_COMMENTS ?? $dto->MBIN_FEED_ALLOW_ENTRY_COMMENTS; + $dto->MBIN_FEED_ALLOW_POST_COMMENTS = $this->MBIN_FEED_ALLOW_POST_COMMENTS ?? $dto->MBIN_FEED_ALLOW_POST_COMMENTS; return $dto; } @@ -106,6 +110,8 @@ public function jsonSerialize(): mixed 'MBIN_DOWNVOTES_MODE' => $this->MBIN_DOWNVOTES_MODE, 'MBIN_NEW_USERS_NEED_APPROVAL' => $this->MBIN_NEW_USERS_NEED_APPROVAL, 'MBIN_USE_FEDERATION_ALLOW_LIST' => $this->MBIN_USE_FEDERATION_ALLOW_LIST, + 'MBIN_FEED_ALLOW_ENTRY_COMMENTS' => $this->MBIN_FEED_ALLOW_ENTRY_COMMENTS, + 'MBIN_FEED_ALLOW_POST_COMMENTS' => $this->MBIN_FEED_ALLOW_POST_COMMENTS, ]; } } diff --git a/src/Entity/Entry.php b/src/Entity/Entry.php index ed041d956f..429cfa9e3e 100644 --- a/src/Entity/Entry.php +++ b/src/Entity/Entry.php @@ -16,6 +16,7 @@ use App\Entity\Traits\ActivityPubActivityTrait; use App\Entity\Traits\CreatedAtTrait; use App\Entity\Traits\EditedAtTrait; +use App\Entity\Traits\ExtendedContentTrait; use App\Entity\Traits\RankingTrait; use App\Entity\Traits\VisibilityTrait; use App\Entity\Traits\VotableTrait; @@ -56,6 +57,7 @@ class Entry implements VotableInterface, CommentInterface, DomainInterface, Visi use CreatedAtTrait { CreatedAtTrait::__construct as createdAtTraitConstruct; } + use ExtendedContentTrait; public const ENTRY_TYPE_ARTICLE = 'article'; public const ENTRY_TYPE_LINK = 'link'; diff --git a/src/Entity/EntryComment.php b/src/Entity/EntryComment.php index 471030a1bc..3c901d4787 100644 --- a/src/Entity/EntryComment.php +++ b/src/Entity/EntryComment.php @@ -13,6 +13,7 @@ use App\Entity\Traits\ActivityPubActivityTrait; use App\Entity\Traits\CreatedAtTrait; use App\Entity\Traits\EditedAtTrait; +use App\Entity\Traits\ExtendedContentTrait; use App\Entity\Traits\VisibilityTrait; use App\Entity\Traits\VotableTrait; use App\Repository\Criteria as MbinCriteria; @@ -48,6 +49,7 @@ class EntryComment implements VotableInterface, VisibilityInterface, ReportInter use CreatedAtTrait { CreatedAtTrait::__construct as createdAtTraitConstruct; } + use ExtendedContentTrait; #[ManyToOne(targetEntity: User::class, inversedBy: 'entryComments')] #[JoinColumn(nullable: false, onDelete: 'CASCADE')] diff --git a/src/Entity/Post.php b/src/Entity/Post.php index f3ccdc7e25..127bc010b0 100644 --- a/src/Entity/Post.php +++ b/src/Entity/Post.php @@ -14,6 +14,7 @@ use App\Entity\Traits\ActivityPubActivityTrait; use App\Entity\Traits\CreatedAtTrait; use App\Entity\Traits\EditedAtTrait; +use App\Entity\Traits\ExtendedContentTrait; use App\Entity\Traits\RankingTrait; use App\Entity\Traits\VisibilityTrait; use App\Entity\Traits\VotableTrait; @@ -52,6 +53,7 @@ class Post implements VotableInterface, CommentInterface, VisibilityInterface, R use CreatedAtTrait { CreatedAtTrait::__construct as createdAtTraitConstruct; } + use ExtendedContentTrait; #[ManyToOne(targetEntity: User::class, inversedBy: 'posts')] #[JoinColumn(nullable: false, onDelete: 'CASCADE')] diff --git a/src/Entity/PostComment.php b/src/Entity/PostComment.php index 16c87a562c..acf637b2d2 100644 --- a/src/Entity/PostComment.php +++ b/src/Entity/PostComment.php @@ -13,6 +13,7 @@ use App\Entity\Traits\ActivityPubActivityTrait; use App\Entity\Traits\CreatedAtTrait; use App\Entity\Traits\EditedAtTrait; +use App\Entity\Traits\ExtendedContentTrait; use App\Entity\Traits\VisibilityTrait; use App\Entity\Traits\VotableTrait; use App\Repository\Criteria as MbinCriteria; @@ -48,6 +49,7 @@ class PostComment implements VotableInterface, VisibilityInterface, ReportInterf use CreatedAtTrait { CreatedAtTrait::__construct as createdAtTraitConstruct; } + use ExtendedContentTrait; #[ManyToOne(targetEntity: User::class, inversedBy: 'postComments')] #[JoinColumn(nullable: false, onDelete: 'CASCADE')] diff --git a/src/Entity/Traits/ExtendedContentTrait.php b/src/Entity/Traits/ExtendedContentTrait.php new file mode 100644 index 0000000000..989fccdf41 --- /dev/null +++ b/src/Entity/Traits/ExtendedContentTrait.php @@ -0,0 +1,16 @@ + + * May contain the following properties: + * - boostUsers: + * - desc: list of (followed) users who boosted this item + when it was boosted + * - value: ['user' => User, 'time' => DateTimeImmutable][] + */ + public array $extendedContentProperties = []; +} diff --git a/src/Factory/ExtendedContentPopulationTransformerFactory.php b/src/Factory/ExtendedContentPopulationTransformerFactory.php new file mode 100644 index 0000000000..d6d5a5c1ec --- /dev/null +++ b/src/Factory/ExtendedContentPopulationTransformerFactory.php @@ -0,0 +1,29 @@ +entityManager, + $this->userRepository, + $criteria, + $loggedInUser, + ); + } +} diff --git a/src/Form/SettingsType.php b/src/Form/SettingsType.php index 657e053e15..a4549cbb21 100644 --- a/src/Form/SettingsType.php +++ b/src/Form/SettingsType.php @@ -50,6 +50,8 @@ public function buildForm(FormBuilderInterface $builder, array $options): void ->add('KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN', CheckboxType::class, ['required' => false]) ->add('MBIN_SIDEBAR_SECTIONS_RANDOM_LOCAL_ONLY', CheckboxType::class, ['required' => false]) ->add('MBIN_SIDEBAR_SECTIONS_USERS_LOCAL_ONLY', CheckboxType::class, ['required' => false]) + ->add('MBIN_FEED_ALLOW_ENTRY_COMMENTS', CheckboxType::class, ['required' => false]) + ->add('MBIN_FEED_ALLOW_POST_COMMENTS', CheckboxType::class, ['required' => false]) ->add('MBIN_RESTRICT_MAGAZINE_CREATION', CheckboxType::class, ['required' => false]) ->add('MBIN_SSO_SHOW_FIRST', CheckboxType::class, ['required' => false]) ->add('MBIN_DOWNVOTES_MODE', ChoiceType::class, [ diff --git a/src/Pagination/Transformation/ContentPopulationTransformer.php b/src/Pagination/Transformation/ContentPopulationTransformer.php index b2c63ceadd..5f809d3831 100644 --- a/src/Pagination/Transformation/ContentPopulationTransformer.php +++ b/src/Pagination/Transformation/ContentPopulationTransformer.php @@ -14,10 +14,10 @@ use App\Utils\SqlHelpers; use Doctrine\ORM\EntityManagerInterface; -class ContentPopulationTransformer implements ResultTransformer +readonly class ContentPopulationTransformer implements ResultTransformer { public function __construct( - private readonly EntityManagerInterface $entityManager, + protected EntityManagerInterface $entityManager, ) { } diff --git a/src/Pagination/Transformation/ExtendedContentPopulationTransformer.php b/src/Pagination/Transformation/ExtendedContentPopulationTransformer.php new file mode 100644 index 0000000000..3a4f9ee66d --- /dev/null +++ b/src/Pagination/Transformation/ExtendedContentPopulationTransformer.php @@ -0,0 +1,115 @@ +extendItems($input, $items); + + return $items; + } + + /** + * @param array $items + */ + private function extendItems(array $rows, array $items): void + { + \assert(\count($rows) === \count($items)); + + $hasUser = null !== $this->loggedInUser; + foreach ($items as $i => $item) { + $row = $rows[$i]; + \assert($row['id'] === $item->getId()); + + if ($hasUser && isset($row['was_boosted']) && true === $row['was_boosted']) { + $this->extendItemBoostList($item); + } + } + } + + private function extendItemBoostList(Entry|EntryComment|Post|PostComment $item): void + { + switch (\get_class($item)) { + case Entry::class: + $vType = 'entry'; + $fkType = 'entry'; + break; + case Post::class: + $vType = 'post'; + $fkType = 'post'; + break; + case EntryComment::class: + $vType = 'entry_comment'; + $fkType = 'comment'; + break; + case PostComment::class: + $vType = 'post_comment'; + $fkType = 'comment'; + break; + default: + throw new \LogicException('unreachable'); + } + + if (null === $this->criteria->cachedUserFollows) { + $sql = 'SELECT v.user_id, v.created_at FROM user_follow uf RIGHT OUTER JOIN %v_type%_vote v ON uf.following_id = v.user_id WHERE v.%fk_type%_id = :itemId AND (uf.follower_id = :loggedInUser OR v.user_id = :loggedInUser) AND v.choice = 1'; + $sql = str_replace('%v_type%', $vType, str_replace('%fk_type%', $fkType, $sql)); + + $boostsQuery = $this->entityManager->getConnection()->prepare($sql); + $boostsQuery->bindValue('itemId', $item->getId(), ParameterType::INTEGER); + $boostsQuery->bindValue('loggedInUser', $this->loggedInUser->getId(), ParameterType::INTEGER); + } else { + $sql = 'SELECT v.user_id, v.created_at FROM %v_type%_vote v WHERE :itemId = v.%fk_type%_id AND (v.user_id IN (:cachedUserFollows) OR v.user_id = :loggedInUser) AND v.choice = 1'; + $sql = str_replace('%v_type%', $vType, str_replace('%fk_type%', $fkType, $sql)); + $parameters = [ + 'itemId' => $item->getId(), + 'loggedInUser' => $this->loggedInUser->getId(), + 'cachedUserFollows' => $this->criteria->cachedUserFollows, + ]; + $rewritten = SqlHelpers::rewriteArrayParameters($parameters, $sql); + + $boostsQuery = $this->entityManager->getConnection()->prepare($rewritten['sql']); + foreach ($rewritten['parameters'] as $key => $value) { + $boostsQuery->bindValue($key, $value, SqlHelpers::getSqlType($value)); + } + } + + $boostInfo = $boostsQuery->executeQuery()->fetchAllAssociative(); + $boostUsers = $this->userRepository->findBy(['id' => array_map(fn ($row) => $row['user_id'], $boostInfo)]); + + $boostExtension = []; + foreach ($boostUsers as $boostUser) { + $boostTime = array_find($boostInfo, fn ($row) => $row['user_id'] === $boostUser->getId())['created_at']; + $boostExtension[] = ['user' => $boostUser, 'time' => new \DateTimeImmutable($boostTime)]; + } + usort($boostExtension, fn ($a, $b) => $a['time'] <=> $b['time']); + + $item->extendedContentProperties['boostUsers'] = $boostExtension; + } +} diff --git a/src/Repository/ContentRepository.php b/src/Repository/ContentRepository.php index bafb3a7bd7..06247f7318 100644 --- a/src/Repository/ContentRepository.php +++ b/src/Repository/ContentRepository.php @@ -8,12 +8,13 @@ use App\Entity\Entry; use App\Entity\Post; use App\Entity\User; +use App\Factory\ExtendedContentPopulationTransformerFactory; use App\Pagination\Cursor\CursorPagination; use App\Pagination\Cursor\CursorPaginationInterface; use App\Pagination\Cursor\NativeQueryCursorAdapter; use App\Pagination\NativeQueryAdapter; use App\Pagination\Pagerfanta; -use App\Pagination\Transformation\ContentPopulationTransformer; +use App\Service\SettingsManager; use App\Utils\SqlHelpers; use Doctrine\DBAL\Exception; use Doctrine\ORM\EntityManagerInterface; @@ -30,7 +31,8 @@ class ContentRepository public function __construct( private readonly Security $security, private readonly EntityManagerInterface $entityManager, - private readonly ContentPopulationTransformer $contentPopulationTransformer, + private readonly ExtendedContentPopulationTransformerFactory $contentPopulationTransformerFactory, + private readonly SettingsManager $settingsManager, private readonly CacheInterface $cache, private readonly LoggerInterface $logger, private readonly KernelInterface $kernel, @@ -47,7 +49,14 @@ public function findByCriteria(Criteria $criteria): PagerfantaInterface // pre-set the results to 1000 pages for queries not very limited by the parameters so the count query is not being executed $numResults = 1000 * ($criteria->perPage ?? self::PER_PAGE); } - $fanta = new Pagerfanta(new NativeQueryAdapter($conn, $query['sql'], $query['parameters'], numOfResults: $numResults, transformer: $this->contentPopulationTransformer, cache: $this->cache)); + $fanta = new Pagerfanta(new NativeQueryAdapter( + $conn, + $query['sql'], + $query['parameters'], + numOfResults: $numResults, + transformer: $this->contentPopulationTransformerFactory->create($criteria, $this->security->getUser()), + cache: $this->cache + )); $fanta->setMaxPerPage($criteria->perPage ?? self::PER_PAGE); $fanta->setCurrentPage($criteria->page); @@ -84,7 +93,7 @@ public function findByCriteriaCursored(Criteria $criteria, mixed $currentCursor, $this->getSecondaryCursorWhereFromCriteriaInverted($criteria), 'c.created_at DESC', 'c.created_at', - transformer: $this->contentPopulationTransformer, + transformer: $this->contentPopulationTransformerFactory->create($criteria, $this->security->getUser()), ), $this->getCursorFieldFromCriteria($criteria), $criteria->perPage ?? self::PER_PAGE, @@ -102,8 +111,8 @@ public function findByCriteriaCursored(Criteria $criteria, mixed $currentCursor, private function getQueryAndParameters(Criteria $criteria, bool $addCursor): array { $includeEntries = Criteria::CONTENT_COMBINED === $criteria->content || Criteria::CONTENT_THREADS === $criteria->content; - $includeEntryComments = $criteria->subscribed && Criteria::CONTENT_COMBINED === $criteria->content && $criteria->includeBoosts; - $includePostComments = $criteria->subscribed && (Criteria::CONTENT_COMBINED === $criteria->content || Criteria::CONTENT_MICROBLOG === $criteria->content) && $criteria->includeBoosts; + $includeEntryComments = $this->settingsManager->getDto()->MBIN_FEED_ALLOW_ENTRY_COMMENTS && $criteria->subscribed && Criteria::CONTENT_COMBINED === $criteria->content && $criteria->includeBoosts; + $includePostComments = $this->settingsManager->getDto()->MBIN_FEED_ALLOW_POST_COMMENTS && $criteria->subscribed && (Criteria::CONTENT_COMBINED === $criteria->content || Criteria::CONTENT_MICROBLOG === $criteria->content) && $criteria->includeBoosts; $parameters = [ 'visible' => VisibilityInterface::VISIBILITY_VISIBLE, @@ -194,6 +203,10 @@ private function getQueryAndParameters(Criteria $criteria, bool $addCursor): arr $subClauseEntry = ''; $subClauseEntryComment = ''; $subClausePostComment = ''; + $selectPostBoosted = ''; + $selectEntryBoosted = ''; + $selectPostCommentBoosted = ''; + $selectEntryCommentBoosted = ''; if ($user && $criteria->subscribed) { $subClausePost = 'c.user_id = :loggedInUser' .(null === $criteria->cachedUserSubscribedMagazines ? @@ -213,23 +226,20 @@ private function getQueryAndParameters(Criteria $criteria, bool $addCursor): arr ' OR EXISTS (SELECT 1 FROM user_follow uf WHERE uf.follower_id = :loggedInUser AND uf.following_id = c.user_id)' : ' OR c.user_id IN (:cachedUserFollows)'); - $subClauseEntryComment = $repliesCommonWhere. - (null === $criteria->cachedUserFollows ? - ' OR EXISTS (SELECT 1 FROM user_follow uf RIGHT OUTER JOIN entry_comment_vote v ON uf.following_id = v.user_id WHERE c.id = v.comment_id AND (uf.follower_id = :loggedInUser OR v.user_id = :loggedInUser) AND v.choice = 1)' : - ' OR EXISTS (SELECT 1 FROM entry_comment_vote v WHERE c.id = v.comment_id AND (v.user_id IN (:cachedUserFollows) OR v.user_id = :loggedInUser) AND v.choice = 1)'); - $subClausePostComment = $repliesCommonWhere. - (null === $criteria->cachedUserFollows ? - ' OR EXISTS (SELECT 1 FROM user_follow uf RIGHT OUTER JOIN post_comment_vote v ON uf.following_id = v.user_id WHERE c.id = v.comment_id AND (uf.follower_id = :loggedInUser OR v.user_id = :loggedInUser) AND v.choice = 1)' : - ' OR EXISTS (SELECT 1 FROM post_comment_vote v WHERE c.id = v.comment_id AND (v.user_id IN (:cachedUserFollows) OR v.user_id = :loggedInUser) AND v.choice = 1)'); + $boostCheckTpl = null === $criteria->cachedUserFollows ? + 'EXISTS (SELECT 1 FROM user_follow uf RIGHT OUTER JOIN %v_type%_vote v ON uf.following_id = v.user_id WHERE c.id = v.%fk_type%_id AND (uf.follower_id = :loggedInUser OR v.user_id = :loggedInUser) AND v.choice = 1)' : + 'EXISTS (SELECT 1 FROM %v_type%_vote v WHERE c.id = v.%fk_type%_id AND (v.user_id IN (:cachedUserFollows) OR v.user_id = :loggedInUser) AND v.choice = 1)'; - $subClausePost = $subClausePost - .(null === $criteria->cachedUserFollows ? - ' OR EXISTS (SELECT 1 FROM user_follow uf RIGHT OUTER JOIN post_vote v ON uf.following_id = v.user_id WHERE c.id = v.post_id AND (uf.follower_id = :loggedInUser OR v.user_id = :loggedInUser) AND v.choice = 1)' : - ' OR EXISTS (SELECT 1 FROM post_vote v WHERE c.id = v.post_id AND (v.user_id IN (:cachedUserFollows) OR v.user_id = :loggedInUser) AND v.choice = 1)'); - $subClauseEntry = $subClauseEntry - .(null === $criteria->cachedUserFollows ? - ' OR EXISTS (SELECT 1 FROM user_follow uf RIGHT OUTER JOIN entry_vote v ON uf.following_id = v.user_id WHERE c.id = v.entry_id AND (uf.follower_id = :loggedInUser OR v.user_id = :loggedInUser) AND v.choice = 1)' : - ' OR EXISTS (SELECT 1 FROM entry_vote v WHERE c.id = v.entry_id AND (v.user_id IN (:cachedUserFollows) OR v.user_id = :loggedInUser) AND v.choice = 1)'); + $subClauseEntryComment = $repliesCommonWhere.' OR '.str_replace('%v_type%', 'entry_comment', str_replace('%fk_type%', 'comment', $boostCheckTpl)); + $subClausePostComment = $repliesCommonWhere.' OR '.str_replace('%v_type%', 'post_comment', str_replace('%fk_type%', 'comment', $boostCheckTpl)); + + $subClausePost = $subClausePost.' OR '.str_replace('%v_type%', 'post', str_replace('%fk_type%', 'post', $boostCheckTpl)); + $subClauseEntry = $subClauseEntry.' OR '.str_replace('%v_type%', 'entry', str_replace('%fk_type%', 'entry', $boostCheckTpl)); + + $selectPostBoosted = ', '.str_replace('%v_type%', 'post', str_replace('%fk_type%', 'post', $boostCheckTpl)).' AS was_boosted'; + $selectEntryBoosted = ', '.str_replace('%v_type%', 'entry', str_replace('%fk_type%', 'entry', $boostCheckTpl)).' AS was_boosted'; + $selectPostCommentBoosted = ', '.str_replace('%v_type%', 'post_comment', str_replace('%fk_type%', 'comment', $boostCheckTpl)).' AS was_boosted'; + $selectEntryCommentBoosted = ', '.str_replace('%v_type%', 'entry_comment', str_replace('%fk_type%', 'comment', $boostCheckTpl)).' AS was_boosted'; } if (null !== $criteria->cachedUserSubscribedMagazines) { @@ -481,17 +491,17 @@ private function getQueryAndParameters(Criteria $criteria, bool $addCursor): arr // only join domain if we are explicitly looking at one $domainJoin = $criteria->domain ? 'LEFT JOIN domain d ON d.id = c.domain_id' : ''; - $entrySql = "SELECT c.id, 'entry' as type, c.type as content_type, c.created_at, c.last_boosted_at, c.ranking, c.score, c.comment_count, c.sticky, c.last_active, c.user_id FROM entry c + $entrySql = "SELECT c.id, 'entry' as type, c.type as content_type, c.created_at, c.last_boosted_at, c.ranking, c.score, c.comment_count, c.sticky, c.last_active, c.user_id $selectEntryBoosted FROM entry c LEFT JOIN magazine m ON c.magazine_id = m.id $domainJoin $entryWhere"; - $postSql = "SELECT c.id, 'post' as type, 'microblog' as content_type, c.created_at, c.last_boosted_at, c.ranking, c.score, c.comment_count, c.sticky, c.last_active, c.user_id FROM post c + $postSql = "SELECT c.id, 'post' as type, 'microblog' as content_type, c.created_at, c.last_boosted_at, c.ranking, c.score, c.comment_count, c.sticky, c.last_active, c.user_id $selectPostBoosted FROM post c LEFT JOIN magazine m ON c.magazine_id = m.id $postWhere"; - $entryCommentSql = "SELECT c.id, 'entry_comment' as type, 'microblog' as content_type, c.created_at, c.last_boosted_at, 0 as ranking, 0 as score, 0 as comment_count, false as sticky, c.last_active, c.user_id FROM entry_comment c + $entryCommentSql = "SELECT c.id, 'entry_comment' as type, 'microblog' as content_type, c.created_at, c.last_boosted_at, 0 as ranking, 0 as score, 0 as comment_count, false as sticky, c.last_active, c.user_id $selectEntryCommentBoosted FROM entry_comment c LEFT JOIN magazine m ON c.magazine_id = m.id $entryCommentWhere"; - $postCommentSql = "SELECT c.id, 'post_comment' as type, 'microblog' as content_type, c.created_at, c.last_boosted_at, 0 as ranking, 0 as score, 0 as comment_count, false as sticky, c.last_active, c.user_id FROM post_comment c + $postCommentSql = "SELECT c.id, 'post_comment' as type, 'microblog' as content_type, c.created_at, c.last_boosted_at, 0 as ranking, 0 as score, 0 as comment_count, false as sticky, c.last_active, c.user_id $selectPostCommentBoosted FROM post_comment c LEFT JOIN magazine m ON c.magazine_id = m.id $postCommentWhere"; diff --git a/src/Service/SettingsManager.php b/src/Service/SettingsManager.php index cfdeb529b6..32740e108e 100644 --- a/src/Service/SettingsManager.php +++ b/src/Service/SettingsManager.php @@ -102,6 +102,8 @@ public function __construct( $this->find($results, 'MBIN_DOWNVOTES_MODE') ?? $this->mbinDownvotesMode->value, $newUsersNeedApprovalEdited, $this->find($results, 'MBIN_USE_FEDERATION_ALLOW_LIST', FILTER_VALIDATE_BOOLEAN) ?? $this->mbinUseFederationAllowList, + $this->find($results, 'MBIN_FEED_ALLOW_ENTRY_COMMENTS', FILTER_VALIDATE_BOOLEAN) ?? true, + $this->find($results, 'MBIN_FEED_ALLOW_POST_COMMENTS', FILTER_VALIDATE_BOOLEAN) ?? true, ); $this->instanceDto = $dto; } else { diff --git a/templates/admin/settings.html.twig b/templates/admin/settings.html.twig index 41131c4c83..ec538e001e 100644 --- a/templates/admin/settings.html.twig +++ b/templates/admin/settings.html.twig @@ -86,6 +86,16 @@ {{ form_label(form.MBIN_SIDEBAR_SECTIONS_USERS_LOCAL_ONLY, 'sidebar_sections_users_local_only') }} {{ form_widget(form.MBIN_SIDEBAR_SECTIONS_USERS_LOCAL_ONLY) }} +
+ {{ form_label(form.MBIN_FEED_ALLOW_ENTRY_COMMENTS, 'feed_allow_entry_comments') }} + {{ form_widget(form.MBIN_FEED_ALLOW_ENTRY_COMMENTS) }} +
+
{{ 'feed_allow_comments_hint'|trans }}
+
+ {{ form_label(form.MBIN_FEED_ALLOW_POST_COMMENTS, 'feed_allow_post_comments') }} + {{ form_widget(form.MBIN_FEED_ALLOW_POST_COMMENTS) }} +
+
{{ 'feed_allow_comments_hint'|trans }}
{{ form_label(form.MBIN_RESTRICT_MAGAZINE_CREATION, 'restrict_magazine_creation') }} {{ form_widget(form.MBIN_RESTRICT_MAGAZINE_CREATION) }} diff --git a/templates/components/entry.html.twig b/templates/components/entry.html.twig index c50691291c..3629318c4a 100644 --- a/templates/components/entry.html.twig +++ b/templates/components/entry.html.twig @@ -203,6 +203,8 @@
+ + {{ include('widget/boosted_by.html.twig', {subject: entry}) }} {% elseif (entry.visibility is same as 'trashed' and this.canSeeTrashed) %}
  • diff --git a/templates/components/entry_comment.html.twig b/templates/components/entry_comment.html.twig index 9c8f3b341f..b4312872ba 100644 --- a/templates/components/entry_comment.html.twig +++ b/templates/components/entry_comment.html.twig @@ -156,6 +156,9 @@
  • {% endif %} + + {{ include('widget/boosted_by.html.twig', {subject: comment}) }} +
    diff --git a/templates/components/entry_comment_combined.html.twig b/templates/components/entry_comment_combined.html.twig index dff85ac055..549d9b9785 100644 --- a/templates/components/entry_comment_combined.html.twig +++ b/templates/components/entry_comment_combined.html.twig @@ -178,6 +178,9 @@ {% endif %} + + {{ include('widget/boosted_by.html.twig', {subject: comment}) }} +
    diff --git a/templates/components/post.html.twig b/templates/components/post.html.twig index d817cbec7f..468b580271 100644 --- a/templates/components/post.html.twig +++ b/templates/components/post.html.twig @@ -141,11 +141,17 @@ + + {% if post.extendedContentProperties.boostUsers is defined %} + {{ include('widget/boosted_by.html.twig', {subject: post}) }} + {% else %} {{ component('voters_inline', { subject: post, url: post_voters_url(post, 'up'), 'data-post-target': 'voters' }) }} + {% endif %} + {% elseif(post.visibility is same as 'trashed' and this.canSeeTrashed) %}
  • diff --git a/templates/components/post_combined.html.twig b/templates/components/post_combined.html.twig index 9f9ad11019..b3bf5f78dc 100644 --- a/templates/components/post_combined.html.twig +++ b/templates/components/post_combined.html.twig @@ -176,6 +176,9 @@
  • {% endif %} + + {{ include('widget/boosted_by.html.twig', {subject: post}) }} +
    diff --git a/templates/components/post_comment.html.twig b/templates/components/post_comment.html.twig index 0e9a6a627f..27f337719a 100644 --- a/templates/components/post_comment.html.twig +++ b/templates/components/post_comment.html.twig @@ -155,10 +155,16 @@ {% endif %} - {{ component('voters_inline', { - subject: comment, - url: post_comment_voters_url(comment, 'up') - }) }} + + {% if comment.extendedContentProperties.boostUsers is defined %} + {{ include('widget/boosted_by.html.twig', {subject: comment}) }} + {% else %} + {{ component('voters_inline', { + subject: comment, + url: post_comment_voters_url(comment, 'up') + }) }} + {% endif %} +
    diff --git a/templates/components/post_comment_combined.html.twig b/templates/components/post_comment_combined.html.twig index e6082d6786..5ea4b12a99 100644 --- a/templates/components/post_comment_combined.html.twig +++ b/templates/components/post_comment_combined.html.twig @@ -170,6 +170,9 @@ {% endif %} + + {{ include('widget/boosted_by.html.twig', {subject: comment}) }} +
    diff --git a/templates/widget/boosted_by.html.twig b/templates/widget/boosted_by.html.twig new file mode 100644 index 0000000000..01a1332bce --- /dev/null +++ b/templates/widget/boosted_by.html.twig @@ -0,0 +1,14 @@ +{% with {boosts: subject.extendedContentProperties.boostUsers|default([])} %} + {% if boosts|length > 0 %} +
    + {{ 'boosted_by'|trans }} + {% for boost in boosts %} + + {{ boost.user.username|username }} + ({{ component('date', {date: boost.time}) }}) + + {%- if not loop.last %},{% endif %} + {% endfor %} +
    + {% endif %} +{% endwith %} diff --git a/tests/Functional/Controller/Api/Combined/CombinedRetrieveApiTest.php b/tests/Functional/Controller/Api/Combined/CombinedRetrieveApiTest.php index 94531817e1..c7e2ef7e37 100644 --- a/tests/Functional/Controller/Api/Combined/CombinedRetrieveApiTest.php +++ b/tests/Functional/Controller/Api/Combined/CombinedRetrieveApiTest.php @@ -54,6 +54,27 @@ public function testApiCanGetSubscribedContentWithBoosts(): void self::assertArrayKeysMatch(self::PAGINATION_KEYS, $jsonData['pagination']); self::assertSame(8, $jsonData['pagination']['count']); + $boostedContentIds = [ + 'post' => $postBoosted->getId(), + 'postComment' => $postCommentBoosted->getId(), + 'entry' => $entryBoosted->getId(), + 'entryComment' => $entryCommentBoosted->getId(), + ]; + $boostedContentSeen = []; + foreach ($jsonData['items'] as $item) { + foreach ($boostedContentIds as $type => $boostedContentId) { + $idKey = str_ends_with($type, 'Comment') ? 'commentId' : $type.'Id'; + if (($item[$type][$idKey] ?? null) === $boostedContentId) { + self::assertCount(1, $item['boostedBy']); + self::assertSame($userFollowing->getId(), $item['boostedBy'][0]['user']['userId']); + self::assertSame($userFollowing->username, $item['boostedBy'][0]['user']['username']); + self::assertNotFalse(\DateTimeImmutable::createFromFormat(\DateTimeInterface::ATOM, $item['boostedBy'][0]['boostedAt'])); + $boostedContentSeen[] = $type; + } + } + } + self::assertEqualsCanonicalizing(array_keys($boostedContentIds), $boostedContentSeen); + $retrievedPostIds = array_map(function ($item) { if (null !== $item['post']) { self::assertArrayKeysMatch(self::POST_RESPONSE_KEYS, $item['post']); diff --git a/tests/Functional/Controller/Api/Instance/Admin/InstanceSettingsRetrieveApiTest.php b/tests/Functional/Controller/Api/Instance/Admin/InstanceSettingsRetrieveApiTest.php index 5b34e8b940..e575d1a4da 100644 --- a/tests/Functional/Controller/Api/Instance/Admin/InstanceSettingsRetrieveApiTest.php +++ b/tests/Functional/Controller/Api/Instance/Admin/InstanceSettingsRetrieveApiTest.php @@ -8,7 +8,7 @@ class InstanceSettingsRetrieveApiTest extends WebTestCase { - public const INSTANCE_SETTINGS_RESPONSE_KEYS = [ + public const array INSTANCE_SETTINGS_RESPONSE_KEYS = [ 'KBIN_DOMAIN', 'KBIN_TITLE', 'KBIN_META_TITLE', @@ -37,6 +37,8 @@ class InstanceSettingsRetrieveApiTest extends WebTestCase 'MBIN_SSO_SHOW_FIRST', 'MBIN_NEW_USERS_NEED_APPROVAL', 'MBIN_USE_FEDERATION_ALLOW_LIST', + 'MBIN_FEED_ALLOW_ENTRY_COMMENTS', + 'MBIN_FEED_ALLOW_POST_COMMENTS', ]; public function testApiCannotRetrieveInstanceSettingsAnonymous(): void diff --git a/tests/Functional/Controller/Api/Instance/Admin/InstanceSettingsUpdateApiTest.php b/tests/Functional/Controller/Api/Instance/Admin/InstanceSettingsUpdateApiTest.php index 0b5d419b7a..416e6d8198 100644 --- a/tests/Functional/Controller/Api/Instance/Admin/InstanceSettingsUpdateApiTest.php +++ b/tests/Functional/Controller/Api/Instance/Admin/InstanceSettingsUpdateApiTest.php @@ -10,37 +10,6 @@ class InstanceSettingsUpdateApiTest extends WebTestCase { - public const INSTANCE_SETTINGS_RESPONSE_KEYS = [ - 'KBIN_DOMAIN', - 'KBIN_TITLE', - 'KBIN_META_TITLE', - 'KBIN_META_KEYWORDS', - 'KBIN_META_DESCRIPTION', - 'KBIN_DEFAULT_LANG', - 'KBIN_CONTACT_EMAIL', - 'KBIN_SENDER_EMAIL', - 'MBIN_DEFAULT_THEME', - 'KBIN_JS_ENABLED', - 'KBIN_FEDERATION_ENABLED', - 'KBIN_REGISTRATIONS_ENABLED', - 'KBIN_HEADER_LOGO', - 'KBIN_CAPTCHA_ENABLED', - 'KBIN_MERCURE_ENABLED', - 'KBIN_FEDERATION_PAGE_ENABLED', - 'KBIN_ADMIN_ONLY_OAUTH_CLIENTS', - 'MBIN_PRIVATE_INSTANCE', - 'KBIN_FEDERATED_SEARCH_ONLY_LOGGEDIN', - 'MBIN_SIDEBAR_SECTIONS_RANDOM_LOCAL_ONLY', - 'MBIN_SIDEBAR_SECTIONS_USERS_LOCAL_ONLY', - 'MBIN_SSO_REGISTRATIONS_ENABLED', - 'MBIN_RESTRICT_MAGAZINE_CREATION', - 'MBIN_DOWNVOTES_MODE', - 'MBIN_SSO_ONLY_MODE', - 'MBIN_SSO_SHOW_FIRST', - 'MBIN_NEW_USERS_NEED_APPROVAL', - 'MBIN_USE_FEDERATION_ALLOW_LIST', - ]; - public function testApiCannotUpdateInstanceSettingsAnonymous(): void { $this->client->request('PUT', '/api/instance/settings'); @@ -114,6 +83,8 @@ public function testApiCanUpdateInstanceSettings(): void 'MBIN_SSO_SHOW_FIRST' => false, 'MBIN_NEW_USERS_NEED_APPROVAL' => false, 'MBIN_USE_FEDERATION_ALLOW_LIST' => false, + 'MBIN_FEED_ALLOW_ENTRY_COMMENTS' => true, + 'MBIN_FEED_ALLOW_POST_COMMENTS' => true, ]; $this->client->jsonRequest('PUT', '/api/instance/settings', $settings, server: ['HTTP_AUTHORIZATION' => $token]); @@ -121,7 +92,7 @@ public function testApiCanUpdateInstanceSettings(): void self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); - self::assertArrayKeysMatch(self::INSTANCE_SETTINGS_RESPONSE_KEYS, $jsonData); + self::assertArrayKeysMatch(InstanceSettingsRetrieveApiTest::INSTANCE_SETTINGS_RESPONSE_KEYS, $jsonData); foreach ($jsonData as $key => $value) { self::assertEquals($settings[$key], $value, "$key did not match!"); } @@ -155,6 +126,8 @@ public function testApiCanUpdateInstanceSettings(): void 'MBIN_SSO_SHOW_FIRST' => true, 'MBIN_NEW_USERS_NEED_APPROVAL' => false, 'MBIN_USE_FEDERATION_ALLOW_LIST' => false, + 'MBIN_FEED_ALLOW_ENTRY_COMMENTS' => false, + 'MBIN_FEED_ALLOW_POST_COMMENTS' => false, ]; $this->client->jsonRequest('PUT', '/api/instance/settings', $settings, server: ['HTTP_AUTHORIZATION' => $token]); @@ -162,7 +135,7 @@ public function testApiCanUpdateInstanceSettings(): void self::assertResponseIsSuccessful(); $jsonData = self::getJsonResponse($this->client); - self::assertArrayKeysMatch(self::INSTANCE_SETTINGS_RESPONSE_KEYS, $jsonData); + self::assertArrayKeysMatch(InstanceSettingsRetrieveApiTest::INSTANCE_SETTINGS_RESPONSE_KEYS, $jsonData); foreach ($jsonData as $key => $value) { self::assertEquals($settings[$key], $value, "$key did not match!"); } diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index 2551c36906..f66f75458d 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -249,6 +249,7 @@ solarized_auto: Solarized (Auto Detect) font_size: Font size size: Size boosts: Boosts +boosted_by: Boosted by show_users_avatars: 'Show users’ avatars' yes: Yes no: No @@ -1115,6 +1116,9 @@ combined: Combined sidebar_sections_random_local_only: Restrict "Random Threads/Posts" sidebar sections to local only sidebar_sections_users_local_only: Restrict "Active people" sidebar section to local only random_local_only_performance_warning: Enabling "Random local only" may cause SQL performance impact. +feed_allow_entry_comments: Include comments of threads in Combined feed +feed_allow_post_comments: Include comments of posts in Combined feed +feed_allow_comments_hint: (might result in slow loading) discoverable: Discoverable user_discoverable_help: If this is enabled, your profile, threads, microblogs and comments can be found through search and the random panels. Your profile might also appear in the active user panel and on the people page.