From c515a3a24b739104a5b6892cdd713f514d3c340e Mon Sep 17 00:00:00 2001 From: ashkesarmaa Date: Tue, 20 Oct 2026 00:35:15 +0200 Subject: [PATCH 1/2] feat: show followers you know on profiles A profile now says which of the people you follow also follow this user: "Followed by @a, @b and N others you follow", with their avatars, most followed first. It opens a Followers you know list, also offered as a tab beside Followers and Following. Hidden for guests, on your own profile, and when you know none of their followers. --- KNOWLEDGE_BASE.md | 2 +- app/Http/Controllers/UserController.php | 6 +- .../UserFollowersYouKnowController.php | 27 +++++++ .../ListUserFollowersYouKnowRequest.php | 34 ++++++++ app/Queries/FollowersYouKnowQuery.php | 64 +++++++++++++++ .../js/components/followers-you-know.tsx | 81 +++++++++++++++++++ resources/js/components/page-tabs.tsx | 19 ++++- resources/js/pages/user/connections.tsx | 40 +++++++-- resources/js/pages/user/show.tsx | 7 ++ routes/web.php | 5 ++ .../Controllers/UserControllerTest.php | 43 ++++++++++ .../UserFollowersYouKnowControllerTest.php | 55 +++++++++++++ tests/Unit/FollowersYouKnow.test.tsx | 70 ++++++++++++++++ 13 files changed, 444 insertions(+), 9 deletions(-) create mode 100644 app/Http/Controllers/UserFollowersYouKnowController.php create mode 100644 app/Http/Requests/ListUserFollowersYouKnowRequest.php create mode 100644 app/Queries/FollowersYouKnowQuery.php create mode 100644 resources/js/components/followers-you-know.tsx create mode 100644 tests/Feature/Controllers/UserFollowersYouKnowControllerTest.php create mode 100644 tests/Unit/FollowersYouKnow.test.tsx diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 5c2685f8b..d50a6ba62 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. 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. 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/UserController.php b/app/Http/Controllers/UserController.php index 0393511a6..e34193246 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -13,6 +13,7 @@ use App\Models\Commit; use App\Models\User; use App\Models\UserTimeout; +use App\Queries\FollowersYouKnowQuery; use App\Queries\PinnedCommitQuery; use App\Queries\PushAccessQuery; use App\Queries\UserAchievementsQuery; @@ -31,7 +32,7 @@ /** * Profiles are public, so guests and crawlers read the same page, with the profile's link preview. */ - public function show(ShowUserRequest $request, #[CurrentUser] ?User $currentUser, User $user, UserActivityQuery $activity, UserStreakQuery $streaks, UserAchievementsQuery $achievements, PushAccessQuery $pushAccess, UserCommitsQuery $commits, PinnedCommitQuery $pinned, PageMetadata $metadata): Response + public function show(ShowUserRequest $request, #[CurrentUser] ?User $currentUser, User $user, UserActivityQuery $activity, UserStreakQuery $streaks, UserAchievementsQuery $achievements, PushAccessQuery $pushAccess, UserCommitsQuery $commits, PinnedCommitQuery $pinned, FollowersYouKnowQuery $followersYouKnow, PageMetadata $metadata): Response { $blockerId = $currentUser?->blockerIdWith($user); @@ -58,6 +59,9 @@ public function show(ShowUserRequest $request, #[CurrentUser] ?User $currentUser 'isFollowing' => fn (): bool => $currentUser?->isFollowing($user) ?? false, 'followsYou' => fn (): bool => $currentUser instanceof User && $user->isFollowing($currentUser), 'isMuting' => fn (): bool => $currentUser?->isMuting($user) ?? false, + 'followersYouKnow' => fn (): ?array => $currentUser instanceof User && $currentUser->isNot($user) + ? $followersYouKnow->preview($user, $currentUser) + : null, 'canPush' => fn (): bool => $currentUser instanceof User && $currentUser->canMessage($user) && ! in_array($pushAccess->get($currentUser, $user), [PushAccess::Restricted, PushAccess::Closed], true), diff --git a/app/Http/Controllers/UserFollowersYouKnowController.php b/app/Http/Controllers/UserFollowersYouKnowController.php new file mode 100644 index 000000000..e4896fdb8 --- /dev/null +++ b/app/Http/Controllers/UserFollowersYouKnowController.php @@ -0,0 +1,27 @@ + $user, + 'type' => 'followers_you_know', + 'users' => Inertia::scroll(fn () => $followersYouKnow->builder($user, $currentUser) + ->withExists(['followers as is_followed' => fn (Builder $query): Builder => $query->whereKey($currentUser->id)]) + ->cursorPaginate(30)), + ]); + } +} diff --git a/app/Http/Requests/ListUserFollowersYouKnowRequest.php b/app/Http/Requests/ListUserFollowersYouKnowRequest.php new file mode 100644 index 000000000..9a2c67b9c --- /dev/null +++ b/app/Http/Requests/ListUserFollowersYouKnowRequest.php @@ -0,0 +1,34 @@ +user(); + assert($viewer instanceof User); + + $user = $this->route('user'); + assert($user instanceof User); + + return $viewer->hasBlockWith($user) ? Response::denyAsNotFound() : Response::allow(); + } + + /** + * @return array{} + */ + public function rules(): array + { + return []; + } +} diff --git a/app/Queries/FollowersYouKnowQuery.php b/app/Queries/FollowersYouKnowQuery.php new file mode 100644 index 000000000..5e76332c4 --- /dev/null +++ b/app/Queries/FollowersYouKnowQuery.php @@ -0,0 +1,64 @@ + + */ + public function builder(User $user, User $viewer): BelongsToMany + { + $following = $viewer->following(); + + return $user->followers() + ->whereIn('users.id', $following->newPivotQuery()->select($following->getRelatedPivotKeyName())) + ->latest('follows.created_at') + ->latest('users.id'); + } + + /** + * The few the profile names, the most followed first so the viewer is likelier to recognise them, and how many + * there are in all. + * + * @return array{users: Collection, count: int} + */ + public function preview(User $user, User $viewer): array + { + $following = $viewer->following(); + $followers = $user->followers(); + + $known = $followers->newPivotQuery() + ->select($followers->getRelatedPivotKeyName()) + ->whereIn($followers->getRelatedPivotKeyName(), $following->newPivotQuery()->select($following->getRelatedPivotKeyName())); + + $count = $known->count(); + + if ($count === 0) { + return ['users' => new Collection(), 'count' => 0]; + } + + $users = User::query() + ->whereIn('id', $known) + ->addSelect(['followers_count' => $following->newPivotStatement() + ->selectRaw('count(*)') + ->whereColumn($following->getQualifiedRelatedPivotKeyName(), 'users.id')]) + ->orderByDesc('followers_count') + ->orderBy('users.id') + ->limit(self::PREVIEW_COUNT) + ->get() + ->toBase(); + + return ['users' => $users, 'count' => $count]; + } +} diff --git a/resources/js/components/followers-you-know.tsx b/resources/js/components/followers-you-know.tsx new file mode 100644 index 000000000..89c742d5e --- /dev/null +++ b/resources/js/components/followers-you-know.tsx @@ -0,0 +1,81 @@ +import { Link } from '@inertiajs/react'; +import { Fragment } from 'react'; +import UserFollowersYouKnowController from '@/actions/App/Http/Controllers/UserFollowersYouKnowController'; +import { UserAvatar } from '@/components/user-avatar'; +import { show } from '@/routes/user'; +import type { User } from '@/types'; + +export type FollowersYouKnowPreview = { + users: User[]; + count: number; +}; + +/** + * Up to two people are named, and the rest are counted, so the line stays one sentence however many there are. + */ +const NAMED = 2; + +/** + * Social proof under a profile's counts: which of the people the viewer follows also follow this user. + */ +export function FollowersYouKnow({ + user, + preview, +}: { + user: Pick; + preview: FollowersYouKnowPreview; +}) { + if (preview.count === 0) { + return null; + } + + const named = preview.users.slice(0, NAMED); + const others = preview.count - named.length; + const list = UserFollowersYouKnowController.index(user); + + return ( +
+ + {preview.users.map((person) => ( + + ))} + +

+ Followed by{' '} + {named.map((person, index) => ( + + {index > 0 && (others > 0 ? ', ' : ' and ')} + + @{person.username} + + + ))} + {others > 0 && ( + <> + {' '} + and{' '} + + {others} {others === 1 ? 'other' : 'others'} you + follow + + + )} +

+
+ ); +} diff --git a/resources/js/components/page-tabs.tsx b/resources/js/components/page-tabs.tsx index da9c9c774..f4c753615 100644 --- a/resources/js/components/page-tabs.tsx +++ b/resources/js/components/page-tabs.tsx @@ -17,6 +17,10 @@ export function PageTabs({ reset?: string[]; tabs: { title: string; + /** + * Shown instead of the title on phones, where a long title would wrap. + */ + shortTitle?: string; href: NonNullable; isActive: boolean; }[]; @@ -45,7 +49,20 @@ export function PageTabs({ { 'text-foreground': tab.isActive }, )} > - {tab.title} + + {tab.shortTitle ? ( + <> + + {tab.shortTitle} + + + {tab.title} + + + ) : ( + tab.title + )} + {tab.isActive && ( ; }) { const { auth } = usePage().props; - const title = type === 'following' ? 'Following' : 'Followers'; + const title = { + followers: 'Followers', + followers_you_know: 'Followers you know', + following: 'Following', + }[type]; + const knowsFollowers = auth.user !== null && auth.user.id !== user.id; return ( <> @@ -69,6 +75,18 @@ export default function Connections({ href: UserFollowersController.index(user), isActive: type === 'followers', }, + ...(knowsFollowers + ? [ + { + title: 'Followers you know', + shortTitle: 'You know', + href: UserFollowersYouKnowController.index( + user, + ), + isActive: type === 'followers_you_know', + }, + ] + : []), { title: 'Following', href: UserFollowingController.index(user), @@ -80,10 +98,20 @@ export default function Connections({ {users.data.length === 0 ? (
- - {type === 'following' - ? `@${user.username} isn't following anyone yet.` - : `Nobody is following @${user.username} yet.`} + + { + { + followers: `Nobody is following @${user.username} yet.`, + followers_you_know: `Nobody you follow is following @${user.username} yet.`, + following: `@${user.username} isn't following anyone yet.`, + }[type] + }
) : ( diff --git a/resources/js/pages/user/show.tsx b/resources/js/pages/user/show.tsx index 7416d8021..e9ed0984f 100644 --- a/resources/js/pages/user/show.tsx +++ b/resources/js/pages/user/show.tsx @@ -21,6 +21,8 @@ import { BioText } from '@/components/bio-text'; import { CommitFeed, EmptyFeed } from '@/components/commit-feed'; import { EditProfileMenu } from '@/components/edit-profile-menu'; import { FollowButton } from '@/components/follow-button'; +import { FollowersYouKnow } from '@/components/followers-you-know'; +import type { FollowersYouKnowPreview } from '@/components/followers-you-know'; import { GitHubIcon } from '@/components/github-icon'; import { LiveAvatarMarker, @@ -63,6 +65,7 @@ export default function Show({ isFollowing, followsYou, isMuting, + followersYouKnow, canPush, moderation, activity, @@ -77,6 +80,7 @@ export default function Show({ isFollowing: boolean; followsYou: boolean; isMuting: boolean; + followersYouKnow: FollowersYouKnowPreview | null; canPush: boolean; moderation: UserModeration | null; activity: Activity; @@ -379,6 +383,9 @@ export default function Show({ ))} + {followersYouKnow && ( + + )} {auth.pasteis && pasteisReceived !== null && (

name('banned.show'); + // Followers the viewer knows... + Route::get('@{user:username}/followers-you-know', [UserFollowersYouKnowController::class, 'index']) + ->name('user-followers-you-know.index'); + // Onboarding... Route::get('onboarding', [OnboardingController::class, 'show'])->name('onboarding.show'); Route::put('onboarding', [OnboardingController::class, 'update'])->name('onboarding.update'); diff --git a/tests/Feature/Controllers/UserControllerTest.php b/tests/Feature/Controllers/UserControllerTest.php index f98d5895f..82cf669ca 100644 --- a/tests/Feature/Controllers/UserControllerTest.php +++ b/tests/Feature/Controllers/UserControllerTest.php @@ -701,3 +701,46 @@ $this->actingAs($user)->get(route('user.show', $user)) ->assertInertia(fn (AssertableInertia $page): AssertableInertia => $page->where('canPush', false)); }); + +it('names the followers the viewer knows, the most followed first', function (): void { + $viewer = User::factory()->create(); + $user = User::factory()->create(); + [$quiet, $popular, $middle, $fourth] = User::factory()->count(4)->create(); + $user->followers()->attach([$quiet->id, $popular->id, $middle->id, $fourth->id, User::factory()->create()->id]); + $viewer->following()->attach([$quiet->id, $popular->id, $middle->id, $fourth->id]); + $popular->followers()->attach(User::factory()->count(3)->create()); + $middle->followers()->attach(User::factory()->count(2)->create()); + $fourth->followers()->attach(User::factory()->create()); + + $this->actingAs($viewer) + ->get(route('user.show', $user)) + ->assertInertia(fn ($page) => $page + ->where('followersYouKnow.count', 4) + ->has('followersYouKnow.users', 3) + ->where('followersYouKnow.users.0.id', $popular->id) + ->where('followersYouKnow.users.1.id', $middle->id) + ->where('followersYouKnow.users.2.id', $fourth->id)); +}); + +it('has no followers the viewer knows when they follow none of them', function (): void { + $viewer = User::factory()->create(); + $user = User::factory()->create(); + $user->followers()->attach(User::factory()->create()); + + $this->actingAs($viewer) + ->get(route('user.show', $user)) + ->assertInertia(fn ($page) => $page + ->where('followersYouKnow.count', 0) + ->has('followersYouKnow.users', 0)); +}); + +it('names no followers the viewer knows to guests or on their own profile', function (): void { + $user = User::factory()->create(); + + $this->get(route('user.show', $user)) + ->assertInertia(fn ($page) => $page->where('followersYouKnow', null)); + + $this->actingAs($user) + ->get(route('user.show', $user)) + ->assertInertia(fn ($page) => $page->where('followersYouKnow', null)); +}); diff --git a/tests/Feature/Controllers/UserFollowersYouKnowControllerTest.php b/tests/Feature/Controllers/UserFollowersYouKnowControllerTest.php new file mode 100644 index 000000000..6f0c30ed5 --- /dev/null +++ b/tests/Feature/Controllers/UserFollowersYouKnowControllerTest.php @@ -0,0 +1,55 @@ +create(); + $user = User::factory()->create(['username' => 'octocat']); + $known = User::factory()->create(); + $stranger = User::factory()->create(); + $user->followers()->attach([$known->id, $stranger->id]); + $viewer->following()->attach($known); + + $this->actingAs($viewer) + ->get(route('user-followers-you-know.index', $user)) + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('user/connections') + ->where('type', 'followers_you_know') + ->where('user.username', 'octocat') + ->has('users.data', 1) + ->where('users.data.0.id', $known->id) + ->where('users.data.0.is_followed', true)); +}); + +it('lists the newest follow of the user first', function (): void { + $viewer = User::factory()->create(); + $user = User::factory()->create(); + [$earlier, $later] = User::factory()->count(2)->create(); + $user->followers()->attach($earlier, ['created_at' => now()->subDay(), 'updated_at' => now()->subDay()]); + $user->followers()->attach($later); + $viewer->following()->attach([$earlier->id, $later->id]); + + $this->actingAs($viewer) + ->get(route('user-followers-you-know.index', $user)) + ->assertInertia(fn ($page) => $page + ->where('users.data.0.id', $later->id) + ->where('users.data.1.id', $earlier->id)); +}); + +it('sends guests home', function (): void { + $this->get(route('user-followers-you-know.index', User::factory()->create())) + ->assertRedirect(route('home')); +}); + +it('does not list them across a block', function (): void { + $viewer = User::factory()->create(); + $user = User::factory()->create(); + $user->blocking()->attach($viewer); + + $this->actingAs($viewer) + ->get(route('user-followers-you-know.index', $user)) + ->assertNotFound(); +}); diff --git a/tests/Unit/FollowersYouKnow.test.tsx b/tests/Unit/FollowersYouKnow.test.tsx new file mode 100644 index 000000000..703b912e3 --- /dev/null +++ b/tests/Unit/FollowersYouKnow.test.tsx @@ -0,0 +1,70 @@ +/// + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { createInertiaApp } from '@inertiajs/react'; +import { renderToString } from 'react-dom/server'; +import { FollowersYouKnow } from '../../resources/js/components/followers-you-know'; +import type { User } from '../../resources/js/types'; + +const person = (username: string) => + ({ id: username, username, name: username, avatar_url: null }) as User; + +async function render(usernames: string[], count: number): Promise { + const result = await createInertiaApp({ + page: { + component: 'profile', + props: { auth: { user: { id: 'viewer' } }, errors: {} }, + url: '/@octocat', + version: null, + clearHistory: false, + encryptHistory: false, + rescuedProps: [], + rememberedState: {}, + flash: {}, + }, + resolve: () => () => ( + + ), + render: renderToString, + setup: ({ App, props }) => , + }); + + assert.ok(result); + + const sentence = /]*>(.*?)<\/p>/s.exec(result.body)?.[1] ?? ''; + + return sentence.replace(//g, '').replace(/<[^>]+>/g, ''); +} + +void test('nothing shows when the viewer knows none of the followers', async () => { + assert.equal(await render([], 0), ''); +}); + +void test('one follower the viewer knows is named alone', async () => { + assert.equal(await render(['alice'], 1), 'Followed by @alice'); +}); + +void test('two followers the viewer knows are joined with and', async () => { + assert.equal( + await render(['alice', 'bob'], 2), + 'Followed by @alice and @bob', + ); +}); + +void test('a third follower the viewer knows is counted in the singular', async () => { + assert.equal( + await render(['alice', 'bob', 'carol'], 3), + 'Followed by @alice, @bob and 1 other you follow', + ); +}); + +void test('more followers the viewer knows are counted', async () => { + assert.equal( + await render(['alice', 'bob', 'carol'], 9), + 'Followed by @alice, @bob and 7 others you follow', + ); +}); From e9a6bbb35c2c78bd58045f026a0ceb66ba660551 Mon Sep 17 00:00:00 2001 From: ashkesarmaa Date: Fri, 23 Oct 2026 16:52:27 +0200 Subject: [PATCH 2/2] fix: let long usernames wrap beside the follower avatars --- resources/js/components/followers-you-know.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/js/components/followers-you-know.tsx b/resources/js/components/followers-you-know.tsx index 89c742d5e..3850f21e0 100644 --- a/resources/js/components/followers-you-know.tsx +++ b/resources/js/components/followers-you-know.tsx @@ -59,7 +59,7 @@ export function FollowersYouKnow({ {index > 0 && (others > 0 ? ', ' : ' and ')} @{person.username}