Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 126 additions & 10 deletions app/Community/Actions/BuildAggregateRecentForumPostsDataAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<int, int> $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'];
}

$shortcodeIds = [];
foreach ($topics as $topic) {
Expand All @@ -49,9 +71,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,
Expand All @@ -63,22 +85,116 @@ 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<int, int> $maskedAuthorIds
* @return array{topics: array<int, array<string, mixed>>, 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)
Comment thread
wescopeland marked this conversation as resolved.
->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),
'total' => count($latestVisibleCommentIdByTopic),
];
}

/**
* @param array<int, int> $commentIds
*/
private function hydrateTopicsFromComments(array $commentIds): 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)
->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)
->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;
Expand Down
43 changes: 34 additions & 9 deletions app/Community/Actions/BuildShowForumTopicPagePropsAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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 = [
Expand All @@ -103,21 +115,34 @@ 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) {
$includes[] = 'sentBy';
$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,
Expand Down Expand Up @@ -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];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,22 +12,28 @@

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<int, int> $maskedAuthorIds authors the viewer has blocked
* @return Collection<int, ForumTopicData>
*/
public function execute(
int $limit = 4,
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
Comment thread
greptile-apps[bot] marked this conversation as resolved.

$latestComments = DB::table(DB::raw("({$subQuery->toSql()}) as LatestComments"))
->mergeBindings($subQuery)
Expand All @@ -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();
Expand Down
50 changes: 50 additions & 0 deletions app/Community/Actions/GetMaskedForumAuthorIdsAction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php

declare(strict_types=1);

namespace App\Community\Actions;

use App\Models\User;
use App\Support\Cache\CacheKey;
use Illuminate\Support\Facades\Cache;

/**
* Finds the author IDs whose forum posts a viewer does not want to see.
* Moderators get no exemption. This action also removes team accounts
* from the set of users.
*/
class GetMaskedForumAuthorIdsAction
{
/**
* @return array<int, int>
*/
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),
));
},
);
}
}
4 changes: 3 additions & 1 deletion app/Community/Components/ForumRecentActivity.php
Comment thread
Jamiras marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
8 changes: 8 additions & 0 deletions app/Community/Concerns/ActsAsCommunityMember.php
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,14 @@ public function followerUsers(): BelongsToMany
return $this->inverseRelatedUsers()->where('status', '=', UserRelationStatus::Following);
}

/**
* @return BelongsToMany<User, $this>
*/
public function blockedUsers(): BelongsToMany
{
return $this->relatedUsers()->where('status', '=', UserRelationStatus::Blocked);
}

/**
* Rows for users this user follows.
*
Expand Down
Loading