diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 6231c306e..a0bd287d4 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. 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. 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 8315cdb7b..348ec66ae 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -10,6 +10,7 @@ use App\Http\Requests\ShowUserRequest; use App\Models\Commit; use App\Models\User; +use App\Queries\FollowersYouKnowQuery; use App\Queries\GitHubReadmeQuery; use App\Queries\PinnedCommitQuery; use App\Queries\PushAccessQuery; @@ -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, UserModerationQuery $moderation, GitHubReadmeQuery $readme, 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, UserModerationQuery $moderation, FollowersYouKnowQuery $followersYouKnow, GitHubReadmeQuery $readme, 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..2a110bd7c --- /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.handle} + + + ))} + {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.handle} isn't following anyone yet.` - : `Nobody is following @${user.handle} 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] + }
) : ( diff --git a/resources/js/pages/user/show.tsx b/resources/js/pages/user/show.tsx index 42eddacdd..267f3e724 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, @@ -64,6 +66,7 @@ export default function Show({ isFollowing, followsYou, isMuting, + followersYouKnow, canPush, moderation, activity, @@ -79,6 +82,7 @@ export default function Show({ isFollowing: boolean; followsYou: boolean; isMuting: boolean; + followersYouKnow: FollowersYouKnowPreview | null; canPush: boolean; moderation: UserModeration | null; activity: Activity; @@ -382,6 +386,9 @@ export default function Show({ ))} + {followersYouKnow && ( + + )} {auth.pasteis && pasteisReceived !== null && (

name('banned.show'); + // Followers the viewer knows... + Route::get('@{user:handle}/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 f8b9be053..3bca2cec5 100644 --- a/tests/Feature/Controllers/UserControllerTest.php +++ b/tests/Feature/Controllers/UserControllerTest.php @@ -735,3 +735,46 @@ $this->get(route('user.show', ['user' => $user, 'tab' => $tab]))->assertOk()->assertInertia(fn ($page) => $page ->where('readme', null)); })->with(['commits', 'replies']); + +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..d26738133 --- /dev/null +++ b/tests/Feature/Controllers/UserFollowersYouKnowControllerTest.php @@ -0,0 +1,55 @@ +create(); + $user = User::factory()->create(['handle' => '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.handle', '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..b81f7c731 --- /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 = (handle: string) => + ({ id: handle, handle, name: handle, avatar_url: null }) as User; + +async function render(handles: 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', + ); +});