diff --git a/app/Http/Controllers/AboutController.php b/app/Http/Controllers/AboutController.php index 19239dd92..812bb4185 100644 --- a/app/Http/Controllers/AboutController.php +++ b/app/Http/Controllers/AboutController.php @@ -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.', diff --git a/resources/js/components/follow-button.tsx b/resources/js/components/follow-button.tsx index 85868d4de..eaa3e199c 100644 --- a/resources/js/components/follow-button.tsx +++ b/resources/js/components/follow-button.tsx @@ -69,28 +69,35 @@ export function useFollowHover() { }; } -const isFollowingKey = (user: Pick): 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: FollowUser, isFollowing: boolean, ): [boolean, (isFollowing: boolean) => void] { return useSharedState(isFollowingKey(user), isFollowing); } -const isPendingKey = (user: Pick): string => - `follow_pending:${user.id}`; +const isPendingKey = (user: FollowUser): string => + `follow_pending:${userKey(user)}`; -const hasBlockKey = (user: Pick): 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): boolean { +export function useHasBlockWith(user: FollowUser): boolean { const [hasBlock] = useSharedState(hasBlockKey(user), false); return hasBlock; @@ -99,7 +106,7 @@ export function useHasBlockWith(user: Pick): boolean { /** * A block works both ways and ends the follow. */ -export function rememberBlock(user: Pick): void { +export function rememberBlock(user: FollowUser): void { refreshSharedState( new Map([ [isFollowingKey(user), false], @@ -112,9 +119,15 @@ export function rememberBlock(user: Pick): 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: Pick & { 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); } @@ -262,7 +275,7 @@ export function FollowIconButton({ marksFollowsYou = false, ...props }: { - user: Pick; + user: Pick & { id?: string }; isFollowing: boolean; followsYou?: boolean; marksFollowsYou?: boolean; @@ -270,7 +283,7 @@ export function FollowIconButton({ }) { const { component, - props: { log, commits }, + props: { auth, log, commits }, } = usePage<{ commits?: CursorPaginated }>(); const [isFollowing, setIsFollowing] = useIsFollowing( user, @@ -278,6 +291,27 @@ export function FollowIconButton({ ); const Icon = isFollowing ? UserCheck : UserPlus; + if (auth.user === null) { + return ( + + + + + {`Follow @${user.username}`} + + ); + } + return ( diff --git a/resources/js/pages/about/show.tsx b/resources/js/pages/about/show.tsx index b52abf33f..0f1531f62 100644 --- a/resources/js/pages/about/show.tsx +++ b/resources/js/pages/about/show.tsx @@ -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 { @@ -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 ( ); } export default function Show({ contributors, + followingLogins = [], }: { contributors: StoryContributor[] | null; + followingLogins?: string[]; }) { const { repository } = usePage().props; return ( @@ -399,7 +432,10 @@ export default function Show({

Everyone who has a commit on production. Thank you.

