From 68762785815bf069b49cc0db242a0fc6773f6ac2 Mon Sep 17 00:00:00 2001 From: Punyapal Shah <53343069+MrPunyapal@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:23:58 +0530 Subject: [PATCH] feat: suggest developers from profile and discussion context --- KNOWLEDGE_BASE.md | 2 +- app/Http/Middleware/HandleInertiaRequests.php | 25 +- app/Queries/SuggestedUsersQuery.php | 173 ++++++++++---- .../Feature/ContextualSuggestedUsersTest.php | 77 +++++++ .../Unit/Queries/SuggestedUsersQueryTest.php | 218 ++++++++++++++++++ 5 files changed, 448 insertions(+), 47 deletions(-) create mode 100644 tests/Feature/ContextualSuggestedUsersTest.php diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index b08a46604..428e5e311 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -143,7 +143,7 @@ Sources: [badge definitions](app/Enums/Achievement.php), [unlock conditions and Follow or unfollow from a developer's profile or a follow control elsewhere in the app. Following puts their commits in your Following log. Following or unfollowing from a log updates the controls, counts, and live-following sidebar without navigating away: Latest and Trending keep their loaded commits and ranking, while Following updates its commits and empty-state suggestions. You cannot follow yourself or follow across a block. GitHub follows are imported once: connections to developers already on /dev/null are added in both directions, with up to 1,000 GitHub connections checked each way. This is not continuous synchronization, so unfollowing someone on /dev/null sticks. -**Who to follow** suggests developers in the side column or within the log on smaller screens, each with the first line of their bio and why they are suggested (how many people you follow follow them, or their follower count). Follow from the icon next to each suggestion; for someone who already follows you, it follows back. The X icon marks a suggestion **Not interested**: that developer is never suggested to you again, without muting them, and the next suggestion takes their place. Refresh for different suggestions, or use **… → Don't show again** to hide the block, with an immediate **Undo** option. Show or hide these suggestions in Settings → Appearance. Suggested developers must have a visible top-level commit. You can also follow back from the notifications list. +**Who to follow** suggests developers in the side column or within the log on smaller screens, each with the first line of their bio and why they are suggested (how many people you follow follow them, or their follower count). Follow from the icon next to each suggestion; for someone who already follows you, it follows back. The X icon marks a suggestion **Not interested**: that developer is never suggested to you again, without muting them, and the next suggestion takes their place. Refresh for different suggestions, or use **… → Don't show again** to hide the block, with an immediate **Undo** option. Show or hide these suggestions in Settings → Appearance. Suggested developers must have a visible top-level commit. On profiles, suggestions prioritize the people that developer follows; on commit pages, they prioritize visible thread participants. Regular suggestions fill any remaining places. You can also follow back from the notifications list. Sources: [follow import](app/Actions/CreateUserGitHubFollows.php), [suggestions](resources/js/components/who-to-follow.tsx), [suggestion eligibility](app/Queries/SuggestedUsersQuery.php), [appearance settings](resources/js/pages/appearance/update.tsx), [notifications page](resources/js/pages/notification/index.tsx). diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 20dbd9588..b7b064c68 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -65,6 +65,21 @@ public function share(Request $request): array $isAdmin = $request->user()?->isAdmin() ?? false; $user = $request->user(); $showsWhoToFollow = $user instanceof User && $request->cookie('who_to_follow') !== 'false'; + $context = match (true) { + $request->routeIs('user.show') => $request->route('user'), + $request->routeIs('commit.show', 'commit-photo.show') => $request->route('commit'), + default => null, + }; + $context = $context instanceof User || $context instanceof Commit ? $context : null; + + $suggestedUsers = $user instanceof User && $showsWhoToFollow && ! $request->routeIs('onboarding.show') + ? Inertia::defer(fn (): Collection => resolve(SuggestedUsersQuery::class)->get( + $user, + round: max(0, (int) $request->cookie('who_to_follow_round')), + spares: 3, + context: $context, + )) + : null; return [ ...parent::share($request), @@ -96,13 +111,9 @@ public function share(Request $request): array : collect(), 'dimsLanes' => $request->cookie('lane_dimming') !== 'false', 'repliesPerPage' => RepliesPerPage::of($request)->value, - // Onboarding has no sidebar to show them in, and the people followed during it change who is suggested. - ...$user instanceof User && $showsWhoToFollow && ! $request->routeIs('onboarding.show') ? [ - 'suggestedUsers' => Inertia::defer(fn (): Collection => resolve(SuggestedUsersQuery::class)->get( - $user, - round: max(0, (int) $request->cookie('who_to_follow_round')), - spares: 3, - ))->once()->merge()->matchOn('id'), + // Onboarding has no sidebar. Contextual suggestions must not reuse the once-cached list across pages. + ...$suggestedUsers !== null ? [ + 'suggestedUsers' => ($context === null ? $suggestedUsers->once() : $suggestedUsers)->merge()->matchOn('id'), ] : [], ]; } diff --git a/app/Queries/SuggestedUsersQuery.php b/app/Queries/SuggestedUsersQuery.php index 7ec591ed0..2bbc91484 100644 --- a/app/Queries/SuggestedUsersQuery.php +++ b/app/Queries/SuggestedUsersQuery.php @@ -4,20 +4,47 @@ namespace App\Queries; +use App\Enums\CommitStatus; use App\Models\Commit; use App\Models\User; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Query\Builder as QueryBuilder; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\DB; use Random\Engine\Mt19937; use Random\Randomizer; final readonly class SuggestedUsersQuery { + private const string THREAD_PARTICIPANTS = <<<'SQL' + WITH RECURSIVE visible_thread(id, user_id) AS ( + SELECT id, user_id FROM commits WHERE id = ? + UNION + SELECT child.id, child.user_id + FROM commits child + JOIN visible_thread parent ON child.parent_id = parent.id OR child.quoted_commit_id = parent.id + WHERE (child.status = ? OR child.user_id = ?) + AND NOT EXISTS (SELECT 1 FROM blocks WHERE (blocker_id = ? AND blocked_id = child.user_id) OR (blocker_id = child.user_id AND blocked_id = ?)) + AND NOT EXISTS (SELECT 1 FROM mutes WHERE muter_id = ? AND muted_id = child.user_id) + AND (child.deleted_at IS NULL OR EXISTS ( + SELECT 1 FROM commits surviving_replies + WHERE (surviving_replies.parent_id = child.id OR surviving_replies.quoted_commit_id = child.id) + AND (surviving_replies.status = ? OR surviving_replies.user_id = ?) + AND NOT EXISTS (SELECT 1 FROM blocks WHERE (blocker_id = ? AND blocked_id = surviving_replies.user_id) OR (blocker_id = surviving_replies.user_id AND blocked_id = ?)) + AND NOT EXISTS (SELECT 1 FROM mutes WHERE muter_id = ? AND muted_id = surviving_replies.user_id) + )) + ) + SELECT visible_thread.user_id + FROM visible_thread + JOIN commits visible_commits ON visible_commits.id = visible_thread.id + WHERE visible_thread.user_id IS NOT NULL AND visible_commits.deleted_at IS NULL + SQL; + /** * People the user does not follow yet: a mix of people their follows already follow and a few - * popular people picked from the 20 most followed. Either side fills in when the other runs short. + * popular people picked from the 20 most followed. On a profile or discussion page, relevant developers come + * first. The regular suggestions fill any remaining places. * * The day's candidates are split into pages of that mix, and each round shows the next page, * going back to the first one after the last. @@ -27,49 +54,77 @@ * * @return Collection */ - public function get(User $user, int $limit = 3, int $round = 0, int $spares = 0): Collection + public function get(User $user, int $limit = 3, int $round = 0, int $spares = 0, User|Commit|null $context = null): Collection { $randomizer = $this->randomizerFor($user); $following = $user->following(); $followingIds = $following->newPivotQuery()->select($following->getRelatedPivotKeyName()); - $followedByFollowing = $following->newPivotStatement() - ->select($following->getRelatedPivotKeyName()) - ->whereIn($following->getForeignPivotKeyName(), $followingIds); - - $network = $this->candidates($user, $followingIds) - ->joinSub( - $followedByFollowing->clone()->selectRaw('count(*) as followed_by_following_count')->groupBy($following->getRelatedPivotKeyName()), - 'network', - 'network.'.$following->getRelatedPivotKeyName(), - '=', - 'users.id', - ) - ->select(['users.*', 'network.followed_by_following_count']) - ->addSelect(['followers_count' => $this->followersCount($following), 'follows_you' => $this->followsUser($user)]) - ->whereIn('users.id', $this->committers($user)) - ->orderByDesc('followed_by_following_count') - ->orderByDesc('followers_count') - ->latest('users.id') - ->limit($limit * 10) - ->get(); - - // Ranked before looking for commits, so only the most followed are looked up rather than every candidate. - $popularPool = User::query() - ->fromSub($this->candidates($user, $followingIds) - ->whereNotExists($this->followedByFollowing($user, $following)) - ->select('users.*') - ->selectRaw('0 as followed_by_following_count') + if ($context instanceof User && $user->hasBlockWith($context)) { + $context = null; + } + + $contextUserId = $context instanceof User ? $context->id : ($context instanceof Commit ? $context->user_id : null); + + $contextual = $context instanceof User || $context instanceof Commit + ? $this->contextual($user, $context, $followingIds, $following, $limit) + : new Collection; + + if ($contextual->count() >= $limit * 5 && $limit + $spares <= $contextual->count()) { + $pages = $this->pages($randomizer, $contextual, new Collection, $limit, prioritizeNetwork: true); + } else { + $followedByFollowing = $following->newPivotStatement() + ->select($following->getRelatedPivotKeyName()) + ->whereIn($following->getForeignPivotKeyName(), $followingIds); + + $network = $this->candidates($user, $followingIds) + ->joinSub( + $followedByFollowing->clone()->selectRaw('count(*) as followed_by_following_count')->groupBy($following->getRelatedPivotKeyName()), + 'network', + 'network.'.$following->getRelatedPivotKeyName(), + '=', + 'users.id', + ) + ->select(['users.*', 'network.followed_by_following_count']) ->addSelect(['followers_count' => $this->followersCount($following), 'follows_you' => $this->followsUser($user)]) + ->whereIn('users.id', $this->committers($user)) + ->orderByDesc('followed_by_following_count') ->orderByDesc('followers_count') - ->latest('users.id'), 'users') - ->whereIn('users.id', $this->committers($user)) - ->orderByDesc('followers_count') - ->latest('users.id') - ->limit(20) - ->get(); + ->latest('users.id') + ->limit($limit * 10) + ->get(); + + // Ranked before looking for commits, so only the most followed are looked up rather than every candidate. + $popularPool = User::query() + ->fromSub($this->candidates($user, $followingIds) + ->whereNotExists($this->followedByFollowing($user, $following)) + ->select('users.*') + ->selectRaw('0 as followed_by_following_count') + ->addSelect(['followers_count' => $this->followersCount($following), 'follows_you' => $this->followsUser($user)]) + ->orderByDesc('followers_count') + ->latest('users.id'), 'users') + ->whereIn('users.id', $this->committers($user)) + ->orderByDesc('followers_count') + ->latest('users.id') + ->limit(20) + ->get(); + + $pages = $this->pages($randomizer, $network, $popularPool, $limit); + + if ($contextual->isNotEmpty()) { + /** @var Collection $general */ + $general = new Collection(array_merge(...array_map(fn (Collection $page): array => $page->all(), $pages))); + $contextualIds = $contextual->pluck('id')->all(); + if ($contextUserId !== null) { + $contextualIds[] = $contextUserId; + } + + $general = $general->unique('id')->whereNotIn('id', $contextualIds)->values(); + + $pages = $this->pages($randomizer, $contextual, $general, $limit, prioritizeNetwork: true); + } + } - $pages = $this->pages($randomizer, $network, $popularPool, $limit); $current = max(0, $round) % count($pages); $rotated = [...array_slice($pages, $current), ...array_slice($pages, 0, $current)]; @@ -80,6 +135,44 @@ public function get(User $user, int $limit = 3, int $round = 0, int $spares = 0) ->values(); } + /** + * On a profile, suggest people that developer follows. In a discussion, suggest its visible participants. + * The regular network and popular pools fill any remaining slots. + * + * @param BelongsToMany $following + * @return Collection + */ + private function contextual(User $viewer, User|Commit $context, QueryBuilder $followingIds, BelongsToMany $following, int $limit): Collection + { + if ($context instanceof User) { + $ids = $context->following()->newPivotQuery()->select('followed_id'); + } else { + $ids = DB::query() + ->fromRaw('('.self::THREAD_PARTICIPANTS.') AS visible_thread_participants', [ + $context->root_id ?? $context->id, + CommitStatus::Approved->value, $viewer->id, $viewer->id, $viewer->id, $viewer->id, + CommitStatus::Approved->value, $viewer->id, $viewer->id, $viewer->id, $viewer->id, + ]) + ->select('visible_thread_participants.user_id'); + } + + return $this->candidates($viewer, $followingIds) + ->whereIn('users.id', $ids) + ->whereIn('users.id', $this->committers($viewer)) + ->when( + $context instanceof User ? $context->id : $context->user_id, + fn (Builder $query, string $id): Builder => $query->whereKeyNot($id), + ) + ->select('users.*') + ->selectRaw('0 as followed_by_following_count') + ->addSelect(['followers_count' => $this->followersCount($following), 'follows_you' => $this->followsUser($viewer)]) + ->orderByDesc('followers_count') + ->latest('users.id') + ->limit($limit * 5) + ->get() + ->toBase(); + } + /** * The last page is topped up from the first one, so a refresh never shows fewer people. * @@ -87,13 +180,13 @@ public function get(User $user, int $limit = 3, int $round = 0, int $spares = 0) * @param Collection $popularPool * @return non-empty-list> */ - private function pages(Randomizer $randomizer, Collection $network, Collection $popularPool, int $limit): array + private function pages(Randomizer $randomizer, Collection $network, Collection $popularPool, int $limit, bool $prioritizeNetwork = false): array { $popular = null; $pages = []; do { - $networkSlots = $limit - $randomizer->getInt(1, max(1, $limit - 1)); + $networkSlots = $prioritizeNetwork ? $limit : $limit - $randomizer->getInt(1, max(1, $limit - 1)); $popular ??= new Collection($randomizer->shuffleArray($popularPool->all())); $pageNetwork = $network->take(max($networkSlots, $limit - $popular->count())); @@ -102,7 +195,9 @@ private function pages(Randomizer $randomizer, Collection $network, Collection $ $network = $network->skip($pageNetwork->count()); $popular = $popular->skip($pagePopular->count()); - $pages[] = $randomizer->shuffleArray([...$pageNetwork, ...$pagePopular]); + $pages[] = $prioritizeNetwork + ? [...$randomizer->shuffleArray($pageNetwork->all()), ...$randomizer->shuffleArray($pagePopular->all())] + : $randomizer->shuffleArray([...$pageNetwork, ...$pagePopular]); } while ($network->isNotEmpty() || $popular->isNotEmpty()); $last = array_key_last($pages); diff --git a/tests/Feature/ContextualSuggestedUsersTest.php b/tests/Feature/ContextualSuggestedUsersTest.php new file mode 100644 index 000000000..27b54813e --- /dev/null +++ b/tests/Feature/ContextualSuggestedUsersTest.php @@ -0,0 +1,77 @@ +create(); + $profile = User::factory()->has(Commit::factory())->create(); + $related = User::factory()->has(Commit::factory())->create(); + + $profile->following()->attach($related); + + $response = $this->actingAs($viewer)->get(route('user.show', $profile)); + + $response->assertOk() + ->assertInertia(fn ($page) => $page + ->component('user/show') + ->missing('suggestedUsers') + ->loadDeferredProps(fn ($reload) => $reload + ->has('suggestedUsers', 1) + ->where('suggestedUsers.0.id', $related->id))); + + $data = $response->viewData('page'); + + expect(collect($data['deferredProps'] ?? [])->flatten())->toContain('suggestedUsers') + ->and($data['mergeProps'] ?? [])->toContain('suggestedUsers') + ->and($data['matchPropsOn'] ?? [])->toContain('suggestedUsers.id') + ->and($data['onceProps'] ?? [])->not->toHaveKey('suggestedUsers'); +}); + +it('loads visible thread participants as contextual suggestions', function (): void { + $viewer = User::factory()->create(); + $author = User::factory()->create(); + $root = Commit::factory()->for($author)->create(); + $participant = User::factory()->has(Commit::factory())->create(); + + Commit::factory()->for($participant)->for($root, 'parent')->create(['root_id' => $root->id]); + + $response = $this->actingAs($viewer)->get(route('commit.show', [$author, $root])); + + $response->assertOk() + ->assertInertia(fn ($page) => $page + ->component('commit/show') + ->missing('suggestedUsers') + ->loadDeferredProps(fn ($reload) => $reload + ->has('suggestedUsers', 1) + ->where('suggestedUsers.0.id', $participant->id))); + + $data = $response->viewData('page'); + + expect(collect($data['deferredProps'] ?? [])->flatten())->toContain('suggestedUsers') + ->and($data['mergeProps'] ?? [])->toContain('suggestedUsers') + ->and($data['matchPropsOn'] ?? [])->toContain('suggestedUsers.id') + ->and($data['onceProps'] ?? [])->not->toHaveKey('suggestedUsers'); +}); + +it('loads contextual suggestions on the commit photo page', function (): void { + $viewer = User::factory()->create(); + $author = User::factory()->create(); + $root = Commit::factory()->for($author)->create(); + CommitImage::factory()->for($root)->create(); + $participant = User::factory()->has(Commit::factory())->create(); + + Commit::factory()->for($participant)->for($root, 'parent')->create(['root_id' => $root->id]); + + $this->actingAs($viewer)->get(route('commit-photo.show', [$author, $root, 1])) + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('commit/show') + ->missing('suggestedUsers') + ->loadDeferredProps(fn ($reload) => $reload + ->has('suggestedUsers', 1) + ->where('suggestedUsers.0.id', $participant->id))); +}); diff --git a/tests/Unit/Queries/SuggestedUsersQueryTest.php b/tests/Unit/Queries/SuggestedUsersQueryTest.php index cb71fa899..4877aa442 100644 --- a/tests/Unit/Queries/SuggestedUsersQueryTest.php +++ b/tests/Unit/Queries/SuggestedUsersQueryTest.php @@ -275,3 +275,221 @@ function popularUsers(int $count): Collection ->and($suggestions[$follower->id]->follows_you)->toBeTrue() ->and($suggestions[$stranger->id]->follows_you)->toBeFalse(); }); + +it('suggests people followed by the profile being viewed', function (): void { + $viewer = User::factory()->create(); + $profile = User::factory()->create(); + $related = User::factory()->has(Commit::factory())->create(); + + $profile->following()->attach($related); + + expect(resolve(SuggestedUsersQuery::class)->get($viewer, context: $profile)->pluck('id')->all()) + ->toContain($related->id); +}); + +it('suggests developers who participated in a visible thread', function (): void { + $viewer = User::factory()->create(); + $author = User::factory()->create(); + $root = Commit::factory()->for($author)->create(); + $participant = User::factory()->has(Commit::factory())->create(); + + Commit::factory()->for($participant)->for($root, 'parent')->create(['root_id' => $root->id]); + + expect(resolve(SuggestedUsersQuery::class)->get($viewer, context: $root)->pluck('id')->all()) + ->toContain($participant->id); +}); + +it('suggests authors of visible quotes in a thread', function (): void { + $viewer = User::factory()->create(); + $root = Commit::factory()->create(); + $quoter = User::factory()->has(Commit::factory())->create(); + + Commit::factory()->for($quoter)->create(['quoted_commit_id' => $root->id]); + + expect(resolve(SuggestedUsersQuery::class)->get($viewer, context: $root)->pluck('id')->all()) + ->toContain($quoter->id); +}); + +it('does not suggest participants whose only path to the thread is hidden', function (): void { + $viewer = User::factory()->create(); + $root = Commit::factory()->create(); + $hiddenParent = User::factory()->create(); + $hiddenParticipant = User::factory()->has(Commit::factory())->create(); + $visibleParticipants = User::factory()->count(15)->has(Commit::factory())->create(); + + $hiddenReply = Commit::factory()->for($hiddenParent)->for($root, 'parent')->create(['root_id' => $root->id]); + Commit::factory()->for($hiddenParticipant)->for($hiddenReply, 'parent')->create(['root_id' => $root->id]); + foreach ($visibleParticipants as $participant) { + Commit::factory()->for($participant)->for($root, 'parent')->create(['root_id' => $root->id]); + } + + $viewer->muting()->attach($hiddenParent); + + $suggestions = resolve(SuggestedUsersQuery::class)->get($viewer, context: $root)->pluck('id')->all(); + + expect($suggestions)->toHaveCount(3) + ->and($suggestions)->each->toBeIn($visibleParticipants->modelKeys()) + ->and($suggestions)->not->toContain($hiddenParticipant->id); +}); + +it('keeps visible replies under deleted commits in the contextual thread', function (): void { + $viewer = User::factory()->create(); + $root = Commit::factory()->create(); + $deletedAuthor = User::factory()->has(Commit::factory())->create(); + $deletedReply = Commit::factory()->for($deletedAuthor)->for($root, 'parent')->create(['root_id' => $root->id]); + $participant = User::factory()->has(Commit::factory())->create(); + Commit::factory()->for($participant)->for($deletedReply, 'parent')->create(['root_id' => $root->id]); + $visibleParticipants = User::factory()->count(15)->has(Commit::factory())->create(); + + foreach ($visibleParticipants as $visibleParticipant) { + Commit::factory()->for($visibleParticipant)->for($root, 'parent')->create(['root_id' => $root->id]); + } + + $deletedReply->delete(); + + $suggestions = resolve(SuggestedUsersQuery::class)->get($viewer, limit: 16, context: $root)->pluck('id')->all(); + + expect($suggestions)->toHaveCount(16) + ->and($suggestions)->toContain($participant->id) + ->and($suggestions)->not->toContain($deletedAuthor->id); +}); + +it('excludes muted and dismissed contextual candidates', function (): void { + $viewer = User::factory()->create(); + $profile = User::factory()->create(); + $muted = User::factory()->has(Commit::factory())->create(); + $dismissed = User::factory()->has(Commit::factory())->create(); + $available = User::factory()->has(Commit::factory())->create(); + + $profile->following()->attach([$muted->id, $dismissed->id]); + $viewer->muting()->attach($muted); + $viewer->dismissedSuggestions()->attach($dismissed); + + $suggestions = resolve(SuggestedUsersQuery::class)->get($viewer, context: $profile); + + expect($suggestions->pluck('id')->all())->toBe([$available->id]); +}); + +it('keeps contextual suggestions stable for the day and supplies different rounds and spares', function (): void { + $viewer = User::factory()->create(); + $profile = User::factory()->create(); + $related = User::factory()->count(6)->has(Commit::factory())->create(); + vouchedFor($viewer, 6); + + $profile->following()->attach($related); + + $query = resolve(SuggestedUsersQuery::class); + $first = $query->get($viewer, context: $profile)->pluck('id')->all(); + $second = $query->get($viewer, round: 1, context: $profile)->pluck('id')->all(); + $withSpares = $query->get($viewer, spares: 3, context: $profile)->pluck('id')->all(); + + expect($first)->toHaveCount(3) + ->and($first)->toBe($query->get($viewer, context: $profile)->pluck('id')->all()) + ->and(array_intersect($first, $second))->toBeEmpty() + ->and(array_slice($withSpares, 0, 3))->toBe($first) + ->and($withSpares)->toHaveCount(6); +}); + +it('prioritizes contextual suggestions across rounds and spares', function (): void { + $viewer = User::factory()->create(); + $profile = User::factory()->create(); + $related = User::factory()->count(6)->has(Commit::factory())->create(); + $regular = vouchedFor($viewer, 6); + + $profile->following()->attach($related); + + $query = resolve(SuggestedUsersQuery::class); + $first = $query->get($viewer, context: $profile)->pluck('id')->all(); + $second = $query->get($viewer, round: 1, context: $profile)->pluck('id')->all(); + $fallback = $query->get($viewer, round: 2, context: $profile)->pluck('id')->all(); + $withSpares = $query->get($viewer, spares: 3, context: $profile)->pluck('id')->all(); + + expect($first)->toHaveCount(3) + ->and(array_diff($first, $related->modelKeys()))->toBeEmpty() + ->and($second)->toHaveCount(3) + ->and(array_diff($second, $related->modelKeys()))->toBeEmpty() + ->and(array_intersect($first, $second))->toBeEmpty() + ->and(array_intersect($fallback, $regular->modelKeys()))->not->toBeEmpty() + ->and(array_slice($withSpares, 0, 3))->toBe($first) + ->and($withSpares)->toHaveCount(6) + ->and(array_diff($withSpares, $related->modelKeys()))->toBeEmpty(); +}); + +it('cycles after five full rounds of contextual suggestions without loading fallbacks', function (): void { + $viewer = User::factory()->create(); + $profile = User::factory()->create(); + $related = User::factory()->count(18)->has(Commit::factory())->create(); + vouchedFor($viewer, 6); + + $profile->following()->attach($related); + + $query = resolve(SuggestedUsersQuery::class); + $first = $query->get($viewer, context: $profile)->pluck('id')->all(); + $fifthRound = $query->get($viewer, round: 4, context: $profile)->pluck('id')->all(); + $nextRound = $query->get($viewer, round: 5, context: $profile)->pluck('id')->all(); + $withSpares = $query->get($viewer, spares: 12, context: $profile)->pluck('id')->all(); + + expect($first)->toHaveCount(3) + ->and(array_diff($first, $related->modelKeys()))->toBeEmpty() + ->and($fifthRound)->toHaveCount(3) + ->and(array_diff($fifthRound, $related->modelKeys()))->toBeEmpty() + ->and($nextRound)->toBe($first) + ->and($withSpares)->toHaveCount(15) + ->and(array_diff($withSpares, $related->modelKeys()))->toBeEmpty(); +}); + +it('uses regular suggestions to fill slots without contextual candidates', function (): void { + $viewer = User::factory()->create(); + $profile = User::factory()->create(); + $contextual = User::factory()->has(Commit::factory())->create(); + $regular = vouchedFor($viewer, 2); + + $profile->following()->attach($contextual); + + $suggestions = resolve(SuggestedUsersQuery::class)->get($viewer, context: $profile)->pluck('id')->all(); + + expect($suggestions)->toHaveCount(3) + ->and($suggestions[0])->toBe($contextual->id) + ->and(array_diff(array_slice($suggestions, 1), $regular->modelKeys()))->toBeEmpty(); +}); + +it('does not use a blocked profile to find contextual candidates', function (): void { + $viewer = User::factory()->create(); + $profile = User::factory()->create(); + $candidate = User::factory()->has(Commit::factory())->create(); + $other = User::factory()->has(Commit::factory())->create(); + + $profile->following()->attach($candidate); + $viewer->blocking()->attach($profile); + + $query = resolve(SuggestedUsersQuery::class); + + expect($query->get($viewer, context: $profile)->pluck('id')->all()) + ->toBe($query->get($viewer)->pluck('id')->all()); +}); + +it('does not prioritize hidden, unapproved, deleted, muted, or blocked thread participants', function (): void { + $viewer = User::factory()->create(); + $author = User::factory()->create(); + $root = Commit::factory()->for($author)->create(); + $visible = User::factory()->count(3)->has(Commit::factory())->create(); + $pending = User::factory()->has(Commit::factory())->create(); + $rejected = User::factory()->has(Commit::factory())->create(); + $deleted = User::factory()->has(Commit::factory())->create(); + $muted = User::factory()->has(Commit::factory())->create(); + $blocked = User::factory()->has(Commit::factory())->create(); + + $visible->each(fn (User $user) => Commit::factory()->for($user)->for($root, 'parent')->create(['root_id' => $root->id])); + Commit::factory()->for($pending)->for($root, 'parent')->pending()->create(['root_id' => $root->id]); + Commit::factory()->for($rejected)->for($root, 'parent')->rejected()->create(['root_id' => $root->id]); + Commit::factory()->for($deleted)->for($root, 'parent')->create(['root_id' => $root->id])->delete(); + Commit::factory()->for($muted)->for($root, 'parent')->create(['root_id' => $root->id]); + Commit::factory()->for($blocked)->for($root, 'parent')->create(['root_id' => $root->id]); + $viewer->muting()->attach($muted); + $viewer->blocking()->attach($blocked); + + $suggestions = resolve(SuggestedUsersQuery::class)->get($viewer, context: $root)->pluck('id')->all(); + + expect($suggestions)->toHaveCount(3) + ->and(array_diff($suggestions, $visible->modelKeys()))->toBeEmpty(); +});