From ac0c63e1013e9feeb8e84e138e664a16d5c26bb3 Mon Sep 17 00:00:00 2001 From: Kjell Kind Date: Fri, 23 Oct 2026 00:16:59 +0200 Subject: [PATCH 1/3] feat: add a friendships tab next to followers and following Lists the people a user follows who follow them back, newest follow first. Co-Authored-By: Claude Opus 5.5 --- KNOWLEDGE_BASE.md | 2 +- .../Controllers/UserFriendshipsController.php | 41 ++++++ .../Requests/ListUserFriendshipsRequest.php | 33 +++++ app/Services/PageMetadata.php | 22 ++-- resources/js/pages/user/connections.tsx | 27 +++- routes/web.php | 3 + .../UserFriendshipsControllerTest.php | 120 ++++++++++++++++++ 7 files changed, 233 insertions(+), 15 deletions(-) create mode 100644 app/Http/Controllers/UserFriendshipsController.php create mode 100644 app/Http/Requests/ListUserFriendshipsRequest.php create mode 100644 tests/Feature/Controllers/UserFriendshipsControllerTest.php diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 2c68f5276..64f569311 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -94,7 +94,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 **Friendships** 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/UserFriendshipsController.php b/app/Http/Controllers/UserFriendshipsController.php new file mode 100644 index 000000000..dd2c1fb90 --- /dev/null +++ b/app/Http/Controllers/UserFriendshipsController.php @@ -0,0 +1,41 @@ +loadPublicCounts(); + }; + + return Inertia::render('user/connections', [ + 'user' => fn (): User => $currentUser instanceof User ? $user : $getCountedUser(), + 'type' => 'friendships', + 'users' => Inertia::scroll(fn () => $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)) + ->withExists(['followers as is_followed' => fn (Builder $query): Builder => $currentUser instanceof User ? $query->whereKey($currentUser->id) : $query->whereRaw('1 = 0')]) + ->latest('follows.created_at') + ->latest('users.id') + ->cursorPaginate(30)), + ])->withViewData(['preview' => $currentUser instanceof User || $request->inertia() ? null : $metadata->connections($getCountedUser(), 'friendships')]); + } +} diff --git a/app/Http/Requests/ListUserFriendshipsRequest.php b/app/Http/Requests/ListUserFriendshipsRequest.php new file mode 100644 index 000000000..d78de0e6b --- /dev/null +++ b/app/Http/Requests/ListUserFriendshipsRequest.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..4b6eaef2f 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'|'friendships' $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", + 'friendships' => "Friendships 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.", + 'friendships' => "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..0b1ecaf2b 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 UserFriendshipsController from '@/actions/App/Http/Controllers/UserFriendshipsController'; 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' | 'friendships'; + +const titles: Record = { + followers: 'Followers', + following: 'Following', + friendships: 'Friendships', +}; + 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.`, + friendships: `@${user.username} has no friendships yet.`, + }; return ( <> @@ -74,6 +88,11 @@ export default function Connections({ href: UserFollowingController.index(user), isActive: type === 'following', }, + { + title: 'Friendships', + href: UserFriendshipsController.index(user), + isActive: type === 'friendships', + }, ]} /> @@ -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 5ef875a03..20d2d451d 100644 --- a/routes/web.php +++ b/routes/web.php @@ -89,6 +89,7 @@ use App\Http\Controllers\UserFollowController; use App\Http\Controllers\UserFollowersController; use App\Http\Controllers\UserFollowingController; +use App\Http\Controllers\UserFriendshipsController; use App\Http\Controllers\UserLaneController; use App\Http\Controllers\UserLiveController; use App\Http\Controllers\UserMuteController; @@ -157,6 +158,8 @@ ->name('user-following.index'); Route::get('@{user:username}/followers', [UserFollowersController::class, 'index']) ->name('user-followers.index'); + Route::get('@{user:username}/friendships', [UserFriendshipsController::class, 'index']) + ->name('user-friendships.index'); Route::get('@{user:username}/commits/{commit}', [CommitController::class, 'show']) ->scopeBindings() ->name('commit.show'); diff --git a/tests/Feature/Controllers/UserFriendshipsControllerTest.php b/tests/Feature/Controllers/UserFriendshipsControllerTest.php new file mode 100644 index 000000000..95575ded1 --- /dev/null +++ b/tests/Feature/Controllers/UserFriendshipsControllerTest.php @@ -0,0 +1,120 @@ +create(); + $friend = User::factory()->create(); + $user->following()->attach([$friend->id, User::factory()->create()->id]); + $user->followers()->attach([$friend->id, User::factory()->create()->id]); + + $response = $this->actingAs(User::factory()->create()) + ->get(route('user-friendships.index', $user)); + + $response->assertOk() + ->assertInertia(fn ($page) => $page + ->component('user/connections') + ->where('type', 'friendships') + ->has('users.data', 1) + ->where('users.data.0.id', $friend->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-friendships.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-friendships.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-friendships.index', $user)); + + $response->assertNotFound(); +})->with([true, false]); + +it('lists them to guests, without anyone marked as followed', function (): void { + $user = User::factory()->create(); + $friend = User::factory()->create(); + $user->following()->attach($friend); + $user->followers()->attach($friend); + + $response = $this->get(route('user-friendships.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-friendships.index', $user)) + ->assertOk() + ->assertSee('Friendships 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(); + $friend = User::factory()->create(); + $user->following()->attach($friend); + $user->followers()->attach($friend); + + DB::enableQueryLog(); + + $this->get(route('user-friendships.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(*)'); +}); From 8fb4b668b9f04f69404998e433b43000b89b046d Mon Sep 17 00:00:00 2001 From: Kjell Kind Date: Fri, 23 Oct 2026 12:20:31 +0200 Subject: [PATCH 2/3] refactor: rename the friendships tab to mutuals Co-Authored-By: Claude Opus 5.5 --- KNOWLEDGE_BASE.md | 2 +- ...ntroller.php => UserMutualsController.php} | 10 ++--- ...Request.php => ListUserMutualsRequest.php} | 2 +- app/Services/PageMetadata.php | 6 +-- resources/js/pages/user/connections.tsx | 14 +++---- routes/web.php | 6 +-- ...Test.php => UserMutualsControllerTest.php} | 40 +++++++++---------- 7 files changed, 40 insertions(+), 40 deletions(-) rename app/Http/Controllers/{UserFriendshipsController.php => UserMutualsController.php} (82%) rename app/Http/Requests/{ListUserFriendshipsRequest.php => ListUserMutualsRequest.php} (91%) rename tests/Feature/Controllers/{UserFriendshipsControllerTest.php => UserMutualsControllerTest.php} (74%) diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 64f569311..31e33d698 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -94,7 +94,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. Next to Followers and Following, a **Friendships** 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. +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/UserFriendshipsController.php b/app/Http/Controllers/UserMutualsController.php similarity index 82% rename from app/Http/Controllers/UserFriendshipsController.php rename to app/Http/Controllers/UserMutualsController.php index dd2c1fb90..e512c0186 100644 --- a/app/Http/Controllers/UserFriendshipsController.php +++ b/app/Http/Controllers/UserMutualsController.php @@ -4,7 +4,7 @@ namespace App\Http\Controllers; -use App\Http\Requests\ListUserFriendshipsRequest; +use App\Http\Requests\ListUserMutualsRequest; use App\Models\User; use App\Services\PageMetadata; use Illuminate\Container\Attributes\CurrentUser; @@ -13,12 +13,12 @@ use Inertia\Inertia; use Inertia\Response; -final readonly class UserFriendshipsController +final readonly class UserMutualsController { /** * The people a user follows who follow them back. */ - public function index(ListUserFriendshipsRequest $request, #[CurrentUser] ?User $currentUser, User $user, PageMetadata $metadata): Response + public function index(ListUserMutualsRequest $request, #[CurrentUser] ?User $currentUser, User $user, PageMetadata $metadata): Response { $countedUser = null; $getCountedUser = function () use (&$countedUser, $user): User { @@ -27,7 +27,7 @@ public function index(ListUserFriendshipsRequest $request, #[CurrentUser] ?User return Inertia::render('user/connections', [ 'user' => fn (): User => $currentUser instanceof User ? $user : $getCountedUser(), - 'type' => 'friendships', + 'type' => 'mutuals', 'users' => Inertia::scroll(fn () => $user->following() ->whereExists(fn (QueryBuilder $query): QueryBuilder => $query->from('follows', 'follows_back') ->whereColumn('follows_back.follower_id', 'users.id') @@ -36,6 +36,6 @@ public function index(ListUserFriendshipsRequest $request, #[CurrentUser] ?User ->latest('follows.created_at') ->latest('users.id') ->cursorPaginate(30)), - ])->withViewData(['preview' => $currentUser instanceof User || $request->inertia() ? null : $metadata->connections($getCountedUser(), 'friendships')]); + ])->withViewData(['preview' => $currentUser instanceof User || $request->inertia() ? null : $metadata->connections($getCountedUser(), 'mutuals')]); } } diff --git a/app/Http/Requests/ListUserFriendshipsRequest.php b/app/Http/Requests/ListUserMutualsRequest.php similarity index 91% rename from app/Http/Requests/ListUserFriendshipsRequest.php rename to app/Http/Requests/ListUserMutualsRequest.php index d78de0e6b..1a46a4314 100644 --- a/app/Http/Requests/ListUserFriendshipsRequest.php +++ b/app/Http/Requests/ListUserMutualsRequest.php @@ -8,7 +8,7 @@ use Illuminate\Auth\Access\Response; use Illuminate\Foundation\Http\FormRequest; -final class ListUserFriendshipsRequest extends FormRequest +final class ListUserMutualsRequest extends FormRequest { /** * A block hides each one's connections from the other, like the rest of their profile. diff --git a/app/Services/PageMetadata.php b/app/Services/PageMetadata.php index 4b6eaef2f..15302e234 100644 --- a/app/Services/PageMetadata.php +++ b/app/Services/PageMetadata.php @@ -248,7 +248,7 @@ public function user(User $user): array /** * 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'|'friendships' $type + * @param 'followers'|'following'|'mutuals' $type * @return ConnectionsMetadata */ public function connections(User $user, string $type): array @@ -256,7 +256,7 @@ public function connections(User $user, string $type): array $title = match ($type) { 'followers' => "People following {$user->nameWithHandle()} on /dev/null", 'following' => "People followed by {$user->nameWithHandle()} on /dev/null", - 'friendships' => "Friendships of {$user->nameWithHandle()} on /dev/null", + 'mutuals' => "Mutuals of {$user->nameWithHandle()} on /dev/null", }; return [ @@ -265,7 +265,7 @@ public function connections(User $user, string $type): array '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.", - 'friendships' => "See who follows {$user->mention()} back 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, diff --git a/resources/js/pages/user/connections.tsx b/resources/js/pages/user/connections.tsx index 0b1ecaf2b..bf193d62a 100644 --- a/resources/js/pages/user/connections.tsx +++ b/resources/js/pages/user/connections.tsx @@ -3,7 +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 UserFriendshipsController from '@/actions/App/Http/Controllers/UserFriendshipsController'; +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'; @@ -16,12 +16,12 @@ import type { CursorPaginated, User } from '@/types'; type Connection = User & { is_followed: boolean }; -type ConnectionType = 'followers' | 'following' | 'friendships'; +type ConnectionType = 'followers' | 'following' | 'mutuals'; const titles: Record = { followers: 'Followers', following: 'Following', - friendships: 'Friendships', + mutuals: 'Mutuals', }; function ConnectionFollowButton({ person }: { person: Connection }) { @@ -52,7 +52,7 @@ export default function Connections({ const emptyMessages: Record = { followers: `Nobody is following @${user.username} yet.`, following: `@${user.username} isn't following anyone yet.`, - friendships: `@${user.username} has no friendships yet.`, + mutuals: `@${user.username} has no mutuals yet.`, }; return ( @@ -89,9 +89,9 @@ export default function Connections({ isActive: type === 'following', }, { - title: 'Friendships', - href: UserFriendshipsController.index(user), - isActive: type === 'friendships', + title: 'Mutuals', + href: UserMutualsController.index(user), + isActive: type === 'mutuals', }, ]} /> diff --git a/routes/web.php b/routes/web.php index 20d2d451d..145f62dfc 100644 --- a/routes/web.php +++ b/routes/web.php @@ -89,10 +89,10 @@ use App\Http\Controllers\UserFollowController; use App\Http\Controllers\UserFollowersController; use App\Http\Controllers\UserFollowingController; -use App\Http\Controllers\UserFriendshipsController; 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; @@ -158,8 +158,8 @@ ->name('user-following.index'); Route::get('@{user:username}/followers', [UserFollowersController::class, 'index']) ->name('user-followers.index'); - Route::get('@{user:username}/friendships', [UserFriendshipsController::class, 'index']) - ->name('user-friendships.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/UserFriendshipsControllerTest.php b/tests/Feature/Controllers/UserMutualsControllerTest.php similarity index 74% rename from tests/Feature/Controllers/UserFriendshipsControllerTest.php rename to tests/Feature/Controllers/UserMutualsControllerTest.php index 95575ded1..5d6aaf0ec 100644 --- a/tests/Feature/Controllers/UserFriendshipsControllerTest.php +++ b/tests/Feature/Controllers/UserMutualsControllerTest.php @@ -8,19 +8,19 @@ it('lists the people a user follows who follow them back', function (): void { $user = User::factory()->create(); - $friend = User::factory()->create(); - $user->following()->attach([$friend->id, User::factory()->create()->id]); - $user->followers()->attach([$friend->id, User::factory()->create()->id]); + $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-friendships.index', $user)); + ->get(route('user-mutuals.index', $user)); $response->assertOk() ->assertInertia(fn ($page) => $page ->component('user/connections') - ->where('type', 'friendships') + ->where('type', 'mutuals') ->has('users.data', 1) - ->where('users.data.0.id', $friend->id) + ->where('users.data.0.id', $mutual->id) ->missing('users.data.0.email')); }); @@ -33,7 +33,7 @@ $user->followers()->attach([$earlier->id, $later->id]); $response = $this->actingAs(User::factory()->create()) - ->get(route('user-friendships.index', $user)); + ->get(route('user-mutuals.index', $user)); $response->assertOk() ->assertInertia(fn ($page) => $page @@ -51,7 +51,7 @@ $viewer->following()->attach($followed); $response = $this->actingAs($viewer) - ->get(route('user-friendships.index', $user)); + ->get(route('user-mutuals.index', $user)); $response->assertOk() ->assertInertia(fn ($page) => $page @@ -66,18 +66,18 @@ $viewerBlocks ? $viewer->blocking()->attach($user) : $user->blocking()->attach($viewer); $response = $this->actingAs($viewer) - ->get(route('user-friendships.index', $user)); + ->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(); - $friend = User::factory()->create(); - $user->following()->attach($friend); - $user->followers()->attach($friend); + $mutual = User::factory()->create(); + $user->following()->attach($mutual); + $user->followers()->attach($mutual); - $response = $this->get(route('user-friendships.index', $user)); + $response = $this->get(route('user-mutuals.index', $user)); $response->assertOk() ->assertInertia(fn ($page) => $page @@ -89,22 +89,22 @@ 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-friendships.index', $user)) + $this->get(route('user-mutuals.index', $user)) ->assertOk() - ->assertSee('Friendships of The Octocat (@octocat) on /dev/null', false) + ->assertSee('Mutuals of The Octocat (@octocat) on /dev/null', false) ->assertSee('', false) - ->assertSee('', false); + ->assertSee('', false); }); it('does not count the user for guests loading more of the list', function (): void { $user = User::factory()->create(); - $friend = User::factory()->create(); - $user->following()->attach($friend); - $user->followers()->attach($friend); + $mutual = User::factory()->create(); + $user->following()->attach($mutual); + $user->followers()->attach($mutual); DB::enableQueryLog(); - $this->get(route('user-friendships.index', $user), [ + $this->get(route('user-mutuals.index', $user), [ 'X-Inertia' => 'true', 'X-Inertia-Version' => (string) new HandleInertiaRequests()->version(request()), 'X-Inertia-Partial-Component' => 'user/connections', From 1c5adf4210cc693b2163c588e7304099706d28fe Mon Sep 17 00:00:00 2001 From: Kjell Kind Date: Fri, 23 Oct 2026 13:56:23 +0200 Subject: [PATCH 3/3] refactor: reuse the connections query for the mutuals list Co-Authored-By: Claude Opus 5.5 --- .../Controllers/UserMutualsController.php | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/app/Http/Controllers/UserMutualsController.php b/app/Http/Controllers/UserMutualsController.php index e512c0186..ce613b74b 100644 --- a/app/Http/Controllers/UserMutualsController.php +++ b/app/Http/Controllers/UserMutualsController.php @@ -6,6 +6,7 @@ use App\Http\Requests\ListUserMutualsRequest; use App\Models\User; +use App\Queries\UserConnectionsQuery; use App\Services\PageMetadata; use Illuminate\Container\Attributes\CurrentUser; use Illuminate\Database\Eloquent\Builder; @@ -18,7 +19,7 @@ /** * The people a user follows who follow them back. */ - public function index(ListUserMutualsRequest $request, #[CurrentUser] ?User $currentUser, User $user, PageMetadata $metadata): Response + public function index(ListUserMutualsRequest $request, #[CurrentUser] ?User $currentUser, User $user, PageMetadata $metadata, UserConnectionsQuery $connections): Response { $countedUser = null; $getCountedUser = function () use (&$countedUser, $user): User { @@ -28,14 +29,18 @@ public function index(ListUserMutualsRequest $request, #[CurrentUser] ?User $cur return Inertia::render('user/connections', [ 'user' => fn (): User => $currentUser instanceof User ? $user : $getCountedUser(), 'type' => 'mutuals', - 'users' => Inertia::scroll(fn () => $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)) - ->withExists(['followers as is_followed' => fn (Builder $query): Builder => $currentUser instanceof User ? $query->whereKey($currentUser->id) : $query->whereRaw('1 = 0')]) - ->latest('follows.created_at') - ->latest('users.id') - ->cursorPaginate(30)), + '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')]); } }