From a836f4b14e872884339982e7d46273df64a05bae Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Sat, 17 Oct 2026 16:16:41 +0100 Subject: [PATCH 1/3] Add Saved as a main tab between Notifications and Profile The Saved tab lists the commits you saved, last saved first, in the Log's rows, as the web's Saved page does. It has the web's "Only you" line, end line and empty state. Explore Trending opens the Log on Trending. Unsaving a commit there takes its row away once devnull says so. Saving or unsaving anywhere else patches the kept first page, so the change shows the next time Saved opens. Commits unsaved on this phone are remembered too, so rows on later pages go when you come back to it. Co-Authored-By: Claude Opus 5.5 --- app/Devnull/Client.php | 21 +- app/Devnull/ResponseCache.php | 44 +++ app/Icons/Lucide.php | 2 + app/NativeComponents/Layouts/TabsLayout.php | 10 +- app/NativeComponents/Saved.php | 243 +++++++++++++++++ resources/icons/lock-keyhole.svg | 1 + .../views/native/partials/empty.blade.php | 5 +- resources/views/native/saved.blade.php | 42 +++ routes/mobile.php | 2 + tests/Feature/Devnull/ClientCacheTest.php | 30 +++ tests/Feature/Devnull/ResponseCacheTest.php | 35 +++ tests/Feature/Screens/SavedTest.php | 250 ++++++++++++++++++ tests/Feature/Screens/TabsTest.php | 5 +- 13 files changed, 681 insertions(+), 9 deletions(-) create mode 100644 app/NativeComponents/Saved.php create mode 100644 resources/icons/lock-keyhole.svg create mode 100644 resources/views/native/saved.blade.php create mode 100644 tests/Feature/Screens/SavedTest.php 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..177c1ee --- /dev/null +++ b/app/NativeComponents/Saved.php @@ -0,0 +1,243 @@ + */ + 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(), + '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..0bed5bb --- /dev/null +++ b/resources/views/native/saved.blade.php @@ -0,0 +1,42 @@ + + @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. --}} + + + + 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..215f656 --- /dev/null +++ b/tests/Feature/Screens/SavedTest.php @@ -0,0 +1,250 @@ + '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') + ->assertElement('pressable', fn (array $node): bool => ($node['ref'] ?? null) === 'bookmark-s1') + ->assertSee("You've reached the end of your saved commits."); + + expect(savedIds($screen))->toBe(['s2', 's1']); +}); + +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('bookmark-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('bookmark-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('bookmark-c1'); + + holdRefreshes(); + $saved = Native::visit('/saved')->assertSee('Worth keeping'); + expect(savedIds($saved))->toBe(['c1', 's1']); + + Native::visit('/log')->tap('bookmark-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('bookmark-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('bookmark-s1')->tap('bookmark-s1'); + + holdRefreshes(); + expect(savedIds(Native::visit('/saved')))->toBe(['s1']); +}); + +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 () { From 15ded1853efd026952c71c5ed1333d6e4bd5e267 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Sun, 18 Oct 2026 17:54:34 +0100 Subject: [PATCH 2/3] Tighten the space under the Saved description The hairline under the description was a list row of its own, and iOS laid it out far taller than 1pt, which left a big gap above and below it. It now sits in the description's row, so the spacing matches the web's Saved page. Co-Authored-By: Claude Opus 5.5 --- resources/views/native/saved.blade.php | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/resources/views/native/saved.blade.php b/resources/views/native/saved.blade.php index 0bed5bb..b8513cf 100644 --- a/resources/views/native/saved.blade.php +++ b/resources/views/native/saved.blade.php @@ -4,15 +4,20 @@ @include('native.partials.commit-sheet') - {{-- What the web's Saved page says above the list: they're yours alone, last saved first. --}} - - - - Only you - - Commits you want to come back to. Most recently saved first. + {{-- + 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) From 59b240786a7f8d2490d867659abf31ff5465375f Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Sun, 18 Oct 2026 19:34:19 +0100 Subject: [PATCH 3/3] Use the More menu on Saved, and pass it your lane colour Save and Unsave are in a commit's More menu now, so the Saved tests tap its items. Unsaving from the menu still takes the row away, and deleting your own saved commit from it lets the row go. The quote and report sheets draw their button in your lane colour, so Saved hands the view $myColour like the other screens. Without it, quoting or reporting from Saved failed to render. Co-Authored-By: Claude Opus 5.5 --- app/NativeComponents/Saved.php | 1 + tests/Feature/Screens/SavedTest.php | 57 +++++++++++++++++++++++++---- 2 files changed, 50 insertions(+), 8 deletions(-) diff --git a/app/NativeComponents/Saved.php b/app/NativeComponents/Saved.php index 177c1ee..79d43c2 100644 --- a/app/NativeComponents/Saved.php +++ b/app/NativeComponents/Saved.php @@ -236,6 +236,7 @@ public function render(): View return view('native.saved', [ 'viewer' => $this->me(), + 'myColour' => $this->myColour(), 'rows' => Timeline::feed($this->commits), 'lastCommit' => $lastCommit, ]); diff --git a/tests/Feature/Screens/SavedTest.php b/tests/Feature/Screens/SavedTest.php index 215f656..6aedb95 100644 --- a/tests/Feature/Screens/SavedTest.php +++ b/tests/Feature/Screens/SavedTest.php @@ -1,7 +1,9 @@ assertSee('Only you') ->assertSee('Commits you want to come back to. Most recently saved first.') ->assertSee('Saved s2') - ->assertElement('pressable', fn (array $node): bool => ($node['ref'] ?? null) === 'bookmark-s1') ->assertSee("You've reached the end of your saved commits."); - expect(savedIds($screen))->toBe(['s2', 's1']); + 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 () { @@ -106,7 +108,7 @@ function savedIds(TestableComponent $screen): array 'DELETE /api/v1/commits/s2/bookmark' => [$status === 200 ? ['data' => savedCommit('s2', ['is_saved' => false])] : ['message' => 'Server Error'], $status], ]); - $screen = finishRefreshes(Native::visit('/saved'))->tap('bookmark-s2'); + $screen = finishRefreshes(Native::visit('/saved'))->tap('save-s2'); expect(savedIds($screen))->toBe($ids) ->and(array_column(kept('saved')['data'], 'id'))->toBe($ids) @@ -136,7 +138,7 @@ function savedIds(TestableComponent $screen): array 'GET /api/v1/saved' => [savedPage([savedCommit('s2'), savedCommit('s1')])], ]); - $screen = Native::visit('/saved')->tap('bookmark-s3')->assertDontSee('fatal: no saved commits yet'); + $screen = Native::visit('/saved')->tap('save-s3')->assertDontSee('fatal: no saved commits yet'); expect(showsLoading($screen->tree()))->toBeTrue(); @@ -196,13 +198,13 @@ function savedIds(TestableComponent $screen): array 'DELETE /api/v1/commits/s1/bookmark' => [['data' => savedCommit('s1', ['is_saved' => false])]], ]); - finishRefreshes(Native::visit('/log'))->tap('bookmark-c1'); + 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('bookmark-s1'); + Native::visit('/log')->tap('save-s1'); holdRefreshes(); expect(savedIds(Native::visit('/saved')))->toBe(['c1']); @@ -221,7 +223,7 @@ function savedIds(TestableComponent $screen): array $saved = Native::visit('/saved')->call('loadMore'); expect(savedIds($saved))->toBe(['s2', 's1']); - $commit = $saved->tap('commit-s1')->assertNavigatedTo('/saved/commits/s1')->follow()->tap('bookmark-s1'); + $commit = $saved->tap('commit-s1')->assertNavigatedTo('/saved/commits/s1')->follow()->tap('save-s1'); expect(savedIds($commit->goBack()))->toBe(['s2']); }); @@ -236,12 +238,51 @@ function savedIds(TestableComponent $screen): array ]); $commit = Native::visit('/saved/commits/s1'); - $commit->tap('bookmark-s1')->tap('bookmark-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')])]]);