diff --git a/DESIGN.md b/DESIGN.md index fa13d3576..6371e659e 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -287,7 +287,7 @@ A 14px circle (16px on the landing page) centered on a 2px vertical line. The se Centered in the feed: a mono `fatal: …` line in Muted Ink, then one or two plain sentences (15px, max 24rem) with a single Commit Blue link when there is somewhere to go. ### Onboarding -A standalone page (no app shell) after a new account's first sign-in, drawn as a short history on the rail: one row per step with a mono prompt (`$ whoami`, `$ export LANE=teal`, `$ who --should-i-follow`, `$ man devnull`) and a hollow `$ git log` row at the end. Done steps are filled nodes on a solid lane segment with a one-line summary, the current step's node pops in (`node-in`) above a dashed Rail Gray tail and expands into a 1.5rem heading, a muted sentence, its content, and a primary action with a ghost Back; later steps are hollow Rail Gray nodes. The whole rail takes the lane being picked, live. Steps reuse the settings profile list, the color preview and swatches, the suggested-developer rows, and the shortcut sheet. "Skip setup" with an `esc` cap sits top right; Esc skips from any step unless a dialog is open; focus moves to each step's heading. +A standalone page (no app shell) after a new account's first sign-in, drawn as a short history on the rail: one row per step with a mono prompt (`$ whoami`, `$ export LANE=teal`, `$ who --should-i-follow`, `$ man devnull`) and a hollow `$ git log` row at the end. Done steps are filled nodes on a solid lane segment with a one-line summary, the current step's node pops in (`node-in`) above a dashed Rail Gray tail and expands into a 1.5rem heading, a muted sentence, its content, and a primary action with a ghost Back; later steps are hollow Rail Gray nodes. The whole rail takes the lane being picked, live. Steps reuse the settings profile list, the color preview and swatches, the suggested-developer rows, and the shortcut sheet. Above the suggestions, the follow step shows what the GitHub import brought over in one hairline panel under a mono `imported from github.com/{handle}` line: a big mono count each for following and followers that ticks up from zero while the faces behind it pop in with `node-in`, each pulsing in its own lane, then the GitHub accounts still to join, with an invite. A spinner line stands in while the import runs, and the panel is left out when GitHub brought nothing. "Skip setup" with an `esc` cap sits top right; Esc skips from any step unless a dialog is open; focus moves to each step's heading. ### Operations Console Operations opens in place but is a separate app: no log nav, aside or composer (only the docked remotes follow), just the wordmark with a bordered mono `ops` chip. From lg a 240px Feed White sidebar groups the pages under mono labels (`monitor`: Overview, Health, Events; `product`: Forecasts, Growth, Activity, and Traffic with a Fathom token; `control`: Features; `elsewhere`: Moderation), with the active page on the Blue Wash like the side nav, and ends with the theme switch and a "Back to /dev/null" row with the admin's avatar and handle. Below lg the same pages, then Moderation, scroll sideways under the sticky header, with the current one scrolled into view, and the admin's avatar on the right of the header leads back to /dev/null. The header names the page and says in one line what it answers; on the right a mono live toggle (a pulsing Success dot and `15s`, or a Rail Gray dot and `paused`) and the refresh icon. Pages that move by the day (Forecasts, Growth) read `on demand` instead and only refresh from the icon. diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 19e85dff8..21ee56fbb 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -92,7 +92,7 @@ Livestream history: the team agreed on GitHub-only sign-in and importing profile ### First-time setup -The first GitHub sign-in opens a short onboarding flow: review your mirrored profile (after editing it on GitHub, **Sync from GitHub** pulls the changes in without leaving setup), choose a color, follow suggested developers, and learn about the composer, keyboard shortcuts, and terminal commits. Skip setup at any step or finish it to continue to the app. Completed or skipped setup does not appear again. New users also start with Tom as a mutual follow; Tom is the platform's fictional welcome bot. Tag @tom in a public commit to ask about the platform or find posts on a topic. +The first GitHub sign-in opens a short onboarding flow: review your mirrored profile (after editing it on GitHub, **Sync from GitHub** pulls the changes in without leaving setup), choose a color, see who came over from your GitHub network and follow suggested developers, and learn about the composer, keyboard shortcuts, and terminal commits. Skip setup at any step or finish it to continue to the app. The follow step shows what the one-time GitHub import brought over: the people you follow on GitHub who are already on /dev/null, whom you now follow, and your GitHub followers who are here, who now follow you. Completed or skipped setup does not appear again. New users also start with Tom as a mutual follow; Tom is the platform's fictional welcome bot. Tag @tom in a public commit to ask about the platform or find posts on a topic. Sources: [onboarding page](resources/js/pages/onboarding/show.tsx), [sign-in tests](tests/Feature/Controllers/SessionControllerTest.php). diff --git a/app/Http/Controllers/OnboardingController.php b/app/Http/Controllers/OnboardingController.php index a4e35c928..a9880ea1f 100644 --- a/app/Http/Controllers/OnboardingController.php +++ b/app/Http/Controllers/OnboardingController.php @@ -9,6 +9,7 @@ use App\Models\User; use App\Queries\SuggestedUsersQuery; use Illuminate\Container\Attributes\CurrentUser; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Http\RedirectResponse; use Illuminate\Support\Collection; use Inertia\Inertia; @@ -22,6 +23,7 @@ public function show(ShowOnboardingRequest $request, #[CurrentUser] User $user, 'preferredLane' => $user->preferredLane(), 'automaticLane' => $user->automaticLane(), 'suggestions' => fn (): Collection => $suggested->get($user, limit: 5), + 'githubImport' => fn (): ?array => $this->githubImport($request, $user), 'commitScriptUrl' => route('cli-script.show'), ]); } @@ -35,4 +37,32 @@ public function update(#[CurrentUser] User $user, UpdateUserOnboardedAt $action) return redirect()->intended(route('log.show', $user->log, absolute: false)); } + + /** + * What the GitHub import brought over: null until the job has run. The follows it made share its timestamp, give + * or take the time the job took. Tom's friendship is made at sign-up too, right before, so bots are left out. + * + * @return array{following: Collection, followingCount: int, followers: Collection, followersCount: int, pendingCount: int}|null + */ + private function githubImport(ShowOnboardingRequest $request, User $user): ?array + { + $importedAt = $user->github_follows_imported_at; + + if ($importedAt === null) { + return null; + } + + $imported = fn (BelongsToMany $follows): BelongsToMany => $follows + ->wherePivotBetween('created_at', [$importedAt->copy()->subSeconds(10), $importedAt]) + ->whereNotIn('users.github_id', User::BOT_GITHUB_IDS); + + return [ + 'following' => $imported($user->following())->oldest('users.id')->limit(5)->get(), + 'followingCount' => $imported($user->following())->count(), + 'followers' => $imported($user->followers())->oldest('users.id')->limit(5)->get(), + 'followersCount' => $imported($user->followers())->count(), + // SKETCH: pending follows aren't stored yet (see the pending_follows table of feat/unclaimed-profiles). + 'pendingCount' => app()->isLocal() ? $request->integer('pending') : 0, + ]; + } } diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 53061cd09..99a2e193e 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -38,6 +38,7 @@ public function definition(): array 'lane_color' => null, 'pinned_commit_id' => null, 'onboarded_at' => now(), + 'github_follows_imported_at' => null, 'commit_rules_accepted_at' => now(), 'remember_token' => Str::random(10), ]; diff --git a/resources/js/pages/onboarding/show.tsx b/resources/js/pages/onboarding/show.tsx index d03c6becf..4f0e34a6b 100644 --- a/resources/js/pages/onboarding/show.tsx +++ b/resources/js/pages/onboarding/show.tsx @@ -4,9 +4,10 @@ import { Link, router, usePage, + usePoll, useRemember, } from '@inertiajs/react'; -import { ArrowUpRight, Check, Copy, RefreshCw } from 'lucide-react'; +import { ArrowUpRight, Check, Copy, RefreshCw, UserPlus } from 'lucide-react'; import type { CSSProperties, ReactNode, RefObject } from 'react'; import { useEffect, useRef, useState } from 'react'; import { toast } from 'sonner'; @@ -20,16 +21,318 @@ import { KeyboardShortcutsDialog } from '@/components/keyboard-shortcuts-dialog' import { LaneOptions, LanePreview } from '@/components/lane-picker'; import { Button } from '@/components/ui/button'; import { Spinner } from '@/components/ui/spinner'; +import { UserAvatar } from '@/components/user-avatar'; import { SuggestedUserRow } from '@/components/who-to-follow'; import { WordmarkText } from '@/components/wordmark-text'; import { useClipboard } from '@/hooks/use-clipboard'; import { useUser } from '@/hooks/use-user'; import { useKeyLabels } from '@/lib/keyboard'; +import { formatCount } from '@/lib/numbers'; import { cn, laneColor } from '@/lib/utils'; -import type { Lane, SuggestedUser } from '@/types'; +import type { Lane, SuggestedUser, User } from '@/types'; const laneNames = ['orange', 'green', 'violet', 'teal', 'rose', 'ochre']; +type GitHubImport = { + following: User[]; + followingCount: number; + followers: User[]; + followersCount: number; + pendingCount: number; +}; + +function people(count: number): string { + return count === 1 ? '1 person' : `${formatCount(count)} people`; +} + +function developers(count: number): string { + return count === 1 ? '1 developer' : `${formatCount(count)} developers`; +} + +const countUpDuration = 900; + +function prefersReducedMotion(): boolean { + return window.matchMedia('(prefers-reduced-motion: reduce)').matches; +} + +/** + * Counts from 0 to the target, fast then settling, after the given delay. + */ +function useCountUp(target: number, delay: number): number { + const [value, setValue] = useState(0); + + useEffect(() => { + if (prefersReducedMotion()) { + return; + } + + let frame = 0; + const start = performance.now() + delay; + const tick = (now: number) => { + const progress = Math.min( + 1, + Math.max(0, (now - start) / countUpDuration), + ); + setValue(Math.round(target * (1 - (1 - progress) ** 3))); + + if (progress < 1) { + frame = requestAnimationFrame(tick); + } + }; + frame = requestAnimationFrame(tick); + + return () => cancelAnimationFrame(frame); + }, [target, delay]); + + return prefersReducedMotion() ? target : value; +} + +/** + * A count the import brought over, ticking up while the faces behind it pop onto the card like nodes onto the rail, + * each pulsing in its own lane. + */ +function ImportStat({ + users, + count, + label, + delay, + children, +}: { + users: User[]; + count: number; + label: string; + delay: number; + children: ReactNode; +}) { + const value = useCountUp(count, delay); + const shown = users.slice(0, 5); + const stagger = countUpDuration / Math.max(shown.length, 1); + + return ( +
+

