diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 18207a0c9..75fb97f04 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -96,7 +96,7 @@ Sources: [onboarding page](resources/js/pages/onboarding/show.tsx), [sign-in tes ### Profiles and GitHub sync -Every developer has a profile at `/@{username}`, with their GitHub identity, bio and other public profile information, commit/following/follower counts, a **Follows you** badge when they follow you, and an activity graph. Across a block in either direction, the profile is shown as unavailable. The Commits tab lists top-level commits; Replies lists that developer's replies with parent context. Follow and follower counts open the corresponding people lists. The profile links to GitHub and, when available, GitHub Sponsors. Share uses the device's share sheet when supported, or copies the profile link. +Every developer has a profile at `/@{username}`, with their GitHub identity, bio and other public profile information, commit/following/follower counts, a **Follows you** badge when they follow you, and an activity graph. Across a block in either direction, the profile is shown as unavailable. The Commits tab lists top-level commits; Replies lists that developer's replies with parent context. Follow and follower counts open the corresponding people lists. Next to Followers and Following, a **Mutuals** tab lists the people they follow who follow them back. The profile links to GitHub and, when available, GitHub Sponsors. Share uses the device's share sheet when supported, or copies the profile link. Change your name, avatar, bio, company, location, or website on GitHub, not on /dev/null. Settings → Profile and the **Edit profile** menu on your own profile offer **Edit on GitHub** and **Sync from GitHub**. Profile details also refresh on sign-in. Manual sync is limited to once a minute. The GitHub Sponsors link is refreshed on sign-in only. A GitHub username rename is picked up on sign-in or sync; if a stale account still holds the new username because its owner also renamed, that account moves to a `_renamed-` placeholder until its owner signs in or syncs again. Accounts are matched by GitHub id and never merged. diff --git a/app/Http/Controllers/UserMutualsController.php b/app/Http/Controllers/UserMutualsController.php new file mode 100644 index 000000000..ce613b74b --- /dev/null +++ b/app/Http/Controllers/UserMutualsController.php @@ -0,0 +1,46 @@ +loadPublicCounts(); + }; + + return Inertia::render('user/connections', [ + 'user' => fn (): User => $currentUser instanceof User ? $user : $getCountedUser(), + 'type' => 'mutuals', + 'users' => Inertia::scroll(function () use ($connections, $currentUser, $user) { + $mutuals = $connections->builder($user->following() + ->whereExists(fn (QueryBuilder $query): QueryBuilder => $query->from('follows', 'follows_back') + ->whereColumn('follows_back.follower_id', 'users.id') + ->where('follows_back.followed_id', $user->id)), $currentUser); + + if (! $currentUser instanceof User) { + $mutuals->withExists(['followers as is_followed' => fn (Builder $query): Builder => $query->whereRaw('1 = 0')]); + } + + return $mutuals->cursorPaginate(30); + }), + ])->withViewData(['preview' => $currentUser instanceof User || $request->inertia() ? null : $metadata->connections($getCountedUser(), 'mutuals')]); + } +} diff --git a/app/Http/Requests/ListUserMutualsRequest.php b/app/Http/Requests/ListUserMutualsRequest.php new file mode 100644 index 000000000..1a46a4314 --- /dev/null +++ b/app/Http/Requests/ListUserMutualsRequest.php @@ -0,0 +1,33 @@ +user(); + + $user = $this->route('user'); + assert($user instanceof User); + + return $viewer instanceof User && $viewer->hasBlockWith($user) ? Response::denyAsNotFound() : Response::allow(); + } + + /** + * @return array{} + */ + public function rules(): array + { + return []; + } +} diff --git a/app/Services/PageMetadata.php b/app/Services/PageMetadata.php index 84af18477..15302e234 100644 --- a/app/Services/PageMetadata.php +++ b/app/Services/PageMetadata.php @@ -246,24 +246,28 @@ public function user(User $user): array } /** - * The link preview of who follows a profile, or who it follows. The lists are thin pages, so they are not indexed, though their links are followed. + * The link preview of who follows a profile, who it follows, or who of those follow it back. The lists are thin pages, so they are not indexed, though their links are followed. * - * @param 'followers'|'following' $type + * @param 'followers'|'following'|'mutuals' $type * @return ConnectionsMetadata */ public function connections(User $user, string $type): array { - $title = $type === 'followers' - ? "People following {$user->nameWithHandle()} on /dev/null" - : "People followed by {$user->nameWithHandle()} on /dev/null"; + $title = match ($type) { + 'followers' => "People following {$user->nameWithHandle()} on /dev/null", + 'following' => "People followed by {$user->nameWithHandle()} on /dev/null", + 'mutuals' => "Mutuals of {$user->nameWithHandle()} on /dev/null", + }; return [ 'type' => 'profile', 'title' => $title, - 'description' => $type === 'followers' - ? "See who follows {$user->mention()} on /dev/null, a social network for developers." - : "See who {$user->mention()} follows on /dev/null, a social network for developers.", - 'url' => route($type === 'followers' ? 'user-followers.index' : 'user-following.index', $user), + 'description' => match ($type) { + 'followers' => "See who follows {$user->mention()} on /dev/null, a social network for developers.", + 'following' => "See who {$user->mention()} follows on /dev/null, a social network for developers.", + 'mutuals' => "See who follows {$user->mention()} back on /dev/null, a social network for developers.", + }, + 'url' => route("user-{$type}.index", $user), 'robots' => self::NOT_INDEXABLE, 'images' => [$this->profileImage($user, $title)], 'username' => $user->username, diff --git a/resources/js/pages/user/connections.tsx b/resources/js/pages/user/connections.tsx index 799f89a5b..bf193d62a 100644 --- a/resources/js/pages/user/connections.tsx +++ b/resources/js/pages/user/connections.tsx @@ -3,6 +3,7 @@ import { ArrowLeft } from 'lucide-react'; import type { CSSProperties } from 'react'; import UserFollowersController from '@/actions/App/Http/Controllers/UserFollowersController'; import UserFollowingController from '@/actions/App/Http/Controllers/UserFollowingController'; +import UserMutualsController from '@/actions/App/Http/Controllers/UserMutualsController'; import { EmptyFeed } from '@/components/commit-feed'; import { FollowButton } from '@/components/follow-button'; import { PageTabs } from '@/components/page-tabs'; @@ -15,6 +16,14 @@ import type { CursorPaginated, User } from '@/types'; type Connection = User & { is_followed: boolean }; +type ConnectionType = 'followers' | 'following' | 'mutuals'; + +const titles: Record = { + followers: 'Followers', + following: 'Following', + mutuals: 'Mutuals', +}; + function ConnectionFollowButton({ person }: { person: Connection }) { return ( ; }) { const { auth } = usePage().props; - const title = type === 'following' ? 'Following' : 'Followers'; + const title = titles[type]; + const emptyMessages: Record = { + followers: `Nobody is following @${user.username} yet.`, + following: `@${user.username} isn't following anyone yet.`, + mutuals: `@${user.username} has no mutuals yet.`, + }; return ( <> @@ -74,6 +88,11 @@ export default function Connections({ href: UserFollowingController.index(user), isActive: type === 'following', }, + { + title: 'Mutuals', + href: UserMutualsController.index(user), + isActive: type === 'mutuals', + }, ]} /> @@ -81,9 +100,7 @@ export default function Connections({ {users.data.length === 0 ? (
- {type === 'following' - ? `@${user.username} isn't following anyone yet.` - : `Nobody is following @${user.username} yet.`} + {emptyMessages[type]}
) : ( diff --git a/routes/web.php b/routes/web.php index 5241bf712..98694d0f1 100644 --- a/routes/web.php +++ b/routes/web.php @@ -96,6 +96,7 @@ use App\Http\Controllers\UserLaneController; use App\Http\Controllers\UserLiveController; use App\Http\Controllers\UserMuteController; +use App\Http\Controllers\UserMutualsController; use App\Http\Controllers\UserOpenGraphImageController; use App\Http\Controllers\UserPasteisModeController; use App\Http\Controllers\UserPreviewController; @@ -161,6 +162,8 @@ ->name('user-following.index'); Route::get('@{user:username}/followers', [UserFollowersController::class, 'index']) ->name('user-followers.index'); + Route::get('@{user:username}/mutuals', [UserMutualsController::class, 'index']) + ->name('user-mutuals.index'); Route::get('@{user:username}/commits/{commit}', [CommitController::class, 'show']) ->scopeBindings() ->name('commit.show'); diff --git a/tests/Feature/Controllers/UserMutualsControllerTest.php b/tests/Feature/Controllers/UserMutualsControllerTest.php new file mode 100644 index 000000000..5d6aaf0ec --- /dev/null +++ b/tests/Feature/Controllers/UserMutualsControllerTest.php @@ -0,0 +1,120 @@ +create(); + $mutual = User::factory()->create(); + $user->following()->attach([$mutual->id, User::factory()->create()->id]); + $user->followers()->attach([$mutual->id, User::factory()->create()->id]); + + $response = $this->actingAs(User::factory()->create()) + ->get(route('user-mutuals.index', $user)); + + $response->assertOk() + ->assertInertia(fn ($page) => $page + ->component('user/connections') + ->where('type', 'mutuals') + ->has('users.data', 1) + ->where('users.data.0.id', $mutual->id) + ->missing('users.data.0.email')); +}); + +it('lists the newest follow first', function (): void { + $user = User::factory()->create(); + $earlier = User::factory()->create(); + $later = User::factory()->create(); + $user->following()->attach($earlier, ['created_at' => now()->subMinute()]); + $user->following()->attach($later); + $user->followers()->attach([$earlier->id, $later->id]); + + $response = $this->actingAs(User::factory()->create()) + ->get(route('user-mutuals.index', $user)); + + $response->assertOk() + ->assertInertia(fn ($page) => $page + ->has('users.data', 2) + ->where('users.data.0.id', $later->id) + ->where('users.data.1.id', $earlier->id)); +}); + +it('marks the users the viewer follows', function (): void { + $viewer = User::factory()->create(); + $user = User::factory()->create(); + $followed = User::factory()->create(); + $user->following()->attach($followed); + $user->followers()->attach($followed); + $viewer->following()->attach($followed); + + $response = $this->actingAs($viewer) + ->get(route('user-mutuals.index', $user)); + + $response->assertOk() + ->assertInertia(fn ($page) => $page + ->has('users.data', 1) + ->where('users.data.0.is_followed', true)); +}); + +it('looks missing to people across a block', function (bool $viewerBlocks): void { + $viewer = User::factory()->create(); + $user = User::factory()->create(); + + $viewerBlocks ? $viewer->blocking()->attach($user) : $user->blocking()->attach($viewer); + + $response = $this->actingAs($viewer) + ->get(route('user-mutuals.index', $user)); + + $response->assertNotFound(); +})->with([true, false]); + +it('lists them to guests, without anyone marked as followed', function (): void { + $user = User::factory()->create(); + $mutual = User::factory()->create(); + $user->following()->attach($mutual); + $user->followers()->attach($mutual); + + $response = $this->get(route('user-mutuals.index', $user)); + + $response->assertOk() + ->assertInertia(fn ($page) => $page + ->component('user/connections') + ->has('users.data', 1) + ->where('users.data.0.is_followed', false)); +}); + +it('previews the list to guests without letting search engines index it', function (): void { + $user = User::factory()->create(['username' => 'octocat', 'name' => 'The Octocat']); + + $this->get(route('user-mutuals.index', $user)) + ->assertOk() + ->assertSee('Mutuals of The Octocat (@octocat) on /dev/null', false) + ->assertSee('', false) + ->assertSee('', false); +}); + +it('does not count the user for guests loading more of the list', function (): void { + $user = User::factory()->create(); + $mutual = User::factory()->create(); + $user->following()->attach($mutual); + $user->followers()->attach($mutual); + + DB::enableQueryLog(); + + $this->get(route('user-mutuals.index', $user), [ + 'X-Inertia' => 'true', + 'X-Inertia-Version' => (string) new HandleInertiaRequests()->version(request()), + 'X-Inertia-Partial-Component' => 'user/connections', + 'X-Inertia-Partial-Data' => 'users', + ]) + ->assertOk() + ->assertJsonCount(1, 'props.users.data') + ->assertJsonMissingPath('props.user'); + + expect(collect(DB::getQueryLog())->pluck('query')->all()) + ->not->toBeEmpty() + ->each->not->toContain('count(*)'); +});