diff --git a/app/NativeComponents/Profile.php b/app/NativeComponents/Profile.php index 2a8b704..6352d59 100644 --- a/app/NativeComponents/Profile.php +++ b/app/NativeComponents/Profile.php @@ -3,6 +3,7 @@ namespace App\NativeComponents; use App\Devnull\Data\Commit; +use App\Devnull\Data\Page; use App\Devnull\Data\Profile as ProfileData; use App\Devnull\Data\User; use App\Devnull\ErrorCode; @@ -47,6 +48,12 @@ * It opens on the profile as it was last time, or opened from a commit, on * what the commit knew of its author, then asks devnull again behind it. The * header changes in place; newer commits wait in the pill (see HoldsNewCommits). + * + * Commits and Replies are pages you swipe between, as on the Log (see Log), + * under a header that scrolls up with the page you're on until the tabs pin + * under the nav bar. Only the tab you're on is live; the other shows the first + * few commits kept for it. Each has its own list, and the one you leave starts + * again at the top of a fresh one. */ class Profile extends NativeComponent { @@ -60,10 +67,27 @@ class Profile extends NativeComponent use ShowsCommits; use TalksToDevnull; + /** + * Every frame goes in full, as on the Log (see Log::$forceFullFrames). A tab + * tapped again goes back to the top in a list with a new key and the same + * rows, which EDGE's subtree reuse would otherwise never send. + */ + protected bool $forceFullFrames = true; + public string $username = ''; public const array Tabs = ['commits', 'replies']; + /** + * The web's tab labels. + */ + public const array Labels = ['commits' => 'Commits', 'replies' => 'Replies']; + + /** + * How many kept commits the tab you're not on shows, enough to fill the screen while you swipe to it. + */ + public const int PreviewCommits = 5; + /** * The alerts the safety menu confirms with, as the web words them. Each one's id names the profile too, so an * answer only reaches the profile that asked. @@ -86,6 +110,13 @@ class Profile extends NativeComponent public string $tab = 'commits'; + /** + * The list key of the tab you're not on. The tab you're on uses $listKey. + * + * @var array + */ + public array $listKeys = []; + /** * Whether the tab has had commits to show, kept or fresh. Until then it shows skeleton rows. */ @@ -183,18 +214,41 @@ public function load(): void $this->refreshProfile(showNewer: true); } - public function showTab(string $tab): void + /** + * The pager settling on a tab, after a swipe or a tab tap. The tab it's already on changes nothing. + */ + public function showPage(int $index): void { - if (! in_array($tab, self::Tabs, true) || $tab === $this->tab) { - return; + $tab = self::Tabs[$index] ?? null; + + if ($tab !== null && $tab !== $this->tab) { + $this->switchTo($tab); } + } - $this->tab = $tab; + /** + * A tap on the tab you're on, which the pager sends instead of moving: back to the top of it. + */ + public function backToTop(int $index): void + { + if ((self::Tabs[$index] ?? null) === $this->tab) { + $this->listKey++; + } + } + /** + * The tab you go to keeps the list its first few commits were drawn in, + * and fills it. The tab you leave starts again at the top of a fresh one. + */ + private function switchTo(string $tab): void + { if ($this->isMe()) { app(ShownTabs::class)->remember($this->me()?->id, 'profile', $tab); } + $this->listKeys[$this->tab] = $this->listKey + 1; + $this->tab = $tab; + $this->listKey = $this->listKeys[$tab] ?? 0; $this->commits = []; $this->threads = []; $this->nextCursor = null; @@ -649,14 +703,16 @@ private function commitsPath(): string /** * @return array */ - private function commitsQuery(): array + private function commitsQuery(?string $tab = null): array { - return ['replies' => $this->tab === 'replies' ? '1' : null]; + return ['replies' => ($tab ?? $this->tab) === 'replies' ? '1' : null]; } public function render(): View { - $pinnedCommit = $this->pinnedCommit(); + $pages = array_map(fn (string $tab): array => $tab === $this->tab + ? $this->page($tab, $this->commits, $this->nextCursor, $this->commitsLoaded, $this->listKey) + : $this->previewPage($tab), self::Tabs); return view('native.profile', [ 'myColour' => $this->myColour(), @@ -664,8 +720,52 @@ public function render(): View 'isMe' => $this->isMe(), 'newCommitsLabel' => $this->newCommitsLabel(), 'viewer' => $this->me(), - 'pinnedCommit' => $pinnedCommit, - 'rows' => Timeline::feed($this->commits), + 'pages' => $pages, + 'pageIndex' => array_search($this->tab, self::Tabs, true), + 'tabs' => array_values(self::Labels), ]); } + + /** + * The tab you're not on: the first few commits kept for it, as it will + * open, or skeleton rows with nothing kept. + * + * @return array + */ + private function previewPage(string $tab): array + { + $json = $this->cached($this->commitsPath(), $this->commitsQuery($tab)); + $kept = is_array($json['data'] ?? null) ? $json['data'] : []; + $page = $json === null ? null : Page::fromArray(['data' => array_slice($kept, 0, self::PreviewCommits)] + $json, Commit::fromArray(...)); + + // Rows past the preview are left out, with a skeleton row for them, as for the next page. + $nextCursor = count($kept) > self::PreviewCommits ? 'preview' : $page?->nextCursor; + + return $this->page($tab, $page->items ?? [], $nextCursor, $page !== null, $this->listKeys[$tab] ?? 0); + } + + /** + * What a tab's page shows. The web leaves the pinned commit out of the replies tab. + * + * @param list $commits + * @return array + */ + private function page(string $tab, array $commits, ?string $nextCursor, bool $loaded, int $listKey): array + { + $showing = $tab === $this->tab; + + return [ + 'tab' => $tab, + 'showing' => $showing, + 'listKey' => $listKey, + 'loaded' => $loaded, + 'error' => $showing ? $this->error : null, + 'pinnedCommit' => $tab === 'commits' ? $this->profile?->pinnedCommit : null, + 'isEmpty' => $commits === [], + 'nextCursor' => $nextCursor, + 'rows' => Timeline::feed($commits), + // Replies the tab you're on has opened, under their commits. + 'threads' => $showing ? $this->threads : [], + ]; + } } diff --git a/resources/views/native/partials/new-commits-pill.blade.php b/resources/views/native/partials/new-commits-pill.blade.php index 5cbfd1d..25bd715 100644 --- a/resources/views/native/partials/new-commits-pill.blade.php +++ b/resources/views/native/partials/new-commits-pill.blade.php @@ -2,25 +2,42 @@ DESIGN.md's new commits pill: Commit Blue, "↑ 3 new commits". The commits are already fetched, so a tap puts them on top with nothing to wait for. - As on the web, it sits in a strip of its own between the top of the list (the Log's composer, a profile's tabs) and - the commits, and pins to the top once you scroll past it. EDGE tells PHP nothing about where a list is scrolled, so - the list pins it itself, as a sticky header. + As on the web, it sits in a strip of its own between the top of the list (the Log's composer) and the commits, and + pins to the top once you scroll past it. EDGE tells PHP nothing about where a list is scrolled, so the list pins it + itself, as a sticky header. Its header is always in the list, hidden until there's something to show ($shown), so the list keeps its shape when new commits come in. A node added beside a scrolled list sometimes left the list blank on iOS: its rows still in the tree and taking taps, nothing drawn. + + A profile's pager has its tabs above the list, so there it goes in the pager's overlay instead ($hanging), which + hangs it under the tabs wherever the header has scrolled them, over the commits. --}} @php($shown ??= true) - - @if ($shown) - +@php($hanging ??= false) +@if ($hanging) + + @if ($shown) {{ $label }} - - - @endif - + @endif + +@else + + @if ($shown) + + + + + {{ $label }} + + + + + @endif + +@endif diff --git a/resources/views/native/partials/page-tabs.blade.php b/resources/views/native/partials/page-tabs.blade.php deleted file mode 100644 index a2b664f..0000000 --- a/resources/views/native/partials/page-tabs.blade.php +++ /dev/null @@ -1,14 +0,0 @@ -{{-- DESIGN.md's page tabs: a full-width split, the active label in ink over a 3px underline in your colour. --}} - - - @foreach ($tabs as $tab) - - - {{ $tab['label'] }} - - - - @endforeach - - - diff --git a/resources/views/native/partials/profile-header.blade.php b/resources/views/native/partials/profile-header.blade.php new file mode 100644 index 0000000..5d2ac8b --- /dev/null +++ b/resources/views/native/partials/profile-header.blade.php @@ -0,0 +1,108 @@ +{{-- + The profile's header, which the pager draws above its tabs (see profile.blade.php): the avatar ringed in their + lane, Follow or Edit on GitHub, who they are, what GitHub says about them, their counts, then their activity, + streaks and achievements. It scrolls up with the tab you're on until the tabs pin under the nav bar. +--}} + + + + {{-- The avatar wears the author's lane as a ring, offset from the surface. --}} + + + @include('native.partials.avatar', ['user' => $user, 'size' => 80]) + + + + @if ($sketched) + {{-- Follow waits for devnull to say whether you already do. --}} + @elseif ($isMe) + Edit on GitHub + @elseif ($profile->isFollowing) + Unfollow + @else + Follow + @endif + + + + {{ $user->name }} + {{-- + The handle, then what the web marks beside it: Follows you, Muted, and the Unix rank in the + owner's lane. EDGE's rows don't wrap on iOS, so the handle gives way first. + --}} + + + {{ $user->handle() }} + @if ($profile->followsYou && ! $sketched) + + Follows you + + @endif + @if ($profile->isMuting && ! $sketched) + + Muted + + @endif + @if ($profile->achievements && ! $sketched) + + {{ $profile->achievements->label() }} + + @endif + + + + @if ($user->bio) + {{ $user->bio }} + @endif + + {{-- + What GitHub says about them, as the web lists it under the bio, links in Commit Blue. The web wraps + them onto as many lines as it needs; EDGE's rows don't wrap on iOS, so here each has its own line. + --}} + + @if ($user->company) + @include('native.partials.profile-fact', ['key' => 'company', 'icon' => \App\Icons\Lucide::Building2, 'label' => $user->company]) + @endif + @if ($user->location) + @include('native.partials.profile-fact', ['key' => 'location', 'icon' => \App\Icons\Lucide::MapPin, 'label' => $user->location]) + @endif + @if ($user->websiteLabel()) + @include('native.partials.profile-fact', ['key' => 'website', 'icon' => \App\Icons\Lucide::Link, 'label' => $user->websiteLabel(), 'press' => 'openWebsite']) + @endif + @include('native.partials.profile-fact', ['key' => 'github', 'icon' => \App\Icons\Lucide::Github, 'label' => 'github.com/'.$user->username, 'press' => 'openGitHubProfile']) + @if ($user->createdAt) + @include('native.partials.profile-fact', ['key' => 'joined', 'icon' => \App\Icons\Lucide::CalendarDays, 'label' => 'Joined '.$user->createdAt->format('F Y')]) + @endif + + + @if (! $sketched) + {{-- Following and followers open their lists, as the web links them. --}} + + @foreach ([[$profile->commitsCount, 'commit', 'commits', null], [$profile->followingCount, 'following', 'following', 'following'], [$profile->followersCount, 'follower', 'followers', 'followers']] as [$count, $singular, $plural, $list]) + @if ($list === null) + + {{ $count }} + {{ $count === 1 ? $singular : $plural }} + + @else + + + {{ $count }} + {{ $count === 1 ? $singular : $plural }} + + + @endif + @endforeach + + @endif + + + + @if (! $sketched) + @include('native.partials.activity', ['activity' => $profile->activity, 'lane' => $user->lane()]) + @include('native.partials.streaks', ['streaks' => $profile->streaks, 'lane' => $user->lane()]) + @if ($profile->achievements) + @include('native.partials.achievements', ['achievements' => $profile->achievements, 'lane' => $user->lane()]) + @endif + @endif + diff --git a/resources/views/native/partials/profile-page.blade.php b/resources/views/native/partials/profile-page.blade.php new file mode 100644 index 0000000..253bf3c --- /dev/null +++ b/resources/views/native/partials/profile-page.blade.php @@ -0,0 +1,57 @@ +{{-- + One tab's page in the profile's pager (see App\NativeComponents\Profile::page()): the tab you're on, or the first + few commits kept for the other, ready for a swipe. + + The pager draws the header and the tabs at the top of this list, so they scroll with it. As on the Log, each tab has + its own list, a new $page['listKey'] puts it back at the top, and only the tab you're on asks for its next page. +--}} + + + @if (! $page['loaded'] && $page['error'] === null) + @foreach (range(1, 3) as $skeleton) + @include('native.partials.rail-loading', ['ref' => 'loading-'.$skeleton, 'label' => 'Loading commits']) + @endforeach + @endif + + @if ($page['loaded'] && ! $sketched && $page['isEmpty'] && $page['pinnedCommit'] === null && $page['error'] === null) + @if ($page['tab'] === 'replies') + @include('native.partials.empty', ['command' => 'fatal: no replies yet', 'message' => $isMe ? 'Your replies show up here. Join a conversation from your log.' : "{$user->handle()} hasn't replied to anything yet."]) + @elseif ($isMe) + @include('native.partials.empty', ['command' => 'fatal: you do not have any commits yet', 'message' => 'Your commits show up here. Make your first one from your log.']) + @else + @include('native.partials.empty', ['command' => 'fatal: no commits yet', 'message' => "{$user->handle()} hasn't committed anything yet."]) + @endif + @endif + + @if ($page['pinnedCommit']) + {{-- The pinned commit sits above the rest, marked in mono as on the web, with no rail above it. --}} + + + + + Pinned + + + @include('native.partials.commit-row', ['commit' => $page['pinnedCommit'], 'previousLane' => null, 'continuesRun' => false, 'rowKey' => 'pinned-'.$page['pinnedCommit']->id]) + @endif + + @include('native.partials.feed', ['rows' => $page['rows'], 'threads' => $page['threads']]) + + @if (! $page['isEmpty'] || $page['pinnedCommit']) + @if ($page['nextCursor'] !== null) + @include('native.partials.rail-loading') + @elseif ($page['tab'] === 'replies') + @include('native.partials.rail-end', ['lane' => $user->lane(), 'message' => $isMe ? 'That was your first reply.' : "That's {$user->handle()}'s first reply."]) + @else + @include('native.partials.rail-end', ['lane' => $user->lane(), 'message' => $isMe ? 'That was your first commit. Everyone starts somewhere.' : "That's {$user->handle()}'s root commit."]) + @endif + @endif + + diff --git a/resources/views/native/profile.blade.php b/resources/views/native/profile.blade.php index 57184ab..efa527e 100644 --- a/resources/views/native/profile.blade.php +++ b/resources/views/native/profile.blade.php @@ -4,171 +4,59 @@ @include('native.partials.commit-sheet') @include('native.partials.achievements-sheet') - {{-- The list stays alone in here, so the banners above coming and going never add a node beside it. --}} + {{-- The pager or the list stays alone in here, so the banners above coming and going never add a node beside it. --}} - {{-- Keyed so a new key puts the profile back at the top, as the new commits pill does. --}} - - @if ($unavailable) - @include('native.partials.profile-unavailable') - @elseif (! $profile && $error === null) - - - - @endif - - @if ($profile && ! $unavailable) - @php($user = $profile->user) - - - - {{-- The avatar wears the author's lane as a ring, offset from the surface. --}} - - - @include('native.partials.avatar', ['user' => $user, 'size' => 80]) - - - - @if ($sketched) - {{-- Follow waits for devnull to say whether you already do. --}} - @elseif ($isMe) - Edit on GitHub - @elseif ($profile->isFollowing) - Unfollow - @else - Follow - @endif - - - - {{ $user->name }} - {{-- - The handle, then what the web marks beside it: Follows you, Muted, and the Unix rank in the - owner's lane. EDGE's rows don't wrap on iOS, so the handle gives way first. - --}} - - - {{ $user->handle() }} - @if ($profile->followsYou && ! $sketched) - - Follows you - - @endif - @if ($profile->isMuting && ! $sketched) - - Muted - - @endif - @if ($profile->achievements && ! $sketched) - - {{ $profile->achievements->label() }} - - @endif - - - - @if ($user->bio) - {{ $user->bio }} - @endif - - {{-- - What GitHub says about them, as the web lists it under the bio, links in Commit Blue. The web wraps - them onto as many lines as it needs; EDGE's rows don't wrap on iOS, so here each has its own line. - --}} - - @if ($user->company) - @include('native.partials.profile-fact', ['key' => 'company', 'icon' => \App\Icons\Lucide::Building2, 'label' => $user->company]) - @endif - @if ($user->location) - @include('native.partials.profile-fact', ['key' => 'location', 'icon' => \App\Icons\Lucide::MapPin, 'label' => $user->location]) - @endif - @if ($user->websiteLabel()) - @include('native.partials.profile-fact', ['key' => 'website', 'icon' => \App\Icons\Lucide::Link, 'label' => $user->websiteLabel(), 'press' => 'openWebsite']) - @endif - @include('native.partials.profile-fact', ['key' => 'github', 'icon' => \App\Icons\Lucide::Github, 'label' => 'github.com/'.$user->username, 'press' => 'openGitHubProfile']) - @if ($user->createdAt) - @include('native.partials.profile-fact', ['key' => 'joined', 'icon' => \App\Icons\Lucide::CalendarDays, 'label' => 'Joined '.$user->createdAt->format('F Y')]) - @endif - - - @if (! $sketched) - {{-- Following and followers open their lists, as the web links them. --}} - - @foreach ([[$profile->commitsCount, 'commit', 'commits', null], [$profile->followingCount, 'following', 'following', 'following'], [$profile->followersCount, 'follower', 'followers', 'followers']] as [$count, $singular, $plural, $list]) - @if ($list === null) - - {{ $count }} - {{ $count === 1 ? $singular : $plural }} - - @else - - - {{ $count }} - {{ $count === 1 ? $singular : $plural }} - - - @endif - @endforeach - - @endif + @if ($profile && ! $unavailable) + @php($user = $profile->user) + {{-- + Commits and Replies as pages you swipe between, under the tabs the pager draws, as on the Log, with the + underline in your colour. The header goes to the pager too, which draws it at the top of the page you're on, + so it scrolls up with it until the tabs pin under the nav bar. It stays where it is while you swipe, and the + pages keep level with it. + + The pill hangs under the tabs wherever they are. Keyed so the banners above coming and going don't make it + a new pager, with new lists. + --}} + + + @include('native.partials.profile-header') + + + @foreach ($pages as $page) + @include('native.partials.profile-page', ['page' => $page]) + @endforeach + + + @include('native.partials.new-commits-pill', ['label' => $newCommitsLabel, 'press' => 'showNewCommits', 'shown' => $newCommits !== [], 'hanging' => true]) + + + @else + + @if ($unavailable) + @include('native.partials.profile-unavailable') + @elseif ($error === null) + + - - - @if (! $sketched) - @include('native.partials.activity', ['activity' => $profile->activity, 'lane' => $user->lane()]) - @include('native.partials.streaks', ['streaks' => $profile->streaks, 'lane' => $user->lane()]) - @if ($profile->achievements) - @include('native.partials.achievements', ['achievements' => $profile->achievements, 'lane' => $user->lane()]) - @endif - @endif - - - @include('native.partials.page-tabs', ['tabs' => [ - ['key' => 'commits', 'label' => 'Commits', 'active' => $tab === 'commits', 'press' => "showTab('commits')"], - ['key' => 'replies', 'label' => 'Replies', 'active' => $tab === 'replies', 'press' => "showTab('replies')"], - ]]) - - @include('native.partials.new-commits-pill', ['label' => $newCommitsLabel, 'press' => 'showNewCommits', 'shown' => $newCommits !== []]) - - @if (! $commitsLoaded && $error === null) - @foreach (range(1, 3) as $skeleton) - @include('native.partials.rail-loading', ['ref' => 'loading-'.$skeleton, 'label' => 'Loading commits']) - @endforeach - @endif - - @if ($commitsLoaded && ! $sketched && $commits === [] && $pinnedCommit === null && $error === null) - @if ($tab === 'replies') - @include('native.partials.empty', ['command' => 'fatal: no replies yet', 'message' => $isMe ? 'Your replies show up here. Join a conversation from your log.' : "{$user->handle()} hasn't replied to anything yet."]) - @elseif ($isMe) - @include('native.partials.empty', ['command' => 'fatal: you do not have any commits yet', 'message' => 'Your commits show up here. Make your first one from your log.']) - @else - @include('native.partials.empty', ['command' => 'fatal: no commits yet', 'message' => "{$user->handle()} hasn't committed anything yet."]) - @endif - @endif - - @if ($pinnedCommit) - {{-- The pinned commit sits above the rest, marked in mono as on the web, with no rail above it. --}} - - - - - Pinned - - - @include('native.partials.commit-row', ['commit' => $pinnedCommit, 'previousLane' => null, 'continuesRun' => false, 'rowKey' => 'pinned-'.$pinnedCommit->id]) - @endif - - @include('native.partials.feed', ['rows' => $rows]) - - @if ($commits !== [] || $pinnedCommit) - @if ($nextCursor !== null) - @include('native.partials.rail-loading') - @elseif ($tab === 'replies') - @include('native.partials.rail-end', ['lane' => $user->lane(), 'message' => $isMe ? 'That was your first reply.' : "That's {$user->handle()}'s first reply."]) - @else - @include('native.partials.rail-end', ['lane' => $user->lane(), 'message' => $isMe ? 'That was your first commit. Everyone starts somewhere.' : "That's {$user->handle()}'s root commit."]) - @endif @endif - @endif - + + @endif diff --git a/tests/Feature/Screens/LaneButtonsTest.php b/tests/Feature/Screens/LaneButtonsTest.php index 471a012..d3640f9 100644 --- a/tests/Feature/Screens/LaneButtonsTest.php +++ b/tests/Feature/Screens/LaneButtonsTest.php @@ -128,9 +128,10 @@ function laneLog(): TestableComponent 'GET /api/v1/users/local_dev/commits' => [pageJson([])], ]); - $underline = findNode(nodeByRef(Native::visit('/profile'), 'tab-commits'), fn (array $node): bool => ($node['layout']['height'] ?? null) === 3.0); + $pager = nodeByRef(Native::visit('/profile'), 'profile-pager'); - expect(strtolower($underline['style']['bg_color']))->toBe(strtolower(config('native-ui.theme.light.lane-5'))); + expect(strtolower($pager['props']['tab_indicator_color']))->toBe(strtolower(config('native-ui.theme.light.lane-5'))) + ->and(strtolower($pager['props']['dark_tab_indicator_color']))->toBe(strtolower(config('native-ui.theme.dark.lane-5'))); }); it('draws Save color in the colour you picked, before you save it', function () { diff --git a/tests/Feature/Screens/LogsTest.php b/tests/Feature/Screens/LogsTest.php index ca28506..1932011 100644 --- a/tests/Feature/Screens/LogsTest.php +++ b/tests/Feature/Screens/LogsTest.php @@ -4,6 +4,7 @@ use App\Devnull\Timeline; use App\NativeComponents\Log; use App\NativeComponents\Notifications; +use App\NativeComponents\Profile; use Illuminate\Http\Client\Request; use Illuminate\Support\Facades\Http; use Native\Mobile\Testing\Native; @@ -310,10 +311,10 @@ function ada(): array ->and(nodeByRef($screen, 'commit-c1')['id'])->toBe($row); }); -it('sends the Log and Notifications in full every frame, as EDGE advises for lists that change often', function () { +it('sends the Log, Notifications and profiles in full every frame, as EDGE advises for lists that change often', function () { Http::fake(['devnull.test/*' => Http::response(pageJson([]))]); - foreach ([Log::class, Notifications::class] as $screen) { + foreach ([Log::class, Notifications::class, Profile::class] as $screen) { $instance = Native::test($screen)->instance(); expect((fn (): bool => $this->forceFullFrames)->call($instance))->toBeTrue(); diff --git a/tests/Feature/Screens/ProfileCacheTest.php b/tests/Feature/Screens/ProfileCacheTest.php index fb0a757..51cea08 100644 --- a/tests/Feature/Screens/ProfileCacheTest.php +++ b/tests/Feature/Screens/ProfileCacheTest.php @@ -57,7 +57,7 @@ function serveAda(array $commits, int $followers = 5): void ->assertSee('Analytical engine notes') ->assertSee('followers'); - expect(showsLoading($screen->tree()))->toBeFalse() + expect(showsLoading(showingProfile($screen)))->toBeFalse() ->and(count(Http::recorded()))->toBe(0); finishRefreshes($screen); @@ -85,8 +85,9 @@ function serveAda(array $commits, int $followers = 5): void finishRefreshes($profile) ->assertElement('row', fn (array $node): bool => ($node['ref'] ?? null) === 'counts') ->assertElement('button', fn (array $node): bool => ($node['ref'] ?? null) === 'follow') - ->assertSee('Analytical engine notes') - ->assertMissingElement('row', fn (array $node): bool => ($node['ref'] ?? null) === 'loading-1'); + ->assertSee('Analytical engine notes'); + + expect(showsLoading(showingProfile($profile)))->toBeFalse(); }); it('shows a spinner only the first time, when nothing is known of them', function () { @@ -116,33 +117,25 @@ function serveAda(array $commits, int $followers = 5): void expect(count(Http::recorded()))->toBe($requests); }); -it('puts the pill in the list between the tabs and the commits, in a header that pins, there before it shows', function () { +it('hangs the pill under the tabs in the pager, there before it shows', function () { holdRefreshes(); keep('users/ada', adaProfile()); keep('users/ada/commits', pageJson([adaCommit(['id' => 'a1', 'body' => 'Older'])])); serveAda([adaCommit(['id' => 'a2', 'body' => 'Brand new']), adaCommit(['id' => 'a1', 'body' => 'Older'])]); $screen = Native::visit('/log/users/ada'); - $list = fn (): array => findNode($screen->tree(), fn (array $node): bool => ($node['type'] ?? null) === 'list'); - $besideTheList = fn (): array => findNode($screen->tree(), fn (array $node): bool => in_array('list', array_column($node['children'] ?? [], 'type'), true))['children']; - $inTheList = fn (): array => array_slice(array_values(array_filter(array_map( - fn (array $node): ?string => findNode($node, fn (array $tab): bool => ($tab['ref'] ?? null) === 'tab-commits') !== null ? 'tabs' : $node['ref'] ?? null, - $list()['children'], - ), fn (?string $ref): bool => $ref !== null && ! str_starts_with($ref, 'day-'))), 0, 3); - - // Hidden and empty until there are new commits, but already in its place, so showing it never changes the list's shape. - expect($besideTheList())->toHaveCount(1) - ->and($inTheList())->toBe(['tabs', 'new-commits-slot', 'commit-a1']) - ->and(nodeByRef($screen, 'new-commits-slot')['type'])->toBe('sticky_header') + $overlay = fn (): array => findNode(nodeByRef($screen, 'profile-pager'), fn (array $node): bool => ($node['type'] ?? null) === 'pager_overlay'); + + // Hidden and empty until there are new commits, but already in its place, outside the lists. + expect(findNode($overlay(), fn (array $node): bool => ($node['ref'] ?? null) === 'new-commits-slot'))->not->toBeNull() ->and(nodeByRef($screen, 'new-commits-slot')['layout']['display'] ?? null)->toBe(1) - ->and(nodeByRef($screen, 'new-commits-slot')['children'] ?? [])->toBe([]); + ->and(nodeByRef($screen, 'new-commits-slot')['children'] ?? [])->toBe([]) + ->and(findNode(showingProfile($screen), fn (array $node): bool => ($node['ref'] ?? null) === 'new-commits-slot'))->toBeNull(); finishRefreshes($screen)->assertSee('1 new commit'); - expect($besideTheList())->toHaveCount(1) - ->and($inTheList())->toBe(['tabs', 'new-commits-slot', 'commit-a1']) - ->and(nodeByRef($screen, 'new-commits-slot')['layout']['display'] ?? null)->toBeNull() - ->and(findNode(nodeByRef($screen, 'new-commits-slot'), fn (array $node): bool => ($node['ref'] ?? null) === 'new-commits'))->not->toBeNull(); + expect(nodeByRef($screen, 'new-commits-slot')['layout']['display'] ?? null)->toBeNull() + ->and(findNode($overlay(), fn (array $node): bool => ($node['ref'] ?? null) === 'new-commits'))->not->toBeNull(); }); it('keeps a follow, so their profile opens followed next time', function () { @@ -180,16 +173,17 @@ function serveAda(array $commits, int $followers = 5): void keep('users/ada/commits', pageJson([adaCommit(['id' => 'a1', 'body' => 'A commit'])])); keep('users/ada/commits', pageJson([adaCommit(['id' => 'r1', 'parent_id' => 'c0', 'body' => 'A reply'])]), ['replies' => '1']); - $screen = Native::visit('/log/users/ada')->tap('tab-replies')->assertSee('A reply')->assertDontSee('A commit'); + $screen = tapProfileTab(Native::visit('/log/users/ada'), 'replies')->assertSee('A reply'); - expect(showsLoading($screen->tree()))->toBeFalse(); + expect(treeShows(showingProfile($screen), 'A commit'))->toBeFalse() + ->and(showsLoading(showingProfile($screen)))->toBeFalse(); Http::assertNothingSent(); }); it('asks for the replies tab with its own query', function () { serveAda([]); - Native::visit('/log/users/ada')->tap('tab-replies'); + tapProfileTab(Native::visit('/log/users/ada'), 'replies'); Http::assertSent(fn (Request $request): bool => $request->url() === 'http://devnull.test/api/v1/users/ada/commits?replies=1'); }); diff --git a/tests/Feature/Screens/ProfilePagerTest.php b/tests/Feature/Screens/ProfilePagerTest.php new file mode 100644 index 0000000..ca5f3c9 --- /dev/null +++ b/tests/Feature/Screens/ProfilePagerTest.php @@ -0,0 +1,187 @@ + 'http://devnull.test']); + signIn(); +}); + +/** + * Someone's profile, as `GET /users/{username}` sends it. + * + * @param array|null $pinned + * @return array + */ +function pagerProfileJson(string $username = 'ada', ?array $pinned = null): array +{ + return [ + 'data' => userWithCountsJson(['id' => $username === 'local_dev' ? userJson()['id'] : 'u2', 'username' => $username, 'name' => 'Ada Lovelace', 'lane' => 1], commits: 1, followers: 5, following: 2), + 'is_following' => false, + 'activity' => null, + 'streaks' => ['longest' => 0, 'current' => 0], + 'pinned_commit' => $pinned, + ]; +} + +/** + * Keeps a profile with its commits and its replies, so it opens on them. + * + * @param list> $commits + * @param list> $replies + * @param array|null $pinned + */ +function keepProfile(string $username, array $commits, array $replies, ?array $pinned = null): void +{ + keep("users/{$username}", pagerProfileJson($username, $pinned)); + keep("users/{$username}/commits", pageJson($commits)); + keep("users/{$username}/commits", pageJson($replies), ['replies' => '1']); +} + +/** + * The list of a tab's page, the one you're on or the other. + * + * @return array + */ +function profileList(TestableComponent $screen, string $tab): array +{ + return nodeByRef($screen, $screen->get('tab') === $tab ? 'profile' : 'profile-'.$tab); +} + +it('swipes between commits and replies both ways under the tabs the pager draws, and stops at the ends', function () { + holdRefreshes(); + keepProfile('ada', [commitJson(['id' => 'c1', 'body' => 'Kept commit'])], [commitJson(['id' => 'r1', 'body' => 'Kept reply'])]); + + $screen = Native::visit('/log/users/ada'); + $pager = fn (): array => nodeByRef($screen, 'profile-pager'); + + // The pager draws the tabs, and says where it is once a swipe has settled. + expect($pager()['props'])->toMatchArray([ + 'horizontal' => true, + 'count' => 2, + 'page' => 0, + 'tabs' => ['Commits', 'Replies'], + 'report_on_settle' => true, + ])->and($pager()['props'])->toHaveKey('on_tab_reselect'); + + $screen->changeTab('showPage', 1)->assertSet('tab', 'replies'); + + expect($pager()['props']['page'])->toBe(1) + ->and(treeShows(showingProfile($screen), 'Kept reply'))->toBeTrue(); + + // Past the last tab there's nothing. + $screen->changeTab('showPage', 2)->assertSet('tab', 'replies'); + + $screen->changeTab('showPage', 0)->assertSet('tab', 'commits'); + + expect($pager()['props']['page'])->toBe(0) + ->and(treeShows(showingProfile($screen), 'Kept commit'))->toBeTrue(); + + $screen->changeTab('showPage', -1)->assertSet('tab', 'commits'); +}); + +it('draws the header once, for the pager to put above its tabs, and hangs the pill under them', function () { + holdRefreshes(); + keepProfile('ada', [commitJson(['id' => 'c1'])], [commitJson(['id' => 'r1'])]); + + $screen = Native::visit('/log/users/ada'); + $pager = nodeByRef($screen, 'profile-pager'); + $hasRef = fn (string $ref): Closure => fn (array $node): bool => ($node['ref'] ?? null) === $ref; + + expect(array_column($pager['children'], 'type'))->toBe(['pager_header', 'column', 'column', 'pager_overlay']) + ->and(findNode($pager['children'][0], $hasRef('profile-header')))->not->toBeNull() + ->and(findNode(showingProfile($screen), $hasRef('profile-header')))->toBeNull() + ->and(findNode(profileList($screen, 'replies'), $hasRef('profile-header')))->toBeNull() + ->and(findNode($pager['children'][3], $hasRef('new-commits-slot')))->not->toBeNull(); +}); + +it('keeps the list a tab was drawn in when you swipe to it, and starts the tab you leave again in a fresh one', function () { + holdRefreshes(); + keepProfile('ada', [commitJson(['id' => 'c1'])], [commitJson(['id' => 'r1'])]); + + $screen = Native::visit('/log/users/ada'); + $commits = profileList($screen, 'commits')['id']; + $replies = profileList($screen, 'replies')['id']; + + $screen->changeTab('showPage', 1); + + expect(profileList($screen, 'replies')['id'])->toBe($replies) + ->and(profileList($screen, 'commits')['id'])->not->toBe($commits); + + $commits = profileList($screen, 'commits')['id']; + $screen->changeTab('showPage', 0); + + expect(profileList($screen, 'commits')['id'])->toBe($commits) + ->and(profileList($screen, 'replies')['id'])->not->toBe($replies); +}); + +it('takes the tab you are on back to its top when its tab is tapped, and changes nothing when the pager reports where it is', function () { + holdRefreshes(); + keepProfile('ada', [commitJson(['id' => 'c1'])], [commitJson(['id' => 'r1'])]); + + $screen = Native::visit('/log/users/ada'); + $pager = nodeByRef($screen, 'profile-pager')['id']; + $sent = refreshesSent($screen); + $list = showingProfile($screen)['id']; + + $screen->changeTab('showPage', 0); + + expect(refreshesSent($screen))->toBe($sent) + ->and(showingProfile($screen)['id'])->toBe($list); + + // The pager sends a tap on the tab you're on rather than moving. + $screen->changeTab('backToTop', 0)->assertSet('tab', 'commits'); + + expect(nodeByRef($screen, 'profile-pager')['id'])->toBe($pager) + ->and(showingProfile($screen)['id'])->not->toBe($list); + + // A reselect of a tab you're not on can't happen, and moves nothing. + $list = showingProfile($screen)['id']; + $screen->changeTab('backToTop', 1)->assertSet('tab', 'commits'); + + expect(showingProfile($screen)['id'])->toBe($list); +}); + +it('shows the first few commits kept for the tab you are not on, and has only the tab you are on ask for its next page', function () { + holdRefreshes(); + keepProfile( + 'ada', + [commitJson(['id' => 'c1'])], + array_map(fn (int $n): array => commitJson(['id' => "r{$n}", 'body' => "Reply {$n}"]), range(1, 10)), + pinned: commitJson(['id' => 'p1', 'body' => 'Pinned thought']), + ); + + $screen = Native::visit('/log/users/ada'); + $replies = profileList($screen, 'replies'); + + expect(treeShows($replies, 'Reply '.Profile::PreviewCommits))->toBeTrue() + ->and(treeShows($replies, 'Reply '.(Profile::PreviewCommits + 1)))->toBeFalse() + // A skeleton row stands in for the rest, as for the next page. + ->and(findNode($replies, fn (array $node): bool => ($node['ref'] ?? null) === 'loading-more'))->not->toBeNull(); + + // Pull to refresh stays on both lists: iOS rebuilds a list that gains or loses it, at the top. + foreach ([showingProfile($screen), $replies] as $list) { + expect($list['props'])->toHaveKey('on_refresh') + ->and(array_key_exists('on_end_reached', $list['props']))->toBe($list['ref'] === 'profile'); + } + + // Off to the side, the commits tab keeps its pinned commit on top. + $screen->changeTab('showPage', 1); + + expect(treeShows(profileList($screen, 'commits'), 'Pinned thought'))->toBeTrue() + ->and(treeShows(showingProfile($screen), 'Pinned thought'))->toBeFalse(); +}); + +it("pages someone else's profile the same as your own", function () { + holdRefreshes(); + keepProfile('ada', [commitJson(['id' => 'c1'])], [commitJson(['id' => 'r1'])]); + keepProfile('local_dev', [commitJson(['id' => 'm1'])], [commitJson(['id' => 'm2'])]); + + $pager = fn (string $path): array => nodeByRef(Native::visit($path), 'profile-pager'); + $props = fn (array $pager): array => array_intersect_key($pager['props'], array_flip(['horizontal', 'count', 'tabs', 'report_on_settle', 'tab_font', 'tab_active_color'])); + + expect($props($pager('/log/users/ada')))->toBe($props($pager('/profile'))) + ->and(array_column($pager('/profile')['children'], 'type'))->toBe(['pager_header', 'column', 'column', 'pager_overlay']); +}); diff --git a/tests/Feature/Screens/ProfileTest.php b/tests/Feature/Screens/ProfileTest.php index e3b129d..91ebaf6 100644 --- a/tests/Feature/Screens/ProfileTest.php +++ b/tests/Feature/Screens/ProfileTest.php @@ -249,18 +249,21 @@ function adaCommitJson(array $overrides = []): array $screen = Native::visit('/log/users/ada') ->assertSet('tab', 'commits') - ->assertSee('Commits') - ->assertSee('Replies') - ->assertSee('A top-level commit') - ->tap('tab-replies') + ->assertSee('A top-level commit'); + + expect(nodeByRef($screen, 'profile-pager')['props'])->toMatchArray(['tabs' => ['Commits', 'Replies'], 'page' => 0]); + + tapProfileTab($screen, 'replies') ->assertSet('tab', 'replies') ->assertSee('Agreed') ->assertSee('replying to @grace') - ->assertDontSee('A top-level commit') - // The web keeps the pinned commit to the commits tab. - ->assertDontSee('Pinned thought') ->assertSee("That's @ada's first reply."); + expect(nodeByRef($screen, 'profile-pager')['props']['page'])->toBe(1) + ->and(treeShows(showingProfile($screen), 'A top-level commit'))->toBeFalse() + // The web keeps the pinned commit to the commits tab. + ->and(treeShows(showingProfile($screen), 'Pinned thought'))->toBeFalse(); + Http::assertSent(fn (Request $request): bool => $request->url() === 'http://devnull.test/api/v1/users/ada/commits?replies=1'); Http::assertSent(fn (Request $request): bool => $request->url() === 'http://devnull.test/api/v1/users/ada/commits'); @@ -277,7 +280,7 @@ function adaCommitJson(array $overrides = []): array }; }); - Native::visit('/log/users/ada')->tap('tab-replies')->call('loadMore')->assertSee('Older reply'); + tapProfileTab(Native::visit('/log/users/ada'), 'replies')->call('loadMore')->assertSee('Older reply'); Http::assertSent(fn (Request $request): bool => $request->url() === 'http://devnull.test/api/v1/users/ada/commits?replies=1&cursor=older'); }); @@ -290,13 +293,11 @@ function adaCommitJson(array $overrides = []): array 'GET /api/v1/users/local_dev/commits' => [pageJson([])], ]); - Native::visit('/log/users/ada') - ->tap('tab-replies') + tapProfileTab(Native::visit('/log/users/ada'), 'replies') ->assertSee('fatal: no replies yet') ->assertSee("@ada hasn't replied to anything yet."); - Native::visit('/log/users/local_dev') - ->tap('tab-replies') + tapProfileTab(Native::visit('/log/users/local_dev'), 'replies') ->assertSee('Your replies show up here. Join a conversation from your log.'); }); @@ -374,8 +375,7 @@ function adaCommitJson(array $overrides = []): array ->assertSet('username', 'local_dev') ->assertSee('Local Dev') ->assertSee('My own commit') - ->assertSee('Commits') - ->assertSee('Replies') + ->assertElement('pager', fn (array $node): bool => ($node['props']['tabs'] ?? null) === ['Commits', 'Replies']) ->assertSee('That was your first commit. Everyone starts somewhere.') ->assertMissingElement('button', fn (array $node): bool => ($node['ref'] ?? null) === 'follow') // Where the web puts "Edit on GitHub" in place of Follow. diff --git a/tests/Feature/Screens/ShownTabsTest.php b/tests/Feature/Screens/ShownTabsTest.php index 9ea8a3f..87a164c 100644 --- a/tests/Feature/Screens/ShownTabsTest.php +++ b/tests/Feature/Screens/ShownTabsTest.php @@ -56,12 +56,13 @@ function shownTabsProfileJson(string $username): array 'GET /api/v1/me' => [['data' => userWithCountsJson(), 'unread_notifications_count' => 0]], ]); - Native::visit('/profile')->tap('tab-replies')->assertSet('tab', 'replies'); + tapProfileTab(Native::visit('/profile'), 'replies')->assertSet('tab', 'replies'); Native::visit('/profile')->assertSet('tab', 'replies'); // Someone else's profile opens on their commits, and switching it leaves yours alone. - Native::visit('/log/users/ada')->assertSet('tab', 'commits')->tap('tab-replies')->tap('tab-commits'); + $ada = tapProfileTab(Native::visit('/log/users/ada')->assertSet('tab', 'commits'), 'replies'); + tapProfileTab($ada, 'commits'); expect(app(ShownTabs::class)->get('0199e5c4-0000-7000-8000-000000000001', 'profile', ['commits', 'replies'], 'commits'))->toBe('replies'); }); diff --git a/tests/Pest.php b/tests/Pest.php index b195446..c18b1d2 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -8,6 +8,7 @@ use App\Devnull\SshKey; use App\NativeComponents\Log; use App\NativeComponents\Notifications; +use App\NativeComponents\Profile; use Codingwithrk\NativephpSocialAuth\Events\AppleSignInCompleted; use Illuminate\Http\Client\ConnectionException; use Illuminate\Http\Client\Request; @@ -243,6 +244,26 @@ function tapLogTab(TestableComponent $screen, string $log): TestableComponent return $screen->changeTab($screen->get('log') === $log ? 'backToTop' : 'showPage', (int) array_search($log, Log::Logs, true)); } +/** + * The list of the profile tab that's showing in its pager. The other tab's page + * beside it shows the first few commits kept for it, ready for a swipe. + * + * @return array + */ +function showingProfile(TestableComponent $screen): array +{ + return nodeByRef($screen, 'profile'); +} + +/** + * A tap on one of a profile's tabs, which the pager draws, as for the Log's + * tabs (see tapLogTab). + */ +function tapProfileTab(TestableComponent $screen, string $tab): TestableComponent +{ + return $screen->changeTab($screen->get('tab') === $tab ? 'backToTop' : 'showPage', (int) array_search($tab, Profile::Tabs, true)); +} + /** * A tap on one of the notifications filters along the top, which the pager * draws, as for the Log's tabs (see tapLogTab).