diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index ae76f1df4..2bfc962a5 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -98,7 +98,7 @@ Sources: [onboarding page](resources/js/pages/onboarding/show.tsx), [sign-in tes ### Profiles and GitHub sync -Every developer has a profile at `/@{handle}`, 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; README shows the README of their GitHub profile repository (the one named after their username), as GitHub renders it, refreshed daily. It keeps only plain formatting, links, and images hosted by GitHub: no scripts, styles, or classes. When GitHub doesn't answer, the tab says so rather than showing no README. Follow and follower counts open the corresponding people lists. When you are signed in, someone else's profile also shows which of the people you follow follow them: **Followed by @a, @b and N others you follow**, naming the most followed first. It opens a **Followers you know** list, which also appears as a tab beside Followers and Following. 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 `/@{handle}`, 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; README shows the README of their GitHub profile repository (the one named after their username), as GitHub renders it, refreshed daily. It keeps only plain formatting, links, and images hosted by GitHub: no scripts, styles, or classes. When GitHub doesn't answer, the tab says so rather than showing no README. Follow and follower counts open the corresponding people lists. When you are signed in, someone else's profile also shows which of the people you follow follow them: **Followed by @a, @b and N others you follow**, naming the most followed first. It opens a **Followers you know** list, which also appears as a tab beside 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 f41c49e12..2bcaae2be 100644 --- a/app/Services/PageMetadata.php +++ b/app/Services/PageMetadata.php @@ -298,24 +298,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)], 'handle' => $user->handle, diff --git a/resources/js/pages/user/connections.tsx b/resources/js/pages/user/connections.tsx index e974c7473..2b0287332 100644 --- a/resources/js/pages/user/connections.tsx +++ b/resources/js/pages/user/connections.tsx @@ -4,6 +4,7 @@ import type { CSSProperties } from 'react'; import UserFollowersController from '@/actions/App/Http/Controllers/UserFollowersController'; import UserFollowersYouKnowController from '@/actions/App/Http/Controllers/UserFollowersYouKnowController'; 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'; @@ -16,6 +17,19 @@ import type { CursorPaginated, User } from '@/types'; type Connection = User & { is_followed: boolean }; +type ConnectionType = + | 'followers' + | 'followers_you_know' + | 'following' + | 'mutuals'; + +const titles: Record = { + followers: 'Followers', + followers_you_know: 'Followers you know', + following: 'Following', + mutuals: 'Mutuals', +}; + function ConnectionFollowButton({ person }: { person: Connection }) { return ( ; }) { const { auth } = usePage().props; - const title = { - followers: 'Followers', - followers_you_know: 'Followers you know', - following: 'Following', - }[type]; + const title = titles[type]; const knowsFollowers = auth.user !== null && auth.user.id !== user.id; + const emptyMessages: Record = { + followers: `Nobody is following @${user.handle} yet.`, + followers_you_know: `Nobody you follow is following @${user.handle} yet.`, + following: `@${user.handle} isn't following anyone yet.`, + mutuals: `@${user.handle} has no mutuals yet.`, + }; return ( <> @@ -92,6 +108,11 @@ export default function Connections({ href: UserFollowingController.index(user), isActive: type === 'following', }, + { + title: 'Mutuals', + href: UserMutualsController.index(user), + isActive: type === 'mutuals', + }, ]} /> @@ -105,13 +126,7 @@ export default function Connections({ : `fatal: no ${type} yet` } > - { - { - followers: `Nobody is following @${user.handle} yet.`, - followers_you_know: `Nobody you follow is following @${user.handle} yet.`, - following: `@${user.handle} isn't following anyone yet.`, - }[type] - } + {emptyMessages[type]} ) : ( diff --git a/routes/web.php b/routes/web.php index 8e8a7bc10..27c36df3c 100644 --- a/routes/web.php +++ b/routes/web.php @@ -114,6 +114,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; @@ -193,6 +194,8 @@ ->name('user-following.index'); Route::get('@{user:handle}/followers', [UserFollowersController::class, 'index']) ->name('user-followers.index'); + Route::get('@{user:handle}/mutuals', [UserMutualsController::class, 'index']) + ->name('user-mutuals.index'); Route::get('@{user:handle}/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..477caa1f6 --- /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(['handle' => '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(*)'); +});