Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion KNOWLEDGE_BASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<github id>` placeholder until its owner signs in or syncs again. Accounts are matched by GitHub id and never merged.

Expand Down
46 changes: 46 additions & 0 deletions app/Http/Controllers/UserMutualsController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers;

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;
use Illuminate\Database\Query\Builder as QueryBuilder;
use Inertia\Inertia;
use Inertia\Response;

final readonly class UserMutualsController
{
/**
* The people a user follows who follow them back.
*/
public function index(ListUserMutualsRequest $request, #[CurrentUser] ?User $currentUser, User $user, PageMetadata $metadata, UserConnectionsQuery $connections): Response
{
$countedUser = null;
$getCountedUser = function () use (&$countedUser, $user): User {
return $countedUser ??= $user->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')]);
}
}
33 changes: 33 additions & 0 deletions app/Http/Requests/ListUserMutualsRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

declare(strict_types=1);

namespace App\Http\Requests;

use App\Models\User;
use Illuminate\Auth\Access\Response;
use Illuminate\Foundation\Http\FormRequest;

final class ListUserMutualsRequest extends FormRequest
{
/**
* A block hides each one's connections from the other, like the rest of their profile.
*/
public function authorize(): Response
{
$viewer = $this->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 [];
}
}
22 changes: 13 additions & 9 deletions app/Services/PageMetadata.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
27 changes: 22 additions & 5 deletions resources/js/pages/user/connections.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -15,6 +16,14 @@ import type { CursorPaginated, User } from '@/types';

type Connection = User & { is_followed: boolean };

type ConnectionType = 'followers' | 'following' | 'mutuals';

const titles: Record<ConnectionType, string> = {
followers: 'Followers',
following: 'Following',
mutuals: 'Mutuals',
};

function ConnectionFollowButton({ person }: { person: Connection }) {
return (
<FollowButton
Expand All @@ -35,11 +44,16 @@ export default function Connections({
users,
}: {
user: User;
type: 'following' | 'followers';
type: ConnectionType;
users: CursorPaginated<Connection>;
}) {
const { auth } = usePage().props;
const title = type === 'following' ? 'Following' : 'Followers';
const title = titles[type];
const emptyMessages: Record<ConnectionType, string> = {
followers: `Nobody is following @${user.username} yet.`,
following: `@${user.username} isn't following anyone yet.`,
mutuals: `@${user.username} has no mutuals yet.`,
};

return (
<>
Expand Down Expand Up @@ -74,16 +88,19 @@ export default function Connections({
href: UserFollowingController.index(user),
isActive: type === 'following',
},
{
title: 'Mutuals',
href: UserMutualsController.index(user),
isActive: type === 'mutuals',
},
]}
/>
</div>

{users.data.length === 0 ? (
<div className="px-6 py-16 text-center sm:px-12">
<EmptyFeed command={`fatal: no ${type} yet`}>
{type === 'following'
? `@${user.username} isn't following anyone yet.`
: `Nobody is following @${user.username} yet.`}
{emptyMessages[type]}
</EmptyFeed>
</div>
) : (
Expand Down
3 changes: 3 additions & 0 deletions routes/web.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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');
Expand Down
120 changes: 120 additions & 0 deletions tests/Feature/Controllers/UserMutualsControllerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
<?php

declare(strict_types=1);

use App\Http\Middleware\HandleInertiaRequests;
use App\Models\User;
use Illuminate\Support\Facades\DB;

it('lists the people a user follows who follow them back', function (): void {
$user = User::factory()->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('<title>Mutuals of The Octocat (@octocat) on /dev/null</title>', false)
->assertSee('<meta name="robots" content="noindex, follow">', false)
->assertSee('<link rel="canonical" href="'.route('user-mutuals.index', $user).'">', 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(*)');
});