diff --git a/app/Community/Actions/BuildAggregateRecentForumPostsDataAction.php b/app/Community/Actions/BuildAggregateRecentForumPostsDataAction.php index c690b951d1..5bec358cf2 100644 --- a/app/Community/Actions/BuildAggregateRecentForumPostsDataAction.php +++ b/app/Community/Actions/BuildAggregateRecentForumPostsDataAction.php @@ -8,6 +8,7 @@ use App\Data\PaginatedData; use App\Enums\Permissions; use App\Models\ForumTopic; +use App\Models\ForumTopicComment; use App\Support\Shortcode\Shortcode; use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Facades\DB; @@ -18,14 +19,35 @@ */ class BuildAggregateRecentForumPostsDataAction { + /** Maximum comments to inspect for the masked recent-topics paginator. */ + private const int MASKED_SCAN_WINDOW = 20000; + + /** + * @param array $maskedAuthorIds authors the viewer has blocked + */ public function execute( int $permissions = Permissions::Unregistered, ?int $page = null, int $limit = 25, ?string $paginationPath = null, array $paginationQuery = [], + array $maskedAuthorIds = [], ): PaginatedData|array { - $topics = $this->getRecentForumTopics($page, $permissions, $limit); + $currentPage = $page ?? 1; + + if (empty($maskedAuthorIds)) { + $topics = $this->getRecentForumTopics($currentPage, $permissions, $limit); + $total = $this->getTotalRecentForumTopics($permissions); + } else { + $maskedTopics = $this->getMaskedRecentForumTopics( + $currentPage, + $permissions, + $limit, + $maskedAuthorIds, + ); + $topics = $maskedTopics['topics']; + $total = $maskedTopics['total']; + } $shortcodeRecords = Shortcode::fetchRecordsFor(array_column($topics, 'ShortMsg')); @@ -42,9 +64,9 @@ public function execute( // Create a paginated response. $paginator = new LengthAwarePaginator( items: $transformedTopics, - total: $this->getTotalRecentForumTopics($permissions), + total: $total, perPage: $limit, - currentPage: $page, + currentPage: $currentPage, options: [ 'path' => $paginationPath, 'query' => $paginationQuery, @@ -56,22 +78,119 @@ public function execute( private function getTotalRecentForumTopics(int $permissions = Permissions::Unregistered): int { - return ForumTopic::where("required_permissions", "<=", $permissions) - ->whereNull("deleted_at") + return ForumTopic::where('required_permissions', '<=', $permissions) + ->whereNull('deleted_at') ->where(function ($query) { $query - ->whereNotNull("latest_comment_id") - ->orWhereIn("id", function ($subQuery) { + ->whereNotNull('latest_comment_id') + ->orWhereIn('id', function ($subQuery) { $subQuery - ->select("forum_topic_id") + ->select('forum_topic_id') ->distinct() - ->from("forum_topic_comments") - ->where("is_authorized", 1); + ->from('forum_topic_comments') + ->where('is_authorized', 1); }); }) ->count(); } + /** + * @param array $maskedAuthorIds + * @return array{topics: array>, total: int} + */ + private function getMaskedRecentForumTopics( + int $page, + int $permissions, + int $count, + array $maskedAuthorIds, + ): array { + $offset = ($page - 1) * $count; + + $recentVisibleComments = ForumTopicComment::query() + ->select(['forum_topic_comments.id', 'forum_topic_comments.forum_topic_id']) + ->join('forum_topics', 'forum_topics.id', '=', 'forum_topic_comments.forum_topic_id') + ->where('forum_topic_comments.is_authorized', 1) + ->whereNotIn('forum_topic_comments.author_id', $maskedAuthorIds) + ->whereNotIn('forum_topics.author_id', $maskedAuthorIds) + ->where('forum_topics.required_permissions', '<=', $permissions) + ->whereNull('forum_topics.deleted_at') + ->orderByDesc('forum_topic_comments.created_at') + ->orderByDesc('forum_topic_comments.id') + ->limit(self::MASKED_SCAN_WINDOW) + ->toBase() + ->get(); + + $latestVisibleCommentIdByTopic = []; + foreach ($recentVisibleComments as $comment) { + $latestVisibleCommentIdByTopic[(int) $comment->forum_topic_id] ??= (int) $comment->id; + } + + $pagedCommentIds = array_slice(array_values($latestVisibleCommentIdByTopic), $offset, $count); + + return [ + 'topics' => empty($pagedCommentIds) ? [] : $this->hydrateTopicsFromComments($pagedCommentIds, $maskedAuthorIds), + 'total' => count($latestVisibleCommentIdByTopic), + ]; + } + + /** + * @param array $commentIds + * @param array $maskedAuthorIds + */ + private function hydrateTopicsFromComments(array $commentIds, array $maskedAuthorIds): array + { + $oneDayAgo = now()->subDay()->toDateTimeString(); + $sevenDaysAgo = now()->subDays(7)->toDateTimeString(); + + $countsOneDay = ForumTopicComment::query() + ->selectRaw('forum_topic_id, MIN(id) AS CommentID, COUNT(*) AS Count') + ->where('is_authorized', 1) + ->whereNotIn('author_id', $maskedAuthorIds) + ->where('created_at', '>=', $oneDayAgo) + ->groupBy('forum_topic_id'); + + $countsSevenDays = ForumTopicComment::query() + ->selectRaw('forum_topic_id, MIN(id) AS CommentID, COUNT(*) AS Count') + ->where('is_authorized', 1) + ->whereNotIn('author_id', $maskedAuthorIds) + ->where('created_at', '>=', $sevenDaysAgo) + ->groupBy('forum_topic_id'); + + $results = ForumTopicComment::query() + ->select([ + 'forum_topics.id as ForumTopicID', + 'forum_topics.title as ForumTopicTitle', + 'f.id as ForumID', + 'f.title as ForumTitle', + 'forum_topic_comments.id as CommentID', + 'forum_topic_comments.created_at as PostedAt', + 'forum_topic_comments.author_id', + 'ua.username as Author', + 'ua.display_name as AuthorDisplayName', + 'ua.avatar_updated_at', + 'forum_topic_comments.body as ShortMsg', + 'd1.CommentID as CommentID_1d', + 'd1.Count as Count_1d', + 'd7.CommentID as CommentID_7d', + 'd7.Count as Count_7d', + ]) + ->selectRaw('0 AS IsTruncated') + ->join('forum_topics', 'forum_topics.id', '=', 'forum_topic_comments.forum_topic_id') + ->join('forums as f', 'f.id', '=', 'forum_topics.forum_id') + ->leftJoin('users as ua', 'ua.id', '=', 'forum_topic_comments.author_id') + ->leftJoinSub($countsOneDay, 'd1', 'd1.forum_topic_id', '=', 'forum_topics.id') + ->leftJoinSub($countsSevenDays, 'd7', 'd7.forum_topic_id', '=', 'forum_topics.id') + ->whereIn('forum_topic_comments.id', $commentIds) + ->get() + ->map(fn (ForumTopicComment $comment): array => $comment->getAttributes()) + ->keyBy(fn (array $topic): int => (int) $topic['CommentID']); + + return array_values(array_filter(array_map( + fn (int $commentId): ?array => $results->get($commentId), + $commentIds, + ))); + } + private function getRecentForumTopics(int $page = 1, int $permissions = Permissions::Unregistered, int $count = 25): array { $offset = ($page - 1) * $count; diff --git a/app/Community/Actions/BuildShowForumTopicPagePropsAction.php b/app/Community/Actions/BuildShowForumTopicPagePropsAction.php index eab8a5aa3e..1443892314 100644 --- a/app/Community/Actions/BuildShowForumTopicPagePropsAction.php +++ b/app/Community/Actions/BuildShowForumTopicPagePropsAction.php @@ -45,8 +45,20 @@ public function execute( return ['props' => null, 'redirectToPage' => $lastPage]; } + $maskedAuthorIds = (new GetMaskedForumAuthorIdsAction())->execute($user); + $canRevealMaskedPosts = $user && (new ForumTopicCommentPolicy())->manage($user); + $comments = $paginatedForumTopicComments->getCollection()->values(); + // Extract the post bodies for processing before they're sent to the UI. - $postBodies = $paginatedForumTopicComments->getCollection()->pluck('body')->all(); + $postBodies = $comments->pluck('body')->all(); + + if (!$canRevealMaskedPosts) { + foreach ($comments as $index => $comment) { + if (in_array($comment->author_id, $maskedAuthorIds, true)) { + $postBodies[$index] = ''; + } + } + } // Convert user ID shortcodes to use display names. $updatedBodies = (new ConvertUserShortcodesFromIdsToDisplayNamesAction())->execute($postBodies); @@ -83,8 +95,8 @@ public function execute( } // Finally, update the message bodies sent to the UI with the converted user shortcodes. - $forumTopicComments = $paginatedForumTopicComments->getCollection()->map( - function ($comment, $index) use ($updatedBodies, $user, $accessibleTeamIds) { + $forumTopicComments = $comments->map( + function ($comment, $index) use ($updatedBodies, $user, $accessibleTeamIds, $maskedAuthorIds, $canRevealMaskedPosts) { $comment->body = $updatedBodies[$index]; $includes = [ @@ -103,7 +115,7 @@ function ($comment, $index) use ($updatedBodies, $user, $accessibleTeamIds) { */ $shouldIncludeSentByEditedBy = $user && ( ($comment->sent_by_id !== null && in_array($comment->author_id, $accessibleTeamIds, true)) - || ($comment->edited_by_id !== null && (new ForumTopicCommentPolicy())->manage($user)) + || ($comment->edited_by_id !== null && $canRevealMaskedPosts) ); if ($shouldIncludeSentByEditedBy) { @@ -111,13 +123,26 @@ function ($comment, $index) use ($updatedBodies, $user, $accessibleTeamIds) { $includes[] = 'editedBy'; } - return ForumTopicCommentData::from($comment)->include(...$includes); + $data = ForumTopicCommentData::from($comment); + $data->isFromBlockedUser = in_array($comment->author_id, $maskedAuthorIds, true); + + return $data->include(...$includes); } )->all(); - $comments = collect($forumTopicComments); - /** @var ForumTopicComment $selectedComment */ - $selectedComment = $comments->firstWhere('id', $selectedCommentId) ?? $comments->first(); + $commentData = collect($forumTopicComments); + /** @var ForumTopicCommentData|null $selectedComment */ + $selectedComment = $commentData->firstWhere('id', $selectedCommentId) ?? $commentData->first(); + + if ($selectedComment->isFromBlockedUser && !$canRevealMaskedPosts) { + $selectedComment = $commentData->last( + fn (ForumTopicCommentData $comment): bool => !$comment->isFromBlockedUser, + ); + } + + $metaDescription = $selectedComment + ? Shortcode::stripAndClamp($selectedComment->body, 220) + : $topic->title; $props = new ShowForumTopicPagePropsData( accessibleTeamAccounts: $accessibleTeamAccounts, @@ -145,7 +170,7 @@ function ($comment, $index) use ($updatedBodies, $user, $accessibleTeamIds) { total: $paginatedForumTopicComments->total(), items: $forumTopicComments ), - metaDescription: Shortcode::stripAndClamp($selectedComment->body, 220), + metaDescription: $metaDescription, ); return ['props' => $props, 'redirectToPage' => null]; diff --git a/app/Community/Actions/BuildThinRecentForumPostsDataAction.php b/app/Community/Actions/BuildThinRecentForumPostsDataAction.php index d4c9ddfe66..d1c671cad8 100644 --- a/app/Community/Actions/BuildThinRecentForumPostsDataAction.php +++ b/app/Community/Actions/BuildThinRecentForumPostsDataAction.php @@ -12,7 +12,11 @@ class BuildThinRecentForumPostsDataAction { + /** Extra comments to read when masked authors can remove rows after the topic join. */ + public const int MASKED_AUTHOR_SCAN_BUFFER = 250; + /** + * @param array $maskedAuthorIds authors the viewer has blocked * @return Collection */ public function execute( @@ -20,14 +24,16 @@ public function execute( int $numMessageChars = 260, ?int $permissions = Permissions::Unregistered, ?int $fromAuthorId = null, + array $maskedAuthorIds = [], ): Collection { $userClause = $this->buildUserClause($fromAuthorId, $permissions); $subQuery = DB::table('forum_topic_comments as ftc') ->select('*') ->whereRaw($userClause) + ->whereNotIn('ftc.author_id', $maskedAuthorIds) ->orderBy('ftc.created_at', 'desc') - ->limit($limit + 20); // cater for spam messages + ->limit($limit + 20 + (empty($maskedAuthorIds) ? 0 : self::MASKED_AUTHOR_SCAN_BUFFER)); // cater for spam messages $latestComments = DB::table(DB::raw("({$subQuery->toSql()}) as LatestComments")) ->mergeBindings($subQuery) @@ -47,6 +53,7 @@ public function execute( ]) ->where('ft.required_permissions', '<=', $permissions ?? Permissions::Unregistered) ->whereNull('ft.deleted_at') + ->whereNotIn('ft.author_id', $maskedAuthorIds) ->orderBy('LatestComments.created_at', 'desc') ->limit($limit) ->get(); diff --git a/app/Community/Actions/GetMaskedForumAuthorIdsAction.php b/app/Community/Actions/GetMaskedForumAuthorIdsAction.php new file mode 100644 index 0000000000..429ec39983 --- /dev/null +++ b/app/Community/Actions/GetMaskedForumAuthorIdsAction.php @@ -0,0 +1,50 @@ + + */ + public function execute(?User $viewer): array + { + if (!$viewer) { + return []; + } + + return Cache::remember( + CacheKey::buildUserMaskedForumAuthorIdsCacheKey($viewer->id), + now()->addMinutes(5), + function () use ($viewer): array { + $blockedIds = $viewer->blockedUsers()->pluck('users.id')->all(); + + if (empty($blockedIds)) { + return []; + } + + // Official team communication stays visible no matter who a + // viewer has blocked. Team accounts speak for the website itself. + $teamAccountIds = User::whereIn('username', array_keys(config('teams.accounts', []))) + ->pluck('id') + ->all(); + + return array_values(array_map( + 'intval', + array_diff($blockedIds, $teamAccountIds), + )); + }, + ); + } +} diff --git a/app/Community/Components/ForumRecentActivity.php b/app/Community/Components/ForumRecentActivity.php index 5af8dc5655..b38b338b3e 100644 --- a/app/Community/Components/ForumRecentActivity.php +++ b/app/Community/Components/ForumRecentActivity.php @@ -4,6 +4,7 @@ namespace App\Community\Components; +use App\Community\Actions\GetMaskedForumAuthorIdsAction; use App\Enums\Permissions; use App\Enums\UserPreference; use App\Models\User; @@ -41,7 +42,8 @@ public function render(): ?View private function prepareRecentForumPosts(int $numToFetch = 4, int $userPermissions = Permissions::Unregistered, int $userPreferences = 0): array { $recentForumPosts = []; - $rawRecentPosts = getRecentForumPosts(0, $numToFetch, 100, $userPermissions); + $maskedAuthorIds = (new GetMaskedForumAuthorIdsAction())->execute($this->user); + $rawRecentPosts = getRecentForumPosts(0, $numToFetch, 100, $userPermissions, maskedAuthorIds: $maskedAuthorIds); if ($rawRecentPosts->isEmpty()) { return $recentForumPosts; diff --git a/app/Community/Concerns/ActsAsCommunityMember.php b/app/Community/Concerns/ActsAsCommunityMember.php index 2ef076383c..b89e42f5f6 100644 --- a/app/Community/Concerns/ActsAsCommunityMember.php +++ b/app/Community/Concerns/ActsAsCommunityMember.php @@ -135,6 +135,14 @@ public function followerUsers(): BelongsToMany return $this->inverseRelatedUsers()->where('status', '=', UserRelationStatus::Following); } + /** + * @return BelongsToMany + */ + public function blockedUsers(): BelongsToMany + { + return $this->relatedUsers()->where('status', '=', UserRelationStatus::Blocked); + } + /** * Rows for users this user follows. * diff --git a/app/Community/Controllers/ForumTopicController.php b/app/Community/Controllers/ForumTopicController.php index 06f5e3db58..59bb8f9b42 100644 --- a/app/Community/Controllers/ForumTopicController.php +++ b/app/Community/Controllers/ForumTopicController.php @@ -6,6 +6,7 @@ use App\Community\Actions\BuildAggregateRecentForumPostsDataAction; use App\Community\Actions\BuildShowForumTopicPagePropsAction; +use App\Community\Actions\GetMaskedForumAuthorIdsAction; use App\Community\Data\RecentPostsPagePropsData; use App\Community\Requests\ForumTopicRequest; use App\Community\Requests\ShowForumTopicRequest; @@ -101,6 +102,7 @@ public function recentPosts( limit: 25, paginationPath: $request->url(), paginationQuery: $request->query(), + maskedAuthorIds: (new GetMaskedForumAuthorIdsAction())->execute($user), ); $props = new RecentPostsPagePropsData($paginatedTopics); diff --git a/app/Data/ForumTopicCommentData.php b/app/Data/ForumTopicCommentData.php index f6a0ceea07..b1ea57dc77 100644 --- a/app/Data/ForumTopicCommentData.php +++ b/app/Data/ForumTopicCommentData.php @@ -21,6 +21,12 @@ public function __construct( public ?Carbon $editedAt, public ?UserData $user, public bool $isAuthorized, // TODO migrate to $authorizedAt + + /** + * True if the viewing user blocked the author of this post. + */ + public bool $isFromBlockedUser = false, + public ?int $forumTopicId = null, // TODO remove and use $forumTopic instead public Lazy|ForumTopicData|null $forumTopic = null, public Lazy|UserData|null $sentBy = null, @@ -38,6 +44,7 @@ public static function fromForumTopicComment(ForumTopicComment $comment): self editedAt: $comment->edited_at, user: UserData::from($comment->user), isAuthorized: $comment->is_authorized, + isFromBlockedUser: false, forumTopicId: $comment->forum_topic_id, forumTopic: Lazy::create(fn () => $comment->forumTopic ? ForumTopicData::fromForumTopic($comment->forumTopic) : null), sentBy: Lazy::create(fn () => $comment->sent_by_id ? UserData::from($comment->sentBy) : null), diff --git a/app/Helpers/database/forum.php b/app/Helpers/database/forum.php index 6f72ec9ef3..b2f0eb8f47 100644 --- a/app/Helpers/database/forum.php +++ b/app/Helpers/database/forum.php @@ -1,5 +1,6 @@ $maskedAuthorIds + */ +function getForumList(int $categoryID = 0, array $maskedAuthorIds = []): array { $query = DB::table('forums as f') ->selectRaw(' f.id AS ID, f.forum_category_id AS CategoryID, f.title AS Title, f.description AS Description, f.order_column AS DisplayOrder, fc.title AS CategoryName, fc.Description AS CategoryDescription, COUNT(DISTINCT ft.id) AS NumTopics, COUNT( ft.id ) AS NumPosts, - ftc2.id AS LastPostID, ua.username AS LastPostAuthor, ftc2.created_at AS LastPostCreated, + ftc2.id AS LastPostID, ftc2.author_id AS LastPostAuthorID, ua.username AS LastPostAuthor, ftc2.created_at AS LastPostCreated, ft2.title AS LastPostTopicName, ft2.id AS LastPostTopicID ') ->leftJoin('forum_categories as fc', 'fc.id', '=', 'f.forum_category_id') @@ -42,24 +46,61 @@ function getForumList(int $categoryID = 0): array ->orderBy('f.order_column') ->orderBy('f.id'); - return $query->get() + $forums = $query->get() ->map(fn ($row): array => (array) $row) ->toArray(); + + // Masked (blocked) most recent users are swapped for the + // most recent post the current user can see. + foreach ($forums as $index => $forum) { + $lastPostAuthorId = $forum['LastPostAuthorID']; + if ($lastPostAuthorId === null || !in_array((int) $lastPostAuthorId, $maskedAuthorIds, true)) { + continue; + } + + $replacement = ForumTopicComment::query() + ->with(['user', 'forumTopic']) + ->whereNotIn('author_id', $maskedAuthorIds) + ->whereHas('forumTopic', function ($query) use ($forum, $maskedAuthorIds) { + $query->where('forum_id', $forum['ID']) + ->whereNotIn('author_id', $maskedAuthorIds); + }) + ->orderByDesc('created_at') + ->first(); + + $forums[$index]['LastPostID'] = $replacement?->id; + $forums[$index]['LastPostAuthor'] = $replacement?->user?->username; + $forums[$index]['LastPostCreated'] = $replacement?->created_at?->toDateTimeString(); + $forums[$index]['LastPostTopicName'] = $replacement?->forumTopic?->title; + $forums[$index]['LastPostTopicID'] = $replacement?->forumTopic?->id; + } + + return $forums; } -function getForumTopics(int $forumID, int $offset, int $count, int $permissions, ?int &$maxCountOut): array -{ +/** + * @param array $maskedAuthorIds authors the viewing user has blocked + */ +function getForumTopics( + int $forumID, + int $offset, + int $count, + int $permissions, + ?int &$maxCountOut, + array $maskedAuthorIds = [], +): array { $maxCountOut = DB::table('forum_topics') ->join('forum_topic_comments as ftc', 'ftc.id', '=', 'forum_topics.latest_comment_id') ->where('forum_topics.forum_id', $forumID) ->where('ftc.is_authorized', 1) ->where('forum_topics.required_permissions', '<=', $permissions) ->whereNull('forum_topics.deleted_at') + ->whereNotIn('forum_topics.author_id', $maskedAuthorIds) ->count(); $dataOut = DB::table('forum_topics as ft') ->selectRaw(' - f.title AS ForumTitle, ft.id AS ForumTopicID, ft.title AS TopicTitle, LEFT( ftc2.body, 54 ) AS TopicPreview, + f.title AS ForumTitle, ft.id AS ForumTopicID, ft.title AS TopicTitle, SUBSTR( ftc2.body, 1, 54 ) AS TopicPreview, ft.author_id AS AuthorID, ft.created_at AS ForumTopicPostedDate, ftc.id AS LatestCommentID, ftc.author_id AS LatestCommentAuthorID, ftc.created_at AS LatestCommentPostedDate, (COUNT(ftc2.id)-1) AS NumTopicReplies ') @@ -72,6 +113,7 @@ function getForumTopics(int $forumID, int $offset, int $count, int $permissions, ->where('ft.forum_id', $forumID) ->where('ft.required_permissions', '<=', $permissions) ->whereNull('ft.deleted_at') + ->whereNotIn('ft.author_id', $maskedAuthorIds) ->groupBy('ft.id', 'LatestCommentPostedDate') ->havingRaw('NumTopicReplies >= 0') ->orderByDesc('LatestCommentPostedDate') @@ -82,9 +124,74 @@ function getForumTopics(int $forumID, int $offset, int $count, int $permissions, ->values() ->toArray(); + if (!empty($maskedAuthorIds)) { + $dataOut = replaceMaskedLatestComments($dataOut, $maskedAuthorIds); + } + return $dataOut; } +/** + * @param array> $topicRows + * @param array $maskedAuthorIds + * @return array> + */ +function replaceMaskedLatestComments(array $topicRows, array $maskedAuthorIds): array +{ + $affectedTopicIds = []; + foreach ($topicRows as $row) { + if (in_array((int) $row['LatestCommentAuthorID'], $maskedAuthorIds, true)) { + $affectedTopicIds[] = (int) $row['ForumTopicID']; + } + } + + if (empty($affectedTopicIds)) { + return $topicRows; + } + + $replacements = DB::table('forum_topic_comments as ftc') + ->select(['ftc.forum_topic_id', 'ftc.id', 'ftc.author_id', 'ftc.created_at']) + ->whereIn('ftc.forum_topic_id', $affectedTopicIds) + ->where('ftc.is_authorized', 1) + ->whereNotIn('ftc.author_id', $maskedAuthorIds) + ->whereNull('ftc.deleted_at') + ->whereNotExists(function ($query) use ($maskedAuthorIds): void { + $query->selectRaw('1') + ->from('forum_topic_comments as newer_ftc') + ->whereColumn('newer_ftc.forum_topic_id', 'ftc.forum_topic_id') + ->where('newer_ftc.is_authorized', 1) + ->whereNotIn('newer_ftc.author_id', $maskedAuthorIds) + ->whereNull('newer_ftc.deleted_at') + ->where(function ($query): void { + $query->whereColumn('newer_ftc.created_at', '>', 'ftc.created_at') + ->orWhere(function ($query): void { + $query->whereColumn('newer_ftc.created_at', 'ftc.created_at') + ->whereColumn('newer_ftc.id', '>', 'ftc.id'); + }); + }); + }) + ->get(); + + $newestVisibleByTopic = []; + foreach ($replacements as $replacement) { + $newestVisibleByTopic[(int) $replacement->forum_topic_id] = $replacement; + } + + foreach ($topicRows as $index => $row) { + if (!in_array((int) $row['LatestCommentAuthorID'], $maskedAuthorIds, true)) { + continue; + } + + $replacement = $newestVisibleByTopic[(int) $row['ForumTopicID']] ?? null; + + $topicRows[$index]['LatestCommentID'] = $replacement ? (int) $replacement->id : null; + $topicRows[$index]['LatestCommentAuthorID'] = $replacement ? (int) $replacement->author_id : null; + $topicRows[$index]['LatestCommentPostedDate'] = $replacement?->created_at; + } + + return $topicRows; +} + function getUnauthorisedForumLinks(): array { $dataOut = DB::table('forum_topics as ft') @@ -305,6 +412,7 @@ function generateGameForumTopic(User $user, int $gameId): ?ForumTopicComment } /** + * @param array $maskedAuthorIds authors the viewing user has blocked * @return Collection */ function getRecentForumPosts( @@ -313,6 +421,7 @@ function getRecentForumPosts( int $numMessageChars, ?int $permissions = Permissions::Unregistered, ?int $fromAuthorId = null, + array $maskedAuthorIds = [], ): Collection { $effectivePermissions = $permissions ?? Permissions::Unregistered; @@ -330,9 +439,10 @@ function ($query): void { $query->where('ftc.is_authorized', 1); } ) + ->whereNotIn('ftc.author_id', $maskedAuthorIds) ->orderByDesc('ftc.created_at') ->offset($offset) - ->limit($limit + 20); // cater for 20 spam messages + ->limit($limit + 20 + (empty($maskedAuthorIds) ? 0 : BuildThinRecentForumPostsDataAction::MASKED_AUTHOR_SCAN_BUFFER)); $query = DB::query() ->fromSub($latestComments, 'LatestComments') @@ -351,6 +461,7 @@ function ($query): void { ->leftJoin('users as ua', 'ua.id', '=', 'LatestComments.author_id') ->where('ft.required_permissions', '<=', $effectivePermissions) ->whereNull('ft.deleted_at') + ->whereNotIn('ft.author_id', $maskedAuthorIds) ->orderByDesc('LatestComments.created_at') ->limit($limit); diff --git a/app/Http/Controllers/HomeController.php b/app/Http/Controllers/HomeController.php index d3fa0465ce..ab66a3611a 100644 --- a/app/Http/Controllers/HomeController.php +++ b/app/Http/Controllers/HomeController.php @@ -7,6 +7,7 @@ use App\Community\Actions\BuildActivePlayersAction; use App\Community\Actions\BuildThinRecentForumPostsDataAction; use App\Community\Actions\FetchGameActivityDataAction; +use App\Community\Actions\GetMaskedForumAuthorIdsAction; use App\Community\Enums\AwardType; use App\Community\Enums\ClaimStatus; use App\Community\Enums\GameActivitySnapshotType; @@ -69,6 +70,7 @@ public function index( $permissions = $user ? (int) $user->getAttribute('Permissions') : Permissions::Unregistered; $recentForumPosts = $buildThinRecentForumPostsData->execute( permissions: $permissions, + maskedAuthorIds: (new GetMaskedForumAuthorIdsAction())->execute($user), ); $userCurrentGameData = $buildUserCurrentGameData->execute($user); diff --git a/app/Observers/UserRelationObserver.php b/app/Observers/UserRelationObserver.php new file mode 100644 index 0000000000..56763a3701 --- /dev/null +++ b/app/Observers/UserRelationObserver.php @@ -0,0 +1,27 @@ +forgetMaskedForumAuthorIds($userRelation); + } + + public function deleted(UserRelation $userRelation): void + { + $this->forgetMaskedForumAuthorIds($userRelation); + } + + private function forgetMaskedForumAuthorIds(UserRelation $userRelation): void + { + Cache::forget(CacheKey::buildUserMaskedForumAuthorIdsCacheKey($userRelation->user_id)); + } +} diff --git a/app/Policies/ForumTopicCommentPolicy.php b/app/Policies/ForumTopicCommentPolicy.php index eea23ec611..53ad62dd52 100644 --- a/app/Policies/ForumTopicCommentPolicy.php +++ b/app/Policies/ForumTopicCommentPolicy.php @@ -49,11 +49,6 @@ public function view(?User $user, ForumTopicComment $comment): bool return false; } - public function viewUserPosts(User $currentUser, User $targetUser): bool - { - return !$targetUser->isBlocking($currentUser); - } - public function create(User $user, ForumTopic $topic, ?User $teamAccount = null): bool { if ($user->isMuted()) { diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php index 195b8a98b3..3dc68bfba7 100755 --- a/app/Providers/EventServiceProvider.php +++ b/app/Providers/EventServiceProvider.php @@ -20,6 +20,7 @@ use App\Models\TicketComment; use App\Models\User; use App\Models\UserComment; +use App\Models\UserRelation; use App\Observers\CommentObserver; use App\Observers\EventAchievementObserver; use App\Observers\ForumTopicCommentObserver; @@ -30,6 +31,7 @@ use App\Observers\LeaderboardEntryObserver; use App\Observers\TicketObserver; use App\Observers\UserObserver; +use App\Observers\UserRelationObserver; use App\Platform\Events\SiteBadgeAwarded; use Illuminate\Auth\Events\Login; use Illuminate\Auth\Events\Registered; @@ -89,6 +91,7 @@ class EventServiceProvider extends ServiceProvider public function boot(): void { User::observe(UserObserver::class); + UserRelation::observe(UserRelationObserver::class); foreach ([Comment::class, AchievementComment::class, GameComment::class, TicketComment::class, UserComment::class] as $commentClass) { $commentClass::observe(CommentObserver::class); diff --git a/app/Support/Cache/CacheKey.php b/app/Support/Cache/CacheKey.php index 8191b08902..82d22d765c 100644 --- a/app/Support/Cache/CacheKey.php +++ b/app/Support/Cache/CacheKey.php @@ -61,6 +61,16 @@ public static function buildUserExpiringClaimsCacheKey(string $username): string return self::buildNormalizedUserCacheKey($username, "expiring-claims"); } + public static function buildUserMaskedForumAuthorIdsCacheKey(int $userId): string + { + $teamAccountUsernames = array_keys(config('teams.accounts', [])); + sort($teamAccountUsernames); + + return self::buildNormalizedCacheKey("user", $userId, "masked-forum-author-ids", [ + substr(md5(implode(',', $teamAccountUsernames)), 0, 8), + ]); + } + public static function buildUnsubscribeUndoTokenCacheKey(string $token): string { return self::buildNormalizedCacheKey("unsubscribe", "undo", $token); diff --git a/lang/en_US.json b/lang/en_US.json index c008dfd38c..3fce29ed2c 100644 --- a/lang/en_US.json +++ b/lang/en_US.json @@ -154,10 +154,13 @@ "Hardcore": "Hardcore", "Has achievements": "Has achievements", "Hash compatibility questions.": "Hash compatibility questions.", + "Hidden post": "Hidden post", + "Hidden post from {{displayName}}": "Hidden post from {{displayName}}", "Hide": "Hide", "Hide missable achievement indicators": "Hide missable achievement indicators", "Show evergreen event achievement indicators": "Show evergreen event achievement indicators", "Achievements in time-limited events are always marked on game pages. Turn this on to also mark achievements in evergreen events, which never expire.": "Achievements in time-limited events are always marked on game pages. Turn this on to also mark achievements in evergreen events, which never expire.", + "Show post": "Show post", "I have an issue with this achievement that is not described above.": "I have an issue with this achievement that is not described above.", "I met the requirements, but the achievement did not trigger.": "I met the requirements, but the achievement did not trigger.", "It did trigger on a later attempt.": "It did trigger on a later attempt.", diff --git a/resources/js/features/forums/components/ForumPostCard/BlockedPostNotice/BlockedPostNotice.test.tsx b/resources/js/features/forums/components/ForumPostCard/BlockedPostNotice/BlockedPostNotice.test.tsx new file mode 100644 index 0000000000..3d49035c5b --- /dev/null +++ b/resources/js/features/forums/components/ForumPostCard/BlockedPostNotice/BlockedPostNotice.test.tsx @@ -0,0 +1,55 @@ +import userEvent from '@testing-library/user-event'; + +import { render, screen } from '@/test'; + +import { BlockedPostNotice } from './BlockedPostNotice'; + +describe('Component: BlockedPostNotice', () => { + it('renders without crashing', () => { + // ARRANGE + const { container } = render( + , + ); + + // ASSERT + expect(container).toBeTruthy(); + }); + + it('given no reveal handler, it names nobody and offers no control', () => { + // ARRANGE + render(); + + // ASSERT + expect(screen.getByText('Hidden post')).toBeVisible(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + it('given an author name but no reveal handler, the name is still withheld', () => { + // ARRANGE + render(); + + // ASSERT + expect(screen.getByText('Hidden post')).toBeVisible(); + expect(screen.queryByText(/scott/i)).not.toBeInTheDocument(); + }); + + it('given an author display name and a reveal handler, names them in the notice', () => { + // ARRANGE + render(); + + // ASSERT + expect(screen.getByText(/hidden post from scott/i)).toBeVisible(); + }); + + it('given the user activates the reveal control, calls the reveal handler', async () => { + // ARRANGE + const onReveal = vi.fn(); + render(); + + // ACT + await userEvent.click(screen.getByRole('button', { name: 'Show post' })); + + // ASSERT + expect(onReveal).toHaveBeenCalledOnce(); + }); +}); diff --git a/resources/js/features/forums/components/ForumPostCard/BlockedPostNotice/BlockedPostNotice.tsx b/resources/js/features/forums/components/ForumPostCard/BlockedPostNotice/BlockedPostNotice.tsx new file mode 100644 index 0000000000..18c98bd573 --- /dev/null +++ b/resources/js/features/forums/components/ForumPostCard/BlockedPostNotice/BlockedPostNotice.tsx @@ -0,0 +1,37 @@ +import type { FC } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { BaseButton } from '@/common/components/+vendor/BaseButton'; + +interface BlockedPostNoticeProps { + authorDisplayName?: string; + onReveal?: () => void; +} + +export const BlockedPostNotice: FC = ({ authorDisplayName, onReveal }) => { + const { t } = useTranslation(); + + return ( +
+

+ {onReveal && authorDisplayName + ? t('Hidden post from {{displayName}}', { displayName: authorDisplayName }) + : t('Hidden post')} +

+ + {onReveal ? ( + + {t('Show post')} + + ) : null} +
+ ); +}; diff --git a/resources/js/features/forums/components/ForumPostCard/BlockedPostNotice/index.ts b/resources/js/features/forums/components/ForumPostCard/BlockedPostNotice/index.ts new file mode 100644 index 0000000000..ad0c5e9989 --- /dev/null +++ b/resources/js/features/forums/components/ForumPostCard/BlockedPostNotice/index.ts @@ -0,0 +1 @@ +export * from './BlockedPostNotice'; diff --git a/resources/js/features/forums/components/ForumPostCard/ForumPostCard.test.tsx b/resources/js/features/forums/components/ForumPostCard/ForumPostCard.test.tsx index 39f75503d6..e035d8908d 100644 --- a/resources/js/features/forums/components/ForumPostCard/ForumPostCard.test.tsx +++ b/resources/js/features/forums/components/ForumPostCard/ForumPostCard.test.tsx @@ -1,5 +1,7 @@ +import userEvent from '@testing-library/user-event'; + import { createAuthenticatedUser } from '@/common/models'; -import { render, screen } from '@/test'; +import { render, screen, waitFor } from '@/test'; import { createForumTopic, createForumTopicComment, createUser } from '@/test/factories'; import { ForumPostCard } from './ForumPostCard'; @@ -168,4 +170,103 @@ describe('Component: ForumPostCard', () => { // ASSERT expect(screen.queryByRole('link', { name: /report/i })).not.toBeInTheDocument(); }); + + it('given the post is from a blocked user and the viewer cannot manage posts, shows an inert nameless marker', () => { + // ARRANGE + const comment = createForumTopicComment({ + isFromBlockedUser: true, + user: createUser({ displayName: 'BlockedUser' }), + }); + const topic = createForumTopic(); + + render(, { + pageProps: { + auth: { user: createAuthenticatedUser({ displayName: 'CurrentUser' }) }, + can: { + authorizeForumTopicComments: false, + createModerationReports: false, + manageForumTopicComments: false, // !! + }, + }, + }); + + // ASSERT + expect(screen.getByText('Hidden post')).toBeVisible(); + expect(screen.queryByText(/a masked body/i)).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Show post' })).not.toBeInTheDocument(); + expect(screen.queryByText(/blockeduser/i)).not.toBeInTheDocument(); + }); + + it('given the post is from a blocked user and the viewer can manage posts, hides the body behind a reveal control', () => { + // ARRANGE + const comment = createForumTopicComment({ + isFromBlockedUser: true, + user: createUser({ displayName: 'BlockedUser' }), + }); + const topic = createForumTopic(); + + render(, { + pageProps: { + auth: { user: createAuthenticatedUser({ displayName: 'CurrentUser' }) }, + can: { + authorizeForumTopicComments: false, + createModerationReports: false, + manageForumTopicComments: true, // !! + }, + }, + }); + + // ASSERT + expect(screen.getByRole('button', { name: 'Show post' })).toBeVisible(); + expect(screen.getByText(/hidden post from blockeduser/i)).toBeVisible(); + }); + + it('given the post is not from a blocked user, shows the body and no reveal control', () => { + // ARRANGE + const comment = createForumTopicComment({ + isFromBlockedUser: false, + user: createUser({ displayName: 'OtherUser' }), + }); + const topic = createForumTopic(); + + render(, { + pageProps: { + auth: { user: createAuthenticatedUser({ displayName: 'CurrentUser' }) }, + can: { authorizeForumTopicComments: false, createModerationReports: false }, + }, + }); + + // ASSERT + expect(screen.getByText(/a visible body/i)).toBeVisible(); + expect(screen.queryByRole('button', { name: 'Show post' })).not.toBeInTheDocument(); + }); + + it('given a moderator user reveals a blocked post, shows the body and removes the reveal control', async () => { + // ARRANGE + const comment = createForumTopicComment({ + isFromBlockedUser: true, + user: createUser({ displayName: 'BlockedUser' }), + }); + const topic = createForumTopic(); + + render(, { + pageProps: { + auth: { user: createAuthenticatedUser({ displayName: 'CurrentUser' }) }, + can: { + authorizeForumTopicComments: false, + createModerationReports: false, + manageForumTopicComments: true, + }, + }, + }); + + // ACT + await userEvent.click(screen.getByRole('button', { name: 'Show post' })); + + // ASSERT + expect(screen.getByText(/a masked body/i)).toBeVisible(); + await waitFor(() => { + expect(screen.queryByRole('button', { name: 'Show post' })).not.toBeInTheDocument(); + }); + }); }); diff --git a/resources/js/features/forums/components/ForumPostCard/ForumPostCard.tsx b/resources/js/features/forums/components/ForumPostCard/ForumPostCard.tsx index 17d26326ae..13bd559ecc 100644 --- a/resources/js/features/forums/components/ForumPostCard/ForumPostCard.tsx +++ b/resources/js/features/forums/components/ForumPostCard/ForumPostCard.tsx @@ -1,4 +1,6 @@ -import type { FC } from 'react'; +import { AnimatePresence, useReducedMotion } from 'motion/react'; +import * as m from 'motion/react-m'; +import { type FC, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { LuFlag } from 'react-icons/lu'; import { route } from 'ziggy-js'; @@ -9,11 +11,15 @@ import { ShortcodeRenderer } from '@/common/components/ShortcodeRenderer'; import { usePageProps } from '@/common/hooks/usePageProps'; import { cn } from '@/common/utils/cn'; +import { BlockedPostNotice } from './BlockedPostNotice'; import { ForumPostAuthorBox } from './ForumPostAuthorBox'; import { ForumPostCardMeta } from './ForumPostCardMeta'; import { ForumPostCopyLinkButton } from './ForumPostCopyLinkButton'; import { ForumPostManage } from './ForumPostManage'; +const REVEAL_EASE = [0.23, 1, 0.32, 1] as const; +const REVEAL_DURATION = 0.2; + interface ForumPostCardProps { body: string; @@ -32,11 +38,29 @@ export const ForumPostCard: FC = ({ canUpdate = false, isHighlighted = false, }) => { - const { auth, can } = usePageProps(); - const { t } = useTranslation(); + const { can } = usePageProps(); - const canReport = - can?.createModerationReports && comment?.user?.displayName !== auth?.user.displayName; + const [isRevealed, setIsRevealed] = useState(false); + + const prefersReducedMotion = useReducedMotion(); + + const revealTransition = prefersReducedMotion + ? { height: { duration: 0 }, opacity: { duration: 0.15 } } + : { duration: REVEAL_DURATION, ease: REVEAL_EASE }; + + const canRevealBlockedPosts = !!can?.manageForumTopicComments; + const isBlocked = !!comment?.isFromBlockedUser; + const isMasked = isBlocked && !isRevealed; + + const postContent = ( + + ); return (
@@ -45,69 +69,133 @@ export const ForumPostCard: FC = ({ className={cn( 'relative -mx-2 w-[calc(100%+16px)] rounded-lg bg-embed-highlight px-1 py-2 even:bg-embed', 'light:border light:border-neutral-300 light:bg-white', - 'sm:mx-0 sm:w-full lg:flex', + 'sm:mx-0 sm:w-full', + + isBlocked ? null : 'lg:flex', isHighlighted ? 'outline-2' : null, )} > - - -
-
- {comment && topic ? ( - - ) : ( -

{t('Preview')}

- )} - - {comment && topic ? ( -
- {!comment.isAuthorized && canManage ? ( - - ) : null} - - {canUpdate ? ( - - {t('Edit')} - - ) : null} - - {canReport ? ( - - - {t('Report')} - - ) : null} - - -
+ {!isBlocked ? postContent : null} + + {isBlocked && !canRevealBlockedPosts ? : null} + + {isBlocked && canRevealBlockedPosts ? ( + <> + + {isMasked ? ( + + setIsRevealed(true)} + /> + + ) : null} + + + +
{postContent}
+
+ + ) : null} +
+
+
+ ); +}; + +interface ForumPostContentProps { + body: string; + canManage: boolean; + canUpdate: boolean; + + comment?: App.Data.ForumTopicComment; + topic?: App.Data.ForumTopic; +} + +const ForumPostContent: FC = ({ + body, + canManage, + canUpdate, + comment, + topic, +}) => { + const { auth, can } = usePageProps(); + const { t } = useTranslation(); + + const canReport = + can?.createModerationReports && comment?.user?.displayName !== auth?.user.displayName; + + return ( + <> + + +
+
+ {comment && topic ? ( + + ) : ( +

{t('Preview')}

+ )} + + {comment && topic ? ( +
+ {!comment.isAuthorized && canManage ? : null} + + {canUpdate ? ( + + {t('Edit')} + ) : null} -
-
- + {canReport ? ( + + + {t('Report')} + + ) : null} + +
-
+ ) : null} +
+ +
+
- + ); }; diff --git a/resources/js/test/factories/createForumTopicComment.ts b/resources/js/test/factories/createForumTopicComment.ts index 5d1bd691af..bfd495fbae 100644 --- a/resources/js/test/factories/createForumTopicComment.ts +++ b/resources/js/test/factories/createForumTopicComment.ts @@ -9,6 +9,7 @@ export const createForumTopicComment = createFactory forumTopicId: faker.number.int({ min: 1, max: 999999 }), id: faker.number.int({ min: 1, max: 999999 }), isAuthorized: faker.datatype.boolean(), + isFromBlockedUser: false, updatedAt: faker.date.recent().toISOString(), user: createUser(), }; diff --git a/resources/js/types/generated.d.ts b/resources/js/types/generated.d.ts index 8dfd7ecde4..89fa2aff23 100644 --- a/resources/js/types/generated.d.ts +++ b/resources/js/types/generated.d.ts @@ -289,6 +289,7 @@ updatedAt: string | null; editedAt: string | null; user: App.Data.User | null; isAuthorized: boolean; +isFromBlockedUser: boolean; forumTopicId: number | null; forumTopic?: App.Data.ForumTopic | null; sentBy?: App.Data.User | null; diff --git a/resources/views/pages-legacy/forum.blade.php b/resources/views/pages-legacy/forum.blade.php index 0cefaf7d71..795cd3174d 100644 --- a/resources/views/pages-legacy/forum.blade.php +++ b/resources/views/pages-legacy/forum.blade.php @@ -2,13 +2,15 @@ // TODO migrate to controller & view +use App\Community\Actions\GetMaskedForumAuthorIdsAction; use App\Enums\Permissions; $requestedCategoryID = requestInputSanitized('c', null, 'integer'); authenticateFromCookie($user, $permissions, $userDetails); -$forumList = getForumList($requestedCategoryID); +$maskedAuthorIds = (new GetMaskedForumAuthorIdsAction())->execute(Auth::user()); +$forumList = getForumList($requestedCategoryID, maskedAuthorIds: $maskedAuthorIds); $numUnofficialLinks = 0; if ($permissions >= Permissions::Moderator) { diff --git a/resources/views/pages-legacy/viewforum.blade.php b/resources/views/pages-legacy/viewforum.blade.php index 339e8cd872..e5bd210a98 100644 --- a/resources/views/pages-legacy/viewforum.blade.php +++ b/resources/views/pages-legacy/viewforum.blade.php @@ -2,6 +2,7 @@ // TODO migrate to ForumController::show() +use App\Community\Actions\GetMaskedForumAuthorIdsAction; use App\Enums\Permissions; use App\Models\Forum; use App\Models\User; @@ -51,7 +52,14 @@ $thisCategoryID = $forum->category->id; $thisCategoryName = $forum->category->title; - $topicList = getForumTopics($requestedForumID, $offset, $count, $permissions, $numTotalTopics); + $topicList = getForumTopics( + $requestedForumID, + $offset, + $count, + $permissions, + $numTotalTopics, + maskedAuthorIds: (new GetMaskedForumAuthorIdsAction())->execute($userModel), + ); $requestedForum = $thisForumTitle; } diff --git a/tests/Feature/Community/Actions/BuildAggregateRecentForumPostsDataActionTest.php b/tests/Feature/Community/Actions/BuildAggregateRecentForumPostsDataActionTest.php new file mode 100644 index 0000000000..1b87eb42b5 --- /dev/null +++ b/tests/Feature/Community/Actions/BuildAggregateRecentForumPostsDataActionTest.php @@ -0,0 +1,212 @@ +create(array_merge([ + 'required_permissions' => 0, + 'author_id' => User::factory()->create()->id, + ], $attributes)); +} + +function aggregatePost(ForumTopic $topic, User $author, string $body, string $createdAt): ForumTopicComment +{ + $comment = ForumTopicComment::factory()->create([ + 'forum_topic_id' => $topic->id, + 'author_id' => $author->id, + 'body' => $body, + 'is_authorized' => true, + 'created_at' => $createdAt, + ]); + + $topic->latest_comment_id = $comment->id; + $topic->save(); + + return $comment; +} + +function aggregateTitles(mixed $result): array +{ + return array_map(fn ($topic) => $topic->title, $result->items); +} + +it('given masked authors and no masked posts present, topics are ordered by their newest post', function () { + // ARRANGE + $author = User::factory()->create(); + $unrelatedMaskedUser = User::factory()->create(); + + $olderTopic = aggregateTopic(['title' => 'older topic']); + $newerTopic = aggregateTopic(['title' => 'newer topic']); + + aggregatePost($olderTopic, $author, 'older', '2026-08-01 10:00:00'); + aggregatePost($newerTopic, $author, 'newer', '2026-08-02 10:00:00'); + + // ACT + $result = (new BuildAggregateRecentForumPostsDataAction())->execute( + permissions: Permissions::Registered, + page: 1, + maskedAuthorIds: [$unrelatedMaskedUser->id], + ); + + // ASSERT + expect(aggregateTitles($result))->toEqual(['newer topic', 'older topic']); +}); + +it('given a topic whose newest post is masked and whose previous post is stale, it sinks below fresher topics', function () { + // ARRANGE + $masked = User::factory()->create(); + $visible = User::factory()->create(); + + $staleTopic = aggregateTopic(['title' => 'stale topic']); + $freshTopic = aggregateTopic(['title' => 'fresh topic']); + + aggregatePost($staleTopic, $visible, 'six months old', '2026-02-01 10:00:00'); + aggregatePost($freshTopic, $visible, 'yesterday', '2026-08-11 10:00:00'); + aggregatePost($staleTopic, $masked, 'masked reply today', '2026-08-12 10:00:00'); + + // ACT + $result = (new BuildAggregateRecentForumPostsDataAction())->execute( + permissions: Permissions::Registered, + page: 1, + maskedAuthorIds: [$masked->id], + ); + + // ASSERT + expect(aggregateTitles($result))->toEqual(['fresh topic', 'stale topic']); +}); + +it('given a topic whose newest post is masked and whose previous post is recent, it shows the previous post', function () { + // ARRANGE + $masked = User::factory()->create(); + $visible = User::factory()->create(); + + $topic = aggregateTopic(['title' => 'a topic']); + + aggregatePost($topic, $visible, 'the visible reply', '2026-08-11 10:00:00'); + aggregatePost($topic, $masked, 'the masked reply', '2026-08-12 10:00:00'); + + // ACT + $result = (new BuildAggregateRecentForumPostsDataAction())->execute( + permissions: Permissions::Registered, + page: 1, + maskedAuthorIds: [$masked->id], + ); + + // ASSERT + expect($result->items)->toHaveCount(1); + expect($result->items[0]->latestComment->body)->toEqual('the visible reply'); +}); + +it('given a topic where every post is masked, the topic is absent', function () { + // ARRANGE + $masked = User::factory()->create(); + $visible = User::factory()->create(); + + $maskedTopic = aggregateTopic(['title' => 'all masked']); + $visibleTopic = aggregateTopic(['title' => 'visible topic']); + + aggregatePost($maskedTopic, $masked, 'masked one', '2026-08-11 10:00:00'); + aggregatePost($maskedTopic, $masked, 'masked two', '2026-08-12 10:00:00'); + aggregatePost($visibleTopic, $visible, 'visible', '2026-08-10 10:00:00'); + + // ACT + $result = (new BuildAggregateRecentForumPostsDataAction())->execute( + permissions: Permissions::Registered, + page: 1, + maskedAuthorIds: [$masked->id], + ); + + // ASSERT + expect(aggregateTitles($result))->toEqual(['visible topic']); +}); + +it('given a topic started by a masked author, it is absent even when other folks reply to it', function () { + // ARRANGE + $masked = User::factory()->create(); + $visible = User::factory()->create(); + + $maskedTopic = ForumTopic::factory()->create([ + 'required_permissions' => 0, + 'author_id' => $masked->id, + 'title' => 'started by a blocked author', + ]); + $visibleTopic = ForumTopic::factory()->create([ + 'required_permissions' => 0, + 'author_id' => $visible->id, + 'title' => 'started by anyone else', + ]); + + aggregatePost($maskedTopic, $visible, 'a reply from someone else', '2026-08-12 10:00:00'); + aggregatePost($visibleTopic, $visible, 'an ordinary post', '2026-08-11 10:00:00'); + + // ACT + $result = (new BuildAggregateRecentForumPostsDataAction())->execute( + permissions: Permissions::Registered, + page: 1, + maskedAuthorIds: [$masked->id], + ); + + // ASSERT + expect(aggregateTitles($result))->toEqual(['started by anyone else']); +}); + +it('given a topic started by a masked author, the pagination total does not count it', function () { + // ARRANGE + $masked = User::factory()->create(); + $visible = User::factory()->create(); + + $maskedTopic = ForumTopic::factory()->create([ + 'required_permissions' => 0, + 'author_id' => $masked->id, + 'title' => 'started by a blocked author', + ]); + $visibleTopic = aggregateTopic(['title' => 'started by anyone else']); + + aggregatePost($maskedTopic, $visible, 'a reply', '2026-08-12 10:00:00'); + aggregatePost($visibleTopic, $visible, 'an ordinary post', '2026-08-11 10:00:00'); + + // ACT + $result = (new BuildAggregateRecentForumPostsDataAction())->execute( + permissions: Permissions::Registered, + page: 1, + maskedAuthorIds: [$masked->id], + ); + + // ASSERT + expect($result->total)->toEqual(1); +}); + +it('given masked posts inside the recent window, the recent post counts and links ignore them', function () { + // ARRANGE + $masked = User::factory()->create(); + $visible = User::factory()->create(); + + $topic = aggregateTopic(['title' => 'a topic']); + + $firstVisible = aggregatePost($topic, $visible, 'visible one', now()->subDays(2)->toDateTimeString()); + aggregatePost($topic, $masked, 'masked reply', now()->subDays(1)->toDateTimeString()); + aggregatePost($topic, $visible, 'visible two', now()->subHours(2)->toDateTimeString()); + + // ACT + $result = (new BuildAggregateRecentForumPostsDataAction())->execute( + permissions: Permissions::Registered, + page: 1, + maskedAuthorIds: [$masked->id], + ); + + // ASSERT + expect($result->items)->toHaveCount(1); + $topicData = $result->items[0]->toArray(); + expect($topicData['commentCount7d'])->toEqual(2); + expect($topicData['oldestComment7dId'])->toEqual($firstVisible->id); +}); diff --git a/tests/Feature/Community/Actions/BuildThinRecentForumPostsDataActionTest.php b/tests/Feature/Community/Actions/BuildThinRecentForumPostsDataActionTest.php new file mode 100644 index 0000000000..08aa72073b --- /dev/null +++ b/tests/Feature/Community/Actions/BuildThinRecentForumPostsDataActionTest.php @@ -0,0 +1,115 @@ +create(array_merge([ + 'required_permissions' => 0, + 'author_id' => User::factory()->create()->id, + ], $attributes)); +} + +function thinPost(ForumTopic $topic, User $author, string $body, string $createdAt): ForumTopicComment +{ + return ForumTopicComment::factory()->create([ + 'forum_topic_id' => $topic->id, + 'author_id' => $author->id, + 'body' => $body, + 'is_authorized' => true, + 'created_at' => $createdAt, + ]); +} + +it('given no masked authors, every recent post is returned', function () { + // ARRANGE + $author = User::factory()->create(); + $topic = thinTopic(); + + thinPost($topic, $author, 'older post', '2026-08-01 10:00:00'); + thinPost($topic, $author, 'newer post', '2026-08-02 10:00:00'); + + // ACT + $result = (new BuildThinRecentForumPostsDataAction())->execute(permissions: Permissions::Registered); + + // ASSERT + expect($result)->toHaveCount(2); +}); + +it('given the newest post is by a masked author, the next visible post leads', function () { + // ARRANGE + $masked = User::factory()->create(); + $visible = User::factory()->create(); + $topic = thinTopic(); + + thinPost($topic, $visible, 'visible post', '2026-08-01 10:00:00'); + thinPost($topic, $masked, 'masked post', '2026-08-02 10:00:00'); + + // ACT + $result = (new BuildThinRecentForumPostsDataAction())->execute( + permissions: Permissions::Registered, + maskedAuthorIds: [$masked->id], + ); + + // ASSERT + expect($result)->toHaveCount(1); + expect($result->first()->latestComment->body)->toEqual('visible post'); +}); + +it('given a masked author, none of their posts are returned', function () { + // ARRANGE + $masked = User::factory()->create(); + $visible = User::factory()->create(); + $topic = thinTopic(); + + thinPost($topic, $masked, 'masked one', '2026-08-01 10:00:00'); + thinPost($topic, $masked, 'masked two', '2026-08-02 10:00:00'); + thinPost($topic, $visible, 'visible one', '2026-08-03 10:00:00'); + + // ACT + $result = (new BuildThinRecentForumPostsDataAction())->execute( + permissions: Permissions::Registered, + maskedAuthorIds: [$masked->id], + ); + + // ASSERT + expect($result)->toHaveCount(1); + expect($result->first()->latestComment->body)->toEqual('visible one'); +}); + +it('given a topic started by a masked author, none of its posts appear even from other authors', function () { + // ARRANGE + $masked = User::factory()->create(); + $visible = User::factory()->create(); + + $maskedTopic = ForumTopic::factory()->create([ + 'required_permissions' => 0, + 'author_id' => $masked->id, + ]); + $visibleTopic = ForumTopic::factory()->create([ + 'required_permissions' => 0, + 'author_id' => $visible->id, + ]); + + thinPost($maskedTopic, $visible, 'a reply inside a blocked topic', '2026-08-02 10:00:00'); + thinPost($visibleTopic, $visible, 'an ordinary post', '2026-08-01 10:00:00'); + + // ACT + $result = (new BuildThinRecentForumPostsDataAction())->execute( + permissions: Permissions::Registered, + maskedAuthorIds: [$masked->id], + ); + + // ASSERT + expect($result)->toHaveCount(1); + expect($result->first()->latestComment->body)->toEqual('an ordinary post'); +}); diff --git a/tests/Feature/Community/Actions/GetMaskedForumAuthorIdsActionTest.php b/tests/Feature/Community/Actions/GetMaskedForumAuthorIdsActionTest.php new file mode 100644 index 0000000000..5b1828099b --- /dev/null +++ b/tests/Feature/Community/Actions/GetMaskedForumAuthorIdsActionTest.php @@ -0,0 +1,168 @@ +execute(null); + + // ASSERT + expect($result)->toEqual([]); +}); + +it('given a viewer who blocked nobody, it returns an empty set', function () { + // ARRANGE + $viewer = User::factory()->create(); + + // ACT + $result = (new GetMaskedForumAuthorIdsAction())->execute($viewer); + + // ASSERT + expect($result)->toEqual([]); +}); + +it('given a viewer who blocked two users, then it returns exactly those ids', function () { + // ARRANGE + $viewer = User::factory()->create(); + $blockedOne = User::factory()->create(); + $blockedTwo = User::factory()->create(); + + UserRelation::factory()->blocked()->create([ + 'user_id' => $viewer->id, + 'related_user_id' => $blockedOne->id, + ]); + UserRelation::factory()->blocked()->create([ + 'user_id' => $viewer->id, + 'related_user_id' => $blockedTwo->id, + ]); + + // ACT + $result = (new GetMaskedForumAuthorIdsAction())->execute($viewer); + + // ASSERT + expect($result)->toHaveCount(2); + expect($result)->toContain($blockedOne->id); + expect($result)->toContain($blockedTwo->id); +}); + +it('given a viewer who only follows another user, that user is not masked', function () { + // ARRANGE + $viewer = User::factory()->create(); + $followed = User::factory()->create(); + + UserRelation::factory()->following()->create([ + 'user_id' => $viewer->id, + 'related_user_id' => $followed->id, + ]); + + // ACT + $result = (new GetMaskedForumAuthorIdsAction())->execute($viewer); + + // ASSERT + expect($result)->toEqual([]); +}); + +it('given a blocked user who follows the viewer back, the block still masks them', function () { + // ARRANGE + $viewer = User::factory()->create(); + $blocked = User::factory()->create(); + + UserRelation::factory()->blocked()->create([ + 'user_id' => $viewer->id, + 'related_user_id' => $blocked->id, + ]); + UserRelation::factory()->following()->create([ + 'user_id' => $blocked->id, + 'related_user_id' => $viewer->id, + ]); + + // ACT + $result = (new GetMaskedForumAuthorIdsAction())->execute($viewer); + + // ASSERT + expect($result)->toEqual([$blocked->id]); +}); + +it('given the viewer was blocked by someone but blocked nobody, it returns an empty set', function () { + // ARRANGE + $viewer = User::factory()->create(); + $blocker = User::factory()->create(); + + UserRelation::factory()->blocked()->create([ + 'user_id' => $blocker->id, + 'related_user_id' => $viewer->id, + ]); + + // ACT + $result = (new GetMaskedForumAuthorIdsAction())->execute($viewer); + + // ASSERT + expect($result)->toEqual([]); +}); + +it('given a blocked user is a team account, that account is not masked', function () { + // ARRANGE + $viewer = User::factory()->create(); + $teamAccount = User::factory()->create(['username' => 'RAdmin']); + $regularUser = User::factory()->create(); + + UserRelation::factory()->blocked()->create([ + 'user_id' => $viewer->id, + 'related_user_id' => $teamAccount->id, + ]); + UserRelation::factory()->blocked()->create([ + 'user_id' => $viewer->id, + 'related_user_id' => $regularUser->id, + ]); + + // ACT + $result = (new GetMaskedForumAuthorIdsAction())->execute($viewer); + + // ASSERT + expect($result)->toEqual([$regularUser->id]); +}); + +it('invalidates the cached set when a relation becomes blocked', function () { + // ARRANGE + $viewer = User::factory()->create(); + $blocked = User::factory()->create(); + $relation = UserRelation::factory()->following()->create([ + 'user_id' => $viewer->id, + 'related_user_id' => $blocked->id, + ]); + $action = new GetMaskedForumAuthorIdsAction(); + expect($action->execute($viewer))->toEqual([]); + + // ACT + $relation->status = UserRelationStatus::Blocked; + $relation->save(); + + // ASSERT + expect($action->execute($viewer))->toEqual([$blocked->id]); +}); + +it('invalidates the cached set when a blocked relation is deleted', function () { + // ARRANGE + $viewer = User::factory()->create(); + $blocked = User::factory()->create(); + $relation = UserRelation::factory()->blocked()->create([ + 'user_id' => $viewer->id, + 'related_user_id' => $blocked->id, + ]); + $action = new GetMaskedForumAuthorIdsAction(); + expect($action->execute($viewer))->toEqual([$blocked->id]); + + // ACT + $relation->delete(); + + // ASSERT + expect($action->execute($viewer))->toEqual([]); +}); diff --git a/tests/Feature/Community/Controllers/ForumTopicControllerTest.php b/tests/Feature/Community/Controllers/ForumTopicControllerTest.php index 9769e6c2fa..6efcb9a617 100644 --- a/tests/Feature/Community/Controllers/ForumTopicControllerTest.php +++ b/tests/Feature/Community/Controllers/ForumTopicControllerTest.php @@ -10,6 +10,7 @@ use App\Models\ForumTopicComment; use App\Models\Role; use App\Models\User; +use App\Models\UserRelation; use Database\Seeders\RolesTableSeeder; use Illuminate\Foundation\Testing\RefreshDatabase; use Inertia\Testing\AssertableInertia as Assert; @@ -154,4 +155,87 @@ public function testShowIncludesSentByForHistoricalTeamAccountPosts(): void ->where('paginatedForumTopicComments.items.0.sentBy.displayName', $originalAuthor->display_name) ); } + + public function testShowRemovesBlockedPostBodiesAndUsesAVisibleMetaDescription(): void + { + // Arrange + $viewer = User::factory()->create(); + $topicAuthor = User::factory()->create(); + $blockedAuthor = User::factory()->create(); + $topic = ForumTopic::factory()->create([ + 'author_id' => $topicAuthor->id, + 'required_permissions' => 0, + 'title' => 'A topic title', + ]); + $blockedComment = ForumTopicComment::factory()->create([ + 'forum_topic_id' => $topic->id, + 'author_id' => $blockedAuthor->id, + 'body' => 'private blocked text', + 'is_authorized' => true, + 'created_at' => '2026-08-11 10:00:00', + ]); + ForumTopicComment::factory()->create([ + 'forum_topic_id' => $topic->id, + 'author_id' => $topicAuthor->id, + 'body' => 'visible description text', + 'is_authorized' => true, + 'created_at' => '2026-08-12 10:00:00', + ]); + UserRelation::factory()->blocked()->create([ + 'user_id' => $viewer->id, + 'related_user_id' => $blockedAuthor->id, + ]); + + // Act + $response = $this->actingAs($viewer)->get(route('forum-topic.show', [ + 'topic' => $topic, + 'comment' => $blockedComment->id, + ])); + + // Assert + $response->assertOk(); + $response->assertInertia(fn (Assert $page) => $page + ->where('paginatedForumTopicComments.total', 2) + ->where('paginatedForumTopicComments.items.0.isFromBlockedUser', true) + ->where('paginatedForumTopicComments.items.0.body', '') + ->where('paginatedForumTopicComments.items.1.isFromBlockedUser', false) + ->where('paginatedForumTopicComments.items.1.body', 'visible description text') + ->where('metaDescription', 'visible description text') + ); + $response->assertDontSee('private blocked text', false); + } + + public function testShowKeepsBlockedPostBodiesForModerators(): void + { + // Arrange + $this->seed(RolesTableSeeder::class); + + $moderator = User::factory()->create(); + $moderator->assignRole(Role::MODERATOR); + $blockedAuthor = User::factory()->create(); + $topic = ForumTopic::factory()->create([ + 'author_id' => $blockedAuthor->id, + 'required_permissions' => 0, + ]); + ForumTopicComment::factory()->create([ + 'forum_topic_id' => $topic->id, + 'author_id' => $blockedAuthor->id, + 'body' => 'moderator-visible blocked text', + 'is_authorized' => true, + ]); + UserRelation::factory()->blocked()->create([ + 'user_id' => $moderator->id, + 'related_user_id' => $blockedAuthor->id, + ]); + + // Act + $response = $this->actingAs($moderator)->get(route('forum-topic.show', $topic)); + + // Assert + $response->assertOk(); + $response->assertInertia(fn (Assert $page) => $page + ->where('paginatedForumTopicComments.items.0.isFromBlockedUser', true) + ->where('paginatedForumTopicComments.items.0.body', 'moderator-visible blocked text') + ); + } }