+ + {formatCount(value)} + + {label} +

+
+ {shown.map((user, index) => ( + + + + ))} + {count > shown.length && ( + + +{formatCount(count - shown.length)} + + )} +
+

+ {children} +

+
+ ); +} + +function ImportRow({ + visual, + delay, + children, +}: { + visual: ReactNode; + delay: number; + children: ReactNode; +}) { + return ( +
  • +
    {visual}
    +
    + {children} +
    +
  • + ); +} + +function InviteActions() { + const [, copy] = useClipboard(); + const invite = `I'm on /dev/null, a social network for developers. Sign in with GitHub and you'll follow the people you already follow there: ${window.location.origin}`; + + const copyInvite = async () => { + if (await copy(invite)) { + toast( + + Copied an invite, paste it anywhere + , + ); + + return; + } + + toast.error( + + fatal: could not copy the invite + , + ); + }; + + return ( + + + + + ); +} + +/** + * What the GitHub import brought over, or a line saying it is still running. + */ +function GitHubImportCard({ + githubImport, + hasGivenUp, + handle, +}: { + githubImport: GitHubImport | null; + hasGivenUp: boolean; + handle: string; +}) { + if (githubImport === null) { + return hasGivenUp ? null : ( +
    + + Looking for your GitHub network on /dev/null… +
    + ); + } + + const { following, followingCount, followers, followersCount } = + githubImport; + const { pendingCount } = githubImport; + + if (followingCount === 0 && followersCount === 0 && pendingCount === 0) { + return null; + } + + const hasImported = followingCount > 0 || followersCount > 0; + const followersDelay = followingCount > 0 ? 450 : 150; + + return ( +
    +

    + + + + {hasImported ? 'imported from' : 'checked'} github.com/{handle} +

    + {hasImported && ( +
    0 && followersCount > 0, + })} + > + {followingCount > 0 && ( + + {followingCount === 1 + ? 'Someone you follow on GitHub is already here. You follow them now.' + : 'People you follow on GitHub who are already here. You follow them now.'} + + )} + {followersCount > 0 && ( + + {followersCount === 1 + ? 'Someone following you on GitHub is already here. They follow you now.' + : 'People following you on GitHub who are already here. They follow you now.'} + + )} +
    + )} + {pendingCount > 0 && ( +
      + + {[0, 1, 2].map((placeholder) => ( + + {placeholder === 2 && ( + + )} + + ))} + + } + > + + {followingCount > 0 + ? `${formatCount(pendingCount)} more ${pendingCount === 1 ? 'person' : 'people'} you follow on GitHub` + : pendingCount === 1 + ? 'The person you follow on GitHub' + : `The ${people(pendingCount)} you follow on GitHub`} + {' '} + {pendingCount === 1 ? "isn't" : "aren't"} here yet. + You'll follow them automatically when they join. + + +
    + )} +
    + ); +} + type StepState = 'done' | 'current' | 'todo'; function Kbd({ children }: { children: ReactNode }) { @@ -217,11 +520,13 @@ export default function Show({ preferredLane, automaticLane, suggestions, + githubImport, commitScriptUrl, }: { preferredLane: Lane | null; automaticLane: number; suggestions: SuggestedUser[]; + githubImport: GitHubImport | null; commitScriptUrl: string; }) { const { auth } = usePage().props; @@ -238,6 +543,40 @@ export default function Show({ const keys = useKeyLabels(); const headingRef = useRef(null); + const importedFollowingCount = githubImport?.followingCount ?? 0; + const isImporting = githubImport === null; + const [hasGivenUp, setHasGivenUp] = useState(false); + const wasImporting = useRef(isImporting); + // The import runs in a queued job right after signing in, so it usually lands while the first steps are open, + // before the follow step shows it... + const { stop } = usePoll( + 1500, + { only: ['githubImport'] }, + { autoStart: isImporting }, + ); + + useEffect(() => { + if (!isImporting) { + stop(); + + // The suggestions were picked before the import, so they may suggest people it just followed. + if (wasImporting.current) { + wasImporting.current = false; + router.reload({ only: ['suggestions'] }); + } + + return; + } + + // A failed import leaves nothing to wait for: the suggestions are enough. + const timeout = setTimeout(() => { + stop(); + setHasGivenUp(true); + }, 15_000); + + return () => clearTimeout(timeout); + }, [isImporting, stop]); + const next = () => setStep(step + 1); const back = () => setStep(step - 1); @@ -401,8 +740,16 @@ export default function Show({ content: ( 0 + ? `You already follow ${developers(importedFollowingCount)}.` + : 'Follow a few developers.' + } + description={ + importedFollowingCount > 0 + ? "We brought your GitHub network over, so your log doesn't start empty. Follow a few more to fill it up; unfollowing is one click." + : 'Your log is your commits plus the commits of everyone you follow. Here are a few to start with; unfollowing is one click.' + } actions={ <> @@ -410,6 +757,16 @@ export default function Show({ } > + + {importedFollowingCount > 0 && suggestions.length > 0 && ( +

    + # more developers you may like +

    + )} {suggestions.length > 0 ? (
      {suggestions.map((user) => ( diff --git a/tests/Feature/Controllers/OnboardingControllerTest.php b/tests/Feature/Controllers/OnboardingControllerTest.php index 035137947..2124ae63e 100644 --- a/tests/Feature/Controllers/OnboardingControllerTest.php +++ b/tests/Feature/Controllers/OnboardingControllerTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use App\Actions\CreateTomFriendship; use App\Enums\Log; use App\Http\Middleware\HandleInertiaRequests; use App\Models\Commit; @@ -72,6 +73,44 @@ expect(collect(DB::getQueryLog())->filter(fn (array $query): bool => str_contains($query['query'], 'as "followed_by_following_count"')))->toBeEmpty(); }); +it('waits for the GitHub import to have run', function (): void { + $user = User::factory()->notOnboarded()->create(['github_follows_imported_at' => null]); + + $response = $this->actingAs($user) + ->get(route('onboarding.show')); + + $response->assertOk() + ->assertInertia(fn ($page) => $page->where('githubImport', null)); +}); + +it('shows the follows the GitHub import brought over', function (): void { + $this->travelTo(now()->startOfSecond()); + + $user = User::factory()->notOnboarded()->create(['github_follows_imported_at' => now()]); + // Tom's friendship is made at sign-up too, right before the import... + resolve(CreateTomFriendship::class)->handle($user); + $followed = User::factory()->count(9)->create(); + $followers = User::factory()->count(2)->create(); + $user->following()->attach($followed->modelKeys(), ['created_at' => now()->subSeconds(3), 'updated_at' => now()]); + $user->followers()->attach($followers->modelKeys(), ['created_at' => now()->subSeconds(3), 'updated_at' => now()]); + + // Followed after the import, from the onboarding itself... + $this->travel(1)->minute(); + $user->following()->attach(User::factory()->create(), ['created_at' => now(), 'updated_at' => now()]); + + $response = $this->actingAs($user) + ->get(route('onboarding.show')); + + $response->assertOk() + ->assertInertia(fn ($page) => $page + ->has('githubImport.following', 5) + ->where('githubImport.following', fn ($users): bool => collect($users)->pluck('id')->diff($followed->modelKeys())->isEmpty()) + ->where('githubImport.followingCount', 9) + ->where('githubImport.followers', fn ($users): bool => collect($users)->pluck('id')->sort()->values()->all() === collect($followers->modelKeys())->sort()->values()->all()) + ->where('githubImport.followersCount', 2) + ->where('githubImport.pendingCount', 0)); +}); + it('is not found once the user has onboarded', function (): void { $user = User::factory()->create();