From 462c897c392aaa85ade7505e76039ceeebf764db Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Sat, 17 Oct 2026 17:27:40 +0100 Subject: [PATCH] Swipe between a profile's commits and replies under a header that scrolls away The profile's Commits and Replies are now pages of a pager, with the tabs the pager draws, as on the Log and Notifications, underlined in your lane colour. The header (avatar, facts, counts, activity, streaks, achievements) goes in the pager's new header slot from NativePHP/mobile-ui#139. It scrolls up with the tab you're on, the tabs pin under the nav bar once it has gone, and the pages swipe under the tabs while the header stays put. Each tab has its own list, cache and pull to refresh. Only the tab you're on is live. The other shows the first five commits kept for it, so the frame stays small. The tab you leave starts again at the top of a fresh list, and tapping the tab you're on takes it back to the top. Your own profile still opens on the tab you last had. The new commits pill hangs under the tabs wherever they are, in the pager's overlay slot, instead of the sticky header between the tabs and the commits. The Log keeps its sticky header. The profile now sends every frame in full, like the Log: a reselect only changes a list's key, and EDGE's subtree reuse never sent it. The PHP-drawn page tabs partial is gone, since nothing uses it now. Co-Authored-By: Claude Opus 5.5 --- app/NativeComponents/Profile.php | 118 +++++++++- .../partials/new-commits-pill.blade.php | 37 ++- .../views/native/partials/page-tabs.blade.php | 14 -- .../native/partials/profile-header.blade.php | 108 +++++++++ .../native/partials/profile-page.blade.php | 57 +++++ resources/views/native/profile.blade.php | 214 +++++------------- tests/Feature/Screens/LaneButtonsTest.php | 5 +- tests/Feature/Screens/LogsTest.php | 5 +- tests/Feature/Screens/ProfileCacheTest.php | 40 ++-- tests/Feature/Screens/ProfilePagerTest.php | 187 +++++++++++++++ tests/Feature/Screens/ProfileTest.php | 28 +-- tests/Feature/Screens/ShownTabsTest.php | 5 +- tests/Pest.php | 21 ++ 13 files changed, 600 insertions(+), 239 deletions(-) delete mode 100644 resources/views/native/partials/page-tabs.blade.php create mode 100644 resources/views/native/partials/profile-header.blade.php create mode 100644 resources/views/native/partials/profile-page.blade.php create mode 100644 tests/Feature/Screens/ProfilePagerTest.php 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).