{contributors ? ( - + ) : (

GitHub couldn't be reached. Try again in a minute. diff --git a/tests/Feature/Controllers/AboutControllerTest.php b/tests/Feature/Controllers/AboutControllerTest.php index d6b342b53..0d114404a 100644 --- a/tests/Feature/Controllers/AboutControllerTest.php +++ b/tests/Feature/Controllers/AboutControllerTest.php @@ -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'])); +}); diff --git a/tests/Unit/ContributorCard.test.tsx b/tests/Unit/ContributorCard.test.tsx new file mode 100644 index 000000000..2093a3f7c --- /dev/null +++ b/tests/Unit/ContributorCard.test.tsx @@ -0,0 +1,172 @@ +/// +import assert from 'node:assert/strict'; +import * as React from 'react'; +import type { ReactElement, ReactNode } from 'react'; +import { afterAll, test, vi } from 'vite-plus/test'; + +type Props = Record & { children?: ReactNode }; + +let authUser: { id: string; username: string } | null = { + id: 'viewer', + username: 'viewer', +}; + +vi.doMock('react', () => ({ + ...React, + useEffect: () => {}, + useLayoutEffect: () => {}, + useRef: (initial: unknown) => ({ current: initial }), + useState: (initial: unknown) => [ + typeof initial === 'function' ? initial() : initial, + () => {}, + ], + useSyncExternalStore: (_subscribe: unknown, read: () => unknown) => read(), +})); + +vi.doMock('@inertiajs/react', async () => ({ + ...(await vi.importActual('@inertiajs/react')), + usePage: () => ({ + component: 'about/show', + props: { + auth: { user: authUser }, + repository: 'laravel/dev-null', + }, + }), +})); + +vi.doMock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: Props) => children, + TooltipTrigger: ({ children }: Props) => children, + TooltipContent: ({ children }: Props) => children, + TooltipProvider: ({ children }: Props) => children, +})); + +const { default: Show } = await import('../../resources/js/pages/about/show'); + +const assigned: string[] = []; +vi.stubGlobal('window', { + matchMedia: () => ({ matches: false }), + location: { + assign: (url: string) => assigned.push(url), + }, +}); +vi.stubGlobal('document', { body: {} }); +afterAll(() => vi.unstubAllGlobals()); + +function findAll( + node: ReactNode, + matches: (props: Props) => boolean, +): ReactElement[] { + const results: ReactElement[] = []; + + function search(current: ReactNode) { + if (Array.isArray(current)) { + for (const child of current) { + search(child); + } + + return; + } + + if (!React.isValidElement(current)) { + return; + } + + if (matches(current.props)) { + results.push(current); + + return; + } + + if (typeof current.type === 'function') { + try { + search( + (current.type as (props: unknown) => ReactNode)( + current.props, + ), + ); + + return; + } catch { + // Fall back to traversing children if component function fails + } + } + + search(current.props.children); + } + + search(node); + + return results; +} + +test('renders follow buttons for contributors except the authenticated user', () => { + authUser = { id: 'viewer', username: 'viewer' }; + + const tree = Show({ + contributors: [ + { login: 'xHeaven', commits: 100 }, + { login: 'viewer', commits: 50 }, + { login: 'nunomaduro', commits: 80 }, + ], + followingLogins: ['nunomaduro'], + }); + + const followButtons = findAll( + tree, + (props) => props['data-test'] === 'contributor-follow-button', + ); + + assert.equal(followButtons.length, 2); + + const xHeavenButton = followButtons.find( + (btn) => + typeof btn.props.user === 'object' && + btn.props.user !== null && + 'username' in btn.props.user && + btn.props.user.username === 'xHeaven', + ); + assert.ok(xHeavenButton); + assert.equal(xHeavenButton.props.isFollowing, false); + + const nunoButton = followButtons.find( + (btn) => + typeof btn.props.user === 'object' && + btn.props.user !== null && + 'username' in btn.props.user && + btn.props.user.username === 'nunomaduro', + ); + assert.ok(nunoButton); + assert.equal(nunoButton.props.isFollowing, true); +}); + +test('renders follow buttons for guests and directs to login when clicked', () => { + authUser = null; + + const tree = Show({ + contributors: [{ login: 'xHeaven', commits: 100 }], + }); + + const followButtons = findAll( + tree, + (props) => props['data-test'] === 'contributor-follow-button', + ); + + assert.equal(followButtons.length, 1); + const button = followButtons[0]; + assert.ok(button); + + // Call FollowIconButton with auth.user = null + const renderedButton = ( + button.type as (props: Record) => ReactElement + )(button.props); + + const trigger = findAll( + renderedButton, + (props) => typeof props.onClick === 'function', + )[0]; + assert.ok(trigger); + (trigger.props.onClick as () => void)(); + + assert.deepEqual(assigned, ['/login']); +});