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
7 changes: 6 additions & 1 deletion app/Http/Controllers/AboutController.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,22 @@

namespace App\Http\Controllers;

use App\Models\User;
use App\Queries\ContributorsQuery;
use App\Services\PageMetadata;
use Illuminate\Container\Attributes\CurrentUser;
use Inertia\Inertia;
use Inertia\Response;

final readonly class AboutController
{
public function show(ContributorsQuery $contributors, PageMetadata $metadata): Response
public function show(ContributorsQuery $contributors, PageMetadata $metadata, #[CurrentUser] ?User $user): Response
{
return Inertia::render('about/show', [
'contributors' => $contributors->get(...),
'followingLogins' => fn (): array => $user instanceof User
? $user->following()->pluck('username')->map(fn (string $username): string => mb_strtolower($username))->all()
: [],
])->withViewData(['preview' => $metadata->page(
'About - /dev/null',
'How /dev/null was built in the open: from the first commit on stream to the developers who joined and the people who shipped it.',
Expand Down
56 changes: 45 additions & 11 deletions resources/js/components/follow-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,28 +69,35 @@ export function useFollowHover() {
};
}

const isFollowingKey = (user: Pick<User, 'id'>): string =>
`is_following:${user.id}`;
type FollowUser =
| { id: string; username?: string }
| { id?: string; username: string };

const userKey = (user: FollowUser): string =>
user.id ?? ('username' in user && user.username ? user.username : '');

const isFollowingKey = (user: FollowUser): string =>
`is_following:${userKey(user)}`;

/**
* Every follow control shares whether you follow someone, so a follow from a profile or a preview also shows in "Who to follow".
*/
export function useIsFollowing(
user: Pick<User, 'id'>,
user: FollowUser,
isFollowing: boolean,
): [boolean, (isFollowing: boolean) => void] {
return useSharedState(isFollowingKey(user), isFollowing);
}

const isPendingKey = (user: Pick<User, 'id'>): string =>
`follow_pending:${user.id}`;
const isPendingKey = (user: FollowUser): string =>
`follow_pending:${userKey(user)}`;

const hasBlockKey = (user: Pick<User, 'id'>): string => `has_block:${user.id}`;
const hasBlockKey = (user: FollowUser): string => `has_block:${userKey(user)}`;

/**
* Whether this tab has seen a block between you, either way: one you made, or a follow the server refused.
*/
export function useHasBlockWith(user: Pick<User, 'id'>): boolean {
export function useHasBlockWith(user: FollowUser): boolean {
const [hasBlock] = useSharedState(hasBlockKey(user), false);

return hasBlock;
Expand All @@ -99,7 +106,7 @@ export function useHasBlockWith(user: Pick<User, 'id'>): boolean {
/**
* A block works both ways and ends the follow.
*/
export function rememberBlock(user: Pick<User, 'id'>): void {
export function rememberBlock(user: FollowUser): void {
refreshSharedState(
new Map([
[isFollowingKey(user), false],
Expand All @@ -112,9 +119,15 @@ export function rememberBlock(user: Pick<User, 'id'>): void {
* The server only refuses a follow across a block, which a suggestion can't know about until it tries, so it toasts instead of swapping in the 403 page.
*/
export function onFollowHttpException(
user: Pick<User, 'id' | 'username'>,
user: Pick<User, 'username'> & { id?: string },
response: { status: number },
): boolean {
if (response.status === 404) {
toast.error(`@${user.username} hasn't joined /dev/null yet.`);

return false;
}

if (response.status !== 403) {
return !toastWhenThrottled(response);
}
Expand Down Expand Up @@ -262,22 +275,43 @@ export function FollowIconButton({
marksFollowsYou = false,
...props
}: {
user: Pick<User, 'id' | 'username'>;
user: Pick<User, 'username'> & { id?: string };
isFollowing: boolean;
followsYou?: boolean;
marksFollowsYou?: boolean;
'data-test'?: string;
}) {
const {
component,
props: { log, commits },
props: { auth, log, commits },
} = usePage<{ commits?: CursorPaginated<Commit> }>();
const [isFollowing, setIsFollowing] = useIsFollowing(
user,
initialIsFollowing,
);
const Icon = isFollowing ? UserCheck : UserPlus;

if (auth.user === null) {
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={signIn}
aria-label={`Follow @${user.username}`}
className={cn(
'shrink-0 rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none',
)}
{...props}
>
<Icon aria-hidden className="size-4" />
</button>
</TooltipTrigger>
<TooltipContent>{`Follow @${user.username}`}</TooltipContent>
</Tooltip>
);
}

return (
<Tooltip>
<TooltipTrigger asChild>
Expand Down
96 changes: 66 additions & 30 deletions resources/js/pages/about/show.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
DocumentPage,
DocumentSection,
} from '@/components/document-page';
import { FollowIconButton } from '@/components/follow-button';
import { WordmarkText } from '@/components/wordmark-text';
import AppLayout from '@/layouts/app-layout';
import type {
Expand Down Expand Up @@ -265,47 +266,79 @@ function Stream({ stream }: { stream: StoryStream }) {
);
}

function Contributors({ contributors }: { contributors: StoryContributor[] }) {
function Contributors({
contributors,
followingLogins = [],
}: {
contributors: StoryContributor[];
followingLogins?: string[];
}) {
const { auth } = usePage().props;

return (
<ul className="grid grid-cols-[repeat(auto-fill,minmax(11rem,1fr))] gap-2">
{contributors.map((contributor) => (
<li key={contributor.login}>
<a
href={`https://github.com/${contributor.login}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2.5 rounded-md border px-2.5 py-2 no-underline! transition-colors hover:bg-muted"
{contributors.map((contributor) => {
const isCurrentUser =
auth.user?.username.toLowerCase() ===
contributor.login.toLowerCase();
const isFollowing = followingLogins.includes(
contributor.login.toLowerCase(),
);

return (
<li
key={contributor.login}
className="flex items-center justify-between gap-2 rounded-md border px-2.5 py-2 transition-colors hover:bg-muted"
>
<img
src={`https://github.com/${contributor.login}.png?size=64`}
alt=""
width={28}
height={28}
loading="lazy"
className="size-7 shrink-0 rounded-full bg-muted"
/>
<span className="min-w-0">
<span className="block truncate text-sm font-medium">
@{contributor.login}
</span>
<span className="block font-mono text-xs text-muted-foreground tabular-nums">
{contributor.commits}{' '}
{contributor.commits === 1
? 'commit'
: 'commits'}
<a
href={`https://github.com/${contributor.login}`}
target="_blank"
rel="noopener noreferrer"
className="flex min-w-0 flex-1 items-center gap-2.5 no-underline!"
>
<img
src={`https://github.com/${contributor.login}.png?size=64`}
alt=""
width={28}
height={28}
loading="lazy"
className="size-7 shrink-0 rounded-full bg-muted"
/>
<span className="min-w-0">
<span className="block truncate text-sm font-medium">
@{contributor.login}
</span>
<span className="block font-mono text-xs text-muted-foreground tabular-nums">
{contributor.commits}{' '}
{contributor.commits === 1
? 'commit'
: 'commits'}
</span>
</span>
</span>
</a>
</li>
))}
</a>
{!isCurrentUser && (
<FollowIconButton
user={{
id: contributor.login,
username: contributor.login,
}}
isFollowing={isFollowing}
data-test="contributor-follow-button"
/>
)}
</li>
);
})}
</ul>
);
}

export default function Show({
contributors,
followingLogins = [],
}: {
contributors: StoryContributor[] | null;
followingLogins?: string[];
}) {
const { repository } = usePage().props;
return (
Expand Down Expand Up @@ -399,7 +432,10 @@ export default function Show({
<DocumentSection id="contributors" title="Contributors">
<p>Everyone who has a commit on production. Thank you.</p>
{contributors ? (
<Contributors contributors={contributors} />
<Contributors
contributors={contributors}
followingLogins={followingLogins}
/>
) : (
<p className="text-muted-foreground">
GitHub couldn't be reached. Try again in a minute.
Expand Down
13 changes: 13 additions & 0 deletions tests/Feature/Controllers/AboutControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,16 @@
->assertOk()
->assertInertia(fn ($page) => $page->where('contributors', null));
});

it('hands which contributors the user follows to the page', function (): void {
fakeGitHubContributors([gitHubContributor('xHeaven', 187)]);
$user = User::factory()->create();
$contributor = User::factory()->create(['username' => 'xHeaven']);
$user->following()->attach($contributor);

$this->actingAs($user)
->get(route('about.show'))
->assertOk()
->assertInertia(fn ($page) => $page
->where('followingLogins', ['xheaven']));
});
Loading