Skip to content
Merged
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 @@ -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-<github id>` placeholder until its owner signs in or syncs again. Accounts are matched by GitHub id and never merged.

Expand Down
6 changes: 5 additions & 1 deletion app/Http/Controllers/UserController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);

Expand All @@ -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,
Comment thread
nunomaduro marked this conversation as resolved.
'canPush' => fn (): bool => $currentUser instanceof User
&& $currentUser->canMessage($user)
&& ! in_array($pushAccess->get($currentUser, $user), [PushAccess::Restricted, PushAccess::Closed], true),
Expand Down
27 changes: 27 additions & 0 deletions app/Http/Controllers/UserFollowersYouKnowController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers;

use App\Http\Requests\ListUserFollowersYouKnowRequest;
use App\Models\User;
use App\Queries\FollowersYouKnowQuery;
use Illuminate\Container\Attributes\CurrentUser;
use Illuminate\Database\Eloquent\Builder;
use Inertia\Inertia;
use Inertia\Response;

final readonly class UserFollowersYouKnowController
{
public function index(ListUserFollowersYouKnowRequest $request, #[CurrentUser] User $currentUser, User $user, FollowersYouKnowQuery $followersYouKnow): Response
{
return Inertia::render('user/connections', [
'user' => $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)),
]);
}
}
34 changes: 34 additions & 0 deletions app/Http/Requests/ListUserFollowersYouKnowRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?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 ListUserFollowersYouKnowRequest 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();
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 [];
}
}
64 changes: 64 additions & 0 deletions app/Queries/FollowersYouKnowQuery.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?php

declare(strict_types=1);

namespace App\Queries;

use App\Models\User;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Support\Collection;

final readonly class FollowersYouKnowQuery
{
public const int PREVIEW_COUNT = 3;

/**
* A user's followers whom the viewer follows too, as the web lists them: the newest follow of the user first.
*
* @return BelongsToMany<User, User>
*/
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<int, User>, 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];
}
}
81 changes: 81 additions & 0 deletions resources/js/components/followers-you-know.tsx
Original file line number Diff line number Diff line change
@@ -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<User, 'handle'>;
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 (
<div
className="mt-3 flex items-center gap-2.5 text-sm text-muted-foreground"
data-test="followers-you-know"
>
<Link
href={list}
aria-label="Followers you know"
className="flex shrink-0 -space-x-1.5 rounded-full focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
>
{preview.users.map((person) => (
<UserAvatar
key={person.id}
user={person}
pixels={40}
className="size-5 text-[10px] ring-2 ring-card"
/>
))}
</Link>
<p className="min-w-0 leading-snug">
Followed by{' '}
{named.map((person, index) => (
<Fragment key={person.id}>
{index > 0 && (others > 0 ? ', ' : ' and ')}
<Link
href={show(person)}
className="font-mono text-[13px] font-medium wrap-anywhere text-foreground hover:underline"
>
@{person.handle}
</Link>
</Fragment>
))}
{others > 0 && (
<>
{' '}
and{' '}
<Link href={list} className="hover:underline">
{others} {others === 1 ? 'other' : 'others'} you
follow
</Link>
</>
)}
</p>
</div>
);
}
19 changes: 18 additions & 1 deletion resources/js/components/page-tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<InertiaLinkProps['href']>;
isActive: boolean;
}[];
Expand Down Expand Up @@ -45,7 +49,20 @@ export function PageTabs({
{ 'text-foreground': tab.isActive },
)}
>
<span className="py-3.5">{tab.title}</span>
<span className="py-3.5">
{tab.shortTitle ? (
<>
<span className="sm:hidden">
{tab.shortTitle}
</span>
<span className="hidden sm:inline">
{tab.title}
</span>
</>
) : (
tab.title
)}
</span>
{tab.isActive && (
<span
aria-hidden
Expand Down
40 changes: 34 additions & 6 deletions resources/js/pages/user/connections.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Head, InfiniteScroll, Link, router, usePage } from '@inertiajs/react';
import { ArrowLeft } from 'lucide-react';
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 { EmptyFeed } from '@/components/commit-feed';
import { FollowButton } from '@/components/follow-button';
Expand Down Expand Up @@ -35,11 +36,16 @@ export default function Connections({
users,
}: {
user: User;
type: 'following' | 'followers';
type: 'following' | 'followers' | 'followers_you_know';
users: CursorPaginated<Connection>;
}) {
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 (
<>
Expand Down Expand Up @@ -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),
Expand All @@ -80,10 +98,20 @@ export default function Connections({

{users.data.length === 0 ? (
<div className="px-6 py-16 text-center sm:px-12">
<EmptyFeed command={`fatal: no ${type} yet`}>
{type === 'following'
? `@${user.handle} isn't following anyone yet.`
: `Nobody is following @${user.handle} yet.`}
<EmptyFeed
command={
type === 'followers_you_know'
? 'fatal: no followers you know'
: `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]
}
</EmptyFeed>
</div>
) : (
Expand Down
7 changes: 7 additions & 0 deletions resources/js/pages/user/show.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -64,6 +66,7 @@ export default function Show({
isFollowing,
followsYou,
isMuting,
followersYouKnow,
canPush,
moderation,
activity,
Expand All @@ -79,6 +82,7 @@ export default function Show({
isFollowing: boolean;
followsYou: boolean;
isMuting: boolean;
followersYouKnow: FollowersYouKnowPreview | null;
canPush: boolean;
moderation: UserModeration | null;
activity: Activity;
Expand Down Expand Up @@ -382,6 +386,9 @@ export default function Show({
</div>
))}
</dl>
{followersYouKnow && (
<FollowersYouKnow user={user} preview={followersYouKnow} />
)}
{auth.pasteis && pasteisReceived !== null && (
<p
className="mt-3 flex items-center gap-1.5 text-sm text-muted-foreground"
Expand Down
Loading