diff --git a/app/Devnull/Client.php b/app/Devnull/Client.php index 8e37647..7602a9a 100644 --- a/app/Devnull/Client.php +++ b/app/Devnull/Client.php @@ -172,12 +172,22 @@ public function unstar(string $id): Commit */ public function bookmark(string $id): Commit { - return $this->react('PUT', 'commits/'.rawurlencode($id).'/bookmark'); + return $this->react('PUT', 'commits/'.rawurlencode($id).'/bookmark', saving: true); } public function unbookmark(string $id): Commit { - return $this->react('DELETE', 'commits/'.rawurlencode($id).'/bookmark'); + return $this->react('DELETE', 'commits/'.rawurlencode($id).'/bookmark', saving: true); + } + + /** + * The commits you saved, last saved first, as the web's Saved page lists them. + * + * @return Page + */ + public function saved(?string $cursor = null): Page + { + return Page::fromArray($this->get('saved', ['cursor' => $cursor]), Commit::fromArray(...)); } /** @@ -314,12 +324,17 @@ private function get(string $path, array $query = []): array /** * Stars, unstars, saves or unsaves, and gives every kept copy of the commit the new counts. + * Saving or unsaving puts it on top of your kept saved commits, or takes it out. */ - private function react(string $method, string $path): Commit + private function react(string $method, string $path, bool $saving = false): Commit { $commit = $this->data($this->send($method, $path)); $this->cache->commitReacted($commit); + if ($saving) { + $this->cache->commitSaved($commit); + } + return Commit::fromArray($commit); } diff --git a/app/Devnull/ResponseCache.php b/app/Devnull/ResponseCache.php index d05d0fa..15f1ffb 100644 --- a/app/Devnull/ResponseCache.php +++ b/app/Devnull/ResponseCache.php @@ -42,6 +42,7 @@ final class ResponseCache '#^commits/[^/]+/replies$#', '#^users/[^/]+$#', '#^users/[^/]+/commits$#', + '#^saved$#', ]; /** @@ -65,6 +66,11 @@ final class ResponseCache */ private const int RememberedDeletions = 50; + /** + * How many of the commits unsaved on this phone it remembers. + */ + private const int RememberedUnsaves = 50; + /** * What every copy of a user has of their profile, as a commit's author, a * notification's actor or a profile's header. @@ -224,6 +230,44 @@ public function commitReacted(array $commit): void $this->patchCommit($id, fn (array $kept): array => [...$kept, ...$reactions]); } + /** + * You saved or unsaved a commit. A commit you saved is the last you saved, + * so it goes on top of your saved commits; one you unsaved leaves them, + * and is remembered for the pages of them loaded past the first. + * + * @param array $commit + */ + public function commitSaved(array $commit): void + { + $id = (string) ($commit['id'] ?? ''); + $userId = $this->userId(); + + if ($id === '' || $userId === null || ! is_bool($commit['is_saved'] ?? null)) { + return; + } + + $this->patch('saved', [], fn (array $json): array => $commit['is_saved'] + ? self::prepend($json, $commit) + : [...$json, 'data' => self::without($json['data'] ?? [], $id)]); + + $unsaved = array_diff($this->unsavedIds(), [$id]); + $this->store()->forever("{$userId} unsaved", array_slice($commit['is_saved'] ? $unsaved : [$id, ...$unsaved], 0, self::RememberedUnsaves)); + } + + /** + * Commits unsaved on this phone lately, and not saved again since, for a + * screen showing more of your saved commits than the first page kept. + * + * @return list + */ + public function unsavedIds(): array + { + $userId = $this->userId(); + $ids = $userId === null ? null : $this->store()->get("{$userId} unsaved"); + + return is_array($ids) ? array_values(array_filter($ids, is_string(...))) : []; + } + /** * You committed. A commit goes on top of your logs and your profile; a reply * goes under its parent and counts there, and on top of your replies. diff --git a/app/Icons/Lucide.php b/app/Icons/Lucide.php index 4475070..653e2c4 100644 --- a/app/Icons/Lucide.php +++ b/app/Icons/Lucide.php @@ -51,6 +51,7 @@ enum Lucide case Ellipsis; case Flag; case RefreshCw; + case LockKeyhole; // The achievement badges, named as devnull names their icons (see forBadge()). case Terminal; @@ -128,6 +129,7 @@ public function custom(bool $filled = false): ?string self::Ellipsis => 'ellipsis', self::Flag => 'flag', self::RefreshCw => 'refresh-cw', + self::LockKeyhole => 'lock-keyhole', self::Terminal => 'terminal', self::FileCode => 'file-code', self::Flame => 'flame', diff --git a/app/NativeComponents/Layouts/TabsLayout.php b/app/NativeComponents/Layouts/TabsLayout.php index 11b9435..1b2d782 100644 --- a/app/NativeComponents/Layouts/TabsLayout.php +++ b/app/NativeComponents/Layouts/TabsLayout.php @@ -14,16 +14,17 @@ use Native\Mobile\Edge\NativeComponent; /** - * The three top-level sections: Log, Notifications and your Profile, with the - * icons of the web's phone nav, picked out in the colour you chose for your - * lane. Notifications wears a badge with the unread count devnull last gave. + * The top-level sections: Log, Notifications, Saved and your Profile, with the + * icons of the web's nav, picked out in the colour you chose for your lane. + * Notifications wears a badge with the unread count devnull last gave. The + * web's phone keeps Saved in its account menu; here it's a tab of its own. */ class TabsLayout extends NativeLayout { /** * Each tab's URI. Commits and profiles open beneath the one they were tapped in. */ - public const array Tabs = ['/log', '/notifications', '/profile']; + public const array Tabs = ['/log', '/notifications', '/saved', '/profile']; /** * The icon and "/dev/null" wordmark as the web's MobileHeader draws them, 105 x 28pt. @@ -81,6 +82,7 @@ public function tabBar(NativeComponent $screen): ?TabBar ->labelVisibility('labeled') ->add(Tab::link('Log', '/log', ios: Lucide::GitCommitVertical->ios(), android: Lucide::GitCommitVertical->android())) ->add($notifications) + ->add(Tab::link('Saved', '/saved', ios: Lucide::Bookmark->ios(), android: Lucide::Bookmark->android())) ->add(Tab::link('Profile', '/profile', ios: Lucide::UserRound->ios(), android: Lucide::UserRound->android())); } diff --git a/app/NativeComponents/Saved.php b/app/NativeComponents/Saved.php new file mode 100644 index 0000000..79d43c2 --- /dev/null +++ b/app/NativeComponents/Saved.php @@ -0,0 +1,244 @@ + */ + public array $commits = []; + + public ?string $nextCursor = null; + + /** + * Whether the list has had commits to show, kept or fresh. Until then it shows skeleton rows. + */ + public bool $loaded = false; + + public function mount(): void + { + $this->showKept(); + $this->revalidate(); + } + + /** + * Back from a commit or a profile: take any change made there from the cache at once, then ask devnull. + */ + public function onResume(): void + { + $this->forgetRefreshes(); + $this->forgetGone(); + $this->showKept(); + $this->revalidate(); + } + + public function navTitle(): string + { + return 'Saved'; + } + + /** + * Pull to refresh. + */ + public function load(): void + { + $this->refresh('saved', [], fn (array $json) => $this->takeFresh($json), again: true); + $this->refreshThreads(); + $this->refreshUnreadNotifications(); + } + + protected function revalidate(): void + { + $this->refresh('saved', [], fn (array $json) => $this->takeFresh($json)); + $this->refreshThreads(); + $this->refreshUnreadNotifications(); + } + + protected function showsContent(): bool + { + return $this->loaded; + } + + public function loadMore(): void + { + if ($this->nextCursor === null) { + return; + } + + $page = $this->attempt(fn ($devnull) => $devnull->saved($this->nextCursor)); + + if ($page !== null) { + $this->commits = [...$this->commits, ...self::without($page->items, self::ids($this->commits))]; + $this->nextCursor = $page->nextCursor; + } + } + + /** + * Saves or unsaves straight away, as anywhere else. A commit you unsaved + * leaves the list once devnull says so. When it was the last one showing + * and there are more, the list starts again from the top, as on the web. + */ + public function toggleBookmark(string $id): void + { + $this->toggleSaved($id); + + $row = array_values(array_filter($this->commits, fn (Commit $commit): bool => $commit->id === $id))[0] ?? null; + + // A reply under a saved commit can be unsaved too, and it isn't a row. + if ($row?->isSaved !== false) { + return; + } + + $this->commits = self::without($this->commits, [$id]); + unset($this->threads[$id]); + + if ($this->commits === [] && $this->nextCursor !== null) { + $this->nextCursor = null; + $this->loaded = false; + $this->load(); + } + } + + /** + * The empty list's Explore Trending: the Log, open on Trending, as its tab opens it. + */ + public function exploreTrending(): void + { + app(ShownTabs::class)->remember($this->me()?->id, 'log', Log::Ranked); + + $this->replace(TabsLayout::Tabs[0])->transition(Transition::None); + } + + /** + * @return iterable + */ + protected function shownCommits(): iterable + { + return [...$this->commits, ...$this->threadCommits()]; + } + + protected function updateShownCommits(Closure $update): void + { + $this->commits = array_map($update, $this->commits); + $this->threads = array_map(fn (array $replies): array => array_map($update, $replies), $this->threads); + } + + private function showKept(): void + { + if (($json = $this->cached('saved')) !== null) { + $this->takeFresh($json); + } + } + + /** + * Commits unsaved or deleted on this phone leave the list, wherever they + * are in it. A kept first page only knows about its own. + */ + private function forgetGone(): void + { + $cache = app(ResponseCache::class); + + $this->commits = self::without($this->commits, [...$cache->unsavedIds(), ...$cache->deletedIds()]); + } + + /** + * A fresh first page replaces the top of the list. The pages loaded below + * it stay, from the last commit the fresh page also has, unless it's the + * whole list. Saving a commit again moves it to the top, so rows are + * matched from the end of the page, where the order hasn't changed. + * + * @param array $json + */ + private function takeFresh(array $json): void + { + $page = Page::fromArray($json, Commit::fromArray(...)); + $shownAt = array_flip(self::ids($this->commits)); + $later = []; + + foreach ($page->nextCursor === null ? [] : array_reverse($page->items) as $commit) { + if (isset($shownAt[$commit->id])) { + $later = self::without(array_slice($this->commits, $shownAt[$commit->id] + 1), self::ids($page->items)); + + break; + } + } + + $this->commits = [...$page->items, ...$later]; + $this->nextCursor = $later === [] ? $page->nextCursor : $this->nextCursor; + $this->loaded = true; + } + + /** + * @param list $commits + * @param list $ids + * @return list + */ + private static function without(array $commits, array $ids): array + { + $leaving = array_flip($ids); + + return array_values(array_filter($commits, fn (Commit $commit): bool => ! isset($leaving[$commit->id]))); + } + + /** + * @param list $commits + * @return list + */ + private static function ids(array $commits): array + { + return array_map(fn (Commit $commit): string => $commit->id, $commits); + } + + public function render(): View + { + $lastCommit = $this->commits[array_key_last($this->commits) ?? -1] ?? null; + + return view('native.saved', [ + 'viewer' => $this->me(), + 'myColour' => $this->myColour(), + 'rows' => Timeline::feed($this->commits), + 'lastCommit' => $lastCommit, + ]); + } +} diff --git a/resources/icons/lock-keyhole.svg b/resources/icons/lock-keyhole.svg new file mode 100644 index 0000000..074567c --- /dev/null +++ b/resources/icons/lock-keyhole.svg @@ -0,0 +1 @@ + diff --git a/resources/views/native/partials/empty.blade.php b/resources/views/native/partials/empty.blade.php index 52419a1..afa3478 100644 --- a/resources/views/native/partials/empty.blade.php +++ b/resources/views/native/partials/empty.blade.php @@ -1,5 +1,8 @@ -{{-- Empty states speak in git's voice: a mono line, then a plain sentence or two. --}} +{{-- Empty states speak in git's voice: a mono line, then a plain sentence or two, and maybe a link on its own line. --}} {{ $command }} {{ $message }} + @isset($link) + {{ $link }} + @endisset diff --git a/resources/views/native/saved.blade.php b/resources/views/native/saved.blade.php new file mode 100644 index 0000000..b8513cf --- /dev/null +++ b/resources/views/native/saved.blade.php @@ -0,0 +1,47 @@ + + @include('native.partials.error') + @include('native.partials.refresh-failed') + @include('native.partials.commit-sheet') + + + {{-- + What the web's Saved page says above the list: they're yours alone, last saved first. The hairline is in + the same row: as a list row of its own, iOS laid it out far taller than 1pt, with a gap either side. + --}} + + + + + Only you + + Commits you want to come back to. Most recently saved first. + + + + + @if (! $loaded && $error === null) + @foreach (range(1, 3) as $skeleton) + @include('native.partials.rail-loading', ['ref' => 'loading-'.$skeleton, 'label' => 'Loading saved commits']) + @endforeach + @endif + + @if ($loaded && $commits === [] && $error === null) + @include('native.partials.empty', [ + 'command' => 'fatal: no saved commits yet', + 'message' => 'Save a commit to find it here. Your saved commits are private.', + 'link' => 'Explore Trending', + 'press' => 'exploreTrending', + ]) + @endif + + @include('native.partials.feed', ['rows' => $rows]) + + @if ($lastCommit) + @if ($nextCursor !== null) + @include('native.partials.rail-loading') + @else + @include('native.partials.rail-end', ['lane' => $lastCommit->lane(), 'message' => "You've reached the end of your saved commits."]) + @endif + @endif + + diff --git a/routes/mobile.php b/routes/mobile.php index 363df28..c0ebf04 100644 --- a/routes/mobile.php +++ b/routes/mobile.php @@ -8,6 +8,7 @@ use App\NativeComponents\Notifications; use App\NativeComponents\Onboarding; use App\NativeComponents\Profile; +use App\NativeComponents\Saved; use App\NativeComponents\ThisDevice; use Illuminate\Support\Facades\Route; @@ -18,6 +19,7 @@ Route::nativeGroup(TabsLayout::class, function (): void { Route::native('/log', Log::class); Route::native('/notifications', Notifications::class); + Route::native('/saved', Saved::class); Route::native('/profile', Profile::class); Route::native('/profile/device', ThisDevice::class); diff --git a/tests/Feature/Devnull/ClientCacheTest.php b/tests/Feature/Devnull/ClientCacheTest.php index a467368..b5e607d 100644 --- a/tests/Feature/Devnull/ClientCacheTest.php +++ b/tests/Feature/Devnull/ClientCacheTest.php @@ -4,6 +4,7 @@ use App\Devnull\Exceptions\ApiException; use App\Devnull\FetchInBackground; use App\Devnull\RequestSigner; +use App\Devnull\ResponseCache; use Illuminate\Http\Client\Request; use Illuminate\Support\Facades\Http; @@ -114,6 +115,35 @@ expect(kept('log/following')['data'][0])->toMatchArray(['starred' => false, 'stars_count' => 1]); }); +it('keeps the kept saved commits up to date as you save and unsave, but not as you star', function () { + keep('saved', pageJson([commitJson(['id' => 'c2', 'is_saved' => true])], 'page2')); + serveApi([ + 'POST /api/v1/commits/c9/star' => [['data' => commitJson(['id' => 'c9', 'starred' => true, 'stars_count' => 1, 'is_saved' => true])]], + 'PUT /api/v1/commits/c1/bookmark' => [['data' => commitJson(['id' => 'c1', 'is_saved' => true])]], + 'DELETE /api/v1/commits/c2/bookmark' => [['data' => commitJson(['id' => 'c2', 'is_saved' => false])]], + ]); + + // c9 was saved long ago, past the kept page: a star doesn't make it the last saved. + $this->devnull->star('c9'); + expect(array_column(kept('saved')['data'], 'id'))->toBe(['c2']); + + $this->devnull->bookmark('c1'); + expect(array_column(kept('saved')['data'], 'id'))->toBe(['c1', 'c2']); + + $this->devnull->unbookmark('c2'); + expect(array_column(kept('saved')['data'], 'id'))->toBe(['c1']) + ->and(app(ResponseCache::class)->unsavedIds())->toBe(['c2']); +}); + +it('keeps the first page of your saved commits', function () { + serveApi(['GET /api/v1/saved' => [pageJson([commitJson(['id' => 'c1', 'is_saved' => true])], 'page2')]]); + + expect($this->devnull->saved()->items[0]->id)->toBe('c1') + ->and($this->devnull->saved('page2')->nextCursor)->toBe('page2') + ->and(array_column(kept('saved')['data'], 'id'))->toBe(['c1']) + ->and(Http::recorded()[1][0]->url())->toBe('http://devnull.test/api/v1/saved?cursor=page2'); +}); + it('keeps a new commit and a new reply where they belong', function () { keep('log/following', pageJson([commitJson(['id' => 'c1'])])); keep('commits/c1/replies', pageJson([])); diff --git a/tests/Feature/Devnull/ResponseCacheTest.php b/tests/Feature/Devnull/ResponseCacheTest.php index 7aeb34d..936cb2e 100644 --- a/tests/Feature/Devnull/ResponseCacheTest.php +++ b/tests/Feature/Devnull/ResponseCacheTest.php @@ -27,6 +27,8 @@ ->and(ResponseCache::caches('commits/c1/replies'))->toBeTrue() ->and(ResponseCache::caches('users/ada'))->toBeTrue() ->and(ResponseCache::caches('users/ada/commits', ['replies' => '1']))->toBeTrue() + ->and(ResponseCache::caches('saved'))->toBeTrue() + ->and(ResponseCache::caches('saved', ['cursor' => 'page2']))->toBeFalse() ->and(ResponseCache::caches('log/following', ['cursor' => 'page2']))->toBeFalse() ->and(ResponseCache::caches('devices'))->toBeFalse() ->and(ResponseCache::caches('key-syncs'))->toBeFalse() @@ -222,6 +224,39 @@ expect($this->cache->deletedIds())->toBe([]); }); +it('puts a commit you saved on top of your saved commits, and takes one you unsaved out', function () { + $this->cache->put('saved', [], pageJson([commitJson(['id' => 'c2', 'is_saved' => true]), commitJson(['id' => 'c1', 'is_saved' => true])], 'page2')); + + $this->cache->commitSaved(commitJson(['id' => 'c3', 'body' => 'Worth keeping', 'is_saved' => true])); + $this->cache->commitSaved(commitJson(['id' => 'c1', 'is_saved' => true])); + + expect(array_column($this->cache->get('saved')['data'], 'id'))->toBe(['c1', 'c3', 'c2']) + ->and($this->cache->get('saved')['data'][1]['body'])->toBe('Worth keeping') + ->and($this->cache->get('saved')['meta']['next_cursor'])->toBe('page2'); + + $this->cache->commitSaved(commitJson(['id' => 'c3', 'is_saved' => false])); + $this->cache->commitSaved(['id' => 'c2']); + + expect(array_column($this->cache->get('saved')['data'], 'id'))->toBe(['c1', 'c2']); +}); + +it('remembers the commits unsaved on this phone until they are saved again, newest first and only the last fifty', function () { + foreach (range(1, 52) as $n) { + $this->cache->commitSaved(commitJson(['id' => "c{$n}", 'is_saved' => false])); + } + + $this->cache->commitSaved(commitJson(['id' => 'c51', 'is_saved' => true])); + + expect($this->cache->unsavedIds())->toHaveCount(49) + ->and($this->cache->unsavedIds()[0])->toBe('c52') + ->and($this->cache->unsavedIds())->not->toContain('c51') + ->and($this->cache->unsavedIds())->not->toContain('c2'); + + $this->cache->flush(); + + expect($this->cache->unsavedIds())->toBe([]); +}); + it('keeps a log the same whatever it was asked since, without what only ?since= answers', function () { $this->cache->put('log/trending', ['since' => 'c1'], [ ...pageJson([commitJson(['id' => 'c2'])]), diff --git a/tests/Feature/Screens/SavedTest.php b/tests/Feature/Screens/SavedTest.php new file mode 100644 index 0000000..6aedb95 --- /dev/null +++ b/tests/Feature/Screens/SavedTest.php @@ -0,0 +1,291 @@ + 'http://devnull.test']); + signIn(); +}); + +/** + * A commit you saved, by Ada. + * + * @param array $overrides + * @return array + */ +function savedCommit(string $id, array $overrides = []): array +{ + return commitJson(['id' => $id, 'body' => "Saved {$id}", 'is_saved' => true, 'author' => userJson(['id' => 'u2', 'username' => 'ada', 'lane' => 1]), ...$overrides]); +} + +/** + * `GET /saved` as devnull sends it. + * + * @param list> $commits + * @return array + */ +function savedPage(array $commits, ?string $nextCursor = null): array +{ + return pageJson($commits, $nextCursor, 'http://devnull.test/api/v1/saved'); +} + +/** + * The ids of the commits the Saved screen lists, top to bottom. + * + * @return list + */ +function savedIds(TestableComponent $screen): array +{ + return array_map(fn ($commit): string => $commit->id, $screen->instance()->commits); +} + +it('lists the commits you saved, last saved first, in the rows of the log', function () { + fakeApi(['GET /api/v1/saved' => [savedPage([savedCommit('s2'), savedCommit('s1')])]]); + + $screen = finishRefreshes(Native::visit('/saved')) + ->assertNavTitle('Saved') + ->assertTabActive('Saved') + ->assertSee('Only you') + ->assertSee('Commits you want to come back to. Most recently saved first.') + ->assertSee('Saved s2') + ->assertSee("You've reached the end of your saved commits."); + + expect(savedIds($screen))->toBe(['s2', 's1']) + ->and(array_slice(menuItems($screen, 'more-s1')[0], 0, 2))->toBe(['save-s1', 'Unsave commit']); +}); + +it('opens on the saved commits kept last time, with no request and no skeleton, then refreshes behind them', function () { + holdRefreshes(); + keep('saved', savedPage([savedCommit('s1')])); + serveApi(['GET /api/v1/saved' => [savedPage([savedCommit('s2'), savedCommit('s1')])]]); + + $screen = Native::visit('/saved')->assertSee('Saved s1')->assertDontSee('Saved s2'); + + expect(showsLoading($screen->tree()))->toBeFalse() + ->and(Http::recorded())->toHaveCount(0); + + finishRefreshes($screen); + + expect(savedIds($screen))->toBe(['s2', 's1']) + ->and(array_column(kept('saved')['data'], 'id'))->toBe(['s2', 's1']); +}); + +it('shows skeleton rows the first time, until devnull answers', function () { + holdRefreshes(); + serveApi(['GET /api/v1/saved' => [savedPage([savedCommit('s1')])]]); + + $screen = Native::visit('/saved'); + + expect(showsLoading($screen->tree()))->toBeTrue(); + + finishRefreshes($screen)->assertSee('Saved s1'); + + expect(showsLoading($screen->tree()))->toBeFalse(); +}); + +it('says so as the web does when nothing is saved, and Explore Trending opens the Log on Trending', function () { + fakeApi(['GET /api/v1/saved' => [savedPage([])]]); + + $screen = finishRefreshes(Native::visit('/saved')) + ->assertSee('fatal: no saved commits yet') + ->assertSee('Save a commit to find it here. Your saved commits are private.') + ->assertDontSee("You've reached the end of your saved commits."); + + $screen->press('exploreTrending')->assertReplacedWith('/log')->assertTransition('none'); + + expect(app(ShownTabs::class)->get('0199e5c4-0000-7000-8000-000000000001', 'log', ['following', 'latest', 'trending'], 'following'))->toBe('trending'); +}); + +it('takes a commit off the list once devnull says it is unsaved, and keeps it when devnull says no', function (int $status, array $ids) { + keep('saved', savedPage([savedCommit('s2'), savedCommit('s1')])); + fakeApi([ + 'GET /api/v1/saved' => [savedPage([savedCommit('s2'), savedCommit('s1')])], + 'DELETE /api/v1/commits/s2/bookmark' => [$status === 200 ? ['data' => savedCommit('s2', ['is_saved' => false])] : ['message' => 'Server Error'], $status], + ]); + + $screen = finishRefreshes(Native::visit('/saved'))->tap('save-s2'); + + expect(savedIds($screen))->toBe($ids) + ->and(array_column(kept('saved')['data'], 'id'))->toBe($ids) + ->and($screen->instance()->commits[0]->isSaved)->toBeTrue(); +})->with([ + 'unsaved' => [200, ['s1']], + 'refused' => [500, ['s2', 's1']], +]); + +it('keeps a commit when only a reply under it is unsaved', function () { + $reply = replyJson(['id' => 'r1', 'parent_id' => 's1', 'is_saved' => true]); + fakeApi([ + 'GET /api/v1/saved' => [savedPage([savedCommit('s1', ['replies_count' => 1, 'reply_preview' => ['data' => [$reply], 'next_cursor' => null]])])], + 'DELETE /api/v1/commits/r1/bookmark' => [['data' => [...$reply, 'is_saved' => false]]], + ]); + + $screen = finishRefreshes(Native::visit('/saved'))->call('toggleBookmark', 'r1'); + + expect(savedIds($screen))->toBe(['s1']); +}); + +it('starts again from the top when the last commit showing is unsaved and there are more', function () { + holdRefreshes(); + keep('saved', savedPage([savedCommit('s3')], nextCursor: 'page2')); + serveApi([ + 'DELETE /api/v1/commits/s3/bookmark' => [['data' => savedCommit('s3', ['is_saved' => false])]], + 'GET /api/v1/saved' => [savedPage([savedCommit('s2'), savedCommit('s1')])], + ]); + + $screen = Native::visit('/saved')->tap('save-s3')->assertDontSee('fatal: no saved commits yet'); + + expect(showsLoading($screen->tree()))->toBeTrue(); + + finishRefreshes($screen); + + expect(savedIds($screen))->toBe(['s2', 's1']); +}); + +it('loads the next page at the end of the list, and pulls to refresh', function () { + serveApi(fn ($request) => Http::response(str_contains($request->url(), 'cursor=page2') + ? savedPage([savedCommit('s1')]) + : savedPage([savedCommit('s3'), savedCommit('s2')], nextCursor: 'page2'))); + + $screen = finishRefreshes(Native::visit('/saved'))->assertElement('row', fn (array $node): bool => ($node['ref'] ?? null) === 'loading-more'); + + $screen->call('loadMore')->assertSee("You've reached the end of your saved commits."); + + expect(savedIds($screen))->toBe(['s3', 's2', 's1']) + ->and(kept('saved', ['cursor' => 'page2']))->toBeNull(); + + // Saved on the web since: a pull puts it on top and keeps the page loaded below. + serveApi(fn () => Http::response(savedPage([savedCommit('s4'), savedCommit('s3')], nextCursor: 'page2'))); + finishRefreshes($screen->call('load')); + + expect(savedIds($screen))->toBe(['s4', 's3', 's2', 's1']); +}); + +it('lays a fresh first page over the list, keeping the pages below it in place', function (array $fresh, ?string $cursor, array $ids) { + holdRefreshes(); + keep('saved', savedPage([savedCommit('s5'), savedCommit('s4'), savedCommit('s3')], nextCursor: 'page2')); + serveApi(['GET /api/v1/saved' => [savedPage([savedCommit('s2'), savedCommit('s1')], nextCursor: 'page3')]]); + + $screen = Native::visit('/saved')->call('loadMore'); + expect(savedIds($screen))->toBe(['s5', 's4', 's3', 's2', 's1']); + + serveApi(['GET /api/v1/saved' => [savedPage(array_map(fn (string $id): array => savedCommit($id), $fresh), $cursor)]]); + finishRefreshes($screen->call('load')); + + expect(savedIds($screen))->toBe($ids); +})->with([ + // s4 unsaved elsewhere, so s2 now ends the first page. + 'one unsaved' => [['s5', 's3', 's2'], 'page2', ['s5', 's3', 's2', 's1']], + // s2 saved again: it's the last saved now, and leaves its place below. + 'one saved again' => [['s2', 's5', 's4'], 'page2', ['s2', 's5', 's4', 's3', 's1']], + // Nothing more to load, so the page is the whole list. + 'the whole list' => [['s5', 's4'], null, ['s5', 's4']], + // Nothing in common: the list starts again from the fresh page. + 'all new' => [['s9', 's8'], 'page2', ['s9', 's8']], +]); + +it('shows a commit saved on another screen the next time it opens, and leaves out one unsaved there', function () { + $ada = userJson(['id' => 'u2', 'username' => 'ada', 'lane' => 1]); + keep('saved', savedPage([savedCommit('s1')])); + fakeApi([ + 'GET /api/v1/log/following' => [pageJson([commitJson(['id' => 'c1', 'body' => 'Worth keeping', 'author' => $ada]), savedCommit('s1')])], + 'PUT /api/v1/commits/c1/bookmark' => [['data' => commitJson(['id' => 'c1', 'body' => 'Worth keeping', 'author' => $ada, 'is_saved' => true])]], + 'DELETE /api/v1/commits/s1/bookmark' => [['data' => savedCommit('s1', ['is_saved' => false])]], + ]); + + finishRefreshes(Native::visit('/log'))->tap('save-c1'); + + holdRefreshes(); + $saved = Native::visit('/saved')->assertSee('Worth keeping'); + expect(savedIds($saved))->toBe(['c1', 's1']); + + Native::visit('/log')->tap('save-s1'); + + holdRefreshes(); + expect(savedIds(Native::visit('/saved')))->toBe(['c1']); +}); + +it('drops a commit unsaved on its own page as soon as you come back, even past the first page', function () { + holdRefreshes(); + keep('saved', savedPage([savedCommit('s2')], nextCursor: 'page2')); + keep('commits/s1', commitPageJson(savedCommit('s1'))); + keep('commits/s1/replies', pageJson([])); + serveApi([ + 'GET /api/v1/saved' => [savedPage([savedCommit('s1')])], + 'DELETE /api/v1/commits/s1/bookmark' => [['data' => savedCommit('s1', ['is_saved' => false])]], + ]); + + $saved = Native::visit('/saved')->call('loadMore'); + expect(savedIds($saved))->toBe(['s2', 's1']); + + $commit = $saved->tap('commit-s1')->assertNavigatedTo('/saved/commits/s1')->follow()->tap('save-s1'); + + expect(savedIds($commit->goBack()))->toBe(['s2']); +}); + +it('keeps a commit saved again after it was unsaved', function () { + keep('saved', savedPage([savedCommit('s1')])); + fakeApi([ + 'DELETE /api/v1/commits/s1/bookmark' => [['data' => savedCommit('s1', ['is_saved' => false])]], + 'PUT /api/v1/commits/s1/bookmark' => [['data' => savedCommit('s1')]], + 'GET /api/v1/commits/s1' => [commitPageJson(savedCommit('s1'))], + 'GET /api/v1/commits/s1/replies' => [pageJson([])], + ]); + + $commit = Native::visit('/saved/commits/s1'); + $commit->tap('save-s1')->tap('save-s1'); + + holdRefreshes(); + expect(savedIds(Native::visit('/saved')))->toBe(['s1']); +}); + +it('deletes your own saved commit from its menu, and lets its row go', function () { + $deleted = false; + + serveApi(function (Request $request) use (&$deleted) { + if ($request->method() === 'DELETE') { + $deleted = true; + + return Http::response(null, 204); + } + + return Http::response(savedPage([ + ...($deleted ? [] : [savedCommit('s2', ['body' => 'Delete me', 'author' => userJson()])]), + savedCommit('s1'), + ])); + }); + + $screen = finishRefreshes(Native::visit('/saved', platform: 'ios')) + ->tap('delete-s2') + ->emitNative(ButtonPressed::class, ['index' => 1, 'label' => 'Delete', 'id' => 'delete-commit']) + ->assertNativeCalled('Dialog.Toast', fn (array $params): bool => $params['message'] === 'rm s2 → /dev/null') + ->assertNoNavigation() + ->assertDontSee('Delete me'); + + expect(savedIds(finishRefreshes($screen)))->toBe(['s1']) + ->and(array_column(kept('saved')['data'], 'id'))->toBe(['s1']); +}); + +it('quotes and reports from Saved with your lane colour on the sheet', function (string $item, string $button) { + signIn(['lane' => 4]); + fakeApi(['GET /api/v1/saved' => [savedPage([savedCommit('s1')])]]); + + $screen = finishRefreshes(Native::visit('/saved', platform: 'ios'))->tap("{$item}-s1"); + + expect(nodeByRef($screen, $button)['props']['variant'] ?? null)->toBe('lane-4'); +})->with([ + 'Quote' => ['quote', 'sheet-commit'], + 'Report commit' => ['report', 'send-report'], +]); + +it('opens commits and profiles on the Saved tab', function () { + fakeApi(['GET /api/v1/saved' => [savedPage([savedCommit('s1')])]]); + + finishRefreshes(Native::visit('/saved'))->tap('commit-s1')->assertNavigatedTo('/saved/commits/s1'); + finishRefreshes(Native::visit('/saved'))->call('openProfile', 'ada')->assertNavigatedTo('/saved/users/ada'); +}); diff --git a/tests/Feature/Screens/TabsTest.php b/tests/Feature/Screens/TabsTest.php index 5e8067b..3d29102 100644 --- a/tests/Feature/Screens/TabsTest.php +++ b/tests/Feature/Screens/TabsTest.php @@ -21,13 +21,14 @@ function tabIcons(TestableComponent $screen): array ->all(); } -it('has Log, Notifications and Profile tabs with the Lucide icons of the web phone nav', function (string $platform) { +it('has Log, Notifications, Saved and Profile tabs with the Lucide icons of the web nav', function (string $platform) { Http::fake(['devnull.test/*' => Http::response(pageJson([]))]); // The same drawings on both platforms. A custom icon has no Material font variant. expect(tabIcons(Native::visit('/log', platform: $platform)))->toBe([ 'Log' => ['custom:git-commit-vertical', null], 'Notifications' => ['custom:bell', null], + 'Saved' => ['custom:bookmark', null], 'Profile' => ['custom:user-round', null], ]); })->with(['ios', 'android']); @@ -62,6 +63,8 @@ function tabIcons(TestableComponent $screen): array Native::visit('/profile/commits/c1')->call('openProfile', 'ada')->assertNavigatedTo('/profile/users/ada'); Native::visit('/notifications/commits/c1')->call('openCommit', 'c0')->assertNavigatedTo('/notifications/commits/c0'); + Native::visit('/saved/commits/c1')->call('openProfile', 'ada')->assertNavigatedTo('/saved/users/ada'); + Native::visit('/saved/users/ada')->call('openCommit', 'c0')->assertNavigatedTo('/saved/commits/c0'); }); it('stays put when a tap leads to the commit already showing', function () {