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/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/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 044bb25..7c0f321 100644 --- a/app/NativeComponents/Concerns/AttachesToCommits.php +++ b/app/NativeComponents/Concerns/AttachesToCommits.php @@ -3,21 +3,17 @@ 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 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, @@ -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 { - Dialog::toast($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/DraftsIssues.php b/app/NativeComponents/Concerns/DraftsIssues.php index c4fb1cb..ab72c20 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 @@ -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) { - Dialog::toast("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/OpensCommitSheet.php b/app/NativeComponents/Concerns/OpensCommitSheet.php index 397ade4..028323f 100644 --- a/app/NativeComponents/Concerns/OpensCommitSheet.php +++ b/app/NativeComponents/Concerns/OpensCommitSheet.php @@ -5,9 +5,13 @@ 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; +use Native\Mobile\Facades\System; /** * The sheet under a commit (see partials/commit-sheet). Report commit, in the @@ -17,13 +21,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 +49,7 @@ trait OpensCommitSheet { use AttachesToCommits; use DraftsIssues; + use SyncsOutbox; /** * Whether the sheet is up, or on its way up. @@ -71,13 +79,30 @@ 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. */ public bool $sheetRestored = false; + /** + * 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; + + /** + * 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. */ @@ -203,7 +228,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 +237,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->closeCommitSheet(); + $this->showCommitted($write->stagedCommit($me)); + $this->sendFromOutbox($write); } /** @@ -247,7 +270,7 @@ public function commitFromSheet(): void */ public function hideCommitSheet(): void { - $this->sheetOpen = false; + $this->closeCommitSheet(); } /** @@ -256,13 +279,23 @@ 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); + } } /** - * 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 +310,92 @@ 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. + * + * When devnull answers at once, as it does with no connection, 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. + */ + protected function reopenFailedCommit(PendingWrite $write): bool + { + if ($this->sheetOpen || ($write->composer() === 'compose' && ! $this->opensComposer())) { + return false; + } + + if ($this->sheetClosing) { + $this->reopenAfterSheet = $write->id; + + 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]); + } + } + + /** + * 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 && System::isIos()); + $this->sheetOpen = false; + } + + /** + * 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 +432,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 cdf12ab..0689240 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; @@ -110,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]); } @@ -130,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])]); } @@ -144,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 { @@ -156,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); } /** @@ -179,7 +190,7 @@ public function givePastel(string $id): void } if ($pasteis->left() === 0) { - Dialog::toast(Pasteis::Exhausted); + Toasts::failed(Pasteis::Exhausted); return; } @@ -219,7 +230,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)); } @@ -310,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; } @@ -389,7 +400,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 +423,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 +462,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, @@ -460,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 @@ -475,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..13ac67e --- /dev/null +++ b/app/NativeComponents/Concerns/SyncsOutbox.php @@ -0,0 +1,428 @@ +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, Edit or View issue, 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), + 'view-issue' => $this->viewIssueFromToast($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); + } + } + + /** + * 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). + */ + 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/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/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/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..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", @@ -37,7 +41,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": "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 c5b93d0..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": "8bcaeeb71989e08fe920116ba3e08bcd", + "content-hash": "4c795f7f152e1855d8c1e257076e3f74", "packages": [ { "name": "bacon/bacon-qr-code", @@ -7443,6 +7443,71 @@ }, "time": "2025-12-02T11:56:42+00:00" }, + { + "name": "victorycodedev/toastkit", + "version": "dev-fix/android-toasts-above-sheets", + "source": { + "type": "git", + "url": "https://github.com/simonhamp/toastkit.git", + "reference": "30f7c15d4b0f7641490eeee3e8a5213054ec9dbe" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/simonhamp/toastkit/zipball/30f7c15d4b0f7641490eeee3e8a5213054ec9dbe", + "reference": "30f7c15d4b0f7641490eeee3e8a5213054ec9dbe", + "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/" + } + }, + "scripts": { + "test": [ + "@php vendor/bin/pest --configuration phpunit.xml.dist" + ] + }, + "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-25T03:45:04+00:00" + }, { "name": "vlucas/phpdotenv", "version": "v5.7.0", @@ -11141,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, 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/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..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') - ->assertNativeNotCalled('Dialog.Toast'); + ->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); @@ -256,18 +257,26 @@ 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 () { +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 () { @@ -323,7 +339,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..d87d783 100644 --- a/tests/Feature/Screens/ComposeSheetTest.php +++ b/tests/Feature/Screens/ComposeSheetTest.php @@ -1,6 +1,7 @@ [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 @@ -100,17 +101,9 @@ function sheetCommitDisabled(TestableComponent $screen): bool expect(sheetCommitDisabled($screen))->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); +it('does not submit a closed composer twice', function () { + composeSheetOnLog()->input('sheet-body', 'a')->tap('sheet-commit')->call('commitFromSheet'); - // 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(); - - $screen->call('commitFromSheet'); expect(Http::recorded(fn (Request $request): bool => $request->method() === 'POST'))->toHaveCount(1); }); @@ -121,7 +114,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' @@ -143,18 +136,72 @@ function sheetCommitDisabled(TestableComponent $screen): bool ->sheetBody->toBe(''); }); -it('shows why devnull said no in the sheet, keeping 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'); - expect(nodeByRef($screen, 'sheet-error')['props']['text'])->toBe('Too Many Attempts.') + // 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); + + // 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($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); + + // 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 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]); + $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/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/IssueCommandsTest.php b/tests/Feature/Screens/IssueCommandsTest.php index 0fe7568..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']); @@ -190,7 +191,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'); @@ -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('Dialog.Toast', 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) { 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/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..1620f75 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. @@ -152,13 +152,20 @@ 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.' + && $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-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/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' 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