Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
2 changes: 1 addition & 1 deletion KNOWLEDGE_BASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
25 changes: 18 additions & 7 deletions app/Http/Middleware/HandleInertiaRequests.php
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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'),
] : [],
];
}
Expand Down
173 changes: 134 additions & 39 deletions app/Queries/SuggestedUsersQuery.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -27,49 +54,77 @@
*
* @return Collection<int, User>
*/
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<int, User> $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)];
Expand All @@ -80,20 +135,58 @@ 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<User, User> $following
* @return Collection<int, User>
*/
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.
*
* @param Collection<int, User> $network
* @param Collection<int, User> $popularPool
* @return non-empty-list<Collection<int, User>>
*/
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()));
Expand All @@ -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);
Expand Down
77 changes: 77 additions & 0 deletions tests/Feature/ContextualSuggestedUsersTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
<?php

declare(strict_types=1);

use App\Models\Commit;
use App\Models\CommitImage;
use App\Models\User;

it('loads contextual profile suggestions without reusing cached suggestions', function (): void {
$viewer = User::factory()->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)));
});
Loading
Loading