From efafc2da8646b605f19f7aa4d6116f33d4e7febf Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Sat, 24 Oct 2026 20:25:24 +0100 Subject: [PATCH 1/6] Show toasts with ToastKit instead of Dialog::toast The built-in toast is a plain system toast. ToastKit draws native overlays on both platforms with variants and action buttons, which the outbox work needs for Retry and Edit. Every Dialog::toast call now goes through App\Devnull\Toasts, which sets the position, font and durations in one place. Confirmations are neutral and failures are errors. Dialog::alert is unchanged. The plugin is free, so it also works with composer.free.json. Co-Authored-By: Claude Opus 5.5 --- app/Devnull/Toasts.php | 76 +++++++++++++++++++ .../Concerns/AttachesToCommits.php | 4 +- .../Concerns/DraftsIssues.php | 4 +- .../Concerns/ShowsCommits.php | 11 +-- .../Concerns/VotesOnPolls.php | 4 +- app/Providers/NativeServiceProvider.php | 2 + composer.json | 3 +- composer.lock | 63 ++++++++++++++- tests/Feature/Screens/AccountColorTest.php | 2 +- tests/Feature/Screens/AccountPasteisTest.php | 2 +- tests/Feature/Screens/AccountProfileTest.php | 4 +- tests/Feature/Screens/CommitDetailTest.php | 6 +- tests/Feature/Screens/CommitMenuTest.php | 10 +-- tests/Feature/Screens/CommitSheetTest.php | 2 +- tests/Feature/Screens/ComposeSheetTest.php | 2 +- tests/Feature/Screens/IssueCommandsTest.php | 4 +- tests/Feature/Screens/PasteisTest.php | 2 +- tests/Feature/Screens/PollVoteTest.php | 2 +- tests/Feature/Screens/QuoteTest.php | 2 +- tests/Feature/Screens/StarredTest.php | 2 +- tests/Feature/Screens/ThisDeviceTest.php | 2 +- 21 files changed, 175 insertions(+), 34 deletions(-) create mode 100644 app/Devnull/Toasts.php diff --git a/app/Devnull/Toasts.php b/app/Devnull/Toasts.php new file mode 100644 index 0000000..42f790d --- /dev/null +++ b/app/Devnull/Toasts.php @@ -0,0 +1,76 @@ +duration($short ? self::Short : self::Long)->show(); + } + + /** + * Something didn't go, with why, and a button to try again or go back to it. + * + * @param array{label: string, id: string, data?: array}|null $action + */ + public static function failed(string $message, ?array $action = null): string + { + return self::withAction(self::styled(Toast::error($message)), $action)->show(); + } + + /** + * Something worth knowing that isn't a failure, with a button to follow it up, such as an issue just opened. + * + * @param array{label: string, id: string, data?: array}|null $action + */ + public static function done(string $message, ?array $action = null): string + { + return self::withAction(self::styled(Toast::success($message)), $action)->show(); + } + + private static function styled(PendingToast $toast): PendingToast + { + return $toast->position('top')->text(font: 'default'); + } + + /** + * @param array{label: string, id: string, data?: array}|null $action + */ + private static function withAction(PendingToast $toast, ?array $action): PendingToast + { + if ($action === null) { + return $toast->duration(self::Long); + } + + return $toast->action($action['label'], $action['id'])->data($action['data'] ?? [])->duration(self::WithAction); + } +} diff --git a/app/NativeComponents/Concerns/AttachesToCommits.php b/app/NativeComponents/Concerns/AttachesToCommits.php index 044bb25..0aa3ac1 100644 --- a/app/NativeComponents/Concerns/AttachesToCommits.php +++ b/app/NativeComponents/Concerns/AttachesToCommits.php @@ -11,13 +11,13 @@ use App\Devnull\Exceptions\InvalidDraftImage; use App\Devnull\ResponseCache; use App\Devnull\SignedRequest; +use App\Devnull\Toasts; use Closure; use Native\Mobile\Attributes\On; use Native\Mobile\Events\Camera\PermissionDenied; use Native\Mobile\Events\Camera\PhotoTaken; use Native\Mobile\Events\Gallery\MediaSelected; use Native\Mobile\Facades\Camera; -use Native\Mobile\Facades\Dialog; /** * What the composer sheet adds to a commit, as the web's composer does: photos, @@ -273,7 +273,7 @@ function (string $message): void { if ($this->isComposing()) { $this->sheetError = $message; } else { - Dialog::toast($message); + Toasts::failed($message); } }, self::UploadTimeout, diff --git a/app/NativeComponents/Concerns/DraftsIssues.php b/app/NativeComponents/Concerns/DraftsIssues.php index c4fb1cb..c46b3dc 100644 --- a/app/NativeComponents/Concerns/DraftsIssues.php +++ b/app/NativeComponents/Concerns/DraftsIssues.php @@ -6,9 +6,9 @@ use App\Devnull\Exceptions\ApiException; use App\Devnull\IssueCommand; use App\Devnull\Stash; +use App\Devnull\Toasts; use Illuminate\Support\Facades\Cache; use Native\Mobile\Facades\Browser; -use Native\Mobile\Facades\Dialog; /** * The web's `/feature` and `/bug` commands, in the compose sheet (see @@ -135,7 +135,7 @@ public function openIssueFromSheet(): void // Swiped away while it was on its way, so a toast says it, without View issue. if (! $this->sheetOpen) { - Dialog::toast("Opened issue #{$number}."); + Toasts::done("Opened issue #{$number}."); return; } diff --git a/app/NativeComponents/Concerns/ShowsCommits.php b/app/NativeComponents/Concerns/ShowsCommits.php index cdf12ab..ec313ba 100644 --- a/app/NativeComponents/Concerns/ShowsCommits.php +++ b/app/NativeComponents/Concerns/ShowsCommits.php @@ -11,6 +11,7 @@ use App\Devnull\ErrorCode; use App\Devnull\Exceptions\ApiException; use App\Devnull\ResponseCache; +use App\Devnull\Toasts; use Closure; use Native\Mobile\Attributes\On; use Native\Mobile\Edge\SharedValue; @@ -179,7 +180,7 @@ public function givePastel(string $id): void } if ($pasteis->left() === 0) { - Dialog::toast(Pasteis::Exhausted); + Toasts::failed(Pasteis::Exhausted); return; } @@ -219,7 +220,7 @@ public function togglePin(string $id): void default => $shown, }); - Dialog::toast($fresh->isPinned ? 'Commit pinned to your profile.' : 'Commit unpinned.', 'short'); + Toasts::say($fresh->isPinned ? 'Commit pinned to your profile.' : 'Commit unpinned.', short: true); $this->showPinned($commit->withPinned($fresh->isPinned)); } @@ -389,7 +390,7 @@ private function pastelGiven(string $id, array $json): void app(Account::class)->rememberPasteis($pasteis); if ($pasteis->left() === 0) { - Dialog::toast(Pasteis::Exhausted); + Toasts::say(Pasteis::Exhausted); } } @@ -412,7 +413,7 @@ public function deleteCommit(string $id): void }); if ($deleted === true) { - Dialog::toast('rm '.$commit->shortHash.' → /dev/null', 'short'); + Toasts::say('rm '.$commit->shortHash.' → /dev/null', short: true); $this->showDeleted($id); } } @@ -451,7 +452,7 @@ private function pastelTurnedDown(string $id, ?ApiException $exception): void app(Account::class)->rememberPasteis($exhausted ? $pasteis->exhausted() : $pasteis->afterTakingBack()); } - Dialog::toast(match (true) { + Toasts::failed(match (true) { $exhausted => Pasteis::Exhausted, $exception?->is(ErrorCode::RateLimited) === true => $exception->firstError(), default => Pasteis::CouldNotGive, diff --git a/app/NativeComponents/Concerns/VotesOnPolls.php b/app/NativeComponents/Concerns/VotesOnPolls.php index b410b34..0d6e38a 100644 --- a/app/NativeComponents/Concerns/VotesOnPolls.php +++ b/app/NativeComponents/Concerns/VotesOnPolls.php @@ -5,8 +5,8 @@ use App\Devnull\Client; use App\Devnull\Data\Commit; use App\Devnull\ResponseCache; +use App\Devnull\Toasts; use Closure; -use Native\Mobile\Facades\Dialog; /** * Votes on the polls a screen shows, as the web's CommitPoll does: tap an @@ -64,7 +64,7 @@ public function voteOnPoll(string $id): void }, function (string $message) use ($id): void { $this->voting = array_values(array_diff($this->voting, [$id])); - Dialog::toast($message); + Toasts::failed($message); }); } diff --git a/app/Providers/NativeServiceProvider.php b/app/Providers/NativeServiceProvider.php index 42d58cf..dfdf29b 100644 --- a/app/Providers/NativeServiceProvider.php +++ b/app/Providers/NativeServiceProvider.php @@ -10,6 +10,7 @@ use Native\Mobile\Providers\ShareServiceProvider; use Native\Mobile\UI\NativeUIServiceProvider; use S2BR\MobileSplashscreen\MobileSplashscreenServiceProvider; +use Victorycodedev\ToastKit\ToastKitServiceProvider; class NativeServiceProvider extends ServiceProvider { @@ -50,6 +51,7 @@ public function plugins(): array SecureStorageServiceProvider::class, ShareServiceProvider::class, MobileSplashscreenServiceProvider::class, + ToastKitServiceProvider::class, ...(config('devnull.apple_sign_in') ? [NativephpSocialAuthServiceProvider::class] : []), ]; } diff --git a/composer.json b/composer.json index 7ac7e9b..9d766cd 100644 --- a/composer.json +++ b/composer.json @@ -37,7 +37,8 @@ "nativephp/mobile-secure-storage": "^1.1", "nativephp/mobile-share": "^1.1", "nativephp/mobile-ui": "dev-main as 0.8.0", - "s2br/nativephp-mobile-splashscreen": "^1.4" + "s2br/nativephp-mobile-splashscreen": "^1.4", + "victorycodedev/toastkit": "^1.4" }, "require-dev": { "fakerphp/faker": "^1.23", diff --git a/composer.lock b/composer.lock index c5b93d0..a557736 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "8bcaeeb71989e08fe920116ba3e08bcd", + "content-hash": "53e0af74fc9e3271140e4406bff0097e", "packages": [ { "name": "bacon/bacon-qr-code", @@ -7443,6 +7443,67 @@ }, "time": "2025-12-02T11:56:42+00:00" }, + { + "name": "victorycodedev/toastkit", + "version": "v1.4.0", + "source": { + "type": "git", + "url": "https://github.com/victorycodedev/toastkit.git", + "reference": "2f1ffeca2c9ff853e656311fc82dd0f238d01538" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/victorycodedev/toastkit/zipball/2f1ffeca2c9ff853e656311fc82dd0f238d01538", + "reference": "2f1ffeca2c9ff853e656311fc82dd0f238d01538", + "shasum": "" + }, + "require": { + "nativephp/mobile": "^4.1", + "php": "^8.4" + }, + "require-dev": { + "pestphp/pest": "^4.0" + }, + "type": "nativephp-plugin", + "extra": { + "laravel": { + "providers": [ + "Victorycodedev\\ToastKit\\ToastKitServiceProvider" + ] + }, + "nativephp": { + "manifest": "nativephp.json" + } + }, + "autoload": { + "psr-4": { + "Victorycodedev\\ToastKit\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Victory Efekpogua" + } + ], + "description": "Rich, customizable native toast notifications for NativePHP Mobile.", + "homepage": "https://github.com/victorycodedev/toastkit", + "keywords": [ + "mobile", + "nativephp", + "nativephp-plugin", + "notifications", + "toast" + ], + "support": { + "issues": "https://github.com/victorycodedev/toastkit/issues", + "source": "https://github.com/victorycodedev/toastkit" + }, + "time": "2026-10-21T19:57:09+00:00" + }, { "name": "vlucas/phpdotenv", "version": "v5.7.0", diff --git a/tests/Feature/Screens/AccountColorTest.php b/tests/Feature/Screens/AccountColorTest.php index 2956ce6..1cb30d5 100644 --- a/tests/Feature/Screens/AccountColorTest.php +++ b/tests/Feature/Screens/AccountColorTest.php @@ -144,7 +144,7 @@ function laneSaves(): array Http::assertSent(fn (Request $request): bool => $request->method() === 'PUT' && $request->url() === 'http://devnull.test/api/v1/'.Client::LanePath && $request->hasHeader('X-SSH-Signature')); - $bridge->assertNotCalled('Dialog.Toast'); + $bridge->assertNotCalled('ToastKit.Show'); // Your profile wears it as soon as you go back to it. $profile = Native::visit('/profile'); diff --git a/tests/Feature/Screens/AccountPasteisTest.php b/tests/Feature/Screens/AccountPasteisTest.php index 395bc52..2003192 100644 --- a/tests/Feature/Screens/AccountPasteisTest.php +++ b/tests/Feature/Screens/AccountPasteisTest.php @@ -82,7 +82,7 @@ function pasteisModeSaves(): array ->and(app(Account::class)->pasteis()?->left())->toBe(8) ->and(kept('me')['data']['pasteis_mode'])->toBeTrue(); - $bridge->assertNotCalled('Dialog.Toast'); + $bridge->assertNotCalled('ToastKit.Show'); }); it('turns pastéis mode off, and the pastéis leave the rows', function () { diff --git a/tests/Feature/Screens/AccountProfileTest.php b/tests/Feature/Screens/AccountProfileTest.php index 479e945..af6c10d 100644 --- a/tests/Feature/Screens/AccountProfileTest.php +++ b/tests/Feature/Screens/AccountProfileTest.php @@ -200,7 +200,7 @@ function mutedMono(array $node): bool && $request->body() === '' && $request->hasHeader('X-SSH-Signature')); expect(collect(Http::recorded())->filter(fn (array $sent): bool => $sent[0]->method() === 'PUT'))->toHaveCount(1); - $bridge->assertNotCalled('Dialog.Toast'); + $bridge->assertNotCalled('ToastKit.Show'); }); it('gives your profile on the Profile tab the synced name at once', function () { @@ -237,7 +237,7 @@ function mutedMono(array $node): bool ->and(nodeByRef($screen, 'sync-github')['props'])->not->toHaveKey('disabled') ->and(app(Account::class)->user()?->name)->toBe('Kept Name'); - $bridge->assertNotCalled('Dialog.Toast'); + $bridge->assertNotCalled('ToastKit.Show'); })->with([ 'GitHub could not be reached' => [503, ['message' => 'Whatever devnull words it as.', 'code' => ErrorCode::GitHubSyncFailed->value], 'Could not sync from GitHub. Try again in a minute.'], 'synced a moment ago' => [429, ['message' => 'You just synced. Try again in a minute.', 'code' => 'rate_limited'], 'You just synced. Try again in a minute.'], diff --git a/tests/Feature/Screens/CommitDetailTest.php b/tests/Feature/Screens/CommitDetailTest.php index 2f0ee42..c99a0d8 100644 --- a/tests/Feature/Screens/CommitDetailTest.php +++ b/tests/Feature/Screens/CommitDetailTest.php @@ -186,7 +186,7 @@ function fakeThread(array $commit = [], array $replies = [], array $extra = []): ->assertSet('reply', '') ->assertSee('A fresh reply') ->assertNavigatedTo('/log/commits/r9') - ->assertNativeNotCalled('Dialog.Toast'); + ->assertNativeNotCalled('ToastKit.Show'); Http::assertSent(fn (Request $request): bool => $request->method() === 'POST' && $request->body() === '{"body":"A fresh reply","parent_id":"c2"}'); @@ -256,7 +256,7 @@ function fakeThread(array $commit = [], array $replies = [], array $extra = []): ->input('reply-input', 'Hold on') ->tap('send-reply') ->assertSee('pending') - ->assertNativeNotCalled('Dialog.Toast'); + ->assertNativeNotCalled('ToastKit.Show'); }); it('shows validation messages from the server', function () { @@ -323,7 +323,7 @@ function fakeThread(array $commit = [], array $replies = [], array $extra = []): $screen->press("confirmDelete('c2')") ->emitNative(ButtonPressed::class, ['index' => 1, 'label' => 'Delete', 'id' => 'delete-commit']) - ->assertNativeCalled('Dialog.Toast', fn (array $params): bool => $params['message'] === 'rm c2 → /dev/null') + ->assertNativeCalled('ToastKit.Show', fn (array $params): bool => $params['message'] === 'rm c2 → /dev/null') ->assertWentBack(); Http::assertSent(fn (Request $request): bool => $request->method() === 'DELETE' && $request->url() === 'http://devnull.test/api/v1/commits/c2'); diff --git a/tests/Feature/Screens/CommitMenuTest.php b/tests/Feature/Screens/CommitMenuTest.php index 9bb8750..4cc483c 100644 --- a/tests/Feature/Screens/CommitMenuTest.php +++ b/tests/Feature/Screens/CommitMenuTest.php @@ -85,7 +85,7 @@ $screen->tap('delete-c2') ->emitNative(ButtonPressed::class, ['index' => 1, 'label' => 'Delete', 'id' => 'delete-commit']) - ->assertNativeCalled('Dialog.Toast', fn (array $params): bool => $params['message'] === 'rm c2 → /dev/null') + ->assertNativeCalled('ToastKit.Show', fn (array $params): bool => $params['message'] === 'rm c2 → /dev/null') ->assertNoNavigation() ->assertDontSee('Delete me') ->assertSee('Keep me'); @@ -144,7 +144,7 @@ $screen->tap('delete-r1') ->emitNative(ButtonPressed::class, ['index' => 1, 'label' => 'Delete', 'id' => 'delete-commit']) - ->assertNativeCalled('Dialog.Toast', fn (array $params): bool => $params['message'] === 'rm r1 → /dev/null') + ->assertNativeCalled('ToastKit.Show', fn (array $params): bool => $params['message'] === 'rm r1 → /dev/null') ->assertNoNavigation() ->assertDontSee('Soon gone') ->assertSee('Ada asks'); @@ -161,7 +161,7 @@ Native::visit('/log', platform: 'ios') ->tap('delete-c1') ->emitNative(ButtonPressed::class, ['index' => 1, 'label' => 'Delete', 'id' => 'delete-commit']) - ->assertNativeNotCalled('Dialog.Toast') + ->assertNativeNotCalled('ToastKit.Show') ->assertSee('Too Many Attempts.') ->assertSee('Still here'); }); @@ -190,7 +190,7 @@ expect(findNode($screen->tree(), fn (array $node): bool => ($node['ref'] ?? null) === 'pinned'))->toBeNull(); $screen->tap('pin-c2') - ->assertNativeCalled('Dialog.Toast', fn (array $params): bool => $params['message'] === 'Commit pinned to your profile.') + ->assertNativeCalled('ToastKit.Show', fn (array $params): bool => $params['message'] === 'Commit pinned to your profile.') ->assertNoNavigation(); Http::assertSent(fn (Request $request): bool => $request->method() === 'PUT' && $request->url() === 'http://devnull.test/api/v1/commits/c2/pin'); @@ -202,7 +202,7 @@ ->and(menuItems($screen, 'more-c2'))->toContain(['pin-c2', 'Unpin from profile', 'pin']); $screen->tap('pin-c2') - ->assertNativeCalled('Dialog.Toast', fn (array $params): bool => $params['message'] === 'Commit unpinned.'); + ->assertNativeCalled('ToastKit.Show', fn (array $params): bool => $params['message'] === 'Commit unpinned.'); Http::assertSent(fn (Request $request): bool => $request->method() === 'DELETE' && $request->url() === 'http://devnull.test/api/v1/commits/c2/pin'); diff --git a/tests/Feature/Screens/CommitSheetTest.php b/tests/Feature/Screens/CommitSheetTest.php index 5bd1372..ce455d9 100644 --- a/tests/Feature/Screens/CommitSheetTest.php +++ b/tests/Feature/Screens/CommitSheetTest.php @@ -137,7 +137,7 @@ function reportFormOnLog(array $reportAnswer = [null, 204]): TestableComponent $screen->assertSee('Report received. Moderators will review it privately.') ->assertDontSee('Send report') - ->assertNativeNotCalled('Dialog.Toast'); + ->assertNativeNotCalled('ToastKit.Show'); expect(sheetProps($screen))->toMatchArray(['visible' => true, 'detents' => '0.3']); diff --git a/tests/Feature/Screens/ComposeSheetTest.php b/tests/Feature/Screens/ComposeSheetTest.php index 319ca25..28b12c6 100644 --- a/tests/Feature/Screens/ComposeSheetTest.php +++ b/tests/Feature/Screens/ComposeSheetTest.php @@ -121,7 +121,7 @@ function sheetCommitDisabled(TestableComponent $screen): bool $screen->input('sheet-body', ' Shipped from the sheet. ') ->tap('sheet-commit') ->assertNoNavigation() - ->assertNativeNotCalled('Dialog.Toast') + ->assertNativeNotCalled('ToastKit.Show') ->assertSee('Shipped from the sheet.'); Http::assertSent(fn (Request $request): bool => $request->method() === 'POST' diff --git a/tests/Feature/Screens/IssueCommandsTest.php b/tests/Feature/Screens/IssueCommandsTest.php index 0fe7568..b0f48c1 100644 --- a/tests/Feature/Screens/IssueCommandsTest.php +++ b/tests/Feature/Screens/IssueCommandsTest.php @@ -190,7 +190,7 @@ function hasRef(TestableComponent $screen, string $ref): bool ->and(app(Stash::class)->get($this->userId, 'commit'))->toBe('') ->and(nodeByRef($screen, 'log')['id'])->toBe($list) ->and($screen->instance()->commits)->toHaveCount(1); - $bridge->assertNotCalled('Dialog.Toast'); + $bridge->assertNotCalled('ToastKit.Show'); $screen->tap('view-issue') ->assertNativeCalled('Browser.OpenInApp', fn (array $params): bool => $params['url'] === 'https://github.com/nunomaduro/dev-null.sh/issues/123'); @@ -209,7 +209,7 @@ function hasRef(TestableComponent $screen, string $ref): bool $screen = issueSheetOnLog()->input('sheet-body', '/feature Dark mode')->tap('sheet-open-issue')->dismissSheet('sheet'); finishRefreshes($screen) - ->assertNativeCalled('Dialog.Toast', fn (array $params): bool => $params['message'] === 'Opened issue #123.'); + ->assertNativeCalled('ToastKit.Show', fn (array $params): bool => $params['message'] === 'Opened issue #123.'); expect(app(Stash::class)->get($this->userId, 'commit'))->toBe('') ->and(nodeByRef($screen, 'sheet')['props']['visible'])->toBeFalse(); diff --git a/tests/Feature/Screens/PasteisTest.php b/tests/Feature/Screens/PasteisTest.php index 4217158..0cedd69 100644 --- a/tests/Feature/Screens/PasteisTest.php +++ b/tests/Feature/Screens/PasteisTest.php @@ -52,7 +52,7 @@ function pastelAnswer(array $commit, int $remaining, int $received = 1): array */ function pastelToasts(FakeBridge $bridge): array { - return array_map(fn (array $call): string => $call['params']['message'], $bridge->callsTo('Dialog.Toast')); + return array_map(fn (array $call): string => $call['params']['message'], $bridge->callsTo('ToastKit.Show')); } /** diff --git a/tests/Feature/Screens/PollVoteTest.php b/tests/Feature/Screens/PollVoteTest.php index abda290..7513c2a 100644 --- a/tests/Feature/Screens/PollVoteTest.php +++ b/tests/Feature/Screens/PollVoteTest.php @@ -87,7 +87,7 @@ function pollOnLog(array $commit = [], ?array $answer = null): TestableComponent expect(nodeByRef($screen, 'poll-vote-c1')['props'])->toMatchArray(['loading' => true, 'disabled' => true]); - finishRefreshes($screen)->assertNativeCalled('Dialog.Toast', fn (array $params): bool => $params['message'] === 'This poll has closed.'); + finishRefreshes($screen)->assertNativeCalled('ToastKit.Show', fn (array $params): bool => $params['message'] === 'This poll has closed.'); expect(nodeByRef($screen, 'poll-vote-c1')['props'])->not->toHaveKey('loading') ->and($screen->instance()->pollChoices)->toBe(['c1' => 'o1']); diff --git a/tests/Feature/Screens/QuoteTest.php b/tests/Feature/Screens/QuoteTest.php index 6d6ef38..a744f7d 100644 --- a/tests/Feature/Screens/QuoteTest.php +++ b/tests/Feature/Screens/QuoteTest.php @@ -141,7 +141,7 @@ function quoteButtonDisabled(TestableComponent $screen): bool expect(strpos($json, '"commit-q1"'))->toBeLessThan(strpos($json, '"commit-c1"')); $screen->assertSee('This, all of it.') - ->assertNativeNotCalled('Dialog.Toast') + ->assertNativeNotCalled('ToastKit.Show') ->assertNoNavigation(); // Once it's gone, the draft goes with it. diff --git a/tests/Feature/Screens/StarredTest.php b/tests/Feature/Screens/StarredTest.php index e67d34e..9171bad 100644 --- a/tests/Feature/Screens/StarredTest.php +++ b/tests/Feature/Screens/StarredTest.php @@ -219,7 +219,7 @@ function starredIds(TestableComponent $screen): array $screen = finishRefreshes(Native::visit('/profile/starred', 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') + ->assertNativeCalled('ToastKit.Show', fn (array $params): bool => $params['message'] === 'rm s2 → /dev/null') ->assertNoNavigation()->assertDontSee('Delete me'); expect(starredIds(finishRefreshes($screen)))->toBe(['s1']) ->and(array_column(kept('starred')['data'], 'id'))->toBe(['s1']); diff --git a/tests/Feature/Screens/ThisDeviceTest.php b/tests/Feature/Screens/ThisDeviceTest.php index 8056d18..58726e0 100644 --- a/tests/Feature/Screens/ThisDeviceTest.php +++ b/tests/Feature/Screens/ThisDeviceTest.php @@ -111,7 +111,7 @@ function deleteSent(): bool Native::visit('/profile/device') ->emitNative(ButtonPressed::class, ['index' => 1, 'label' => 'Delete', 'id' => 'delete-account']) ->assertNativeCalled('SecureStorage.Delete', fn (array $params): bool => $params['key'] === DeviceKey::StorageKey) - ->assertNativeNotCalled('Dialog.Toast') + ->assertNativeNotCalled('ToastKit.Show') ->assertReplacedWith('/onboarding'); Http::assertSent(fn (Request $request): bool => $request->method() === 'DELETE' From 7789cb234aadca87b2035c00e97073f0a5a2fb8b Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Sat, 24 Oct 2026 20:26:47 +0100 Subject: [PATCH 2/6] Send stars, commits, replies and messages through a local outbox Starring went through a synchronous request on the UI thread, and the composer and reply bar waited on devnull before closing. Now every one of these writes goes into a pending_writes table first, the screen shows it straight away, and the request goes from a background task. Nothing on the tap path touches the network. The answer comes back as a shared AsyncTask event, so whichever screen is showing handles it, not only the one the write came from. On success the row goes and the screen and ResponseCache take devnull's answer. On failure the screen and cache go back and an error toast says why: - A star flips back, and the toast offers Retry. - A commit or reply leaves the screen. Its text goes back into its stash and the failed row keeps the photos and poll. The screen that owns the composer opens it again with everything. Elsewhere the toast offers Edit, which opens the composer or the commit it was for. - A Remotes message stays in the thread as rejected with its inline Retry, and the toast has Retry too. Staged and rejected messages now survive leaving the thread. New commits and replies show as a dimmed "staged" row at the top of the Log or under their parent until the real one replaces them. When the Log opens, rows left over from a killed app are handled. Stars and messages are sent again since devnull can't do them twice (messages dedupe on client_id). Commits are not: the commit API has no idempotency key, so they're marked failed with a toast and Edit. A commit whose task times out says devnull may have it for the same reason. Signing out empties the outbox. Tests use LazilyRefreshDatabase now that screens touch the database. Co-Authored-By: Claude Opus 5.5 --- app/Devnull/Account.php | 1 + app/Devnull/Client.php | 11 + app/Devnull/Data/Commit.php | 14 + app/Devnull/FetchInBackground.php | 31 ++ app/Devnull/Outbox.php | 216 +++++++++ app/Devnull/ResponseCache.php | 59 ++- app/Models/PendingWrite.php | 207 +++++++++ app/NativeComponents/CommitDetail.php | 142 +++++- .../Concerns/AttachesToCommits.php | 70 +-- .../Concerns/OpensCommitSheet.php | 141 ++++-- .../Concerns/ShowsCommits.php | 40 +- app/NativeComponents/Concerns/SyncsOutbox.php | 412 ++++++++++++++++++ .../Concerns/WritesInBackground.php | 14 +- app/NativeComponents/Connections.php | 2 + app/NativeComponents/Conversation.php | 100 +++-- app/NativeComponents/Log.php | 82 +++- app/NativeComponents/NewOrg.php | 2 + app/NativeComponents/NewRemote.php | 2 + app/NativeComponents/Notifications.php | 2 + app/NativeComponents/Remotes.php | 2 + app/NativeComponents/Safety.php | 2 + app/NativeComponents/Starred.php | 20 +- app/NativeComponents/ThisDevice.php | 2 + ..._24_000000_create_pending_writes_table.php | 35 ++ .../views/native/commit-detail.blade.php | 2 +- .../partials/commit-row-content.blade.php | 8 +- .../native/partials/commit-sheet.blade.php | 5 +- .../native/partials/composer-tools.blade.php | 6 +- .../native/partials/thread-reply.blade.php | 8 +- tests/Feature/Screens/CommitDetailTest.php | 72 +-- tests/Feature/Screens/ComposeSheetTest.php | 26 +- tests/Feature/Screens/ComposerPhotosTest.php | 28 +- tests/Feature/Screens/LogTest.php | 11 +- tests/Feature/Screens/OutboxTest.php | 252 +++++++++++ tests/Feature/Screens/QuoteTest.php | 9 +- tests/TestCase.php | 7 + 36 files changed, 1806 insertions(+), 237 deletions(-) create mode 100644 app/Devnull/Outbox.php create mode 100644 app/Models/PendingWrite.php create mode 100644 app/NativeComponents/Concerns/SyncsOutbox.php create mode 100644 database/migrations/2026_10_24_000000_create_pending_writes_table.php create mode 100644 tests/Feature/Screens/OutboxTest.php diff --git a/app/Devnull/Account.php b/app/Devnull/Account.php index 17ff5f5..725e744 100644 --- a/app/Devnull/Account.php +++ b/app/Devnull/Account.php @@ -160,6 +160,7 @@ public function forget(): void { app(ResponseCache::class)->flush(); app(Stash::class)->forget($this->user()?->id); + app(Outbox::class)->forgetAll($this->user()?->id); Cache::forget(self::CacheKey); Cache::forget(self::UnreadKey); diff --git a/app/Devnull/Client.php b/app/Devnull/Client.php index c4da605..b7edc13 100644 --- a/app/Devnull/Client.php +++ b/app/Devnull/Client.php @@ -224,6 +224,17 @@ public function createCommit(string $body, ?string $parentId = null, ?string $qu return Commit::fromArray($commit); } + /** + * Signs a commit without sending it, for the outbox to send from the + * background, as createCommit() would send it (see Outbox). + * + * @param array{options: list, duration: int}|null $poll + */ + public function signedCommit(string $body, ?string $parentId = null, ?string $quotedCommitId = null, ?array $poll = null): SignedRequest + { + return $this->signedWrite('POST', 'commits', self::commitPayload($body, $parentId, $quotedCommitId, poll: $poll)); + } + /** * Signs a commit with images without sending it, for the background to * send while the sheet says it's under way, since photos take a while to diff --git a/app/Devnull/Data/Commit.php b/app/Devnull/Data/Commit.php index dd0cc7b..73c33dc 100644 --- a/app/Devnull/Data/Commit.php +++ b/app/Devnull/Data/Commit.php @@ -38,6 +38,12 @@ public const int PreviewLines = 8; + /** + * The status of a commit you made that devnull doesn't have yet, which + * stands in for it on the screen (see PendingWrite::stagedCommit()). + */ + public const string Staged = 'staged'; + /** * @param list $mentions * @param list $images @@ -297,6 +303,14 @@ public function isPending(): bool return $this->status === 'pending'; } + /** + * Whether this is a commit still on its way to devnull, standing in for the one it will become. + */ + public function isStaged(): bool + { + return $this->status === self::Staged; + } + public function isApproved(): bool { return $this->status === 'approved'; diff --git a/app/Devnull/FetchInBackground.php b/app/Devnull/FetchInBackground.php index 11795ee..bbb087c 100644 --- a/app/Devnull/FetchInBackground.php +++ b/app/Devnull/FetchInBackground.php @@ -2,6 +2,7 @@ namespace App\Devnull; +use App\Devnull\Exceptions\ApiException; use Illuminate\Http\Client\ConnectionException; use Native\Mobile\AsyncTask; @@ -32,6 +33,36 @@ final class FetchInBackground extends AsyncTask */ public const int ImagesWithin = 5; + /** + * What went wrong with a write this task sent, as the API would have + * thrown it on the UI thread, or null when devnull took it. A phone that + * couldn't reach devnull reads as not connecting, and an answer that + * isn't devnull's, such as a captive portal's page, as unexpected. + */ + public static function failureOf(mixed $result): ?ApiException + { + $result = is_array($result) ? $result : []; + $status = (int) ($result['status'] ?? 0); + $successful = $status >= 200 && $status < 300; + + return match (true) { + $successful && ($result['from_devnull'] ?? false) === true => null, + $status === 0 => ApiException::couldNotConnect((string) config('devnull.url')), + $successful => ApiException::unexpectedAnswer((string) config('devnull.url'), $status), + default => ApiException::fromParts($status, $result['json'] ?? null, (string) ($result['retry_after'] ?? '')), + }; + } + + /** + * The JSON devnull answered a write with, or an empty array for a write it answered with nothing. + * + * @return array + */ + public static function jsonOf(mixed $result): array + { + return is_array($result) && is_array($result['json'] ?? null) ? $result['json'] : []; + } + /** * @param array $headers * @return array{status: int, json: mixed, retry_after: string, from_devnull: bool} diff --git a/app/Devnull/Outbox.php b/app/Devnull/Outbox.php new file mode 100644 index 0000000..05bb5b4 --- /dev/null +++ b/app/Devnull/Outbox.php @@ -0,0 +1,216 @@ + $payload + */ + public function add(string $kind, ?string $target, array $payload, ?string $clientId = null): ?PendingWrite + { + $userId = $this->userId(); + + if ($userId === null) { + return null; + } + + return PendingWrite::query()->create([ + 'user_id' => $userId, + 'kind' => $kind, + 'target' => $target, + 'client_id' => $clientId ?? (string) Str::uuid(), + 'payload' => $payload, + 'status' => PendingWrite::Queued, + ]); + } + + /** + * It went, as the background task with this id. + */ + public function sending(PendingWrite $write, string $taskId): void + { + $write->forceFill([ + 'status' => PendingWrite::Sending, + 'task_id' => $taskId, + 'error' => null, + 'attempts' => $write->attempts + 1, + ])->save(); + } + + /** + * devnull turned it down, in its own words. + */ + public function failed(PendingWrite $write, string $error): void + { + $write->forceFill(['status' => PendingWrite::Failed, 'task_id' => null, 'error' => $error])->save(); + } + + public function forget(PendingWrite $write): void + { + $write->delete(); + } + + /** + * The write a background task was sending. + */ + public function forTask(string $taskId): ?PendingWrite + { + return $taskId === '' ? null : $this->mine()->where('task_id', $taskId)->first(); + } + + public function find(int $id): ?PendingWrite + { + return $this->mine()->whereKey($id)->first(); + } + + public function findByClientId(string $clientId): ?PendingWrite + { + return $this->mine()->where('client_id', $clientId)->first(); + } + + /** + * What's still waiting to go, or on its way, of a kind, about $target + * when given, oldest first. Failed writes aren't, unless asked for too. + * + * @return Collection + */ + public function pending(string $kind, ?string $target = null, bool $withFailed = false): Collection + { + return $this->mine() + ->where('kind', $kind) + ->when($target !== null, fn ($query) => $query->where('target', $target)) + ->whereIn('status', $withFailed ? [PendingWrite::Queued, PendingWrite::Sending, PendingWrite::Failed] : [PendingWrite::Queued, PendingWrite::Sending]) + ->orderBy('id') + ->get(); + } + + /** + * Commits on their way that reply to $parentId, or with null, new commits and quotes. + * + * @return Collection + */ + public function commitsUnder(?string $parentId): Collection + { + return $this->pending(PendingWrite::Commit)->filter(fn (PendingWrite $write): bool => $write->parentId() === $parentId)->values(); + } + + /** + * Whether a star or an unstar of the commit other than $write is still waiting or on its way, which has the last word. + */ + public function starsAfter(PendingWrite $write): bool + { + return $this->mine() + ->whereIn('kind', [PendingWrite::Star, PendingWrite::Unstar]) + ->where('target', $write->target) + ->where('id', '>', $write->id) + ->whereIn('status', [PendingWrite::Queued, PendingWrite::Sending]) + ->exists(); + } + + /** + * The commit devnull last turned down that was written under the draft $stashName, for its composer to have back. + */ + public function failedDraft(string $stashName): ?PendingWrite + { + return $this->mine() + ->where('kind', PendingWrite::Commit) + ->where('status', PendingWrite::Failed) + ->get() + ->filter(fn (PendingWrite $write): bool => $write->stashName() === $stashName) + ->last(); + } + + /** + * What was waiting or on its way when the app last closed: anything not + * sent from this run of the app, since a background task doesn't outlive + * the app, and one still on its way is known to it (see AsyncTaskRegistry). + * + * @return Collection + */ + public function leftovers(): Collection + { + return $this->mine() + ->whereIn('status', [PendingWrite::Queued, PendingWrite::Sending]) + ->orderBy('id') + ->get() + ->filter(fn (PendingWrite $write): bool => $write->task_id === null || AsyncTaskRegistry::scope($write->task_id) === null) + ->values(); + } + + /** + * Empties the outbox of a user signing out. + */ + public function forgetAll(?string $userId): void + { + if ($userId !== null) { + PendingWrite::query()->where('user_id', $userId)->delete(); + } + } + + /** + * @return Builder + */ + private function mine() + { + return PendingWrite::query()->where('user_id', $this->userId() ?? ''); + } + + private function userId(): ?string + { + $id = app(Account::class)->user()?->id; + + return $id === null || $id === '' ? null : $id; + } +} diff --git a/app/Devnull/ResponseCache.php b/app/Devnull/ResponseCache.php index 1adcd54..46ddacd 100644 --- a/app/Devnull/ResponseCache.php +++ b/app/Devnull/ResponseCache.php @@ -288,8 +288,63 @@ public function commitStarred(array $commit): void ? self::prepend($json, $commit) : [...$json, 'data' => self::without($json['data'] ?? [], $id)]); - $unstarred = array_diff($this->unstarredIds(), [$id]); - $this->store()->forever("{$userId} unstarred", array_slice($commit['starred'] ? $unstarred : [$id, ...$unstarred], 0, self::RememberedUnstars)); + $this->rememberUnstarred($id, ! $commit['starred']); + } + + /** + * Remembers a commit as unstarred on this phone, or forgets it was once it's starred again. + */ + private function rememberUnstarred(string $id, bool $unstarred): void + { + $userId = $this->userId(); + + if ($userId === null) { + return; + } + + $others = array_diff($this->unstarredIds(), [$id]); + $this->store()->forever("{$userId} unstarred", array_slice($unstarred ? [$id, ...$others] : $others, 0, self::RememberedUnstars)); + } + + /** + * You starred or unstarred a commit, before devnull has said so, or it + * turned that down and the star goes back. Every copy of the commit flips + * its star and moves its count to match, as the screen does (see + * Commit::withStar()), and your starred commits take it on top or let it + * go, as for devnull's answer. A commit starred with no copy kept anywhere + * but $copy, as one taken off your starred commits had before, waits for + * devnull's answer to go on top of them. + * + * @param array|null $copy + */ + public function starChanged(string $id, bool $starred, ?array $copy = null): void + { + $kept = $this->findCommit($id) ?? $copy; + $change = fn (array $commit): array => ($commit['starred'] ?? false) === $starred ? $commit : [ + ...$commit, + 'starred' => $starred, + 'stars_count' => max(0, (int) ($commit['stars_count'] ?? 0) + ($starred ? 1 : -1)), + ]; + + $this->patchCommit($id, $change); + + if ($starred && $kept === null) { + $this->rememberUnstarred($id, false); + + return; + } + + $this->commitStarred($change([...($kept ?? []), 'id' => $id])); + } + + /** + * Any kept copy of a commit, as devnull last sent it. + * + * @return array|null + */ + public function keptCommit(string $id): ?array + { + return $this->findCommit($id); } /** diff --git a/app/Models/PendingWrite.php b/app/Models/PendingWrite.php new file mode 100644 index 0000000..f016859 --- /dev/null +++ b/app/Models/PendingWrite.php @@ -0,0 +1,207 @@ + $payload + * @property string $status + * @property string|null $task_id + * @property string|null $error + * @property int $attempts + * @property CarbonImmutable|null $created_at + * @property CarbonImmutable|null $updated_at + */ +class PendingWrite extends Model +{ + public const string Star = 'star'; + + public const string Unstar = 'unstar'; + + public const string Commit = 'commit'; + + public const string Message = 'message'; + + public const string Queued = 'queued'; + + public const string Sending = 'sending'; + + public const string Failed = 'failed'; + + /** + * What a commit still on its way is called in its place on the screen, after its client id. + */ + public const string StagedPrefix = 'staged-'; + + protected $guarded = []; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'payload' => 'array', + 'attempts' => 'integer', + 'created_at' => 'immutable_datetime', + 'updated_at' => 'immutable_datetime', + ]; + } + + public function isStar(): bool + { + return in_array($this->kind, [self::Star, self::Unstar], true); + } + + public function isCommit(): bool + { + return $this->kind === self::Commit; + } + + public function isMessage(): bool + { + return $this->kind === self::Message; + } + + public function isFailed(): bool + { + return $this->status === self::Failed; + } + + /** + * Whether sending it again can't do it twice: a star or an unstar sets + * the star as it says, and devnull stores a message once per client id. + * A commit has nothing devnull could know it by, so it's never sent again + * without you. + */ + public function canBeSentAgain(): bool + { + return $this->isStar() || $this->isMessage(); + } + + public function body(): string + { + return is_string($this->payload['body'] ?? null) ? $this->payload['body'] : ''; + } + + public function parentId(): ?string + { + return is_string($this->payload['parent_id'] ?? null) ? $this->payload['parent_id'] : null; + } + + public function quotedCommitId(): ?string + { + return is_string($this->payload['quoted_commit_id'] ?? null) ? $this->payload['quoted_commit_id'] : null; + } + + /** + * The commit's poll as devnull takes it, or null with none. + * + * @return array{options: list, duration: int}|null + */ + public function poll(): ?array + { + $poll = $this->payload['poll'] ?? null; + + return is_array($poll) && is_array($poll['options'] ?? null) && is_int($poll['duration'] ?? null) ? $poll : null; + } + + /** + * The draft a commit was written under (see App\Devnull\Stash), which it goes back to if devnull turns it down. + */ + public function stashName(): ?string + { + return is_string($this->payload['stash'] ?? null) ? $this->payload['stash'] : null; + } + + /** + * Where a commit was written: the reply bar on a commit's page, or the composer sheet ('compose', 'quote' or 'reply'). + */ + public function composer(): string + { + return is_string($this->payload['composer'] ?? null) ? $this->payload['composer'] : 'compose'; + } + + /** + * The commit's photos still on the phone. Photos of a draft are deleted after a day (see DraftImages). + * + * @return list + */ + public function images(): array + { + $images = []; + + foreach (is_array($this->payload['images'] ?? null) ? $this->payload['images'] : [] as $image) { + if (is_array($image) && is_string($image['path'] ?? null) && is_file($image['path'])) { + $images[] = new DraftImage($image['path'], (string) ($image['mime_type'] ?? ''), (int) ($image['width'] ?? 0), (int) ($image['height'] ?? 0), (string) ($image['description'] ?? '')); + } + } + + return $images; + } + + /** + * @param list $images + * @return list + */ + public static function imagePayload(array $images): array + { + return array_map(fn (DraftImage $image): array => [ + 'path' => $image->path, + 'mime_type' => $image->mimeType, + 'width' => $image->width, + 'height' => $image->height, + 'description' => $image->description, + ], $images); + } + + /** + * The commit a commit on its way stands in as, by you, at once: what you + * wrote and its photos, marked staged, as the web's git words go, until + * devnull has it and the real one takes its place. + */ + public function stagedCommit(User $author): Commit + { + return new Commit( + id: self::StagedPrefix.$this->client_id, + body: $this->body(), + status: Commit::Staged, + author: $author, + createdAt: $this->created_at ?? CarbonImmutable::now(), + parentId: $this->parentId(), + images: array_map(fn (DraftImage $image, int $index) => $image->asCommitImage($index), $this->images(), array_keys($this->images())), + content: CommitBody::blocks($this->body(), null, []), + quotedCommitId: $this->quotedCommitId(), + ); + } + + public static function stagedId(string $clientId): string + { + return self::StagedPrefix.$clientId; + } +} diff --git a/app/NativeComponents/CommitDetail.php b/app/NativeComponents/CommitDetail.php index 20c1fc7..9f1b9c7 100644 --- a/app/NativeComponents/CommitDetail.php +++ b/app/NativeComponents/CommitDetail.php @@ -7,10 +7,12 @@ use App\Devnull\Data\CommitThread; use App\Devnull\Data\Page; use App\Devnull\Display; +use App\Devnull\Outbox; use App\Devnull\RowCache; use App\Devnull\Stash; use App\Devnull\Timeline; use App\Icons\Lucide; +use App\Models\PendingWrite; use App\NativeComponents\Concerns\OpensInTab; use App\NativeComponents\Concerns\RefreshesInBackground; use App\NativeComponents\Concerns\ShowsCommits; @@ -40,12 +42,22 @@ * The reply bar has no room for photos or a preview, so its expand button * opens the reply in the composer sheet, which has them (see replyInSheet()). * The draft goes with it, and back to the bar if the sheet closes unsent. + * + * A reply goes at once: the bar empties and the reply shows staged under the + * commit while it goes to devnull from the outbox (see SyncsOutbox), then the + * real one takes its place. If devnull turns it down, it leaves, and what you + * wrote is back in the bar, or in the sheet with its photos. Edit, on the + * toast of a reply or a quote turned down after you'd left, opens its commit + * here with it ($data['edit']). */ class CommitDetail extends NativeComponent { use OpensInTab; use RefreshesInBackground; - use ShowsCommits; + use ShowsCommits { + reopenFailedCommit as private reopenFailedCommitInSheet; + editFailedCommit as private editFailedCommitInSheet; + } use TalksToDevnull; public string $id = ''; @@ -81,8 +93,6 @@ class CommitDetail extends NativeComponent public string $reply = ''; - public bool $sendingReply = false; - /** * Whether the reply was stashed when the page opened, which the bar marks until you empty it or drop it. */ @@ -130,6 +140,7 @@ public function mount(): void $this->replyRestored = $this->reply !== ''; $this->showKept(); $this->revalidate(); + $this->editFromData(); } /** @@ -247,34 +258,39 @@ public function loadMoreReplies(): void $this->replies = [...array_values(array_filter($this->replies, fn (Commit $reply): bool => ! isset($loaded[$reply->id]))), ...$page->items]; $this->repliesCursor = $page->nextCursor; + $this->keepStagedReplies(); } } + /** + * Sends the reply in the bar: the bar empties, and the reply shows staged + * under the commit, at once, while it goes from the outbox. + */ public function sendReply(): void { $body = trim($this->reply); + $me = $this->me(); - if ($this->sendingReply || ! Commit::fits($body)) { + if ($me === null || $this->commit === null || $this->commit->isDeleted() || ! Commit::fits($body)) { return; } - $this->sendingReply = true; - - try { - $reply = $this->attempt(fn ($devnull) => $devnull->createCommit($body, $this->id)); - } finally { - $this->sendingReply = false; - } + $write = app(Outbox::class)->add(PendingWrite::Commit, $this->id, [ + 'body' => $body, + 'parent_id' => $this->id, + 'stash' => $this->replyStashName(), + 'composer' => 'bar', + ]); - if ($reply === null) { + if ($write === null) { return; } $this->reply = ''; $this->updatedReply(); - $this->replies = [...$this->replies, $reply]; $this->repliesShown = true; - $this->openCommit($reply->id); + $this->keepStagedReplies(); + $this->sendFromOutbox($write); } /** @@ -299,7 +315,7 @@ public function dropReplyStash(): void } /** - * A reply sent from the composer sheet goes under the commit, as one sent from the reply bar does. + * A reply sent from the composer sheet goes under the commit, staged, as one sent from the reply bar does. */ protected function showCommitted(Commit $commit): void { @@ -309,8 +325,61 @@ protected function showCommitted(Commit $commit): void $this->reply = ''; $this->replyRestored = false; - $this->replies = [...$this->replies, $commit]; $this->repliesShown = true; + $this->keepStagedReplies(); + } + + /** + * devnull has the reply: the real one takes the staged one's place. + */ + protected function showCommitWent(PendingWrite $write, Commit $commit): void + { + $staged = PendingWrite::stagedId($write->client_id); + + $this->replies = array_map(fn (Commit $reply): Commit => $reply->id === $staged ? $commit : $reply, $this->replies); + } + + /** + * devnull turned the reply down: the staged one leaves. + */ + protected function showCommitTurnedDown(PendingWrite $write): void + { + $this->keepStagedReplies(); + } + + /** + * A reply to this commit from the bar goes back into the bar, marked as + * restored, when the bar's free. One from the sheet opens the sheet again, + * with its photos. + */ + protected function reopenFailedCommit(PendingWrite $write): bool + { + if ($write->composer() !== 'bar') { + return $this->reopenFailedCommitInSheet($write); + } + + if ($write->parentId() !== $this->id || $this->sheetOpen || trim($this->reply) !== '') { + return false; + } + + $this->takeFailedReply($write); + + return true; + } + + /** + * Edit, on the toast of a reply from the bar: this commit's bar, or the commit it was for. + */ + protected function editFailedCommit(PendingWrite $write): void + { + if ($write->composer() !== 'bar' || $write->parentId() !== $this->id) { + $this->editFailedCommitInSheet($write); + + return; + } + + $this->sheetOpen = false; + $this->takeFailedReply($write); } /** @@ -356,6 +425,44 @@ protected function shownCommits(): iterable return [...($this->commit === null ? [] : [$this->commit]), ...$this->ancestors, ...$this->series, ...$this->replies, ...array_merge([], ...array_values($this->tombstoneReplies))]; } + /** + * Opened by Edit on the toast of a reply or a quote devnull turned down: its composer, with what it had. + */ + private function editFromData(): void + { + $id = $this->data('edit'); + $write = is_numeric($id) ? app(Outbox::class)->find((int) $id) : null; + + if ($write !== null && $write->isCommit() && $write->isFailed()) { + $this->editFailedCommit($write); + } + } + + /** + * The bar takes back a reply devnull turned down, and the outbox lets it go: it's a draft again. + */ + private function takeFailedReply(PendingWrite $write): void + { + $this->reply = $write->body(); + $this->replyRestored = true; + $this->focusReply = true; + $this->updatedReply(); + app(Outbox::class)->forget($write); + } + + /** + * Your replies to this commit still on their way go after the rest, + * staged, oldest first. Ones that went or were turned down while the page + * wasn't showing leave, and come back with devnull's replies if they went. + */ + private function keepStagedReplies(): void + { + $this->replies = [ + ...array_values(array_filter($this->replies, fn (Commit $reply): bool => ! $reply->isStaged())), + ...array_reverse($this->stagedCommits($this->id)), + ]; + } + /** * The commit and its replies as they were kept last time. Opened from a row * with nothing kept, it starts from what the row knew: the commit, and the @@ -373,6 +480,8 @@ private function showKept(): void if (($replies = $this->cached($this->threadPath().'/replies')) !== null) { $this->takeReplies($replies); } + + $this->keepStagedReplies(); } /** @@ -404,6 +513,7 @@ private function takeReplies(array $json): void $this->replies = [...$page->items, ...$later]; $this->repliesCursor = $later === [] ? $page->nextCursor : $this->repliesCursor; $this->repliesLoaded = true; + $this->keepStagedReplies(); } private function replyStashName(): string diff --git a/app/NativeComponents/Concerns/AttachesToCommits.php b/app/NativeComponents/Concerns/AttachesToCommits.php index 0aa3ac1..7c0f321 100644 --- a/app/NativeComponents/Concerns/AttachesToCommits.php +++ b/app/NativeComponents/Concerns/AttachesToCommits.php @@ -3,15 +3,11 @@ namespace App\NativeComponents\Concerns; use App\Devnull\Client; -use App\Devnull\Data\Commit; use App\Devnull\Data\DraftImage; use App\Devnull\Data\PollDraft; use App\Devnull\DraftImages; use App\Devnull\Exceptions\ApiException; use App\Devnull\Exceptions\InvalidDraftImage; -use App\Devnull\ResponseCache; -use App\Devnull\SignedRequest; -use App\Devnull\Toasts; use Closure; use Native\Mobile\Attributes\On; use Native\Mobile\Events\Camera\PermissionDenied; @@ -31,18 +27,13 @@ * Preview shows the draft as the commit it would become, by you, at once, and * takes the body devnull sends back, with its code and mentions, when it comes. * - * A commit with photos goes from the background, since photos take a while to - * upload, and Commit spins until devnull has it. Photos and a poll go with the - * sheet: closing it drops them, and deletes the photos from the phone, as - * sending them does. + * A commit with photos goes through the outbox like any other, given longer + * to upload (see SyncsOutbox). The photos go with it, and are deleted from + * the phone once devnull has them. Otherwise photos and a poll go with the + * sheet: closing it drops them, and deletes the photos from the phone. */ trait AttachesToCommits { - /** - * Photos take longer than a usual write to upload over a slow connection. - */ - public const int UploadTimeout = 120; - /** * @var list */ @@ -77,13 +68,6 @@ trait AttachesToCommits */ public ?array $sheetPreview = null; - /** - * @param Closure(Client): SignedRequest $sign - * @param Closure(array): void $done - * @param Closure(string, ?ApiException): void $failed - */ - abstract protected function sendInBackground(Closure $sign, Closure $done, Closure $failed, int $timeout = 30): void; - /** * @param array|null $payload * @param Closure(array): void $done @@ -136,7 +120,7 @@ public function cameraDenied(): void */ public function removePhoto(int $index): void { - if (! isset($this->sheetImages[$index]) || $this->sheetPosting) { + if (! isset($this->sheetImages[$index])) { return; } @@ -247,48 +231,12 @@ protected function sheetPollPayload(): ?array } /** - * Sends a commit with photos from the background. The sheet goes once devnull has it, as for any commit, or says - * why it didn't take it, keeping the photos. Should the sheet have gone by then, a toast says so instead. - * - * @param Closure(Commit): void $sent - */ - protected function commitPhotosFromSheet(string $body, ?Commit $parent, ?Commit $quoted, Closure $sent): void - { - $images = $this->sheetImages; - $this->sheetPosting = true; - $this->sheetError = null; - - $this->sendInBackground( - fn (Client $devnull): SignedRequest => $devnull->signedCommitWithImages($body, $images, $parent?->id, $quoted?->id), - function (array $json) use ($sent): void { - $this->sheetPosting = false; - $commit = is_array($json['data'] ?? null) ? $json['data'] : []; - - app(ResponseCache::class)->commitAdded($commit); - $sent(Commit::fromArray($commit)); - }, - function (string $message): void { - $this->sheetPosting = false; - - if ($this->isComposing()) { - $this->sheetError = $message; - } else { - Toasts::failed($message); - } - }, - self::UploadTimeout, - ); - } - - /** - * Lets go of the photos, the poll and the preview. Photos still on the phone are deleted, unless they're on - * their way to devnull. + * Lets go of the photos, the poll and the preview. Photos still on the phone are deleted. Ones on their way to + * devnull have left the sheet by then. */ protected function forgetAttachments(): void { - if (! $this->sheetPosting) { - app(DraftImages::class)->discard($this->sheetImages); - } + app(DraftImages::class)->discard($this->sheetImages); $this->sheetImages = []; $this->describing = null; @@ -300,7 +248,7 @@ protected function forgetAttachments(): void private function canAttachPhotos(): bool { - return $this->isComposing() && $this->sheetPoll === null && ! $this->sheetPosting && count($this->sheetImages) < DraftImages::MaxImages; + return $this->isComposing() && $this->sheetPoll === null && count($this->sheetImages) < DraftImages::MaxImages; } /** diff --git a/app/NativeComponents/Concerns/OpensCommitSheet.php b/app/NativeComponents/Concerns/OpensCommitSheet.php index 397ade4..4a42c87 100644 --- a/app/NativeComponents/Concerns/OpensCommitSheet.php +++ b/app/NativeComponents/Concerns/OpensCommitSheet.php @@ -5,8 +5,11 @@ use App\Devnull\Client; use App\Devnull\Data\Commit; use App\Devnull\Data\Draft; +use App\Devnull\Data\PollDraft; +use App\Devnull\Outbox; use App\Devnull\ReportReason; use App\Devnull\Stash; +use App\Models\PendingWrite; use Closure; /** @@ -17,13 +20,16 @@ * Quote opens the same sheet as a composer, over the commit being quoted, as * the web's Quote dialog. The Log opens it with nothing to quote, for a new * commit of your own, so you can write one without leaving where you are in - * it. Once devnull has the commit the sheet goes, and a screen that lists - * your new commits puts it in place (see showCommitted()). + * it. Commit closes the sheet at once: the commit goes through the outbox + * (see SyncsOutbox), and a screen that lists your new commits puts it in + * place, staged, until devnull has it (see showCommitted()). * * What you write in it is stashed as you type, as on the web (see Stash): a * quote per commit quoted, and a new commit under the web's composer draft. * Opened again, it starts from the draft, marked as restored. It's forgotten - * once it's sent. + * once it's sent. If devnull turns the commit down, it goes back to its + * draft, photos and poll too, and the screen it was written on opens the + * sheet with it again (see reopenFailedCommit()). * * A new commit's draft can become a `/feature` or `/bug` issue, as on the web * (see DraftsIssues). @@ -42,6 +48,7 @@ trait OpensCommitSheet { use AttachesToCommits; use DraftsIssues; + use SyncsOutbox; /** * Whether the sheet is up, or on its way up. @@ -71,8 +78,6 @@ trait OpensCommitSheet */ public string $sheetBody = ''; - public bool $sheetPosting = false; - /** * Whether the sheet opened on a stashed draft, which it marks until you empty it or drop it. */ @@ -203,7 +208,8 @@ public function dropSheetStash(): void /** * Commits what's written in the sheet, quoting its commit if it's a quote, or replying to it, with its photos or - * its poll. The sheet goes once devnull has it, or keeps the draft with why it didn't take it. + * its poll. The sheet goes at once, and the commit shows staged where the screen lists your new ones, while it + * goes to devnull from the outbox (see SyncsOutbox). The photos go with it, so closing the sheet keeps them. */ public function commitFromSheet(): void { @@ -211,34 +217,31 @@ public function commitFromSheet(): void $quoted = $this->sheetView === 'quote' ? $this->sheetCommit : null; $stashName = $this->sheetStashName(); $body = trim($this->sheetBody); + $me = $this->me(); - if (! $this->sheetOpen || $this->sheetPosting || $stashName === null || $this->sheetIssue !== null || ! Draft::canCommit($body, $this->sheetImages, $this->sheetPoll)) { + if (! $this->sheetOpen || $me === null || $stashName === null || $this->sheetIssue !== null || ! Draft::canCommit($body, $this->sheetImages, $this->sheetPoll)) { return; } - $sent = function (Commit $commit) use ($stashName): void { - app(Stash::class)->put($this->me()?->id, $stashName, ''); - $this->sheetOpen = false; - $this->showCommitted($commit); - }; - - if ($this->sheetImages !== []) { - $this->commitPhotosFromSheet($body, $parent, $quoted, $sent); - + $write = app(Outbox::class)->add(PendingWrite::Commit, $parent?->id ?? $quoted?->id, [ + 'body' => $body, + 'parent_id' => $parent?->id, + 'quoted_commit_id' => $quoted?->id, + 'poll' => $this->sheetPollPayload(), + 'images' => PendingWrite::imagePayload($this->sheetImages), + 'stash' => $stashName, + 'composer' => $this->sheetView, + ]); + + if ($write === null) { return; } - $this->sheetPosting = true; - - try { - $commit = $this->attemptInSheet(fn (Client $devnull): Commit => $devnull->createCommit($body, $parent?->id, $quoted?->id, $this->sheetPollPayload())); - } finally { - $this->sheetPosting = false; - } - - if ($commit !== null) { - $sent($commit); - } + app(Stash::class)->put($me->id, $stashName, ''); + $this->sheetImages = []; + $this->sheetOpen = false; + $this->showCommitted($write->stagedCommit($me)); + $this->sendFromOutbox($write); } /** @@ -262,7 +265,8 @@ public function commitSheetClosed(): void } /** - * A commit you just made, for a screen to put where it lists your new ones. Most don't. + * A commit you just made, staged until devnull has it, for a screen to put where it lists your new ones. Most + * don't. */ protected function showCommitted(Commit $commit): void { @@ -277,11 +281,69 @@ protected function commitSheetGone(string $view): void // } + /** + * A commit devnull turned down goes back into the sheet it was written in, + * on this screen, if the sheet's free: a new commit on the screen that + * opens it (see opensComposer()), and a quote or a reply in the sheet + * where the commit it was for is showing. + */ + protected function reopenFailedCommit(PendingWrite $write): bool + { + if ($this->sheetOpen || ($write->composer() === 'compose' && ! $this->opensComposer())) { + return false; + } + + return $this->openFailedCommit($write); + } + + /** + * Edit, on the toast of a commit devnull turned down: its sheet, here if + * the commit it quotes or replies to is showing, or the commit's own page. + * A new commit's sheet opens anywhere it can. + */ + protected function editFailedCommit(PendingWrite $write): void + { + if ($this->openFailedCommit($write)) { + return; + } + + $for = $write->parentId() ?? $write->quotedCommitId(); + + if ($for !== null) { + $this->openInTab('/commits/'.$for, ['edit' => $write->id]); + } + } + + /** + * Whether this screen has the button for a new commit, which a new commit devnull turned down goes back to. + */ + protected function opensComposer(): bool + { + return false; + } + + /** + * Opens the sheet a commit devnull turned down was written in, with everything it had (see restoreSheetDraft()). + */ + private function openFailedCommit(PendingWrite $write): bool + { + $for = $write->parentId() ?? $write->quotedCommitId(); + + match ($write->composer()) { + 'compose' => $this->composeInSheet(), + 'quote' => $for === null ? null : $this->quoteCommit($for), + 'reply' => $for === null ? null : $this->replyInSheet($for), + default => null, + }; + + return $this->sheetOpen && $this->sheetStashName() === $write->stashName(); + } + private function canQuote(Commit $commit): bool { $viewer = $this->me(); - return $viewer !== null && ! $commit->isDeleted() && ($commit->isApproved() || $commit->author->id === $viewer->id); + return $viewer !== null && ! $commit->isDeleted() && ! $commit->isStaged() && ($commit->isApproved() || $commit->author->id === $viewer->id); } private function canReport(Commit $commit): bool @@ -318,14 +380,35 @@ private function sheetStashName(): ?string }; } + /** + * The sheet starts from its stashed draft. A commit devnull turned down + * that was written under it comes back whole, with its photos and poll, + * and leaves the outbox: from here it's a draft again. + */ private function restoreSheetDraft(): void { $name = $this->sheetStashName(); + $failed = $name === null ? null : app(Outbox::class)->failedDraft($name); + + if ($failed !== null) { + $this->takeFailedCommit($failed); + } $this->sheetBody = $name === null ? '' : app(Stash::class)->get($this->me()?->id, $name); $this->sheetRestored = $this->sheetBody !== ''; } + private function takeFailedCommit(PendingWrite $write): void + { + $poll = $write->poll(); + + app(Stash::class)->put($this->me()?->id, (string) $write->stashName(), $write->body()); + $this->sheetImages = $write->images(); + $this->sheetPoll = $poll === null || $this->sheetView === 'reply' ? null : $poll['options']; + $this->sheetPollLength = $poll === null ? PollDraft::DefaultLength : (string) (array_search($poll['duration'], PollDraft::Lengths, true) ?: PollDraft::DefaultLength); + app(Outbox::class)->forget($write); + } + /** * Like attempt(), but a failure shows in the sheet rather than where the screen prints its errors. * diff --git a/app/NativeComponents/Concerns/ShowsCommits.php b/app/NativeComponents/Concerns/ShowsCommits.php index ec313ba..0689240 100644 --- a/app/NativeComponents/Concerns/ShowsCommits.php +++ b/app/NativeComponents/Concerns/ShowsCommits.php @@ -111,6 +111,11 @@ public function openCommit(string $id): void { $commit = $this->shownCommit($id); + // A staged commit has no page until devnull has it. + if ($commit?->isStaged() === true) { + return; + } + $this->openInTab('/commits/'.$id, $commit === null ? [] : ['commit' => $commit]); } @@ -131,6 +136,10 @@ public function replyTo(string $id): void { $commit = $this->shownCommit($id); + if ($commit?->isStaged() === true) { + return; + } + $this->openInTab('/commits/'.$id, ['reply' => true, ...($commit === null ? [] : ['commit' => $commit])]); } @@ -145,9 +154,11 @@ public function showMore(string $id): void } /** - * Stars or unstars straight away, then takes the count devnull sends back, - * or puts the star back if it says no. Like the web, any visible commit - * can be starred, including your own and your pending commits. + * Stars or unstars straight away, here and in every kept copy, while the + * star goes to devnull from the outbox (see SyncsOutbox). The commit takes + * the count devnull sends back, or the star comes back off it if devnull + * says no. Like the web, any visible commit can be starred, including + * your own and your pending commits. */ public function toggleStar(string $id): void { @@ -157,9 +168,8 @@ public function toggleStar(string $id): void return; } - $this->react($commit, $commit->withStar(! $commit->starred), fn ($devnull) => $commit->starred - ? $devnull->unstar($id) - : $devnull->star($id)); + $this->showStar($id, ! $commit->starred); + $this->queueStar($id, ! $commit->starred); } /** @@ -311,7 +321,7 @@ public function shareCommit(string $id): void { $commit = $this->shownCommit($id); - if ($commit === null || $commit->isDeleted()) { + if ($commit === null || $commit->isDeleted() || $commit->isStaged()) { return; } @@ -461,7 +471,7 @@ private function pastelTurnedDown(string $id, ?ApiException $exception): void private function canReactTo(Commit $commit): bool { - return ! $commit->isDeleted() && ($commit->isApproved() || $commit->author->id === $this->me()?->id); + return ! $commit->isDeleted() && ! $commit->isStaged() && ($commit->isApproved() || $commit->author->id === $this->me()?->id); } private function canGivePastel(Commit $commit): bool @@ -476,19 +486,19 @@ private function canPin(Commit $commit): bool private function canDelete(Commit $commit): bool { - return ! $commit->isDeleted() && $commit->author->id === $this->me()?->id; + return ! $commit->isDeleted() && ! $commit->isStaged() && $commit->author->id === $this->me()?->id; } /** - * @param Closure(Client): Commit $call + * The commit shows your star as it now is, or with devnull's count when it answered ($fresh). */ - private function react(Commit $before, Commit $optimistic, Closure $call): void + protected function showStar(string $id, bool $starred, ?Commit $fresh = null): void { - $this->replaceShownCommit($optimistic); - - $fresh = $this->attempt($call); + $shown = $this->shownCommit($id); - $this->replaceShownCommit($fresh instanceof Commit ? $before->withReactionsFrom($fresh) : $before); + if ($shown !== null) { + $this->replaceShownCommit($fresh === null ? $shown->withStar($starred) : $shown->withReactionsFrom($fresh)); + } } private function replaceShownCommit(Commit $replacement): void diff --git a/app/NativeComponents/Concerns/SyncsOutbox.php b/app/NativeComponents/Concerns/SyncsOutbox.php new file mode 100644 index 0000000..dc1a1f5 --- /dev/null +++ b/app/NativeComponents/Concerns/SyncsOutbox.php @@ -0,0 +1,412 @@ +add($starred ? PendingWrite::Star : PendingWrite::Unstar, $id, $starred ? [] : ['kept' => $cache->keptCommit($id)]); + + $cache->starChanged($id, $starred); + + if ($write !== null) { + $this->sendFromOutbox($write); + } + } + + /** + * Sends a write from the outbox, from the background. Its answer comes + * back as Outbox::Answered to whichever screen is showing (see + * outboxAnswered()). The fake that runs tasks in tests has no screen to + * deliver to, so it answers this one directly. + */ + protected function sendFromOutbox(PendingWrite $write): void + { + try { + $request = $this->signWrite($write); + } catch (KeychainUnavailable $exception) { + $this->writeTurnedDown($write, $exception->getMessage()); + + return; + } + + $task = FetchInBackground::dispatch($request->url, $request->headers, $request->method, $request->body, false, null, $request->contentType); + $taskId = $task->getId(); + + app(Outbox::class)->sending($write, $taskId); + + $task->timeout($write->images() === [] ? Outbox::Timeout : Outbox::UploadTimeout) + ->shared(Outbox::Answered) + ->finished(fn (mixed $result) => $this->outboxAnswered($taskId, 'finished', $result)) + ->failed(fn (Throwable $exception) => $this->outboxAnswered($taskId, 'failed', null, $exception->getMessage())) + ->start(); + } + + /** + * devnull answered a write from the outbox, or it couldn't be sent. + */ + #[On(Outbox::Answered)] + public function outboxAnswered(string $id = '', string $status = 'finished', mixed $result = null, ?string $message = null): void + { + $write = app(Outbox::class)->forTask($id); + + if ($write === null) { + return; + } + + // The task gave up waiting, so devnull may have a commit without having said so. + if ($status === 'failed') { + $this->writeTurnedDown($write, $write->isCommit() ? Outbox::NoAnswer : 'fatal: '.($message ?? 'the request did not finish.')); + + return; + } + + $exception = FetchInBackground::failureOf($result); + + if ($exception instanceof Unauthenticated) { + $this->signOut($exception); + + return; + } + + $exception === null + ? $this->writeWent($write, FetchInBackground::jsonOf($result)) + : $this->writeTurnedDown($write, $exception->firstError()); + } + + /** + * What was left in the outbox when the app last closed. Stars and + * messages go again, since devnull can't do either twice. A commit might + * have reached devnull, and has nothing it could be known by, so it's + * turned down with why, for you to check and send again yourself. + */ + protected function sendLeftovers(): void + { + foreach (app(Outbox::class)->leftovers() as $write) { + $write->canBeSentAgain() + ? $this->sendFromOutbox($write) + : $this->writeTurnedDown($write, Outbox::ClosedBeforeAnswer, reopen: false); + } + } + + /** + * The commits on their way that this screen would show, as they stand in for themselves (see + * PendingWrite::stagedCommit()): replies to $parentId, or with null, new commits and quotes. Newest first. + * + * @return list + */ + protected function stagedCommits(?string $parentId): array + { + $me = $this->me(); + + if ($me === null) { + return []; + } + + return app(Outbox::class)->commitsUnder($parentId) + ->reverse() + ->map(fn (PendingWrite $write): Commit => $write->stagedCommit($me)) + ->values() + ->all(); + } + + /** + * A toast's button, Retry or Edit, pressed on whichever screen is showing. + * + * @param array $data + */ + #[On(ToastActionPressed::class)] + public function toastActionPressed(string $toastId = '', string $actionId = '', array $data = []): void + { + match ($actionId) { + 'retry-star' => $this->retryStar($data), + 'retry-message' => $this->retryMessage($data), + 'edit' => $this->editFromToast($data), + default => null, + }; + } + + /** + * The star devnull took: the commit takes devnull's count, unless you've starred or unstarred it again since, + * which has the last word. + * + * @param array $json + */ + private function writeWent(PendingWrite $write, array $json): void + { + $outbox = app(Outbox::class); + $data = is_array($json['data'] ?? null) ? $json['data'] : []; + + if ($write->isStar()) { + $later = $outbox->starsAfter($write); + $outbox->forget($write); + + if (! $later && $data !== []) { + app(ResponseCache::class)->commitReacted($data); + app(ResponseCache::class)->commitStarred($data); + $this->showStar((string) $write->target, ($data['starred'] ?? false) === true, Commit::fromArray($data)); + } + + return; + } + + $outbox->forget($write); + + if ($write->isCommit()) { + app(ResponseCache::class)->commitAdded($data); + app(DraftImages::class)->discard($write->images()); + $this->showCommitWent($write, Commit::fromArray($data)); + + return; + } + + $this->showMessage($write, $json); + } + + /** + * devnull turned the write down, or it couldn't go: the screen and the kept copies go back, and a toast says why. + */ + private function writeTurnedDown(PendingWrite $write, string $error, bool $reopen = true): void + { + $outbox = app(Outbox::class); + + if ($write->isStar()) { + $later = $outbox->starsAfter($write); + $outbox->forget($write); + $starred = $write->kind === PendingWrite::Star; + + if ($later) { + return; + } + + app(ResponseCache::class)->starChanged((string) $write->target, ! $starred, is_array($write->payload['kept'] ?? null) ? $write->payload['kept'] : null); + $this->showStar((string) $write->target, ! $starred); + + Toasts::failed($error, ['label' => 'Retry', 'id' => 'retry-star', 'data' => ['commit' => $write->target, 'starred' => $starred]]); + + return; + } + + $outbox->failed($write, $error); + + if ($write->isMessage()) { + $this->showMessage($write, null); + + Toasts::failed($error, ['label' => 'Retry', 'id' => 'retry-message', 'data' => ['write' => $write->id]]); + + return; + } + + $this->turnDownCommit($write, $error, $reopen); + } + + /** + * A commit devnull turned down leaves the screen, and goes back to its + * draft: its text into its stash, unless you've started another there, + * and the rest stays with the failed write for the composer to take. Its + * composer opens again if it's this screen's, unless it's left over from + * when the app closed, which waits for you to press Edit. + */ + private function turnDownCommit(PendingWrite $write, string $error, bool $reopen): void + { + $name = $write->stashName(); + $stash = app(Stash::class); + + if ($name !== null && $stash->get($this->me()?->id, $name) === '') { + $stash->put($this->me()?->id, $name, $write->body()); + } + + $this->showCommitTurnedDown($write); + + if ($reopen && $this->reopenFailedCommit($write)) { + Toasts::failed($error); + + return; + } + + Toasts::failed($error, ['label' => 'Edit', 'id' => 'edit', 'data' => ['write' => $write->id]]); + } + + /** + * Signs a write as devnull takes it, here on the UI thread. + */ + private function signWrite(PendingWrite $write): SignedRequest + { + $devnull = app(Client::class); + $commit = 'commits/'.rawurlencode((string) $write->target).'/star'; + + return match ($write->kind) { + PendingWrite::Star => $devnull->signedWrite('POST', $commit), + PendingWrite::Unstar => $devnull->signedWrite('DELETE', $commit), + PendingWrite::Message => $devnull->signedWrite('POST', $write->target.'/messages', array_filter([ + 'body' => $write->body(), + 'client_id' => $write->client_id, + 'reply_to_id' => $write->payload['reply_to_id'] ?? null, + ], fn (mixed $value): bool => $value !== null)), + default => $write->images() === [] + ? $devnull->signedCommit($write->body(), $write->parentId(), $write->quotedCommitId(), $write->poll()) + : $devnull->signedCommitWithImages($write->body(), $write->images(), $write->parentId(), $write->quotedCommitId()), + }; + } + + /** + * Retry on a star's toast: the star goes again as you meant it. + * + * @param array $data + */ + private function retryStar(array $data): void + { + if (! is_string($data['commit'] ?? null) || ! is_bool($data['starred'] ?? null)) { + return; + } + + $this->showStar($data['commit'], $data['starred']); + $this->queueStar($data['commit'], $data['starred']); + } + + /** + * Retry on a message's toast, as the thread's own Retry does: it goes again under the same client id. + * + * @param array $data + */ + private function retryMessage(array $data): void + { + $write = is_numeric($data['write'] ?? null) ? app(Outbox::class)->find((int) $data['write']) : null; + + if ($write !== null) { + $this->resendMessage($write); + } + } + + /** + * Sends a message devnull turned down again, under the same client id, staged once more while it goes. + */ + protected function resendMessage(PendingWrite $write): void + { + if (! $write->isMessage() || ! $write->isFailed()) { + return; + } + + $write->status = PendingWrite::Queued; + $this->showMessage($write, null); + $this->sendFromOutbox($write); + } + + /** + * Edit on a commit's toast: its composer again, with everything it had. + * + * @param array $data + */ + private function editFromToast(array $data): void + { + $write = is_numeric($data['write'] ?? null) ? app(Outbox::class)->find((int) $data['write']) : null; + + if ($write !== null && $write->isCommit() && $write->isFailed()) { + $this->editFailedCommit($write); + } + } + + /** + * Your star on a commit changed: by you, by devnull turning it down, or as devnull counted it ($fresh). + */ + protected function showStar(string $id, bool $starred, ?Commit $fresh = null): void {} + + /** + * devnull has a commit you made: the real one takes the staged one's place. + */ + protected function showCommitWent(PendingWrite $write, Commit $commit): void {} + + /** + * devnull turned a commit down: the staged one leaves. + */ + protected function showCommitTurnedDown(PendingWrite $write): void {} + + /** + * Opens the composer a commit devnull turned down was written in, with + * everything it had, when it's this screen's and it isn't busy, so you're + * back where you were. Answers whether it did. + */ + protected function reopenFailedCommit(PendingWrite $write): bool + { + return false; + } + + /** + * Edit, pressed for a commit devnull turned down: its composer, here when + * this screen can open it. A screen with no composer opens the commit a + * reply or a quote was for, whose page opens it. A new commit's draft + * waits in the stash for the Log's compose button. + */ + protected function editFailedCommit(PendingWrite $write): void + { + $for = $write->parentId() ?? $write->quotedCommitId(); + + if ($for !== null && method_exists($this, 'openInTab')) { + $this->openInTab('/commits/'.$for, ['edit' => $write->id]); + } + } + + /** + * A message you pushed went, with devnull's answer, or didn't, with null. + * + * @param array|null $json + */ + protected function showMessage(PendingWrite $write, ?array $json): void {} +} diff --git a/app/NativeComponents/Concerns/WritesInBackground.php b/app/NativeComponents/Concerns/WritesInBackground.php index fa7fa00..d7cb0b4 100644 --- a/app/NativeComponents/Concerns/WritesInBackground.php +++ b/app/NativeComponents/Concerns/WritesInBackground.php @@ -56,22 +56,14 @@ protected function sendInBackground(Closure $sign, Closure $done, Closure $faile FetchInBackground::dispatch($request->url, $request->headers, $request->method, $request->body, false, null, $request->contentType) ->timeout($timeout) ->finished(function (mixed $result) use ($done, $failed): void { - $result = is_array($result) ? $result : []; - $status = (int) ($result['status'] ?? 0); - $successful = $status >= 200 && $status < 300; + $exception = FetchInBackground::failureOf($result); - if ($successful && ($result['from_devnull'] ?? false) === true) { - $done(is_array($result['json'] ?? null) ? $result['json'] : []); + if ($exception === null) { + $done(FetchInBackground::jsonOf($result)); return; } - $exception = match (true) { - $status === 0 => ApiException::couldNotConnect((string) config('devnull.url')), - $successful => ApiException::unexpectedAnswer((string) config('devnull.url'), $status), - default => ApiException::fromParts($status, $result['json'] ?? null, (string) ($result['retry_after'] ?? '')), - }; - if ($exception instanceof Unauthenticated) { $this->signOut($exception); diff --git a/app/NativeComponents/Connections.php b/app/NativeComponents/Connections.php index fb00ac6..519b338 100644 --- a/app/NativeComponents/Connections.php +++ b/app/NativeComponents/Connections.php @@ -10,6 +10,7 @@ use App\Devnull\FreshPage; use App\NativeComponents\Concerns\OpensInTab; use App\NativeComponents\Concerns\RefreshesInBackground; +use App\NativeComponents\Concerns\SyncsOutbox; use App\NativeComponents\Concerns\TalksToDevnull; use Illuminate\View\View; use Native\Mobile\Edge\NativeComponent; @@ -33,6 +34,7 @@ class Connections extends NativeComponent { use OpensInTab; use RefreshesInBackground; + use SyncsOutbox; use TalksToDevnull; public const array Types = ['followers', 'following']; diff --git a/app/NativeComponents/Conversation.php b/app/NativeComponents/Conversation.php index e5e4ed5..213feaa 100644 --- a/app/NativeComponents/Conversation.php +++ b/app/NativeComponents/Conversation.php @@ -8,9 +8,12 @@ use App\Devnull\Data\Thread; use App\Devnull\Data\User; use App\Devnull\Display; +use App\Devnull\Outbox; use App\Icons\Lucide; +use App\Models\PendingWrite; use App\NativeComponents\Concerns\OpensInTab; use App\NativeComponents\Concerns\RefreshesInBackground; +use App\NativeComponents\Concerns\SyncsOutbox; use App\NativeComponents\Concerns\TalksToDevnull; use App\NativeComponents\Concerns\WritesInBackground; use Carbon\CarbonImmutable; @@ -37,7 +40,11 @@ * * A message you push shows staged at once, then pushed once devnull has it, * or rejected with a retry, which sends it again under the same client id so - * it's never stored twice. A long press on a message opens its reactions and + * it's never stored twice. It goes through the outbox (see SyncsOutbox), so + * it's still staged or rejected when you come back to the thread, and one + * still on its way when the app closed goes again when it next opens. A + * toast says why devnull didn't take one, wherever you are by then, with a + * Retry of its own. A long press on a message opens its reactions and * Reply, and a double tap toggles a heart, as on the web's phone layout. * * When the composer can't push (a request to merge, your own request waiting, @@ -48,6 +55,7 @@ class Conversation extends NativeComponent { use OpensInTab; use RefreshesInBackground; + use SyncsOutbox; use TalksToDevnull; use WritesInBackground; @@ -96,7 +104,7 @@ class Conversation extends NativeComponent public array $messages = []; /** - * Messages you pushed that devnull hasn't taken yet, staged or rejected, after the rest. + * Messages you pushed that devnull hasn't taken yet, staged or rejected, after the rest, as the outbox has them. * * @var list */ @@ -192,6 +200,7 @@ public function mount(): void $row = $this->data('conversation'); $this->row = $row instanceof ConversationData ? $row : null; $this->showKept(); + $this->showPending(); $this->revalidate(); $this->refresh('remotes/reactions', [], fn (array $json) => $this->usage = self::usageFrom($json), quiet: true); } @@ -200,6 +209,7 @@ public function onResume(): void { $this->forgetRefreshes(); $this->showKept(); + $this->showPending(); $this->revalidate(); } @@ -279,6 +289,11 @@ public function send(): void } $message = Message::staged($me->id, (string) Str::uuid(), $body, $this->findMessage($this->replyToId)); + $write = app(Outbox::class)->add(PendingWrite::Message, $this->threadPath(), ['body' => $body, 'reply_to_id' => $message->replyToId], $message->clientId); + + if ($write === null) { + return; + } $this->pending[] = $message; $this->draft = ''; @@ -286,7 +301,7 @@ public function send(): void $this->lastActivityAt = self::now(); $this->lastTypingAt = 0; - $this->push($message); + $this->sendFromOutbox($write); } /** @@ -294,11 +309,10 @@ public function send(): void */ public function retry(string $clientId): void { - foreach ($this->pending as $index => $message) { - if ($message->clientId === $clientId && $message->status === Message::Rejected) { - $this->pending[$index] = $message->withStatus(Message::Staged); - $this->push($this->pending[$index]); - } + $write = app(Outbox::class)->findByClientId($clientId); + + if ($write !== null) { + $this->resendMessage($write); } } @@ -510,31 +524,65 @@ private function answerRequest(string $answer): void }); } - private function push(Message $message): void + /** + * A message you pushed to this thread went, with devnull's answer: the + * real one shows in its place. Or it didn't, or it's going again, and it + * shows as the outbox has it now: rejected, or staged. + * + * @param array|null $json + */ + protected function showMessage(PendingWrite $write, ?array $json): void { - $payload = ['body' => $message->body, 'client_id' => $message->clientId]; + if ($write->target !== $this->threadPath()) { + return; + } - if ($message->replyToId !== null) { - $payload['reply_to_id'] = $message->replyToId; + if ($json === null) { + $status = $write->isFailed() ? Message::Rejected : Message::Staged; + $this->pending = array_map( + fn (Message $pending): Message => $pending->clientId === $write->client_id ? $pending->withStatus($status) : $pending, + $this->pending, + ); + + return; } - $this->writeInBackground('POST', $this->messagesPath(), $payload, function (array $json) use ($message): void { - $pushed = Message::fromArray(is_array($json['data'] ?? null) ? $json['data'] : []); + $pushed = Message::fromArray(is_array($json['data'] ?? null) ? $json['data'] : []); - $this->pending = array_values(array_filter($this->pending, fn (Message $pending): bool => $pending->clientId !== $message->clientId)); + $this->pending = array_values(array_filter($this->pending, fn (Message $pending): bool => $pending->clientId !== $write->client_id)); - if ($pushed->id > 0) { - $this->messages = self::merged($this->messages, [$pushed]); - $this->readThroughId = max($this->readThroughId, $pushed->id); - } + if ($pushed->id > 0) { + $this->messages = self::merged($this->messages, [$pushed]); + $this->readThroughId = max($this->readThroughId, $pushed->id); + } - $this->lastActivityAt = self::now(); - }, function () use ($message): void { - $this->pending = array_map( - fn (Message $pending): Message => $pending->clientId === $message->clientId ? $pending->withStatus(Message::Rejected) : $pending, - $this->pending, - ); - }); + $this->lastActivityAt = self::now(); + } + + /** + * The messages you pushed to this thread that devnull hasn't taken, from + * the outbox: staged while they're on their way, rejected once devnull + * turned them down. Ones devnull has sent back already show as pushed. + */ + private function showPending(): void + { + $me = $this->me(); + + if ($me === null) { + return; + } + + $this->pending = app(Outbox::class)->pending(PendingWrite::Message, $this->threadPath(), withFailed: true) + ->map(function (PendingWrite $write) use ($me): Message { + $replyTo = $write->payload['reply_to_id'] ?? null; + $message = Message::staged($me->id, $write->client_id, $write->body(), is_int($replyTo) ? $this->findMessage($replyTo) : null); + + return $write->isFailed() ? $message->withStatus(Message::Rejected) : $message; + }) + ->values() + ->all(); + + $this->dropPushed(); } /** diff --git a/app/NativeComponents/Log.php b/app/NativeComponents/Log.php index 04e28ad..96de3ee 100644 --- a/app/NativeComponents/Log.php +++ b/app/NativeComponents/Log.php @@ -17,6 +17,7 @@ use App\Devnull\Timeline; use App\Icons\Android; use App\Icons\Ios; +use App\Models\PendingWrite; use App\NativeComponents\Concerns\DrawsSkeletonFirst; use App\NativeComponents\Concerns\ExpandsThreads; use App\NativeComponents\Concerns\HoldsNewCommits; @@ -240,10 +241,15 @@ class Log extends NativeComponent */ private bool $taskLanded = false; + /** + * The log is the first screen the app opens on, so it sends what was + * left in the outbox when the app last closed (see SyncsOutbox). + */ public function mount(): void { $this->log = app(ShownTabs::class)->get($this->me()?->id, 'log', self::Logs, 'following'); $this->lastVisitAt = $this->lastVisitSeenAt(); + $this->sendLeftovers(); $this->fillInAfterFirstFrame('open'); } @@ -441,7 +447,7 @@ public function showNewCommits(): void protected function revalidate(): void { $log = $this->log; - $since = $this->isRanked() ? $this->rankedSince : ($this->commits[0]->id ?? null); + $since = $this->isRanked() ? $this->rankedSince : $this->newestShownId(); $this->refresh('log/'.$log, ['since' => $since], fn (array $json) => $this->isRanked() ? $this->takeRankedUpdate($log, $json) @@ -713,22 +719,88 @@ public function followSuggested(string $username): void } /** - * Puts a commit you just made in the compose sheet at the top of the log. + * Puts a commit you just made in the compose sheet at the top of the log, + * staged until devnull has it (see SyncsOutbox). */ protected function showCommitted(Commit $commit): void { $this->madeHere[] = $commit->id; $this->loaded = true; + $this->keepStagedOnTop(); // Trending has no top to put it on, so it goes above the ranking, as on the web. if ($this->isRanked()) { - $this->latestCommits = [$commit, ...$this->latestCommits]; $this->listKey++; + } + } + + /** + * devnull has the commit: the real one takes the staged one's place. + */ + protected function showCommitWent(PendingWrite $write, Commit $commit): void + { + $staged = PendingWrite::stagedId($write->client_id); + $swap = fn (Commit $shown): Commit => $shown->id === $staged ? $commit : $shown; + + $this->madeHere[] = $commit->id; + $this->commits = array_map($swap, $this->commits); + $this->latestCommits = array_map($swap, $this->latestCommits); + } + + /** + * devnull turned the commit down: the staged one leaves the log. + */ + protected function showCommitTurnedDown(PendingWrite $write): void + { + $this->keepStagedOnTop(); + } + + /** + * The compose button is the log's, so a new commit devnull turned down opens here again. + */ + protected function opensComposer(): bool + { + return true; + } + + /** + * Your commits still on their way stay at the top of the log you're on, + * or above Trending's ranking, newest first, through every fresh page, and + * when you come back to the log. Ones that went or were turned down while + * the log wasn't showing leave it, and a commit that went comes in with + * the log's next answer. + */ + private function keepStagedOnTop(): void + { + $staged = $this->stagedCommits(null); + $settled = fn (Commit $commit): bool => ! $commit->isStaged(); + + foreach ($staged as $commit) { + $this->madeHere[] = $commit->id; + } + + if ($this->isRanked()) { + $this->latestCommits = [...$staged, ...array_values(array_filter($this->latestCommits, $settled))]; + $this->commits = array_values(array_filter($this->commits, $settled)); return; } - $this->commits = [$commit, ...$this->commits]; + $this->commits = [...$staged, ...array_values(array_filter($this->commits, $settled))]; + } + + /** + * The newest commit showing that devnull has, which it's asked what came after. + */ + private function newestShownId(): ?string + { + foreach ($this->commits as $commit) { + if (! $commit->isStaged()) { + return $commit->id; + } + } + + return null; } /** @@ -794,6 +866,7 @@ private function takeRanking(string $log, array $json, bool $prefetch = true): v $this->threads = []; $this->rankedSince = is_string($json['newest_commit_id'] ?? null) ? $json['newest_commit_id'] : null; $this->forgetNewCommits(); + $this->keepStagedOnTop(); $this->loaded = true; if ($prefetch) { @@ -933,6 +1006,7 @@ private function takeFresh(string $log, array $json, bool $showNewer = false): v } $this->layFreshCommits($json, showNewer: $showNewer || ! $this->loaded); + $this->keepStagedOnTop(); $this->suggestions = array_values(array_map(Commit::fromArray(...), array_filter(is_array($json['suggestions'] ?? null) ? $json['suggestions'] : [], is_array(...)))); $this->loaded = true; $this->rememberLastVisit(); diff --git a/app/NativeComponents/NewOrg.php b/app/NativeComponents/NewOrg.php index e39b694..dfda2d9 100644 --- a/app/NativeComponents/NewOrg.php +++ b/app/NativeComponents/NewOrg.php @@ -9,6 +9,7 @@ use App\Devnull\Exceptions\Unauthenticated; use App\NativeComponents\Concerns\OpensInTab; use App\NativeComponents\Concerns\SearchesPeople; +use App\NativeComponents\Concerns\SyncsOutbox; use App\NativeComponents\Concerns\TalksToDevnull; use Illuminate\View\View; use Native\Mobile\Edge\NativeComponent; @@ -23,6 +24,7 @@ class NewOrg extends NativeComponent { use OpensInTab; use SearchesPeople; + use SyncsOutbox; use TalksToDevnull; /** diff --git a/app/NativeComponents/NewRemote.php b/app/NativeComponents/NewRemote.php index d449f44..91bfd22 100644 --- a/app/NativeComponents/NewRemote.php +++ b/app/NativeComponents/NewRemote.php @@ -4,6 +4,7 @@ use App\NativeComponents\Concerns\OpensInTab; use App\NativeComponents\Concerns\SearchesPeople; +use App\NativeComponents\Concerns\SyncsOutbox; use App\NativeComponents\Concerns\TalksToDevnull; use Illuminate\View\View; use Native\Mobile\Edge\NativeComponent; @@ -17,6 +18,7 @@ class NewRemote extends NativeComponent { use OpensInTab; use SearchesPeople; + use SyncsOutbox; use TalksToDevnull; public function navTitle(): string diff --git a/app/NativeComponents/Notifications.php b/app/NativeComponents/Notifications.php index 1957711..a1bf9e6 100644 --- a/app/NativeComponents/Notifications.php +++ b/app/NativeComponents/Notifications.php @@ -12,6 +12,7 @@ use App\NativeComponents\Concerns\DrawsSkeletonFirst; use App\NativeComponents\Concerns\OpensInTab; use App\NativeComponents\Concerns\RefreshesInBackground; +use App\NativeComponents\Concerns\SyncsOutbox; use App\NativeComponents\Concerns\TalksToDevnull; use Carbon\CarbonImmutable; use Illuminate\View\View; @@ -42,6 +43,7 @@ class Notifications extends NativeComponent use DrawsSkeletonFirst; use OpensInTab; use RefreshesInBackground; + use SyncsOutbox; use TalksToDevnull; /** diff --git a/app/NativeComponents/Remotes.php b/app/NativeComponents/Remotes.php index b2eb40d..baf12f3 100644 --- a/app/NativeComponents/Remotes.php +++ b/app/NativeComponents/Remotes.php @@ -11,6 +11,7 @@ use App\NativeComponents\Concerns\DrawsSkeletonFirst; use App\NativeComponents\Concerns\OpensInTab; use App\NativeComponents\Concerns\RefreshesInBackground; +use App\NativeComponents\Concerns\SyncsOutbox; use App\NativeComponents\Concerns\TalksToDevnull; use App\NativeComponents\Layouts\TabsLayout; use Illuminate\View\View; @@ -45,6 +46,7 @@ class Remotes extends NativeComponent use DrawsSkeletonFirst; use OpensInTab; use RefreshesInBackground; + use SyncsOutbox; use TalksToDevnull; /** diff --git a/app/NativeComponents/Safety.php b/app/NativeComponents/Safety.php index da8ab75..e52720e 100644 --- a/app/NativeComponents/Safety.php +++ b/app/NativeComponents/Safety.php @@ -7,6 +7,7 @@ use App\Devnull\Data\User; use App\NativeComponents\Concerns\OpensInTab; use App\NativeComponents\Concerns\RefreshesInBackground; +use App\NativeComponents\Concerns\SyncsOutbox; use App\NativeComponents\Concerns\TalksToDevnull; use Closure; use Illuminate\View\View; @@ -27,6 +28,7 @@ class Safety extends NativeComponent { use OpensInTab; use RefreshesInBackground; + use SyncsOutbox; use TalksToDevnull; /** diff --git a/app/NativeComponents/Starred.php b/app/NativeComponents/Starred.php index b611ea5..d5d497e 100644 --- a/app/NativeComponents/Starred.php +++ b/app/NativeComponents/Starred.php @@ -40,6 +40,7 @@ class Starred extends NativeComponent use RefreshesInBackground; use ShowsCommits { toggleStar as private toggleShownStar; + showStar as private showShownStar; } use TalksToDevnull; @@ -123,8 +124,9 @@ public function loadMore(): void /** * Stars or unstars straight away, as anywhere else. A commit you unstarred - * 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. + * leaves the list at once, and comes back if devnull turns the unstar down + * (see showStar()). When it was the last one showing and there are more, + * the list starts again from the top, as on the web. */ public function toggleStar(string $id): void { @@ -147,6 +149,20 @@ public function toggleStar(string $id): void } } + /** + * An unstar devnull turned down: the commit comes back into the list, from + * the kept copy the star went back on, then as devnull lists it. + */ + protected function showStar(string $id, bool $starred, ?Commit $fresh = null): void + { + $this->showShownStar($id, $starred, $fresh); + + if ($starred && $fresh === null && $this->shownCommit($id) === null) { + $this->showKept(); + $this->revalidate(); + } + } + /** * The empty list's Explore Trending: the Log, open on Trending, as its tab opens it. */ diff --git a/app/NativeComponents/ThisDevice.php b/app/NativeComponents/ThisDevice.php index 6b7a8b4..1793fc5 100644 --- a/app/NativeComponents/ThisDevice.php +++ b/app/NativeComponents/ThisDevice.php @@ -15,6 +15,7 @@ use App\Devnull\ResponseCache; use App\NativeComponents\Concerns\RefreshesInBackground; use App\NativeComponents\Concerns\SignsInWithApple; +use App\NativeComponents\Concerns\SyncsOutbox; use App\NativeComponents\Concerns\TalksToDevnull; use App\NativeComponents\Concerns\WritesInBackground; use Illuminate\View\View; @@ -45,6 +46,7 @@ class ThisDevice extends NativeComponent { use RefreshesInBackground; use SignsInWithApple; + use SyncsOutbox; use TalksToDevnull; use WritesInBackground; diff --git a/database/migrations/2026_10_24_000000_create_pending_writes_table.php b/database/migrations/2026_10_24_000000_create_pending_writes_table.php new file mode 100644 index 0000000..3f77d12 --- /dev/null +++ b/database/migrations/2026_10_24_000000_create_pending_writes_table.php @@ -0,0 +1,35 @@ +id(); + $table->string('user_id')->index(); + $table->string('kind'); + $table->string('target')->nullable(); + $table->uuid('client_id')->unique(); + $table->json('payload'); + $table->string('status')->default('queued'); + $table->string('task_id')->nullable()->index(); + $table->text('error')->nullable(); + $table->unsignedInteger('attempts')->default(0); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('pending_writes'); + } +}; diff --git a/resources/views/native/commit-detail.blade.php b/resources/views/native/commit-detail.blade.php index 3273f10..422f2dc 100644 --- a/resources/views/native/commit-detail.blade.php +++ b/resources/views/native/commit-detail.blade.php @@ -127,7 +127,7 @@ class="w-full py-2 text-theme-ink" /> @include('native.partials.body-counter', ['body' => $reply, 'ref' => 'reply-budget']) - Reply + Reply @if (\App\Devnull\Data\CommitBody::hasOversizedUrl($reply)) Each link must not be greater than {{ \App\Devnull\Data\BodyLink::MaxLength }} characters. diff --git a/resources/views/native/partials/commit-row-content.blade.php b/resources/views/native/partials/commit-row-content.blade.php index 7b7a015..ccc7667 100644 --- a/resources/views/native/partials/commit-row-content.blade.php +++ b/resources/views/native/partials/commit-row-content.blade.php @@ -11,6 +11,9 @@ date and hash, and its replies behind a toggle in its action row. The commits around it ($muted) step back: smaller and in muted ink, with a smaller avatar and action row. They still open their own pages. + A commit you just made that devnull doesn't have yet is staged: dimmed, with the staged chip and no actions, until + the real one takes its place (see SyncsOutbox). + A list builds and measures every node of a row as it scrolls in, so the content is one column: the rail's 64pt gutter is its left margin, and the hairline under it is its bottom border. A border draws inside the box, so the bottom padding takes the hairline's 1pt to keep the row as tall as it was. @@ -29,7 +32,7 @@ @php($inset = $focused ? 16 : 12) @php($padding = 'pt-['.$inset.']'.($threadBelow ? '' : ' pb-['.($hasHairline ? $inset + 1 : $inset).']')) @php($metaSize = $muted ? 'text-xs' : 'text-[13px]') - + {{ $author->name }} {{ $author->handle() }} @@ -69,7 +72,8 @@ {{ $commit->shortHash }} @endif - @if ($focused) + @if ($commit->isStaged()) + @elseif ($focused) @include('native.partials.commit-actions', [ 'withReply' => false, 'repliesToggle' => 'toggleReplies', diff --git a/resources/views/native/partials/commit-sheet.blade.php b/resources/views/native/partials/commit-sheet.blade.php index 4d399ee..18e57f6 100644 --- a/resources/views/native/partials/commit-sheet.blade.php +++ b/resources/views/native/partials/commit-sheet.blade.php @@ -5,7 +5,8 @@ bar, the same on both platforms (see sheet-bar-button), 16pt in from the sheet's top and sides. On Android the bar sits under Material's handle strip, since mobile-ui has no way yet to draw the handle over the content. The title is centred. - Cancel closes it as a swipe does, keeping the draft. If devnull says no, why shows here and the draft stays. The + Cancel closes it as a swipe does, keeping the draft. Commit closes it at once, and the commit goes from the outbox + (see SyncsOutbox). If devnull says no, a toast says why and the draft comes back in the sheet. The Log's compose button opens the same composer for a new commit, without the card. A draft stashed last time comes back in it, with the web's stash@{0} and drop beside the counter, in the toolbar at the bottom. @@ -48,7 +49,7 @@ @if ($issueCommand) @include('native.partials.sheet-bar-button', ['ref' => 'sheet-open-issue', 'icon' => \App\Icons\Lucide::Check, 'label' => 'Open issue', 'main' => true, 'loading' => $openingIssue, 'press' => $openingIssue || ! \App\Devnull\IssueCommand::fits($sheetBody) ? null : 'openIssueFromSheet']) @else - @include('native.partials.sheet-bar-button', ['ref' => 'sheet-commit', 'icon' => \App\Icons\Lucide::Check, 'label' => $replying ? 'Reply' : 'Commit', 'main' => true, 'loading' => $sheetPosting, 'press' => $sheetPosting || ! $canCommit ? null : 'commitFromSheet']) + @include('native.partials.sheet-bar-button', ['ref' => 'sheet-commit', 'icon' => \App\Icons\Lucide::Check, 'label' => $replying ? 'Reply' : 'Commit', 'main' => true, 'press' => $canCommit ? 'commitFromSheet' : null]) @endif diff --git a/resources/views/native/partials/composer-tools.blade.php b/resources/views/native/partials/composer-tools.blade.php index 99d230e..3be6868 100644 --- a/resources/views/native/partials/composer-tools.blade.php +++ b/resources/views/native/partials/composer-tools.blade.php @@ -5,10 +5,10 @@ --}} @php($photosFull = count($sheetImages) >= \App\Devnull\DraftImages::MaxImages) - @include('native.partials.composer-tool', ['ref' => 'sheet-photos', 'icon' => \App\Icons\Lucide::ImagePlus, 'label' => 'Attach up to 3 photos', 'press' => 'pickPhotos', 'disabled' => $sheetPoll !== null || $photosFull || $sheetPosting]) - @include('native.partials.composer-tool', ['ref' => 'sheet-camera', 'icon' => \App\Icons\Lucide::Camera, 'label' => 'Take a photo', 'press' => 'takePhoto', 'disabled' => $sheetPoll !== null || $photosFull || $sheetPosting]) + @include('native.partials.composer-tool', ['ref' => 'sheet-photos', 'icon' => \App\Icons\Lucide::ImagePlus, 'label' => 'Attach up to 3 photos', 'press' => 'pickPhotos', 'disabled' => $sheetPoll !== null || $photosFull]) + @include('native.partials.composer-tool', ['ref' => 'sheet-camera', 'icon' => \App\Icons\Lucide::Camera, 'label' => 'Take a photo', 'press' => 'takePhoto', 'disabled' => $sheetPoll !== null || $photosFull]) @unless ($sheetView === 'reply') - @include('native.partials.composer-tool', ['ref' => 'sheet-poll', 'icon' => \App\Icons\Lucide::ChartBar, 'label' => $sheetImages === [] ? 'Add poll' : 'A commit can have images or a poll, not both', 'press' => 'addPoll', 'disabled' => $sheetImages !== [] || $sheetPoll !== null || $sheetPosting]) + @include('native.partials.composer-tool', ['ref' => 'sheet-poll', 'icon' => \App\Icons\Lucide::ChartBar, 'label' => $sheetImages === [] ? 'Add poll' : 'A commit can have images or a poll, not both', 'press' => 'addPoll', 'disabled' => $sheetImages !== [] || $sheetPoll !== null]) @endunless @include('native.partials.composer-tool', ['ref' => 'sheet-preview', 'icon' => \App\Icons\Lucide::Eye, 'label' => $previewing ? 'Edit' : 'Preview', 'press' => 'togglePreview', 'active' => $previewing]) diff --git a/resources/views/native/partials/thread-reply.blade.php b/resources/views/native/partials/thread-reply.blade.php index 06a90d8..6a1d0a7 100644 --- a/resources/views/native/partials/thread-reply.blade.php +++ b/resources/views/native/partials/thread-reply.blade.php @@ -15,6 +15,8 @@ avatar running down to it. A commit page draws its replies $muted, smaller and in muted ink, so the commit they answer stays the main thing. + + A reply you just sent that devnull doesn't have yet is staged: dimmed, with the staged chip and no actions. --}} @php($previousLane ??= null) @php($muted ??= false) @@ -70,7 +72,7 @@ @include('native.partials.rail-avatar', ['user' => $reply->author, 'lane' => $reply->lane(), 'size' => 24, 'staged' => false]) - + {{ $reply->author->name }} · @@ -81,7 +83,9 @@ @include('native.partials.commit-body', ['size' => $muted ? 'text-sm' : 'text-[15px]']) @include('native.partials.commit-media', ['withEmbeds' => false]) - @include('native.partials.commit-actions', ['expandable' => false, 'withQuote' => false, 'compact' => $muted]) + @unless ($reply->isStaged()) + @include('native.partials.commit-actions', ['expandable' => false, 'withQuote' => false, 'compact' => $muted]) + @endunless @if ($more !== null) diff --git a/tests/Feature/Screens/CommitDetailTest.php b/tests/Feature/Screens/CommitDetailTest.php index c99a0d8..8ec6df5 100644 --- a/tests/Feature/Screens/CommitDetailTest.php +++ b/tests/Feature/Screens/CommitDetailTest.php @@ -1,6 +1,8 @@ tap('send-reply') ->assertSet('reply', '') ->assertSee('A fresh reply') - ->assertNavigatedTo('/log/commits/r9') + ->assertNoNavigation() ->assertNativeNotCalled('ToastKit.Show'); Http::assertSent(fn (Request $request): bool => $request->method() === 'POST' && $request->body() === '{"body":"A fresh reply","parent_id":"c2"}'); }); -it('opens a posted reply in the same tab before its refresh arrives, and goes back to the parent', function (string $tab) { +it('shows a posted reply staged under the commit at once, then the one devnull made in its place', function () { holdRefreshes(); keep('commits/c2', commitPageJson(commitJson(['id' => 'c2', 'body' => 'The focused commit']))); - keep('commits/c2/replies', pageJson([replyJson(['id' => 'r1', 'parent_id' => 'c2', 'body' => 'First reply'])], 'page2')); + keep('commits/c2/replies', pageJson([replyJson(['id' => 'r1', 'parent_id' => 'c2', 'body' => 'First reply'])])); fakeApi([ 'POST /api/v1/commits' => [['data' => replyJson(['id' => 'r9', 'parent_id' => 'c2', 'status' => 'pending', 'body' => 'A fresh reply'])], 201], ]); - $reply = Native::visit("{$tab}/commits/c2") + $screen = Native::visit('/log/commits/c2') ->input('reply-input', 'A fresh reply') ->tap('send-reply') - ->assertNavigatedTo("{$tab}/commits/r9") - ->follow() - ->assertSet('id', 'r9') + ->assertSet('reply', '') + ->assertNoNavigation() ->assertSee('A fresh reply') - ->assertSee('pending') - ->assertDontSee('The focused commit'); + ->assertSee('staged'); - expect(childRefs($reply, 'thread-page'))->toBe(['focused-commit', 'reply-bar-clearance']); + $staged = $screen->instance()->replies[1]->id; - $reply->goBack() - ->assertSet('id', 'c2') - ->assertSet('reply', '') - ->assertSet('replyRestored', false) - ->assertSee('The focused commit') - ->assertSee('A fresh reply'); + expect(childRefs($screen, 'thread-page'))->toBe(['focused-commit', 'thread-reply-r1', "thread-reply-{$staged}", 'reply-bar-clearance']) + ->and(PendingWrite::query()->count())->toBe(1); - Http::assertSentCount(1); -})->with(['/log', '/notifications', '/remotes', '/profile']); + finishRefreshes($screen) + ->assertSee('A fresh reply') + ->assertSee('pending') + ->assertDontSee('staged'); + + expect(childRefs($screen, 'thread-page'))->toBe(['focused-commit', 'thread-reply-r1', 'thread-reply-r9', 'reply-bar-clearance']) + ->and(PendingWrite::query()->count())->toBe(0) + ->and(collect(Http::recorded())->filter(fn (array $sent): bool => $sent[0]->method() === 'POST'))->toHaveCount(1); +}); it('shows a reply you posted once when the next page of replies has it too', function () { Http::fake(function (Request $request) { @@ -241,8 +244,6 @@ function fakeThread(array $commit = [], array $replies = [], array $extra = []): ->tap('replies-c2') ->input('reply-input', 'A fresh reply') ->tap('send-reply') - ->follow() - ->goBack() ->call('loadMoreReplies') ->assertSet('repliesCursor', null); @@ -259,15 +260,23 @@ function fakeThread(array $commit = [], array $replies = [], array $extra = []): ->assertNativeNotCalled('ToastKit.Show'); }); -it('shows validation messages from the server', function () { +it('puts a reply devnull turned down back in the bar, and says why in a toast', function () { fakeThread(extra: ['POST /api/v1/commits' => [['message' => 'The body field must not be greater than 280 characters.', 'errors' => ['body' => ['The body field must not be greater than 280 characters.']]], 422]]); - Native::visit('/log/commits/c2') + $screen = Native::visit('/log/commits/c2') ->input('reply-input', 'Long enough') ->tap('send-reply') - ->assertSee('The body field must not be greater than 280 characters.') + ->assertNativeCalled('ToastKit.Show', fn (array $params): bool => $params['message'] === 'The body field must not be greater than 280 characters.' + && $params['variant'] === 'error' + && ! isset($params['action'])) ->assertSet('reply', 'Long enough') + ->assertSet('replyRestored', true) + ->assertDontSee('staged') ->assertNoNavigation(); + + expect($screen->instance()->replies)->toBe([]) + ->and(app(Stash::class)->get(userJson()['id'], 'reply-c2'))->toBe('Long enough') + ->and(PendingWrite::query()->count())->toBe(0); }); it('turns the budget red at zero and holds back a blank reply', function () { @@ -291,7 +300,7 @@ function fakeThread(array $commit = [], array $replies = [], array $extra = []): expect(nodeByRef($screen, 'send-reply')['props']['disabled'] ?? false)->toBeFalse() ->and(nodeByRef($screen, 'reply-input')['props']['max_length'])->toBe(40000); - $screen->tap('send-reply')->assertSet('reply', '')->assertSet('sendingReply', false); + $screen->tap('send-reply')->assertSet('reply', ''); Http::assertSent(fn (Request $request): bool => $request->method() === 'POST' && $request['body'] === $body); })->with(['a', str_repeat('a', 3976).' https://example.com/'.str_repeat('x', 100)]); @@ -304,10 +313,17 @@ function fakeThread(array $commit = [], array $replies = [], array $extra = []): Http::assertNotSent(fn (Request $request): bool => $request->method() === 'POST'); }); -it('does not send a second reply while one is being submitted', function () { - fakeThread(); - Native::visit('/log/commits/c2')->input('reply-input', 'a')->set('sendingReply', true)->call('sendReply'); - Http::assertNotSent(fn (Request $request): bool => $request->method() === 'POST'); +it('sends a reply once, since the bar empties as it goes', function () { + holdRefreshes(); + fakeThread(extra: ['POST /api/v1/commits' => [['data' => replyJson(['id' => 'r9', 'parent_id' => 'c2', 'body' => 'a'])], 201]]); + + $screen = finishRefreshes(Native::visit('/log/commits/c2'))->input('reply-input', 'a')->tap('send-reply')->call('sendReply'); + + expect(PendingWrite::query()->count())->toBe(1); + + finishRefreshes($screen); + + expect(collect(Http::recorded())->filter(fn (array $sent): bool => $sent[0]->method() === 'POST'))->toHaveCount(1); }); it('deletes your own commit after a confirm', function () { diff --git a/tests/Feature/Screens/ComposeSheetTest.php b/tests/Feature/Screens/ComposeSheetTest.php index 28b12c6..4019863 100644 --- a/tests/Feature/Screens/ComposeSheetTest.php +++ b/tests/Feature/Screens/ComposeSheetTest.php @@ -1,6 +1,7 @@ toBeFalse(); }); -it('does not submit a closed composer twice and leaves a failed draft retryable', function () { - $screen = composeSheetOnLog()->input('sheet-body', 'a')->tap('sheet-commit')->call('commitFromSheet'); - expect(Http::recorded(fn (Request $request): bool => $request->method() === 'POST'))->toHaveCount(1); - - $screen->press('composeInSheet')->input('sheet-body', 'a')->set('sheetPosting', true); - - // While it posts, the check spins in its circle and can't be tapped. - expect(sheetButtonEnabled($screen, 'sheet-commit'))->toBeFalse() - ->and(findNode(nodeByRef($screen, 'sheet-commit'), fn (array $node): bool => $node['type'] === 'activity_indicator'))->not->toBeNull(); +it('does not submit a closed composer twice', function () { + composeSheetOnLog()->input('sheet-body', 'a')->tap('sheet-commit')->call('commitFromSheet'); - $screen->call('commitFromSheet'); expect(Http::recorded(fn (Request $request): bool => $request->method() === 'POST'))->toHaveCount(1); }); @@ -143,18 +136,19 @@ function sheetCommitDisabled(TestableComponent $screen): bool ->sheetBody->toBe(''); }); -it('shows why devnull said no in the sheet, keeping the draft', function () { +it('says why devnull said no in a toast, and opens the sheet again on the draft', function () { $screen = composeSheetOnLog([['message' => 'Too Many Attempts.'], 429]) ->input('sheet-body', 'One more') - ->tap('sheet-commit'); + ->tap('sheet-commit') + ->assertNativeCalled('ToastKit.Show', fn (array $params): bool => $params['message'] === 'Too Many Attempts.' && $params['variant'] === 'error'); - expect(nodeByRef($screen, 'sheet-error')['props']['text'])->toBe('Too Many Attempts.') - ->and(nodeByRef($screen, 'sheet-body')['props']['value'])->toBe('One more') + expect(nodeByRef($screen, 'sheet-body')['props']['value'])->toBe('One more') ->and(sheetCommitDisabled($screen))->toBeFalse() ->and(nodeByRef($screen, 'sheet')['props']['visible'])->toBeTrue() + ->and($screen->instance()->sheetView)->toBe('compose') ->and($screen->instance()->error)->toBeNull() - ->and($screen->instance()->sheetPosting)->toBeFalse() - ->and(array_map(fn (Commit $commit): string => $commit->id, $screen->instance()->commits))->toBe(['c1']); + ->and(array_map(fn (Commit $commit): string => $commit->id, $screen->instance()->commits))->toBe(['c1']) + ->and(PendingWrite::query()->count())->toBe(0); }); it('keeps the draft when the sheet is swiped away, and opens on it again, marked as restored', function () { diff --git a/tests/Feature/Screens/ComposerPhotosTest.php b/tests/Feature/Screens/ComposerPhotosTest.php index 0b7b8c6..ab96cc6 100644 --- a/tests/Feature/Screens/ComposerPhotosTest.php +++ b/tests/Feature/Screens/ComposerPhotosTest.php @@ -196,31 +196,37 @@ function isTool(TestableComponent $screen, string $ref): bool 'short' => ['ok', '{"body":"ok","image_descriptions":[null,"A terminal."]}'], ]); -it('spins Commit while the photos upload, and says why devnull turned them down, keeping them', function () { +it('shows the commit staged with its photos while they upload, and opens the sheet on them again if devnull turns them down', function () { holdRefreshes(); $screen = photosPicked(composerOnLog([['message' => 'The images.0 field must be an image.', 'errors' => ['images.0' => ['The images.0 field must be an image.']]], 422]), [testPhoto()]) ->input('sheet-body', 'Look at this'); + $path = $screen->instance()->sheetImages[0]->path; $sent = refreshesSent($screen); - $screen->tap('sheet-commit'); + $screen->tap('sheet-commit')->dismissSheet('sheet'); - // The check turns into a spinner, and can't be tapped again. - expect(sheetButtonEnabled($screen, 'sheet-commit'))->toBeFalse() - ->and(findNode(sheetButtonCircle($screen, 'sheet-commit'), fn (array $node): bool => ($node['type'] ?? null) === 'activity_indicator'))->not->toBeNull() - ->and(isTool($screen, 'sheet-photos'))->toBeFalse(); + // The sheet goes at once, and the commit shows staged with its photo, which stays on the phone while it uploads. + expect(nodeByRef($screen, 'sheet')['props']['visible'])->toBeFalse() + ->and($screen->instance()->commits[0]->isStaged())->toBeTrue() + ->and($screen->instance()->commits[0]->images[0]->url)->toBe($path) + ->and(file_exists($path))->toBeTrue() + ->and(refreshesSent($screen))->toBe($sent + 1); - // Pressed again while it's on its way, nothing more goes. + // Pressed again once it's gone, nothing more goes. $screen->call('commitFromSheet'); expect(refreshesSent($screen))->toBe($sent + 1); - finishRefreshes($screen); + finishRefreshes($screen) + ->assertNativeCalled('ToastKit.Show', fn (array $params): bool => $params['message'] === 'The images.0 field must be an image.' && $params['variant'] === 'error'); - expect(nodeByRef($screen, 'sheet-error')['props']['text'])->toBe('The images.0 field must be an image.') + expect(nodeByRef($screen, 'sheet')['props']['visible'])->toBeTrue() + ->and(nodeByRef($screen, 'sheet-body')['props']['value'])->toBe('Look at this') ->and(sheetButtonEnabled($screen, 'sheet-commit'))->toBeTrue() - ->and(nodeByRef($screen, 'sheet')['props']['visible'])->toBeTrue() ->and($screen->instance()->sheetImages)->toHaveCount(1) - ->and(file_exists($screen->instance()->sheetImages[0]->path))->toBeTrue(); + ->and($screen->instance()->sheetImages[0]->path)->toBe($path) + ->and(file_exists($path))->toBeTrue() + ->and(array_filter($screen->instance()->commits, fn ($commit): bool => $commit->isStaged()))->toBe([]); }); it('drops the photos when the sheet goes unsent, and deletes them from the phone', function () { diff --git a/tests/Feature/Screens/LogTest.php b/tests/Feature/Screens/LogTest.php index 4cae27c..490dec8 100644 --- a/tests/Feature/Screens/LogTest.php +++ b/tests/Feature/Screens/LogTest.php @@ -468,10 +468,17 @@ 'POST /api/v1/commits/c1/star' => [['message' => 'Too Many Attempts.'], 429], ]); - $screen = Native::visit('/log')->tap('star-c1')->assertSee('Too Many Attempts.'); + $screen = Native::visit('/log') + ->tap('star-c1') + ->assertNativeCalled('ToastKit.Show', fn (array $params): bool => $params['message'] === 'Too Many Attempts.' + && $params['variant'] === 'error' + && $params['action'] === ['id' => 'retry-star', 'label' => 'Retry'] + && $params['data'] === ['commit' => 'c1', 'starred' => true]); expect($screen->instance()->commits[0]->starred)->toBeFalse() - ->and($screen->instance()->commits[0]->starsCount)->toBe(1); + ->and($screen->instance()->commits[0]->starsCount)->toBe(1) + ->and(kept('log/following')['data'][0])->toMatchArray(['starred' => false, 'stars_count' => 1]) + ->and($screen->instance()->error)->toBeNull(); }); it('leaves bookmarks out of More even when an old cached commit has bookmark state', function () { diff --git a/tests/Feature/Screens/OutboxTest.php b/tests/Feature/Screens/OutboxTest.php new file mode 100644 index 0000000..b7274fe --- /dev/null +++ b/tests/Feature/Screens/OutboxTest.php @@ -0,0 +1,252 @@ + 'http://devnull.test']); + signIn(); + $this->userId = userJson()['id']; +}); + +/** + * Someone else's commit in the Following log, with a star or two on it. + * + * @param array $overrides + * @return array + */ +function adasCommit(array $overrides = []): array +{ + return commitJson(['id' => 'c1', 'body' => 'Shipped it.', 'stars_count' => 1, 'author' => userJson(['id' => 'u2', 'username' => 'ada', 'name' => 'Ada']), ...$overrides]); +} + +/** + * Something left in the outbox from when the app last closed, on its way to devnull from a task that has gone. + * + * @param array $payload + */ +function leftover(string $kind, ?string $target, array $payload = []): PendingWrite +{ + return PendingWrite::query()->create([ + 'user_id' => userJson()['id'], + 'kind' => $kind, + 'target' => $target, + 'client_id' => (string) Str::uuid(), + 'payload' => $payload, + 'status' => PendingWrite::Sending, + 'task_id' => 'a-task-from-before-the-app-closed', + 'attempts' => 1, + ]); +} + +/** + * @param array $data + */ +function pressToastAction(TestableComponent $screen, string $action, array $data): TestableComponent +{ + return $screen->emitNative(ToastActionPressed::class, ['toastId' => 'toast-1', 'actionId' => $action, 'data' => $data]); +} + +it('stars at once from the outbox, then takes the count devnull sends back', function () { + holdRefreshes(); + fakeApi([ + 'GET /api/v1/log/following' => [pageJson([adasCommit()])], + 'POST /api/v1/commits/c1/star' => [['data' => adasCommit(['starred' => true, 'stars_count' => 7])]], + ]); + + $screen = finishRefreshes(Native::visit('/log'))->tap('star-c1'); + + // The star shows straight away, here and in the kept log, while it waits in the outbox. + expect($screen->instance()->commits[0])->starred->toBeTrue()->starsCount->toBe(2) + ->and(kept('log/following')['data'][0])->toMatchArray(['starred' => true, 'stars_count' => 2]) + ->and(PendingWrite::query()->sole())->kind->toBe(PendingWrite::Star)->target->toBe('c1')->status->toBe(PendingWrite::Sending) + ->and(sentTo('POST', 'commits/c1/star'))->toBe([]); + + finishRefreshes($screen)->assertNativeNotCalled('ToastKit.Show'); + + expect($screen->instance()->commits[0])->starred->toBeTrue()->starsCount->toBe(7) + ->and(kept('log/following')['data'][0])->toMatchArray(['starred' => true, 'stars_count' => 7]) + ->and(PendingWrite::query()->count())->toBe(0) + ->and(sentTo('POST', 'commits/c1/star'))->toHaveCount(1); +}); + +it('lets the last of several quick taps on a star have the last word', function () { + holdRefreshes(); + fakeApi([ + 'GET /api/v1/log/following' => [pageJson([adasCommit()])], + 'POST /api/v1/commits/c1/star' => [['data' => adasCommit(['starred' => true, 'stars_count' => 9])]], + 'DELETE /api/v1/commits/c1/star' => [['data' => adasCommit(['starred' => false, 'stars_count' => 8])]], + ]); + + $screen = finishRefreshes(Native::visit('/log'))->tap('star-c1')->tap('star-c1'); + + expect($screen->instance()->commits[0])->starred->toBeFalse()->starsCount->toBe(1); + + finishRefreshes($screen); + + expect($screen->instance()->commits[0])->starred->toBeFalse()->starsCount->toBe(8) + ->and(kept('log/following')['data'][0])->toMatchArray(['starred' => false, 'stars_count' => 8]) + ->and(PendingWrite::query()->count())->toBe(0); +}); + +it('puts a star devnull turned down back, and stars again from Retry on its toast', function () { + serveApi([ + 'GET /api/v1/log/following' => [pageJson([adasCommit()])], + 'POST /api/v1/commits/c1/star' => [['message' => 'Server Error'], 500], + ]); + + $screen = Native::visit('/log')->tap('star-c1'); + + expect($screen->instance()->commits[0])->starred->toBeFalse()->starsCount->toBe(1) + ->and(PendingWrite::query()->count())->toBe(0); + + serveApi(['POST /api/v1/commits/c1/star' => [['data' => adasCommit(['starred' => true, 'stars_count' => 2])]]]); + pressToastAction($screen, 'retry-star', ['commit' => 'c1', 'starred' => true]); + + expect($screen->instance()->commits[0])->starred->toBeTrue()->starsCount->toBe(2) + ->and(sentTo('POST', 'commits/c1/star'))->toHaveCount(2); +}); + +it('sends the stars and messages left over from when the app closed, since devnull cannot do them twice', function () { + $star = leftover(PendingWrite::Star, 'c1'); + $message = leftover(PendingWrite::Message, 'remotes/@ada', ['body' => 'still there?']); + serveApi(function (Request $request) { + $path = parse_url($request->url(), PHP_URL_PATH); + + return match (true) { + $path === '/api/v1/commits/c1/star' => Http::response(['data' => adasCommit(['starred' => true, 'stars_count' => 2])]), + $path === '/api/v1/remotes/@ada/messages' => Http::response(['data' => remoteMessageJson(['id' => 7, 'body' => 'still there?'])]), + default => Http::response(pageJson([adasCommit()])), + }; + }); + + Native::visit('/log')->assertNativeNotCalled('ToastKit.Show'); + + expect(sentTo('POST', 'commits/c1/star'))->toHaveCount(1) + ->and(json_decode(sentTo('POST', 'remotes/@ada/messages')[0], true))->toBe(['body' => 'still there?', 'client_id' => $message->client_id]) + ->and(PendingWrite::query()->whereKey([$star->id, $message->id])->count())->toBe(0); +}); + +it('never sends a commit left over from when the app closed, and offers it back to edit instead', function () { + $commit = leftover(PendingWrite::Commit, null, ['body' => 'Lost in transit', 'stash' => 'commit', 'composer' => 'compose']); + fakeApi(['GET /api/v1/log/following' => [pageJson([adasCommit()])]]); + + $screen = Native::visit('/log') + ->assertNativeCalled('ToastKit.Show', fn (array $params): bool => $params['message'] === Outbox::ClosedBeforeAnswer + && $params['variant'] === 'error' + && $params['action'] === ['id' => 'edit', 'label' => 'Edit'] + && $params['data'] === ['write' => $commit->id]); + + // It isn't opened on you as the app opens. It waits for Edit, with its draft back in the stash. + expect(sentTo('POST', 'commits'))->toBe([]) + ->and($commit->fresh())->status->toBe(PendingWrite::Failed)->error->toBe(Outbox::ClosedBeforeAnswer) + ->and(app(Stash::class)->get($this->userId, 'commit'))->toBe('Lost in transit') + ->and($screen->instance()->sheetOpen)->toBeFalse() + ->and(array_map(fn ($shown): string => $shown->id, $screen->instance()->commits))->toBe(['c1']); + + pressToastAction($screen, 'edit', ['write' => $commit->id]); + + expect(nodeByRef($screen, 'sheet')['props']['visible'])->toBeTrue() + ->and($screen->instance()->sheetView)->toBe('compose') + ->and(nodeByRef($screen, 'sheet-body')['props']['value'])->toBe('Lost in transit') + ->and(PendingWrite::query()->count())->toBe(0); +}); + +it('brings back the photos and poll of a commit devnull turned down when its composer opens again', function () { + $commit = leftover(PendingWrite::Commit, null, [ + 'body' => 'Which one?', + 'poll' => ['options' => ['Tabs', 'Spaces'], 'duration' => 4320], + 'stash' => 'commit', + 'composer' => 'compose', + ]); + app(Outbox::class)->failed($commit, 'Too Many Attempts.'); + fakeApi(['GET /api/v1/log/following' => [pageJson([adasCommit()])]]); + + $screen = Native::visit('/log')->press('composeInSheet'); + + expect($screen->instance()) + ->sheetBody->toBe('Which one?') + ->sheetPoll->toBe(['Tabs', 'Spaces']) + ->sheetPollLength->toBe('3 days') + ->sheetRestored->toBeTrue() + ->and(PendingWrite::query()->count())->toBe(0); +}); + +it("hears a reply devnull turned down after you've left its commit, and Edit takes you back to it", function () { + holdRefreshes(); + keep('commits/c2', commitPageJson(commitJson(['id' => 'c2', 'body' => 'The focused commit', 'author' => userJson(['id' => 'u2', 'username' => 'ada'])]))); + keep('commits/c2/replies', pageJson([])); + fakeApi(['POST /api/v1/commits' => [['message' => 'Too Many Attempts.', 'code' => 'rate_limited'], 429]]); + + Native::visit('/log/commits/c2')->input('reply-input', 'Hold on')->tap('send-reply'); + $write = PendingWrite::query()->sole(); + + // devnull answers once you're on another tab, which hears it. + $elsewhere = finishRefreshes(Native::visit('/notifications')) + ->assertNativeCalled('ToastKit.Show', fn (array $params): bool => $params['message'] === 'Too Many Attempts.' + && $params['action'] === ['id' => 'edit', 'label' => 'Edit'] + && $params['data'] === ['write' => $write->id]); + + expect($write->fresh()->status)->toBe(PendingWrite::Failed) + ->and(app(Stash::class)->get($this->userId, 'reply-c2'))->toBe('Hold on'); + + pressToastAction($elsewhere, 'edit', ['write' => $write->id]) + ->assertNavigatedTo('/notifications/commits/c2'); + + $back = Native::visit('/notifications/commits/c2', ['edit' => $write->id]) + ->assertSet('reply', 'Hold on') + ->assertSet('replyRestored', true) + ->assertSet('focusReply', true); + + expect($back->instance()->replies)->toBe([]) + ->and(PendingWrite::query()->count())->toBe(0); +}); + +it('keeps a pushed message staged in its thread when you come back before devnull answers', function () { + holdRefreshes(); + serveApi([ + 'GET /api/v1/remotes/@ada/messages' => [threadJson([remoteMessageJson(['id' => 1])], ['read_through_id' => 1])], + 'POST /api/v1/remotes/@ada/messages' => [['message' => 'Nope.'], 403], + ]); + + $screen = finishRefreshes(Native::visit('/remotes/conversations/ada'))->set('draft', 'on it')->call('send'); + $clientId = $screen->instance()->pending[0]->clientId; + + // A fresh copy of the thread, as after leaving it, still has it staged. + $again = finishRefreshes(Native::visit('/remotes/conversations/ada')); + + expect($again->instance()->pending)->toHaveCount(1) + ->and($again->instance()->pending[0])->clientId->toBe($clientId)->status->toBe('rejected'); + + $again->assertNativeCalled('ToastKit.Show', fn (array $params): bool => $params['message'] === 'Nope.' + && $params['action'] === ['id' => 'retry-message', 'label' => 'Retry']); + + $write = PendingWrite::query()->sole(); + expect($write->status)->toBe(PendingWrite::Failed); + + serveApi([ + 'GET /api/v1/remotes/@ada/messages' => [threadJson([remoteMessageJson(['id' => 1])], ['read_through_id' => 1])], + 'POST /api/v1/remotes/@ada/messages' => [['data' => remoteMessageJson(['id' => 9, 'client_id' => $clientId, 'body' => 'on it'])], 201], + ]); + + finishRefreshes(pressToastAction($again, 'retry-message', ['write' => $write->id]))->assertSet('pending', []); + + expect(array_map(fn (string $body): string => json_decode($body, true)['client_id'], sentTo('POST', 'remotes/@ada/messages')))->toBe([$clientId, $clientId]) + ->and(PendingWrite::query()->count())->toBe(0); +}); + +it('empties the outbox when you sign out', function () { + leftover(PendingWrite::Star, 'c1'); + + app(Account::class)->forget(); + + expect(PendingWrite::query()->count())->toBe(0); +}); diff --git a/tests/Feature/Screens/QuoteTest.php b/tests/Feature/Screens/QuoteTest.php index a744f7d..42445a8 100644 --- a/tests/Feature/Screens/QuoteTest.php +++ b/tests/Feature/Screens/QuoteTest.php @@ -152,13 +152,14 @@ function quoteButtonDisabled(TestableComponent $screen): bool ->sheetBody->toBe(''); }); -it('shows why devnull said no in the sheet, keeping the draft', function () { +it('says why devnull said no in a toast, and opens the quote again on the draft', function () { $screen = quoteSheetOnLog([['message' => 'That commit is no longer available to quote.', 'errors' => ['quoted_commit_id' => ['That commit is no longer available to quote.']]], 422]) ->input('sheet-body', 'This, all of it.') - ->tap('sheet-commit'); + ->tap('sheet-commit') + ->assertNativeCalled('ToastKit.Show', fn (array $params): bool => $params['message'] === 'That commit is no longer available to quote.'); - expect(nodeByRef($screen, 'sheet-error')['props']['text'])->toBe('That commit is no longer available to quote.') - ->and(nodeByRef($screen, 'sheet-body')['props']['value'])->toBe('This, all of it.') + expect(nodeByRef($screen, 'sheet-body')['props']['value'])->toBe('This, all of it.') + ->and($screen->instance()->sheetView)->toBe('quote') ->and(quoteButtonDisabled($screen))->toBeFalse() ->and(nodeByRef($screen, 'sheet')['props']['visible'])->toBeTrue() ->and($screen->instance()->error)->toBeNull() diff --git a/tests/TestCase.php b/tests/TestCase.php index 12049cc..c7bf4ad 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,6 +2,7 @@ namespace Tests; +use Illuminate\Foundation\Testing\LazilyRefreshDatabase; use Illuminate\Foundation\Testing\TestCase as BaseTestCase; use Illuminate\Support\Facades\File; use Native\Mobile\AsyncTask; @@ -9,6 +10,12 @@ abstract class TestCase extends BaseTestCase { + /** + * The outbox lives in the app's database (see App\Devnull\Outbox), so a + * test that stars, commits or pushes gets it migrated, in memory. + */ + use LazilyRefreshDatabase; + /** * Background refreshes run inline, so a screen has its fresh answer by its * first frame. A test that wants to see the frame before it calls From 409477eb2281e4593fcfaf9c23c16ba490bd63fe Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Sat, 24 Oct 2026 20:27:34 +0100 Subject: [PATCH 3/6] Offer View issue on the toast for an issue opened after its sheet closed If the sheet was swiped away before devnull opened the issue, the toast used to say "Opened issue #N." with no way to get to it. It now has a View issue button that opens the issue in the in-app browser, the same as the button in the sheet. Only http and https links are opened. Co-Authored-By: Claude Opus 5.5 --- app/NativeComponents/Concerns/DraftsIssues.php | 10 ++++++---- app/NativeComponents/Concerns/SyncsOutbox.php | 18 +++++++++++++++++- tests/Feature/Screens/IssueCommandsTest.php | 11 +++++++++-- 3 files changed, 32 insertions(+), 7 deletions(-) diff --git a/app/NativeComponents/Concerns/DraftsIssues.php b/app/NativeComponents/Concerns/DraftsIssues.php index c46b3dc..ab72c20 100644 --- a/app/NativeComponents/Concerns/DraftsIssues.php +++ b/app/NativeComponents/Concerns/DraftsIssues.php @@ -37,7 +37,8 @@ * Opening the issue waits on GitHub, so it's sent in the background while the * button spins (see WritesInBackground). Once it's open the draft goes, and * the sheet says "Opened issue #123." with View issue, as the web's toast - * does. When it isn't, the draft stays an issue, with why under it: devnull's + * does, or a toast does, with View issue as its button, if the sheet was + * swiped away by then (see SyncsOutbox::toastActionPressed()). When it isn't, the draft stays an issue, with why under it: devnull's * own words when it sends them, else the web's, which say to check upstream * when the issue might have been opened anyway. */ @@ -131,11 +132,12 @@ public function openIssueFromSheet(): void $this->writeInBackground('POST', Client::IssuesPath, ['command' => $command->command(), 'body' => $body], function (array $json): void { $number = is_int($json['number'] ?? null) ? $json['number'] : null; + $url = is_string($json['url'] ?? null) ? $json['url'] : null; app(Stash::class)->put($this->me()?->id, 'commit', ''); - // Swiped away while it was on its way, so a toast says it, without View issue. + // Swiped away while it was on its way, so a toast says it, with View issue as its button. if (! $this->sheetOpen) { - Toasts::done("Opened issue #{$number}."); + Toasts::done("Opened issue #{$number}.", $url === null ? null : ['label' => 'View issue', 'id' => 'view-issue', 'data' => ['url' => $url]]); return; } @@ -144,7 +146,7 @@ public function openIssueFromSheet(): void $this->sheetBody = ''; $this->sheetRestored = false; $this->openedIssueNumber = $number; - $this->openedIssueUrl = is_string($json['url'] ?? null) ? $json['url'] : null; + $this->openedIssueUrl = $url; $this->sheetView = 'issued'; }, function (string $message, ?ApiException $exception = null): void { $this->openingIssue = false; diff --git a/app/NativeComponents/Concerns/SyncsOutbox.php b/app/NativeComponents/Concerns/SyncsOutbox.php index dc1a1f5..13ac67e 100644 --- a/app/NativeComponents/Concerns/SyncsOutbox.php +++ b/app/NativeComponents/Concerns/SyncsOutbox.php @@ -15,6 +15,7 @@ use App\Devnull\Toasts; use App\Models\PendingWrite; use Native\Mobile\Attributes\On; +use Native\Mobile\Facades\Browser; use Throwable; use Victorycodedev\ToastKit\Events\ToastActionPressed; @@ -168,7 +169,7 @@ protected function stagedCommits(?string $parentId): array } /** - * A toast's button, Retry or Edit, pressed on whichever screen is showing. + * A toast's button: Retry, Edit or View issue, pressed on whichever screen is showing. * * @param array $data */ @@ -179,6 +180,7 @@ public function toastActionPressed(string $toastId = '', string $actionId = '', 'retry-star' => $this->retryStar($data), 'retry-message' => $this->retryMessage($data), 'edit' => $this->editFromToast($data), + 'view-issue' => $this->viewIssueFromToast($data), default => null, }; } @@ -363,6 +365,20 @@ private function editFromToast(array $data): void } } + /** + * View issue on the toast of an issue that opened once its sheet had gone (see DraftsIssues). + * + * @param array $data + */ + private function viewIssueFromToast(array $data): void + { + $url = $data['url'] ?? null; + + if (is_string($url) && in_array(parse_url($url, PHP_URL_SCHEME), ['http', 'https'], true)) { + Browser::inApp($url); + } + } + /** * Your star on a commit changed: by you, by devnull turning it down, or as devnull counted it ($fresh). */ diff --git a/tests/Feature/Screens/IssueCommandsTest.php b/tests/Feature/Screens/IssueCommandsTest.php index b0f48c1..e24aa07 100644 --- a/tests/Feature/Screens/IssueCommandsTest.php +++ b/tests/Feature/Screens/IssueCommandsTest.php @@ -10,6 +10,7 @@ use Illuminate\Support\Facades\Http; use Native\Mobile\Testing\Native; use Native\Mobile\Testing\TestableComponent; +use Victorycodedev\ToastKit\Events\ToastActionPressed; beforeEach(function () { config(['devnull.url' => 'http://devnull.test']); @@ -204,15 +205,21 @@ function hasRef(TestableComponent $screen, string $ref): bool ->and(hasRef($screen, 'sheet-commit'))->toBeTrue(); }); -it('says so in a toast when the sheet was swiped away before the issue opened', function () { +it('says so in a toast with View issue when the sheet was swiped away before the issue opened', function () { holdRefreshes(); $screen = issueSheetOnLog()->input('sheet-body', '/feature Dark mode')->tap('sheet-open-issue')->dismissSheet('sheet'); finishRefreshes($screen) - ->assertNativeCalled('ToastKit.Show', fn (array $params): bool => $params['message'] === 'Opened issue #123.'); + ->assertNativeCalled('ToastKit.Show', fn (array $params): bool => $params['message'] === 'Opened issue #123.' + && $params['variant'] === 'success' + && $params['action'] === ['id' => 'view-issue', 'label' => 'View issue'] + && $params['data'] === ['url' => 'https://github.com/nunomaduro/dev-null.sh/issues/123']); expect(app(Stash::class)->get($this->userId, 'commit'))->toBe('') ->and(nodeByRef($screen, 'sheet')['props']['visible'])->toBeFalse(); + + $screen->emitNative(ToastActionPressed::class, ['toastId' => 'toast-1', 'actionId' => 'view-issue', 'data' => ['url' => 'https://github.com/nunomaduro/dev-null.sh/issues/123']]) + ->assertNativeCalled('Browser.OpenInApp', fn (array $params): bool => $params['url'] === 'https://github.com/nunomaduro/dev-null.sh/issues/123'); }); it('keeps the issue draft with why when devnull or GitHub says no', function (array $answer, string $message) { From 014c63f78d667e4b16db59485273325a51eb0242 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Sun, 25 Oct 2026 01:24:00 +0100 Subject: [PATCH 4/6] Wait for the composer sheet to close before opening it on a failed commit With no connection, devnull's failure lands while the sheet the commit was sent from is still sliding away. The sheet was opened again on the draft right then, and when iOS reported the old sheet gone, commitSheetClosed() reset the sheet and closed the new one too. Because the reopen had reported success, the toast had no Edit button, so the draft was only reachable through the stash. Closing the sheet from here now marks it as closing until iOS says it has gone. A failure that lands in that window is held, the toast keeps Edit, and the sheet opens on the draft once commitSheetClosed() runs. The toast only drops Edit when the sheet really opened. Quotes and replies written in the sheet go through the same path. The reply bar has no sheet, so it isn't affected. Co-Authored-By: Claude Opus 5.5 --- .../Concerns/OpensCommitSheet.php | 47 ++++++++++++++++++- tests/Feature/Screens/ComposeSheetTest.php | 41 ++++++++++++++-- tests/Feature/Screens/QuoteTest.php | 8 +++- 3 files changed, 88 insertions(+), 8 deletions(-) diff --git a/app/NativeComponents/Concerns/OpensCommitSheet.php b/app/NativeComponents/Concerns/OpensCommitSheet.php index 4a42c87..9fe5e1f 100644 --- a/app/NativeComponents/Concerns/OpensCommitSheet.php +++ b/app/NativeComponents/Concerns/OpensCommitSheet.php @@ -83,6 +83,19 @@ trait OpensCommitSheet */ public bool $sheetRestored = false; + /** + * Whether the sheet was closed from here and is still sliding away. iOS + * says when it's gone (see commitSheetClosed()), and a sheet opened before + * then goes with it. + */ + public bool $sheetClosing = false; + + /** + * A commit devnull turned down while the sheet was still sliding away, + * whose sheet opens again once it's gone (see reopenFailedCommit()). + */ + public ?int $reopenAfterSheet = null; + /** * Why devnull didn't take the report or the commit, shown in the sheet. */ @@ -239,7 +252,7 @@ public function commitFromSheet(): void app(Stash::class)->put($me->id, $stashName, ''); $this->sheetImages = []; - $this->sheetOpen = false; + $this->closeCommitSheet(); $this->showCommitted($write->stagedCommit($me)); $this->sendFromOutbox($write); } @@ -250,7 +263,7 @@ public function commitFromSheet(): void */ public function hideCommitSheet(): void { - $this->sheetOpen = false; + $this->closeCommitSheet(); } /** @@ -259,9 +272,18 @@ public function hideCommitSheet(): void public function commitSheetClosed(): void { $view = $this->sheetView; + $held = $this->reopenAfterSheet; + $this->sheetClosing = false; + $this->reopenAfterSheet = null; $this->forgetCommitSheet(); $this->commitSheetGone($view); + + $write = $held === null ? null : app(Outbox::class)->find($held); + + if ($write !== null && $write->isCommit() && $write->isFailed()) { + $this->openFailedCommit($write); + } } /** @@ -286,6 +308,12 @@ protected function commitSheetGone(string $view): void * on this screen, if the sheet's free: a new commit on the screen that * opens it (see opensComposer()), and a quote or a reply in the sheet * where the commit it was for is showing. + * + * When devnull answers at once, as it does with no connection, the sheet + * the commit was sent from is still sliding away, and a sheet opened now + * would go with it when it's gone. So it waits until iOS says the sheet + * has gone (see commitSheetClosed()), and answers that it didn't open, + * so the toast still offers Edit. */ protected function reopenFailedCommit(PendingWrite $write): bool { @@ -293,6 +321,12 @@ protected function reopenFailedCommit(PendingWrite $write): bool return false; } + if ($this->sheetClosing) { + $this->reopenAfterSheet = $write->id; + + return false; + } + return $this->openFailedCommit($write); } @@ -314,6 +348,15 @@ protected function editFailedCommit(PendingWrite $write): void } } + /** + * Closes the sheet from here. It slides away until iOS says it's gone. + */ + private function closeCommitSheet(): void + { + $this->sheetClosing = $this->sheetClosing || $this->sheetOpen; + $this->sheetOpen = false; + } + /** * Whether this screen has the button for a new commit, which a new commit devnull turned down goes back to. */ diff --git a/tests/Feature/Screens/ComposeSheetTest.php b/tests/Feature/Screens/ComposeSheetTest.php index 4019863..1e7638c 100644 --- a/tests/Feature/Screens/ComposeSheetTest.php +++ b/tests/Feature/Screens/ComposeSheetTest.php @@ -136,19 +136,50 @@ function sheetCommitDisabled(TestableComponent $screen): bool ->sheetBody->toBe(''); }); -it('says why devnull said no in a toast, and opens the sheet again on the draft', function () { +it('waits for the sheet to go before opening it again on a commit devnull turned down at once, and offers Edit meanwhile', function () { $screen = composeSheetOnLog([['message' => 'Too Many Attempts.'], 429]) ->input('sheet-body', 'One more') - ->tap('sheet-commit') - ->assertNativeCalled('ToastKit.Show', fn (array $params): bool => $params['message'] === 'Too Many Attempts.' && $params['variant'] === 'error'); + ->tap('sheet-commit'); + + // devnull said no before the sheet finished sliding away, so it isn't opened under the one going. + $write = PendingWrite::query()->sole(); + $screen->assertNativeCalled('ToastKit.Show', fn (array $params): bool => $params['message'] === 'Too Many Attempts.' + && $params['variant'] === 'error' + && $params['action'] === ['id' => 'edit', 'label' => 'Edit'] + && $params['data'] === ['write' => $write->id]); + + expect(nodeByRef($screen, 'sheet')['props']['visible'])->toBeFalse() + ->and($screen->instance()->reopenAfterSheet)->toBe($write->id); - expect(nodeByRef($screen, 'sheet-body')['props']['value'])->toBe('One more') + // iOS says the sheet has gone: it opens again on the draft. + $screen->dismissSheet('sheet'); + + expect(nodeByRef($screen, 'sheet')['props']['visible'])->toBeTrue() + ->and(nodeByRef($screen, 'sheet-body')['props']['value'])->toBe('One more') ->and(sheetCommitDisabled($screen))->toBeFalse() - ->and(nodeByRef($screen, 'sheet')['props']['visible'])->toBeTrue() ->and($screen->instance()->sheetView)->toBe('compose') ->and($screen->instance()->error)->toBeNull() ->and(array_map(fn (Commit $commit): string => $commit->id, $screen->instance()->commits))->toBe(['c1']) ->and(PendingWrite::query()->count())->toBe(0); + + // The new sheet stays open, rather than going with the one before it. + expect($screen->instance()->sheetOpen)->toBeTrue() + ->and($screen->instance()->sheetClosing)->toBeFalse(); +}); + +it('opens the sheet again at once on a commit devnull turned down after the sheet had gone, without Edit', function () { + holdRefreshes(); + $screen = composeSheetOnLog([['message' => 'Too Many Attempts.'], 429]); + $screen = finishRefreshes($screen) + ->input('sheet-body', 'One more') + ->tap('sheet-commit') + ->dismissSheet('sheet'); + + finishRefreshes($screen) + ->assertNativeCalled('ToastKit.Show', fn (array $params): bool => $params['message'] === 'Too Many Attempts.' && ! isset($params['action'])); + + expect(nodeByRef($screen, 'sheet')['props']['visible'])->toBeTrue() + ->and(nodeByRef($screen, 'sheet-body')['props']['value'])->toBe('One more'); }); it('keeps the draft when the sheet is swiped away, and opens on it again, marked as restored', function () { diff --git a/tests/Feature/Screens/QuoteTest.php b/tests/Feature/Screens/QuoteTest.php index 42445a8..1620f75 100644 --- a/tests/Feature/Screens/QuoteTest.php +++ b/tests/Feature/Screens/QuoteTest.php @@ -156,7 +156,13 @@ function quoteButtonDisabled(TestableComponent $screen): bool $screen = quoteSheetOnLog([['message' => 'That commit is no longer available to quote.', 'errors' => ['quoted_commit_id' => ['That commit is no longer available to quote.']]], 422]) ->input('sheet-body', 'This, all of it.') ->tap('sheet-commit') - ->assertNativeCalled('ToastKit.Show', fn (array $params): bool => $params['message'] === 'That commit is no longer available to quote.'); + ->assertNativeCalled('ToastKit.Show', fn (array $params): bool => $params['message'] === 'That commit is no longer available to quote.' + && $params['action'] === ['id' => 'edit', 'label' => 'Edit']); + + // It waits for the sheet it was sent from to go. + expect(nodeByRef($screen, 'sheet')['props']['visible'])->toBeFalse(); + + $screen->dismissSheet('sheet'); expect(nodeByRef($screen, 'sheet-body')['props']['value'])->toBe('This, all of it.') ->and($screen->instance()->sheetView)->toBe('quote') From 6bfeaac8ad7a142a5fd480cdaf4f1e8af48ec31b Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Sun, 25 Oct 2026 01:50:46 +0100 Subject: [PATCH 5/6] Only hold a failed commit's sheet for the close iOS reports The last fix held the reopen until the sheet's @dismiss arrived. That works on iOS, where the sheet reports every close. On Android the sheet only sends @dismiss when the user drags it away or presses back, never when PHP closes it. So sheetClosing stayed true for good and every failed commit after that waited for an event that never came. Android doesn't have the race anyway. It keeps the sheet drawn through its hide animation, so showing it again right away just shows it. The closing flag is now only set on iOS (System::isIos()), so on Android the sheet opens again at once and the toast has no Edit. Nothing else waits on @dismiss after a close from PHP. Cleanup on Android works as it did on main: the next open resets the sheet, and photos sent with a commit leave the sheet before it closes. Co-Authored-By: Claude Opus 5.5 --- .../Concerns/OpensCommitSheet.php | 23 +++++++++++----- tests/Feature/Screens/ComposeSheetTest.php | 26 +++++++++++++++++-- 2 files changed, 40 insertions(+), 9 deletions(-) diff --git a/app/NativeComponents/Concerns/OpensCommitSheet.php b/app/NativeComponents/Concerns/OpensCommitSheet.php index 9fe5e1f..028323f 100644 --- a/app/NativeComponents/Concerns/OpensCommitSheet.php +++ b/app/NativeComponents/Concerns/OpensCommitSheet.php @@ -11,6 +11,7 @@ use App\Devnull\Stash; use App\Models\PendingWrite; use Closure; +use Native\Mobile\Facades\System; /** * The sheet under a commit (see partials/commit-sheet). Report commit, in the @@ -84,9 +85,15 @@ trait OpensCommitSheet public bool $sheetRestored = false; /** - * Whether the sheet was closed from here and is still sliding away. iOS - * says when it's gone (see commitSheetClosed()), and a sheet opened before - * then goes with it. + * Whether the sheet was closed from here and is still sliding away, on + * iOS. iOS says when it's gone (see commitSheetClosed()), and a sheet + * opened before then goes with it. + * + * Android is different on both counts, so this stays false there. Its + * sheet only sends @dismiss when you drag it away or press back, never + * when it's closed from here, so a flag waiting for that would never + * clear. And it keeps the sheet drawn through its hide animation, so + * showing it again straight away simply shows it again. */ public bool $sheetClosing = false; @@ -310,8 +317,8 @@ protected function commitSheetGone(string $view): void * where the commit it was for is showing. * * When devnull answers at once, as it does with no connection, the sheet - * the commit was sent from is still sliding away, and a sheet opened now - * would go with it when it's gone. So it waits until iOS says the sheet + * the commit was sent from may still be sliding away on iOS, and a sheet + * opened now would go with it when it's gone. So it waits until iOS says the sheet * has gone (see commitSheetClosed()), and answers that it didn't open, * so the toast still offers Edit. */ @@ -349,11 +356,13 @@ protected function editFailedCommit(PendingWrite $write): void } /** - * Closes the sheet from here. It slides away until iOS says it's gone. + * Closes the sheet from here. On iOS it slides away until iOS says it's + * gone. Android never says so for a sheet closed from here (see + * $sheetClosing). */ private function closeCommitSheet(): void { - $this->sheetClosing = $this->sheetClosing || $this->sheetOpen; + $this->sheetClosing = $this->sheetClosing || ($this->sheetOpen && System::isIos()); $this->sheetOpen = false; } diff --git a/tests/Feature/Screens/ComposeSheetTest.php b/tests/Feature/Screens/ComposeSheetTest.php index 1e7638c..d87d783 100644 --- a/tests/Feature/Screens/ComposeSheetTest.php +++ b/tests/Feature/Screens/ComposeSheetTest.php @@ -17,14 +17,14 @@ * * @param array{0: mixed, 1?: int}|null $answer */ -function composeSheetOnLog(?array $answer = null): TestableComponent +function composeSheetOnLog(?array $answer = null, string $platform = 'ios'): TestableComponent { fakeApi([ 'GET /api/v1/log/following' => [pageJson([commitJson(['id' => 'c1', 'body' => 'An older commit', 'author' => userJson(['id' => 'u2', 'username' => 'ada'])])])], 'POST /api/v1/commits' => $answer ?? [['data' => commitJson(['id' => '0199e5c4-2222-7000-8000-00000000cafe', 'body' => 'Shipped from the sheet.'])], 201], ]); - return Native::visit('/log', platform: 'ios')->press('composeInSheet'); + return Native::visit('/log', platform: $platform)->press('composeInSheet'); } function sheetCommitDisabled(TestableComponent $screen): bool @@ -167,6 +167,28 @@ function sheetCommitDisabled(TestableComponent $screen): bool ->and($screen->instance()->sheetClosing)->toBeFalse(); }); +it('opens the sheet again at once on Android, whose sheet never says it has gone after a close from here', function () { + $screen = composeSheetOnLog([['message' => 'Too Many Attempts.'], 429], platform: 'android') + ->input('sheet-body', 'One more') + ->tap('sheet-commit') + ->assertNativeCalled('ToastKit.Show', fn (array $params): bool => $params['message'] === 'Too Many Attempts.' && ! isset($params['action'])); + + expect(nodeByRef($screen, 'sheet')['props']['visible'])->toBeTrue() + ->and(nodeByRef($screen, 'sheet-body')['props']['value'])->toBe('One more') + ->and($screen->instance()->sheetClosing)->toBeFalse() + ->and($screen->instance()->reopenAfterSheet)->toBeNull() + ->and(PendingWrite::query()->count())->toBe(0); + + // Cancel closes it with no word from Android either, and the next one devnull turns down still opens again. + $screen->tap('sheet-cancel'); + expect($screen->instance()->sheetClosing)->toBeFalse(); + + $screen->press('composeInSheet')->input('sheet-body', 'Once more')->tap('sheet-commit'); + + expect(nodeByRef($screen, 'sheet')['props']['visible'])->toBeTrue() + ->and(nodeByRef($screen, 'sheet-body')['props']['value'])->toBe('Once more'); +}); + it('opens the sheet again at once on a commit devnull turned down after the sheet had gone, without Edit', function () { holdRefreshes(); $screen = composeSheetOnLog([['message' => 'Too Many Attempts.'], 429]); From 9469ca1e26d9df396e0a23820e35255858f8dd28 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Sun, 25 Oct 2026 03:46:38 +0000 Subject: [PATCH 6/6] Use the ToastKit fork that draws Android toasts above sheets ToastKit 1.4.0 puts its Android toasts in the activity's decor view, so any toast shown while a bottom sheet is open lands behind the sheet. The commit sheet's failure toasts were hidden that way. The fork's fix/android-toasts-above-sheets branch hosts them in their own windows instead. It's required as 1.4.0 until the fix is released upstream. Co-Authored-By: Claude Opus 5.5 --- composer.json | 6 +++++- composer.lock | 29 ++++++++++++++++++++--------- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/composer.json b/composer.json index 9d766cd..ebda584 100644 --- a/composer.json +++ b/composer.json @@ -19,6 +19,10 @@ "type": "vcs", "url": "https://github.com/NativePHP/mobile-ui" }, + { + "type": "vcs", + "url": "https://github.com/simonhamp/toastkit" + }, { "name": "nativephp-plugins", "type": "composer", @@ -38,7 +42,7 @@ "nativephp/mobile-share": "^1.1", "nativephp/mobile-ui": "dev-main as 0.8.0", "s2br/nativephp-mobile-splashscreen": "^1.4", - "victorycodedev/toastkit": "^1.4" + "victorycodedev/toastkit": "dev-fix/android-toasts-above-sheets as 1.4.0" }, "require-dev": { "fakerphp/faker": "^1.23", diff --git a/composer.lock b/composer.lock index a557736..5053530 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "53e0af74fc9e3271140e4406bff0097e", + "content-hash": "4c795f7f152e1855d8c1e257076e3f74", "packages": [ { "name": "bacon/bacon-qr-code", @@ -7445,16 +7445,16 @@ }, { "name": "victorycodedev/toastkit", - "version": "v1.4.0", + "version": "dev-fix/android-toasts-above-sheets", "source": { "type": "git", - "url": "https://github.com/victorycodedev/toastkit.git", - "reference": "2f1ffeca2c9ff853e656311fc82dd0f238d01538" + "url": "https://github.com/simonhamp/toastkit.git", + "reference": "30f7c15d4b0f7641490eeee3e8a5213054ec9dbe" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/victorycodedev/toastkit/zipball/2f1ffeca2c9ff853e656311fc82dd0f238d01538", - "reference": "2f1ffeca2c9ff853e656311fc82dd0f238d01538", + "url": "https://api.github.com/repos/simonhamp/toastkit/zipball/30f7c15d4b0f7641490eeee3e8a5213054ec9dbe", + "reference": "30f7c15d4b0f7641490eeee3e8a5213054ec9dbe", "shasum": "" }, "require": { @@ -7480,7 +7480,11 @@ "Victorycodedev\\ToastKit\\": "src/" } }, - "notification-url": "https://packagist.org/downloads/", + "scripts": { + "test": [ + "@php vendor/bin/pest --configuration phpunit.xml.dist" + ] + }, "license": [ "MIT" ], @@ -7502,7 +7506,7 @@ "issues": "https://github.com/victorycodedev/toastkit/issues", "source": "https://github.com/victorycodedev/toastkit" }, - "time": "2026-10-21T19:57:09+00:00" + "time": "2026-10-25T03:45:04+00:00" }, { "name": "vlucas/phpdotenv", @@ -11202,12 +11206,19 @@ "version": "dev-main", "alias": "0.8.0", "alias_normalized": "0.8.0.0" + }, + { + "package": "victorycodedev/toastkit", + "version": "dev-fix/android-toasts-above-sheets", + "alias": "1.4.0", + "alias_normalized": "1.4.0.0" } ], "minimum-stability": "stable", "stability-flags": { "nativephp/mobile": 20, - "nativephp/mobile-ui": 20 + "nativephp/mobile-ui": 20, + "victorycodedev/toastkit": 20 }, "prefer-stable": true, "prefer-lowest": false,