From 2167f5cc9ff18799923cd9e5a8cc757fd795b6e3 Mon Sep 17 00:00:00 2001 From: blued_gear Date: Sat, 15 Aug 2026 16:21:48 +0000 Subject: [PATCH 1/6] show who boosted content in feed --- assets/styles/app.scss | 1 + assets/styles/widgets/boosted_by.scss | 4 + src/Entity/Entry.php | 2 + src/Entity/EntryComment.php | 2 + src/Entity/Post.php | 2 + src/Entity/PostComment.php | 2 + src/Entity/Traits/ExtendedContentTrait.php | 15 +++ ...dedContentPopulationTransformerFactory.php | 27 ++++ .../ContentPopulationTransformer.php | 4 +- .../ExtendedContentPopulationTransformer.php | 116 ++++++++++++++++++ src/Repository/ContentRepository.php | 56 +++++---- templates/components/entry.html.twig | 2 + templates/components/entry_comment.html.twig | 3 + .../entry_comment_combined.html.twig | 3 + templates/components/post.html.twig | 6 + templates/components/post_combined.html.twig | 3 + templates/components/post_comment.html.twig | 14 ++- .../post_comment_combined.html.twig | 3 + templates/widget/boosted_by.html.twig | 14 +++ translations/messages.en.yaml | 1 + 20 files changed, 251 insertions(+), 29 deletions(-) create mode 100644 assets/styles/widgets/boosted_by.scss create mode 100644 src/Entity/Traits/ExtendedContentTrait.php create mode 100644 src/Factory/ExtendedContentPopulationTransformerFactory.php create mode 100644 src/Pagination/Transformation/ExtendedContentPopulationTransformer.php create mode 100644 templates/widget/boosted_by.html.twig 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..744392fde0 --- /dev/null +++ b/assets/styles/widgets/boosted_by.scss @@ -0,0 +1,4 @@ +.boosted-by { + font-size: 0.8rem; + max-height: 2lh; +} 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..5a0bbb1168 --- /dev/null +++ b/src/Entity/Traits/ExtendedContentTrait.php @@ -0,0 +1,15 @@ + + * 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 = []; +} \ No newline at end of file diff --git a/src/Factory/ExtendedContentPopulationTransformerFactory.php b/src/Factory/ExtendedContentPopulationTransformerFactory.php new file mode 100644 index 0000000000..0229c3d141 --- /dev/null +++ b/src/Factory/ExtendedContentPopulationTransformerFactory.php @@ -0,0 +1,27 @@ +entityManager, + $this->userRepository, + $criteria, + $loggedInUser, + ); + } +} \ No newline at end of file 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..9fc2939123 --- /dev/null +++ b/src/Pagination/Transformation/ExtendedContentPopulationTransformer.php @@ -0,0 +1,116 @@ +extendItems($input, $items); + + return $items; + } + + /** + * @param array $rows + * @param array $items + * @return void + */ + private function extendItems(array $rows, array $items): void { + assert(count($rows) === count($items)); + + $hasUser = $this->loggedInUser !== null; + 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; + } +} \ No newline at end of file diff --git a/src/Repository/ContentRepository.php b/src/Repository/ContentRepository.php index bafb3a7bd7..d88fe0179d 100644 --- a/src/Repository/ContentRepository.php +++ b/src/Repository/ContentRepository.php @@ -8,12 +8,14 @@ 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\Pagination\Transformation\ExtendedContentPopulationTransformer; use App\Utils\SqlHelpers; use Doctrine\DBAL\Exception; use Doctrine\ORM\EntityManagerInterface; @@ -30,7 +32,7 @@ class ContentRepository public function __construct( private readonly Security $security, private readonly EntityManagerInterface $entityManager, - private readonly ContentPopulationTransformer $contentPopulationTransformer, + private readonly ExtendedContentPopulationTransformerFactory $contentPopulationTransformerFactory, 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, @@ -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/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 a1e65484ad..d08e64066d 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 73013ef1ed..2ca5fb2114 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 e13183a28d..8ee61a142a 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/translations/messages.en.yaml b/translations/messages.en.yaml index 1e6b7d28bd..86db81632b 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 From 25dc7d1082825a03fefd4ebd6dd28008e51e2e7e Mon Sep 17 00:00:00 2001 From: blued_gear Date: Sat, 15 Aug 2026 16:58:10 +0000 Subject: [PATCH 2/6] add admin setting for including/excluding comments in feed --- src/DTO/SettingsDto.php | 6 ++++++ src/Form/SettingsType.php | 2 ++ src/Repository/ContentRepository.php | 6 ++++-- src/Service/SettingsManager.php | 2 ++ templates/admin/settings.html.twig | 10 ++++++++++ translations/messages.en.yaml | 3 +++ 6 files changed, 27 insertions(+), 2 deletions(-) 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/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/Repository/ContentRepository.php b/src/Repository/ContentRepository.php index d88fe0179d..c8406724d0 100644 --- a/src/Repository/ContentRepository.php +++ b/src/Repository/ContentRepository.php @@ -16,6 +16,7 @@ use App\Pagination\Pagerfanta; use App\Pagination\Transformation\ContentPopulationTransformer; use App\Pagination\Transformation\ExtendedContentPopulationTransformer; +use App\Service\SettingsManager; use App\Utils\SqlHelpers; use Doctrine\DBAL\Exception; use Doctrine\ORM\EntityManagerInterface; @@ -33,6 +34,7 @@ public function __construct( private readonly Security $security, private readonly EntityManagerInterface $entityManager, private readonly ExtendedContentPopulationTransformerFactory $contentPopulationTransformerFactory, + private readonly SettingsManager $settingsManager, private readonly CacheInterface $cache, private readonly LoggerInterface $logger, private readonly KernelInterface $kernel, @@ -111,8 +113,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, 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/translations/messages.en.yaml b/translations/messages.en.yaml index 86db81632b..5be606e093 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -1116,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. From 34083a345c3a54131e59142e231e7f22ee42cf32 Mon Sep 17 00:00:00 2001 From: blued_gear Date: Sat, 15 Aug 2026 16:58:51 +0000 Subject: [PATCH 3/6] linter --- src/Entity/Traits/ExtendedContentTrait.php | 11 ++++--- ...dedContentPopulationTransformerFactory.php | 10 +++--- .../ExtendedContentPopulationTransformer.php | 33 +++++++++---------- src/Repository/ContentRepository.php | 2 -- 4 files changed, 28 insertions(+), 28 deletions(-) diff --git a/src/Entity/Traits/ExtendedContentTrait.php b/src/Entity/Traits/ExtendedContentTrait.php index 5a0bbb1168..989fccdf41 100644 --- a/src/Entity/Traits/ExtendedContentTrait.php +++ b/src/Entity/Traits/ExtendedContentTrait.php @@ -1,4 +1,5 @@ - * May contain the following properties: - * - boostUsers: - * - desc: list of (followed) users who boosted this item + when it was boosted - * - value: ['user' => User, 'time' => DateTimeImmutable][] + * 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 = []; -} \ No newline at end of file +} diff --git a/src/Factory/ExtendedContentPopulationTransformerFactory.php b/src/Factory/ExtendedContentPopulationTransformerFactory.php index 0229c3d141..d6d5a5c1ec 100644 --- a/src/Factory/ExtendedContentPopulationTransformerFactory.php +++ b/src/Factory/ExtendedContentPopulationTransformerFactory.php @@ -1,4 +1,5 @@ entityManager, $this->userRepository, @@ -24,4 +26,4 @@ public function create(Criteria $criteria, ?User $loggedInUser): ExtendedContent $loggedInUser, ); } -} \ No newline at end of file +} diff --git a/src/Pagination/Transformation/ExtendedContentPopulationTransformer.php b/src/Pagination/Transformation/ExtendedContentPopulationTransformer.php index 9fc2939123..3a4f9ee66d 100644 --- a/src/Pagination/Transformation/ExtendedContentPopulationTransformer.php +++ b/src/Pagination/Transformation/ExtendedContentPopulationTransformer.php @@ -1,4 +1,5 @@ extendItems($input, $items); return $items; } /** - * @param array $rows * @param array $items - * @return void */ - private function extendItems(array $rows, array $items): void { - assert(count($rows) === count($items)); + private function extendItems(array $rows, array $items): void + { + \assert(\count($rows) === \count($items)); - $hasUser = $this->loggedInUser !== null; + $hasUser = null !== $this->loggedInUser; foreach ($items as $i => $item) { $row = $rows[$i]; - assert($row['id'] === $item->getId()); + \assert($row['id'] === $item->getId()); - if($hasUser && isset($row['was_boosted']) && true === $row['was_boosted']){ + if ($hasUser && isset($row['was_boosted']) && true === $row['was_boosted']) { $this->extendItemBoostList($item); } } @@ -57,7 +56,7 @@ private function extendItems(array $rows, array $items): void { private function extendItemBoostList(Entry|EntryComment|Post|PostComment $item): void { - switch (get_class($item)) { + switch (\get_class($item)) { case Entry::class: $vType = 'entry'; $fkType = 'entry'; @@ -78,7 +77,7 @@ private function extendItemBoostList(Entry|EntryComment|Post|PostComment $item): throw new \LogicException('unreachable'); } - if(null === $this->criteria->cachedUserFollows) { + 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)); @@ -102,15 +101,15 @@ private function extendItemBoostList(Entry|EntryComment|Post|PostComment $item): } $boostInfo = $boostsQuery->executeQuery()->fetchAllAssociative(); - $boostUsers = $this->userRepository->findBy(['id' => array_map(fn($row) => $row['user_id'], $boostInfo)]); + $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)]; + $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']); + usort($boostExtension, fn ($a, $b) => $a['time'] <=> $b['time']); $item->extendedContentProperties['boostUsers'] = $boostExtension; } -} \ No newline at end of file +} diff --git a/src/Repository/ContentRepository.php b/src/Repository/ContentRepository.php index c8406724d0..06247f7318 100644 --- a/src/Repository/ContentRepository.php +++ b/src/Repository/ContentRepository.php @@ -14,8 +14,6 @@ use App\Pagination\Cursor\NativeQueryCursorAdapter; use App\Pagination\NativeQueryAdapter; use App\Pagination\Pagerfanta; -use App\Pagination\Transformation\ContentPopulationTransformer; -use App\Pagination\Transformation\ExtendedContentPopulationTransformer; use App\Service\SettingsManager; use App\Utils\SqlHelpers; use Doctrine\DBAL\Exception; From 9eab6d09f5e850f1bf47f09f6b95888bb1924859 Mon Sep 17 00:00:00 2001 From: blued_gear Date: Sat, 15 Aug 2026 17:41:57 +0000 Subject: [PATCH 4/6] fix tests --- .../Admin/InstanceSettingsRetrieveApiTest.php | 4 +- .../Admin/InstanceSettingsUpdateApiTest.php | 39 +++---------------- 2 files changed, 9 insertions(+), 34 deletions(-) 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!"); } From 0b5b46a5cbbcf379d8faf73e69063ea27ec57908 Mon Sep 17 00:00:00 2001 From: blued_gear Date: Sat, 15 Aug 2026 17:49:58 +0000 Subject: [PATCH 5/6] hide overflow in boosted_by --- assets/styles/widgets/boosted_by.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/assets/styles/widgets/boosted_by.scss b/assets/styles/widgets/boosted_by.scss index 744392fde0..1c4730cd64 100644 --- a/assets/styles/widgets/boosted_by.scss +++ b/assets/styles/widgets/boosted_by.scss @@ -1,4 +1,5 @@ .boosted-by { font-size: 0.8rem; max-height: 2lh; + overflow: hidden; } From 3dd16f95a4d7f4d59b0ebab2daedfebb08a33783 Mon Sep 17 00:00:00 2001 From: Melroy van den Berg Date: Wed, 9 Sep 2026 14:48:54 +0200 Subject: [PATCH 6/6] Expose boost attribution in combined API --- .../Api/Combined/CombinedRetrieveApi.php | 57 ++++++++++++------- src/DTO/ContentBoostResponseDto.php | 27 +++++++++ src/DTO/ContentResponseDto.php | 4 ++ .../Api/Combined/CombinedRetrieveApiTest.php | 21 +++++++ 4 files changed, 89 insertions(+), 20 deletions(-) create mode 100644 src/DTO/ContentBoostResponseDto.php 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/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']);