From 130edf479e62a064637f2d5b970275118305f413 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Tue, 27 Oct 2026 13:53:14 +0000 Subject: [PATCH] Keep a stashed draft's photos and poll, not just its text The stash only held the body, so closing the composer sheet threw away its photos (deleting them from the phone) and its poll. Each draft now keeps its photos with their descriptions and its poll with its length, and the sheet opens on all of it again. Photos stay on the phone while a stashed draft or an outbox commit holds them, rather than going after a day. Drop, sign-out and the 50-draft limit delete them. A reply's draft is shared with the commit page's reply bar, so the bar now shows "N photos" under the field when the draft has some. Tapping it opens the sheet, and Reply from the bar sends them. Cancel on Android also hands the draft back to the bar now, since Android never reports that the sheet has gone. Co-Authored-By: Claude Opus 5.5 --- app/Devnull/Data/DraftImage.php | 26 ++++++ app/Devnull/Data/StashedDraft.php | 64 +++++++++++++ app/Devnull/Display.php | 9 +- app/Devnull/DraftImages.php | 25 +++++- app/Devnull/Stash.php | 64 ++++++++++--- app/Models/PendingWrite.php | 20 +---- app/NativeComponents/CommitDetail.php | 52 ++++++++--- .../Concerns/AttachesToCommits.php | 34 +++++-- .../Concerns/DraftsIssues.php | 19 ++-- .../Concerns/OpensCommitSheet.php | 83 ++++++++++------- app/NativeComponents/Concerns/SyncsOutbox.php | 5 +- .../views/native/commit-detail.blade.php | 28 ++++-- tests/Feature/Devnull/DraftImagesTest.php | 27 ++++++ tests/Feature/Devnull/StashTest.php | 90 +++++++++++++------ tests/Feature/Screens/CommitDetailTest.php | 2 +- tests/Feature/Screens/ComposerPhotosTest.php | 81 +++++++++++++++-- tests/Feature/Screens/ComposerPollTest.php | 28 +++++- tests/Feature/Screens/DraftStashTest.php | 7 +- tests/Feature/Screens/IssueCommandsTest.php | 11 +-- tests/Feature/Screens/LogRowCacheTest.php | 3 +- tests/Feature/Screens/OutboxTest.php | 4 +- 21 files changed, 536 insertions(+), 146 deletions(-) create mode 100644 app/Devnull/Data/StashedDraft.php diff --git a/app/Devnull/Data/DraftImage.php b/app/Devnull/Data/DraftImage.php index 211903da..20e832de 100644 --- a/app/Devnull/Data/DraftImage.php +++ b/app/Devnull/Data/DraftImage.php @@ -13,6 +13,32 @@ { public function __construct(public string $path, public string $mimeType, public int $width, public int $height, public string $description = '') {} + /** + * The image as the stash and the outbox keep it, or null when its file has gone from the phone. + */ + public static function fromArray(mixed $image): ?self + { + if (! is_array($image) || ! is_string($image['path'] ?? null) || ! is_file($image['path'])) { + return null; + } + + return new self($image['path'], (string) ($image['mime_type'] ?? ''), (int) ($image['width'] ?? 0), (int) ($image['height'] ?? 0), (string) ($image['description'] ?? '')); + } + + /** + * @return array{path: string, mime_type: string, width: int, height: int, description: string} + */ + public function toArray(): array + { + return [ + 'path' => $this->path, + 'mime_type' => $this->mimeType, + 'width' => $this->width, + 'height' => $this->height, + 'description' => $this->description, + ]; + } + public function withDescription(string $description): self { return new self(...[...get_object_vars($this), 'description' => mb_substr($description, 0, DraftImages::MaxDescriptionLength)]); diff --git a/app/Devnull/Data/StashedDraft.php b/app/Devnull/Data/StashedDraft.php new file mode 100644 index 00000000..881959a3 --- /dev/null +++ b/app/Devnull/Data/StashedDraft.php @@ -0,0 +1,64 @@ + $images + * @param list|null $poll The poll's options, or null with no poll. + */ + public function __construct( + public string $body = '', + public array $images = [], + public ?array $poll = null, + public string $pollLength = PollDraft::DefaultLength, + ) {} + + /** + * The draft as the stash keeps it, without photos that have gone from the phone. A string is a draft kept before + * photos and polls were. + */ + public static function fromArray(mixed $draft): self + { + if (is_string($draft)) { + return new self($draft); + } + + $draft = is_array($draft) ? $draft : []; + $poll = is_array($draft['poll'] ?? null) ? $draft['poll'] : null; + + return new self( + body: is_string($draft['body'] ?? null) ? $draft['body'] : '', + images: array_values(array_filter(array_map(DraftImage::fromArray(...), is_array($draft['images'] ?? null) ? $draft['images'] : []))), + poll: is_array($poll['options'] ?? null) ? array_values(array_filter($poll['options'], is_string(...))) : null, + pollLength: is_string($poll['length'] ?? null) && array_key_exists($poll['length'], PollDraft::Lengths) ? $poll['length'] : PollDraft::DefaultLength, + ); + } + + /** + * @return array{body: string, images: list, poll: array{options: list, length: string}|null} + */ + public function toArray(): array + { + return [ + 'body' => $this->body, + 'images' => array_map(fn (DraftImage $image): array => $image->toArray(), $this->images), + 'poll' => $this->poll === null ? null : ['options' => $this->poll, 'length' => $this->pollLength], + ]; + } + + /** + * Whether there's nothing in it to keep: no body, no photos, and no poll with anything written in it. An empty + * poll still comes back with the body it was added to. + */ + public function isEmpty(): bool + { + return $this->body === '' && $this->images === [] && ($this->poll === null || array_all($this->poll, fn (string $option): bool => trim($option) === '')); + } +} diff --git a/app/Devnull/Display.php b/app/Devnull/Display.php index 2c01ad26..6a54160b 100644 --- a/app/Devnull/Display.php +++ b/app/Devnull/Display.php @@ -24,7 +24,7 @@ final class Display public const int ReplyBarCharactersPerLine = 34; /** - * The row under the reply bar's field that marks a restored draft (see partials/stash-label). + * The row under the reply bar's field that marks a restored draft (see partials/stash-label) and counts its photos. */ public const int ReplyBarStashLabelHeight = 28; @@ -179,9 +179,10 @@ public static function maskedKey(string $openSsh): string * indicator, with no inset for either, so the page pads its own end: the * bar as the draft grows to its five lines, and the indicator below it. * With the keyboard up the bar sits on the keyboard instead, which only - * leaves a little more room. A restored draft adds the row that marks it. + * leaves a little more room. A restored draft, or one with photos, adds + * the row under the field ($draftRow). */ - public static function replyBarClearance(string $draft, bool $restored = false): int + public static function replyBarClearance(string $draft, bool $draftRow = false): int { $lines = 0; @@ -191,7 +192,7 @@ public static function replyBarClearance(string $draft, bool $restored = false): return self::ReplyBarHeight + (min(self::ReplyBarMaxLines, $lines) - 1) * self::ReplyBarLineHeight - + ($restored ? self::ReplyBarStashLabelHeight : 0) + + ($draftRow ? self::ReplyBarStashLabelHeight : 0) + (CommitBody::hasOversizedUrl($draft) ? 3 * self::ReplyBarLineHeight : 0) + self::HomeIndicatorHeight; } diff --git a/app/Devnull/DraftImages.php b/app/Devnull/DraftImages.php index 426dd67b..00b3cf07 100644 --- a/app/Devnull/DraftImages.php +++ b/app/Devnull/DraftImages.php @@ -4,6 +4,7 @@ use App\Devnull\Data\DraftImage; use App\Devnull\Exceptions\InvalidDraftImage; +use App\Models\PendingWrite; /** * The photos attached to the commits you're writing, taken with the camera or @@ -16,7 +17,8 @@ * * The camera and the library hand over copies made for the app, so a photo is * moved here, and one that won't do is deleted, so none are left lying about. - * Photos left behind by a draft that was never sent go after a day. + * A photo stays as long as a stashed draft or a commit in the outbox holds + * it. One nothing holds any more goes after a day. */ final class DraftImages { @@ -45,7 +47,7 @@ final class DraftImages public const string CameraDenied = "devnull can't use the camera or your photos. Allow it in Settings."; /** - * How long a photo of a draft that was never sent is kept. + * How long a photo nothing holds any more is kept. */ private const int KeptForSeconds = 86_400; @@ -125,10 +127,27 @@ private function directory(): string private function prune(): void { + $held = array_flip($this->held()); + foreach (glob($this->directory().'/*') ?: [] as $file) { - if (filemtime($file) < time() - self::KeptForSeconds) { + if (! isset($held[$file]) && filemtime($file) < time() - self::KeptForSeconds) { @unlink($file); } } } + + /** + * The paths of the photos your stashed drafts and the commits in the outbox hold. + * + * @return list + */ + private function held(): array + { + $images = [ + ...app(Stash::class)->images(app(Account::class)->user()?->id), + ...app(Outbox::class)->pending(PendingWrite::Commit, withFailed: true)->flatMap(fn (PendingWrite $write): array => $write->images())->all(), + ]; + + return array_map(fn (DraftImage $image): string => $image->path, $images); + } } diff --git a/app/Devnull/Stash.php b/app/Devnull/Stash.php index cd2ed6dc..c33e59d8 100644 --- a/app/Devnull/Stash.php +++ b/app/Devnull/Stash.php @@ -2,16 +2,23 @@ namespace App\Devnull; +use App\Devnull\Data\DraftImage; +use App\Devnull\Data\StashedDraft; use Illuminate\Support\Facades\Cache; /** - * What you wrote and didn't send, per user, as the web's useStash keeps it. + * What you wrote and didn't send, per user, as the web's useStash keeps it: + * the text, and the photos and poll that go with it. * * Drafts go by the web's names: 'commit' for a new commit, from the compose * sheet, 'quote-{id}' for a quote of a commit and 'reply-{id}' for a - * reply to one. A screen keeps a draft as its field changes, and forgets it - * once it's sent or the field is emptied. Signing out forgets them all (see + * reply to one. A screen keeps a draft as it changes, and forgets it once + * it's sent or there's nothing left in it. Signing out forgets them all (see * Account::forget). + * + * A stashed draft's photos stay on the phone for as long as it's kept (see + * DraftImages). They're deleted when the draft is pushed out by newer ones + * or you sign out, and otherwise by whoever takes them off it. */ final class Stash { @@ -21,17 +28,18 @@ final class Stash public const int MaxDrafts = 50; /** - * The draft kept under $name, or '' when there's none. + * The draft kept under $name, or an empty one when there's none. */ - public function get(?string $userId, string $name): string + public function get(?string $userId, string $name): StashedDraft { - return $this->drafts($userId)[$name] ?? ''; + return $this->drafts($userId)[$name] ?? new StashedDraft; } /** - * Keeps $body under $name. An empty body forgets the draft. + * Keeps $draft under $name. An empty draft forgets it. The photos of a draft it replaces aren't deleted: they've + * been taken off, or gone with the commit. */ - public function put(?string $userId, string $name, string $body): void + public function put(?string $userId, string $name, StashedDraft $draft): void { if ($userId === null) { return; @@ -39,19 +47,22 @@ public function put(?string $userId, string $name, string $body): void $drafts = $this->drafts($userId); - if ($body === '' && ! array_key_exists($name, $drafts)) { + if ($draft->isEmpty() && ! array_key_exists($name, $drafts)) { return; } unset($drafts[$name]); - if ($body !== '') { - $drafts = array_slice([...$drafts, $name => $body], -self::MaxDrafts, preserve_keys: true); + if (! $draft->isEmpty()) { + $drafts = [...$drafts, $name => $draft]; + + app(DraftImages::class)->discard(self::imagesOf(array_slice($drafts, 0, -self::MaxDrafts))); + $drafts = array_slice($drafts, -self::MaxDrafts, preserve_keys: true); } $drafts === [] ? Cache::forget($this->key($userId)) - : Cache::forever($this->key($userId), $drafts); + : Cache::forever($this->key($userId), array_map(fn (StashedDraft $draft): array => $draft->toArray(), $drafts)); } /** @@ -64,21 +75,46 @@ public function names(?string $userId): array return array_map(strval(...), array_keys($this->drafts($userId))); } + /** + * Every photo the drafts hold, which DraftImages keeps for them. + * + * @return list + */ + public function images(?string $userId): array + { + return self::imagesOf($this->drafts($userId)); + } + + /** + * Forgets every draft, and deletes their photos. + */ public function forget(?string $userId): void { if ($userId !== null) { + app(DraftImages::class)->discard($this->images($userId)); Cache::forget($this->key($userId)); } } /** - * @return array + * @return array */ private function drafts(?string $userId): array { $drafts = $userId === null ? null : Cache::get($this->key($userId)); - return is_array($drafts) ? array_filter($drafts, fn (mixed $body): bool => is_string($body) && $body !== '') : []; + return is_array($drafts) + ? array_filter(array_map(StashedDraft::fromArray(...), $drafts), fn (StashedDraft $draft): bool => ! $draft->isEmpty()) + : []; + } + + /** + * @param array $drafts + * @return list + */ + private static function imagesOf(array $drafts): array + { + return array_merge(...array_values(array_map(fn (StashedDraft $draft): array => $draft->images, $drafts))); } private function key(string $userId): string diff --git a/app/Models/PendingWrite.php b/app/Models/PendingWrite.php index f0168595..cf9f1de9 100644 --- a/app/Models/PendingWrite.php +++ b/app/Models/PendingWrite.php @@ -148,21 +148,15 @@ public function composer(): string } /** - * The commit's photos still on the phone. Photos of a draft are deleted after a day (see DraftImages). + * The commit's photos still on the phone. Photos nothing holds any more are deleted after a day (see DraftImages). * * @return list */ public function images(): array { - $images = []; + $images = is_array($this->payload['images'] ?? null) ? $this->payload['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; + return array_values(array_filter(array_map(DraftImage::fromArray(...), $images))); } /** @@ -171,13 +165,7 @@ public function images(): array */ 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); + return array_map(fn (DraftImage $image): array => $image->toArray(), $images); } /** diff --git a/app/NativeComponents/CommitDetail.php b/app/NativeComponents/CommitDetail.php index 399a7860..bed55693 100644 --- a/app/NativeComponents/CommitDetail.php +++ b/app/NativeComponents/CommitDetail.php @@ -6,8 +6,12 @@ use App\Devnull\Client; use App\Devnull\Data\Commit; use App\Devnull\Data\CommitThread; +use App\Devnull\Data\Draft; +use App\Devnull\Data\DraftImage; use App\Devnull\Data\Page; +use App\Devnull\Data\StashedDraft; use App\Devnull\Display; +use App\Devnull\DraftImages; use App\Devnull\Outbox; use App\Devnull\RowCache; use App\Devnull\Stash; @@ -47,6 +51,8 @@ * 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. + * Photos left in it show as a count under the bar's field, which opens the + * sheet on them, and Reply in the bar sends them with the text. * * 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 @@ -105,6 +111,13 @@ class CommitDetail extends NativeComponent public string $reply = ''; + /** + * The photos in the reply's draft, attached in the sheet, which the bar sends with it. + * + * @var list + */ + public array $replyImages = []; + /** * Whether the reply was stashed when the page opened, which the bar marks until you empty it or drop it. */ @@ -149,8 +162,10 @@ public function mount(): void { $this->focusReply = $this->data('reply', false) === true; $this->repliesShown = $this->data('replies', false) === true; - $this->reply = app(Stash::class)->get($this->me()?->id, $this->replyStashName()); - $this->replyRestored = $this->reply !== ''; + $draft = app(Stash::class)->get($this->me()?->id, $this->replyStashName()); + $this->reply = $draft->body; + $this->replyImages = $draft->images; + $this->replyRestored = ! $draft->isEmpty(); $this->showKept(); $this->revalidate(); $this->editFromData(); @@ -300,21 +315,23 @@ public function loadMoreReplies(): void } /** - * 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. + * Sends the reply in the bar, with any photos its draft has: 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 ($me === null || $this->commit === null || $this->commit->isDeleted() || ! Commit::fits($body)) { + if ($me === null || $this->commit === null || $this->commit->isDeleted() || ! Draft::canCommit($body, $this->replyImages)) { return; } $write = app(Outbox::class)->add(PendingWrite::Commit, $this->id, [ 'body' => $body, 'parent_id' => $this->id, + 'images' => PendingWrite::imagePayload($this->replyImages), 'stash' => $this->replyStashName(), 'composer' => 'bar', ]); @@ -324,6 +341,7 @@ public function sendReply(): void } $this->reply = ''; + $this->replyImages = []; $this->updatedReply(); $this->repliesShown = true; $this->keepStagedReplies(); @@ -331,23 +349,27 @@ public function sendReply(): void } /** - * Stashes the reply as it changes. An empty field forgets it, and isn't restored any more. + * Stashes the reply as it changes, with its photos. With nothing left in it, it's forgotten, and isn't restored + * any more. */ public function updatedReply(): void { - app(Stash::class)->put($this->me()?->id, $this->replyStashName(), $this->reply); + $draft = new StashedDraft($this->reply, $this->replyImages); + app(Stash::class)->put($this->me()?->id, $this->replyStashName(), $draft); - if ($this->reply === '') { + if ($draft->isEmpty()) { $this->replyRestored = false; } } /** - * The web's drop, beside a restored reply: it's thrown away, and the field starts again empty. + * The web's drop, beside a restored reply: it's thrown away, photos too, and the field starts again empty. */ public function dropReplyStash(): void { + app(DraftImages::class)->discard($this->replyImages); $this->reply = ''; + $this->replyImages = []; $this->updatedReply(); } @@ -361,6 +383,7 @@ protected function showCommitted(Commit $commit): void } $this->reply = ''; + $this->replyImages = []; $this->replyRestored = false; $this->repliesShown = true; $this->keepStagedReplies(); @@ -395,7 +418,7 @@ protected function reopenFailedCommit(PendingWrite $write): bool return $this->reopenFailedCommitInSheet($write); } - if ($write->parentId() !== $this->id || $this->sheetOpen || trim($this->reply) !== '') { + if ($write->parentId() !== $this->id || $this->sheetOpen || trim($this->reply) !== '' || $this->replyImages !== []) { return false; } @@ -420,12 +443,14 @@ protected function editFailedCommit(PendingWrite $write): void } /** - * The reply bar takes back the draft it handed the sheet, as it was left there. + * The reply bar takes back the draft it handed the sheet, as it was left there, photos too. */ protected function commitSheetGone(string $view): void { if ($view === 'reply') { - $this->reply = app(Stash::class)->get($this->me()?->id, $this->replyStashName()); + $draft = app(Stash::class)->get($this->me()?->id, $this->replyStashName()); + $this->reply = $draft->body; + $this->replyImages = $draft->images; $this->replyRestored = false; } } @@ -476,11 +501,12 @@ private function editFromData(): void } /** - * The bar takes back a reply devnull turned down, and the outbox lets it go: it's a draft again. + * The bar takes back a reply devnull turned down, with its photos, and the outbox lets it go: it's a draft again. */ private function takeFailedReply(PendingWrite $write): void { $this->reply = $write->body(); + $this->replyImages = $write->images(); $this->replyRestored = true; $this->focusReply = true; $this->updatedReply(); diff --git a/app/NativeComponents/Concerns/AttachesToCommits.php b/app/NativeComponents/Concerns/AttachesToCommits.php index 7c0f321d..70bcf250 100644 --- a/app/NativeComponents/Concerns/AttachesToCommits.php +++ b/app/NativeComponents/Concerns/AttachesToCommits.php @@ -29,8 +29,9 @@ * * 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. + * the phone once devnull has them. Until it's sent, the photos and the poll + * are stashed with the draft (see Stash), so closing the sheet keeps them for + * next time. Drop throws them away with it, deleting the photos. */ trait AttachesToCommits { @@ -75,6 +76,11 @@ trait AttachesToCommits */ abstract protected function writeInBackground(string $method, string $path, ?array $payload, Closure $done, Closure $failed): void; + /** + * Keeps the draft in the stash as it is now, its photos and poll with it. + */ + abstract protected function stashSheetDraft(): void; + /** * The web's image button: the photo library, for as many photos as there's still room for. */ @@ -127,6 +133,7 @@ public function removePhoto(int $index): void app(DraftImages::class)->discard([$this->sheetImages[$index]]); array_splice($this->sheetImages, $index, 1); $this->describing = null; + $this->stashSheetDraft(); } /** @@ -143,6 +150,7 @@ public function photoDescribed(int $index, string $description = ''): void { if (isset($this->sheetImages[$index])) { $this->sheetImages[$index] = $this->sheetImages[$index]->withDescription($description); + $this->stashSheetDraft(); } } @@ -155,18 +163,21 @@ public function addPoll(): void if ($this->isComposing() && $this->sheetView !== 'reply' && $this->sheetImages === [] && $this->sheetPoll === null) { $this->sheetPoll = PollDraft::start(); $this->sheetPollLength = PollDraft::DefaultLength; + $this->stashSheetDraft(); } } public function removePoll(): void { $this->sheetPoll = null; + $this->stashSheetDraft(); } public function addPollOption(): void { if ($this->sheetPoll !== null && count($this->sheetPoll) < PollDraft::MaxOptions) { $this->sheetPoll[] = ''; + $this->stashSheetDraft(); } } @@ -177,6 +188,7 @@ public function removePollOption(int $index): void { if ($this->sheetPoll !== null && $index >= PollDraft::MinOptions && isset($this->sheetPoll[$index])) { array_splice($this->sheetPoll, $index, 1); + $this->stashSheetDraft(); } } @@ -184,9 +196,15 @@ public function pollOptionChanged(int $index, string $option = ''): void { if ($this->sheetPoll !== null && isset($this->sheetPoll[$index])) { $this->sheetPoll[$index] = mb_substr($option, 0, PollDraft::MaxOptionLength); + $this->stashSheetDraft(); } } + public function updatedSheetPollLength(): void + { + $this->stashSheetDraft(); + } + /** * The web's preview toggle. The draft shows as the commit it would become at once, and devnull is asked for its * body, unless it already said for this one. If devnull can't be reached, the body shows as written. @@ -231,12 +249,14 @@ protected function sheetPollPayload(): ?array } /** - * 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. + * Lets go of the photos, the poll and the preview. The photos stay on the phone for the stashed draft, unless + * they're thrown away with it ($discardPhotos). */ - protected function forgetAttachments(): void + protected function forgetAttachments(bool $discardPhotos = false): void { - app(DraftImages::class)->discard($this->sheetImages); + if ($discardPhotos) { + app(DraftImages::class)->discard($this->sheetImages); + } $this->sheetImages = []; $this->describing = null; @@ -280,5 +300,7 @@ private function attachPhotos(array $paths): void $this->sheetError = $exception->getMessage(); } } + + $this->stashSheetDraft(); } } diff --git a/app/NativeComponents/Concerns/DraftsIssues.php b/app/NativeComponents/Concerns/DraftsIssues.php index ab72c202..a4650f73 100644 --- a/app/NativeComponents/Concerns/DraftsIssues.php +++ b/app/NativeComponents/Concerns/DraftsIssues.php @@ -3,6 +3,8 @@ namespace App\NativeComponents\Concerns; use App\Devnull\Client; +use App\Devnull\Data\StashedDraft; +use App\Devnull\DraftImages; use App\Devnull\Exceptions\ApiException; use App\Devnull\IssueCommand; use App\Devnull\Stash; @@ -35,12 +37,13 @@ * on this phone, as the web's stops them in that browser. * * 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, 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. + * button spins (see WritesInBackground). Once it's open the draft goes, with + * any photos it had, and the sheet says "Opened issue #123." with View issue, + * as the web's toast 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. */ trait DraftsIssues { @@ -133,7 +136,9 @@ 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', ''); + $stash = app(Stash::class); + app(DraftImages::class)->discard($stash->get($this->me()?->id, 'commit')->images); + $stash->put($this->me()?->id, 'commit', new StashedDraft); // Swiped away while it was on its way, so a toast says it, with View issue as its button. if (! $this->sheetOpen) { diff --git a/app/NativeComponents/Concerns/OpensCommitSheet.php b/app/NativeComponents/Concerns/OpensCommitSheet.php index 028323f3..39f6aa50 100644 --- a/app/NativeComponents/Concerns/OpensCommitSheet.php +++ b/app/NativeComponents/Concerns/OpensCommitSheet.php @@ -6,6 +6,7 @@ use App\Devnull\Data\Commit; use App\Devnull\Data\Draft; use App\Devnull\Data\PollDraft; +use App\Devnull\Data\StashedDraft; use App\Devnull\Outbox; use App\Devnull\ReportReason; use App\Devnull\Stash; @@ -25,12 +26,12 @@ * (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. 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()). + * What you write in it is stashed as you go, as on the web (see Stash): a + * quote per commit quoted, and a new commit under the web's composer draft, + * with its photos and its poll. Opened again, it starts from the draft, + * marked as restored. It's forgotten 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). @@ -199,31 +200,23 @@ public function composeInSheet(): void } /** - * Stashes the draft as it changes. An empty field forgets it, and isn't restored any more. + * Stashes the draft as it changes (see stashSheetDraft()). */ public function updatedSheetBody(): void { $this->typeIssueCommand(); - $name = $this->sheetStashName(); - - if (! $this->sheetOpen || $name === null) { - return; - } - - app(Stash::class)->put($this->me()?->id, $name, $this->sheetBody); - - if ($this->sheetBody === '') { - $this->sheetRestored = false; - } + $this->stashSheetDraft(); } /** - * The web's drop, beside a restored draft: it's thrown away, and the field starts again empty. + * The web's drop, beside a restored draft: it's thrown away, photos and poll too, and the sheet starts again + * empty. */ public function dropSheetStash(): void { $this->sheetBody = ''; - $this->updatedSheetBody(); + $this->forgetAttachments(discardPhotos: true); + $this->stashSheetDraft(); } /** @@ -257,7 +250,7 @@ public function commitFromSheet(): void return; } - app(Stash::class)->put($me->id, $stashName, ''); + app(Stash::class)->put($me->id, $stashName, new StashedDraft); $this->sheetImages = []; $this->closeCommitSheet(); $this->showCommitted($write->stagedCommit($me)); @@ -266,11 +259,13 @@ public function commitFromSheet(): void /** * Done, after a report, and Cancel, in the composer's bar: the sheet goes. A draft in it stays stashed, as when - * it's swiped away, so this mustn't forget it. + * it's swiped away, so this mustn't forget it. A screen whose composer handed it the draft takes it back now, + * since Android never says when a sheet closed from here has gone (see $sheetClosing). */ public function hideCommitSheet(): void { $this->closeCommitSheet(); + $this->commitSheetGone($this->sheetView); } /** @@ -433,8 +428,29 @@ 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, + * Stashes the draft as it changes: what's written, its photos and its + * poll. With nothing left in it, it's forgotten, and isn't marked as + * restored any more. + */ + protected function stashSheetDraft(): void + { + $name = $this->sheetStashName(); + + if (! $this->sheetOpen || $name === null) { + return; + } + + $draft = new StashedDraft($this->sheetBody, $this->sheetImages, $this->sheetView === 'reply' ? null : $this->sheetPoll, $this->sheetPollLength); + app(Stash::class)->put($this->me()?->id, $name, $draft); + + if ($draft->isEmpty()) { + $this->sheetRestored = false; + } + } + + /** + * The sheet starts from its stashed draft, with its photos and poll. A + * commit devnull turned down that was written under it comes back whole, * and leaves the outbox: from here it's a draft again. */ private function restoreSheetDraft(): void @@ -446,18 +462,25 @@ private function restoreSheetDraft(): void $this->takeFailedCommit($failed); } - $this->sheetBody = $name === null ? '' : app(Stash::class)->get($this->me()?->id, $name); - $this->sheetRestored = $this->sheetBody !== ''; + $draft = $name === null ? new StashedDraft : app(Stash::class)->get($this->me()?->id, $name); + + $this->sheetBody = $draft->body; + $this->sheetImages = $draft->images; + $this->sheetPoll = $this->sheetView === 'reply' ? null : $draft->poll; + $this->sheetPollLength = $draft->pollLength; + $this->sheetRestored = ! $draft->isEmpty(); } 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(Stash::class)->put($this->me()?->id, (string) $write->stashName(), new StashedDraft( + $write->body(), + $write->images(), + $poll === null || $this->sheetView === 'reply' ? null : $poll['options'], + $poll === null ? PollDraft::DefaultLength : (string) (array_search($poll['duration'], PollDraft::Lengths, true) ?: PollDraft::DefaultLength), + )); app(Outbox::class)->forget($write); } diff --git a/app/NativeComponents/Concerns/SyncsOutbox.php b/app/NativeComponents/Concerns/SyncsOutbox.php index 13ac67eb..03100fe6 100644 --- a/app/NativeComponents/Concerns/SyncsOutbox.php +++ b/app/NativeComponents/Concerns/SyncsOutbox.php @@ -4,6 +4,7 @@ use App\Devnull\Client; use App\Devnull\Data\Commit; +use App\Devnull\Data\StashedDraft; use App\Devnull\DraftImages; use App\Devnull\Exceptions\KeychainUnavailable; use App\Devnull\Exceptions\Unauthenticated; @@ -271,8 +272,8 @@ private function turnDownCommit(PendingWrite $write, string $error, bool $reopen $name = $write->stashName(); $stash = app(Stash::class); - if ($name !== null && $stash->get($this->me()?->id, $name) === '') { - $stash->put($this->me()?->id, $name, $write->body()); + if ($name !== null && $stash->get($this->me()?->id, $name)->isEmpty()) { + $stash->put($this->me()?->id, $name, new StashedDraft($write->body())); } $this->showCommitTurnedDown($write); diff --git a/resources/views/native/commit-detail.blade.php b/resources/views/native/commit-detail.blade.php index abcf20a3..18ee73f2 100644 --- a/resources/views/native/commit-detail.blade.php +++ b/resources/views/native/commit-detail.blade.php @@ -102,7 +102,7 @@ Display::replyBarClearance): the last reply and its actions end above the bar, keyboard up or down. --}} @if ($commit) - + @endif @@ -134,15 +134,31 @@ 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. @endif - {{-- A reply stashed when you left the commit comes back with the web's stash@{0} and drop under it. --}} - @if ($replyRestored) - - @include('native.partials.stash-label', ['ref' => 'reply-stash', 'drop' => 'dropReplyStash', 'size' => 'text-xs']) + {{-- + A reply stashed when you left the commit comes back with the web's stash@{0} and drop under it. + Photos attached in the sheet show as a count on the left, which opens the sheet on them, and Reply + sends them. + --}} + @if ($replyRestored || $replyImages !== []) + + @if ($replyImages !== []) + @php($photoCount = \App\Devnull\Display::count(count($replyImages), 'photo', 'photos')) + + + + {{ $photoCount }} + + + + @endif + @if ($replyRestored) + @include('native.partials.stash-label', ['ref' => 'reply-stash', 'drop' => 'dropReplyStash', 'size' => 'text-xs']) + @endif @endif diff --git a/tests/Feature/Devnull/DraftImagesTest.php b/tests/Feature/Devnull/DraftImagesTest.php index 7413329b..47b221fa 100644 --- a/tests/Feature/Devnull/DraftImagesTest.php +++ b/tests/Feature/Devnull/DraftImagesTest.php @@ -1,8 +1,13 @@ path))->toBeFalse() ->and(file_exists($recent->path))->toBeTrue(); }); + +it('keeps the photos a stashed draft or the outbox holds, however old', function () { + signIn(); + $me = app(Account::class)->user()?->id; + $stashed = app(DraftImages::class)->keep(testPhoto()); + $failed = app(DraftImages::class)->keep(testPhoto()); + $orphan = app(DraftImages::class)->keep(testPhoto()); + + app(Stash::class)->put($me, 'commit', new StashedDraft('', [$stashed])); + $write = app(Outbox::class)->add(PendingWrite::Commit, null, ['body' => '', 'images' => PendingWrite::imagePayload([$failed])]); + app(Outbox::class)->failed($write, 'Something went wrong.'); + + foreach ([$stashed, $failed, $orphan] as $photo) { + touch($photo->path, time() - 86_401 * 7); + } + + app(DraftImages::class)->keep(testPhoto()); + + expect(file_exists($stashed->path))->toBeTrue() + ->and(file_exists($failed->path))->toBeTrue() + ->and(file_exists($orphan->path))->toBeFalse(); +}); diff --git a/tests/Feature/Devnull/StashTest.php b/tests/Feature/Devnull/StashTest.php index a76a8fc1..0047c80c 100644 --- a/tests/Feature/Devnull/StashTest.php +++ b/tests/Feature/Devnull/StashTest.php @@ -1,69 +1,107 @@ put('u1', 'commit', 'Half a thought'); - $stash->put('u1', 'reply-c2', 'A reply in progress'); - $stash->put('u2', 'commit', 'Someone else on this phone'); + $stash->put('u1', 'commit', new StashedDraft('Half a thought')); + $stash->put('u1', 'reply-c2', new StashedDraft('A reply in progress')); + $stash->put('u2', 'commit', new StashedDraft('Someone else on this phone')); - expect($stash->get('u1', 'commit'))->toBe('Half a thought') - ->and($stash->get('u1', 'reply-c2'))->toBe('A reply in progress') - ->and($stash->get('u1', 'quote-c2'))->toBe('') - ->and($stash->get('u2', 'commit'))->toBe('Someone else on this phone') + expect($stash->get('u1', 'commit')->body)->toBe('Half a thought') + ->and($stash->get('u1', 'reply-c2')->body)->toBe('A reply in progress') + ->and($stash->get('u1', 'quote-c2')->isEmpty())->toBeTrue() + ->and($stash->get('u2', 'commit')->body)->toBe('Someone else on this phone') ->and($stash->names('u1'))->toBe(['commit', 'reply-c2']); }); -it('forgets a draft once it is emptied', function () { +it('keeps a draft\'s photos with their descriptions, and its poll with how long it runs', function () { $stash = app(Stash::class); + $photo = app(DraftImages::class)->keep(testPhoto())->withDescription('A terminal.'); - $stash->put('u1', 'commit', 'Half a thought'); - $stash->put('u1', 'commit', ''); + $stash->put('u1', 'reply-c2', new StashedDraft('', [$photo])); + $stash->put('u1', 'quote-c1', new StashedDraft('Which is it?', poll: ['DNS', ''], pollLength: '3 days')); - expect($stash->get('u1', 'commit'))->toBe('') - ->and($stash->names('u1'))->toBe([]); + expect($stash->get('u1', 'reply-c2')->images)->toEqual([$photo]) + ->and($stash->get('u1', 'quote-c1'))->toEqual(new StashedDraft('Which is it?', poll: ['DNS', ''], pollLength: '3 days')) + ->and($stash->images('u1'))->toEqual([$photo]); + + // A photo gone from the phone is gone from the draft, and a draft with nothing left in it goes too. + unlink($photo->path); + + expect($stash->names('u1'))->toBe(['quote-c1']); }); +it('forgets a draft once there\'s nothing in it', function (StashedDraft $draft) { + $stash = app(Stash::class); + + $stash->put('u1', 'commit', new StashedDraft('Half a thought')); + $stash->put('u1', 'commit', $draft); + + expect($stash->get('u1', 'commit')->isEmpty())->toBeTrue() + ->and($stash->names('u1'))->toBe([]); +})->with([ + 'emptied' => [new StashedDraft], + 'with a poll nothing is written in' => [new StashedDraft(poll: ['', ' '])], +]); + it('keeps nothing when nobody is signed in', function () { $stash = app(Stash::class); - $stash->put(null, 'commit', 'Half a thought'); + $stash->put(null, 'commit', new StashedDraft('Half a thought')); - expect($stash->get(null, 'commit'))->toBe('') + expect($stash->get(null, 'commit')->isEmpty())->toBeTrue() ->and($stash->names(null))->toBe([]); }); -it('lets the draft written longest ago go once there are too many', function () { +it('reads a draft kept before photos and polls were', function () { + Cache::forever('devnull.stash.u1', ['commit' => 'Half a thought']); + + expect(app(Stash::class)->get('u1', 'commit'))->toEqual(new StashedDraft('Half a thought')); +}); + +it('lets the draft written longest ago go once there are too many, and deletes its photos', function () { $stash = app(Stash::class); + $photo = app(DraftImages::class)->keep(testPhoto()); foreach (range(1, Stash::MaxDrafts) as $number) { - $stash->put('u1', "reply-c{$number}", "Reply {$number}"); + $stash->put('u1', "reply-c{$number}", new StashedDraft("Reply {$number}", $number === 2 ? [$photo] : [])); } // Writing a draft again makes it the newest. - $stash->put('u1', 'reply-c1', 'Reply 1, again'); - $stash->put('u1', 'commit', 'One too many'); + $stash->put('u1', 'reply-c1', new StashedDraft('Reply 1, again')); + $stash->put('u1', 'commit', new StashedDraft('One too many')); expect($stash->names('u1'))->toHaveCount(Stash::MaxDrafts) - ->and($stash->get('u1', 'reply-c1'))->toBe('Reply 1, again') - ->and($stash->get('u1', 'reply-c2'))->toBe('') - ->and($stash->get('u1', 'commit'))->toBe('One too many'); + ->and($stash->get('u1', 'reply-c1')->body)->toBe('Reply 1, again') + ->and($stash->get('u1', 'reply-c2')->isEmpty())->toBeTrue() + ->and(file_exists($photo->path))->toBeFalse() + ->and($stash->get('u1', 'commit')->body)->toBe('One too many'); }); -it("forgets the signed-in user's drafts when the phone signs out", function () { +it("forgets the signed-in user's drafts when the phone signs out, and deletes their photos", function () { signIn(); $me = app(Account::class)->user()?->id; $stash = app(Stash::class); + $photo = app(DraftImages::class)->keep(testPhoto()); - $stash->put($me, 'commit', 'Half a thought'); - $stash->put($me, 'quote-c1', 'This, all of it.'); - $stash->put('u2', 'commit', 'Someone else on this phone'); + $stash->put($me, 'commit', new StashedDraft('Half a thought', [$photo])); + $stash->put($me, 'quote-c1', new StashedDraft('This, all of it.')); + $stash->put('u2', 'commit', new StashedDraft('Someone else on this phone')); app(Account::class)->forget(); expect($stash->names($me))->toBe([]) - ->and($stash->get('u2', 'commit'))->toBe('Someone else on this phone'); + ->and(file_exists($photo->path))->toBeFalse() + ->and($stash->get('u2', 'commit')->body)->toBe('Someone else on this phone'); }); diff --git a/tests/Feature/Screens/CommitDetailTest.php b/tests/Feature/Screens/CommitDetailTest.php index 7874676c..7e47fdec 100644 --- a/tests/Feature/Screens/CommitDetailTest.php +++ b/tests/Feature/Screens/CommitDetailTest.php @@ -284,7 +284,7 @@ function fakeThread(array $commit = [], array $replies = [], array $extra = []): ->assertNoNavigation(); expect($screen->instance()->replies)->toBe([]) - ->and(app(Stash::class)->get(userJson()['id'], 'reply-c2'))->toBe('Long enough') + ->and(app(Stash::class)->get(userJson()['id'], 'reply-c2')->body)->toBe('Long enough') ->and(PendingWrite::query()->count())->toBe(0); }); diff --git a/tests/Feature/Screens/ComposerPhotosTest.php b/tests/Feature/Screens/ComposerPhotosTest.php index 73df3d4d..7e772436 100644 --- a/tests/Feature/Screens/ComposerPhotosTest.php +++ b/tests/Feature/Screens/ComposerPhotosTest.php @@ -190,7 +190,7 @@ function isTool(TestableComponent $screen, string $ref): bool ->and($screen->instance()->commits[0]->images[0]->url)->toBe('http://devnull.test/commit-images/i1?expires=1&signature=abc') ->and($screen->instance()->sheetImages)->toBe([]) ->and(array_filter($paths, file_exists(...)))->toBe([]) - ->and(app(Stash::class)->get(app(Account::class)->user()?->id, 'commit'))->toBe(''); + ->and(app(Stash::class)->get(app(Account::class)->user()?->id, 'commit')->body)->toBe(''); })->with([ 'none' => ['', '{"body":"","image_descriptions":[null,"A terminal."]}'], 'short' => ['ok', '{"body":"ok","image_descriptions":[null,"A terminal."]}'], @@ -229,19 +229,36 @@ function isTool(TestableComponent $screen, string $ref): bool ->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 () { - $screen = photosPicked(composerOnLog(), [testPhoto()]); +it('keeps the photos with the draft when the sheet goes unsent, and deletes them once it\'s dropped', function () { + $screen = photosPicked(composerOnLog(), [testPhoto()]) + ->tap('sheet-image-0-alt') + ->input('sheet-image-description-input', 'The cabin at sunrise.'); $path = $screen->instance()->sheetImages[0]->path; $screen->dismissSheet('sheet'); - expect(file_exists($path))->toBeFalse() + expect(file_exists($path))->toBeTrue() ->and($screen->instance()->sheetImages)->toBe([]); // A photo that arrives once the sheet has gone attaches to nothing. photosPicked($screen, [testPhoto()]); expect($screen->instance()->sheetImages)->toBe([]); + + $screen->press('composeInSheet'); + + expect($screen->instance()->sheetImages)->toHaveCount(1) + ->and($screen->instance()->sheetImages[0]->path)->toBe($path) + ->and($screen->instance()->sheetImages[0]->description)->toBe('The cabin at sunrise.') + ->and(nodeByRef($screen, 'sheet-image-0')['type'])->toBe('column') + ->and(sheetButtonEnabled($screen, 'sheet-commit'))->toBeTrue() + ->and(nodeByRef($screen, 'sheet-stash'))->not->toBeNull(); + + $screen->tap('sheet-stash-drop'); + + expect(file_exists($path))->toBeFalse() + ->and($screen->instance()->sheetImages)->toBe([]) + ->and(app(Stash::class)->names(app(Account::class)->user()?->id))->toBe([]); }); it('opens a commit\'s reply in the sheet with what the reply bar had, and replies with photos', function () { @@ -288,7 +305,61 @@ function isTool(TestableComponent $screen, string $ref): bool ->dismissSheet('sheet'); expect(nodeByRef($screen, 'reply-input')['props']['value'])->toBe('Half a reply') - ->and(app(Stash::class)->get(app(Account::class)->user()?->id, 'reply-c2'))->toBe('Half a reply'); + ->and(app(Stash::class)->get(app(Account::class)->user()?->id, 'reply-c2')->body)->toBe('Half a reply'); Http::assertNotSent(fn (Request $request): bool => $request->method() === 'POST'); }); + +it('hands the reply\'s photos back to the reply bar too, which counts them, keeps them and replies with them', function () { + serveApi([ + 'GET /api/v1/commits/c2' => [commitPageJson(commitJson(['id' => 'c2', 'author' => userJson(['id' => 'u2', 'handle' => 'ada'])]))], + 'GET /api/v1/commits/c2/replies' => [pageJson([])], + 'POST /api/v1/commits' => [['data' => replyJson(['id' => 'r9', 'parent_id' => 'c2', 'body' => 'Look'])], 201], + ]); + + $screen = photosPicked(Native::visit('/log/commits/c2')->input('reply-input', 'Look')->tap('reply-expand'), [testPhoto()]) + ->dismissSheet('sheet'); + $path = $screen->instance()->replyImages[0]->path; + + expect(treeShows(nodeByRef($screen, 'reply-photos'), '1 photo'))->toBeTrue() + ->and(nodeByRef($screen, 'reply-photos')['props']['a11y_label'] ?? null)->toBe('1 photo attached'); + + // Coming back to the commit, the bar has them still. + $screen = Native::visit('/log/commits/c2'); + + expect($screen->instance()->reply)->toBe('Look') + ->and($screen->instance()->replyImages)->toHaveCount(1) + ->and(nodeByRef($screen, 'reply-stash'))->not->toBeNull(); + + // The count opens the sheet on them. + $screen->tap('reply-photos'); + + expect(nodeByRef($screen, 'sheet')['props']['visible'])->toBeTrue() + ->and($screen->instance()->sheetImages[0]->path)->toBe($path); + + $screen->dismissSheet('sheet')->tap('send-reply')->assertSee('Look'); + + Http::assertSent(fn (Request $request): bool => $request->method() === 'POST' + && multipartParts($request)[0]['contents'] === '{"body":"Look","parent_id":"c2"}' + && multipartParts($request)[1]['name'] === 'images[]'); + + expect($screen->instance()->replyImages)->toBe([]) + ->and(findNode($screen->tree(), fn (array $node): bool => ($node['ref'] ?? null) === 'reply-photos'))->toBeNull() + ->and(file_exists($path))->toBeFalse() + ->and(app(Stash::class)->names(app(Account::class)->user()?->id))->toBe([]); +}); + +it('hands the reply and its photos back to the reply bar when Cancel closes the sheet on Android, which never says it has gone', function () { + serveApi([ + 'GET /api/v1/commits/c2' => [commitPageJson(commitJson(['id' => 'c2', 'author' => userJson(['id' => 'u2', 'handle' => 'ada'])]))], + 'GET /api/v1/commits/c2/replies' => [pageJson([])], + ]); + + $screen = photosPicked(Native::visit('/log/commits/c2', platform: 'android')->input('reply-input', 'Half')->tap('reply-expand'), [testPhoto()]) + ->input('sheet-body', 'Half a reply') + ->tap('sheet-cancel') + ->input('reply-input', 'Half a reply, and more'); + + expect($screen->instance()->replyImages)->toHaveCount(1) + ->and(app(Stash::class)->get(app(Account::class)->user()?->id, 'reply-c2')->images)->toHaveCount(1); +}); diff --git a/tests/Feature/Screens/ComposerPollTest.php b/tests/Feature/Screens/ComposerPollTest.php index 5db2f1eb..b1f47919 100644 --- a/tests/Feature/Screens/ComposerPollTest.php +++ b/tests/Feature/Screens/ComposerPollTest.php @@ -91,7 +91,7 @@ function sheetCanCommit(TestableComponent $screen): bool expect($screen->instance()->sheetPoll[0])->toBe(str_repeat('a', 50)); }); -it('drops the poll with Remove poll, and with the sheet', function () { +it('drops the poll with Remove poll, and an empty one with the sheet', function () { $screen = pollComposerOnLog()->tap('sheet-poll')->tap('poll-remove'); expect($screen->instance()->sheetPoll)->toBeNull() @@ -100,7 +100,31 @@ function sheetCanCommit(TestableComponent $screen): bool $screen->tap('sheet-poll')->dismissSheet('sheet')->press('composeInSheet'); - expect($screen->instance()->sheetPoll)->toBeNull(); + expect($screen->instance()->sheetPoll)->toBeNull() + ->and($screen->instance()->sheetRestored)->toBeFalse(); +}); + +it('keeps the poll with the draft when the sheet goes, until the draft is dropped', function () { + $screen = pollComposerOnLog() + ->tap('sheet-poll') + ->tap('poll-add-option') + ->input('poll-option-0', 'Tabs') + ->select('poll-length', '3 days') + ->dismissSheet('sheet') + ->press('composeInSheet'); + + expect($screen->instance()) + ->sheetPoll->toBe(['Tabs', '', '']) + ->sheetPollLength->toBe('3 days') + ->sheetRestored->toBeTrue() + ->and(nodeByRef($screen, 'poll-option-0')['props']['value'])->toBe('Tabs'); + + $screen->tap('sheet-stash-drop')->dismissSheet('sheet')->press('composeInSheet'); + + expect($screen->instance()) + ->sheetPoll->toBeNull() + ->sheetPollLength->toBe('1 day') + ->sheetRestored->toBeFalse(); }); it('offers no poll once there are photos', function () { diff --git a/tests/Feature/Screens/DraftStashTest.php b/tests/Feature/Screens/DraftStashTest.php index 8105cc98..46baedf4 100644 --- a/tests/Feature/Screens/DraftStashTest.php +++ b/tests/Feature/Screens/DraftStashTest.php @@ -2,6 +2,7 @@ use App\Devnull\Account; use App\Devnull\Data\Pasteis; +use App\Devnull\Data\StashedDraft; use App\Devnull\Display; use App\Devnull\Stash; use Carbon\CarbonImmutable; @@ -44,12 +45,12 @@ function serveStashLog(): void function stashed(string $name): string { - return app(Stash::class)->get(app(Account::class)->user()?->id, $name); + return app(Stash::class)->get(app(Account::class)->user()?->id, $name)->body; } function stash(string $name, string $body): void { - app(Stash::class)->put(app(Account::class)->user()?->id, $name, $body); + app(Stash::class)->put(app(Account::class)->user()?->id, $name, new StashedDraft($body)); } function showsRef(TestableComponent $screen, string $ref): bool @@ -74,7 +75,7 @@ function stashLabel(TestableComponent $screen, string $ref): array it('keeps your drafts to you', function () { serveStashLog(); - app(Stash::class)->put('u2', 'commit', "Ada's draft"); + app(Stash::class)->put('u2', 'commit', new StashedDraft("Ada's draft")); $screen = Native::visit('/log', platform: 'ios')->press('composeInSheet'); diff --git a/tests/Feature/Screens/IssueCommandsTest.php b/tests/Feature/Screens/IssueCommandsTest.php index 848d3152..dc4a69ec 100644 --- a/tests/Feature/Screens/IssueCommandsTest.php +++ b/tests/Feature/Screens/IssueCommandsTest.php @@ -1,6 +1,7 @@ instance()->sheetIssue)->toBe('bug') ->and(nodeByRef($screen, 'sheet-body')['props']['value'])->toBe('') ->and(hasRef($screen, 'issue-commands'))->toBeFalse() - ->and(app(Stash::class)->get($this->userId, 'commit'))->toBe(''); + ->and(app(Stash::class)->get($this->userId, 'commit')->body)->toBe(''); }); it('opens the menu for a restored slash only once you type, as the web opens it only while you type', function () { - app(Stash::class)->put($this->userId, 'commit', '/'); + app(Stash::class)->put($this->userId, 'commit', new StashedDraft('/')); $screen = issueSheetOnLog(); @@ -188,7 +189,7 @@ function hasRef(TestableComponent $screen, string $ref): bool expect(issueRequestsSent())->toBe(1) ->and(nodeByRef($screen, 'sheet')['props'])->toMatchArray(['visible' => true, 'detents' => '0.3']) - ->and(app(Stash::class)->get($this->userId, 'commit'))->toBe('') + ->and(app(Stash::class)->get($this->userId, 'commit')->body)->toBe('') ->and(nodeByRef($screen, 'log')['id'])->toBe($list) ->and($screen->instance()->commits)->toHaveCount(1); $bridge->assertNotCalled('ToastKit.Show'); @@ -215,7 +216,7 @@ function hasRef(TestableComponent $screen, string $ref): bool && $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('') + expect(app(Stash::class)->get($this->userId, 'commit')->body)->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']]) @@ -229,7 +230,7 @@ function hasRef(TestableComponent $screen, string $ref): bool ->and($screen->instance()->sheetIssue)->toBe('bug') ->and(nodeByRef($screen, 'sheet-body')['props']['value'])->toBe('Broken.') ->and(sheetButtonEnabled($screen, 'sheet-open-issue'))->toBeTrue() - ->and(app(Stash::class)->get($this->userId, 'commit'))->toBe('Broken.'); + ->and(app(Stash::class)->get($this->userId, 'commit')->body)->toBe('Broken.'); })->with([ 'over the limit' => [[['message' => 'Issue limit reached. Try again in an hour.', 'code' => 'rate_limited'], 429], 'Issue limit reached. Try again in an hour.'], 'GitHub said no' => [[['message' => 'GitHub did not create the issue. Your draft is safe.', 'code' => 'github_issue_failed'], 503], 'GitHub did not create the issue. Your draft is safe.'], diff --git a/tests/Feature/Screens/LogRowCacheTest.php b/tests/Feature/Screens/LogRowCacheTest.php index 485108f7..1d07409a 100644 --- a/tests/Feature/Screens/LogRowCacheTest.php +++ b/tests/Feature/Screens/LogRowCacheTest.php @@ -2,6 +2,7 @@ use App\Devnull\Account; use App\Devnull\Data\Pasteis; +use App\Devnull\Data\StashedDraft; use App\Devnull\ImageCache; use App\Devnull\Stash; use App\NativeComponents\Log; @@ -61,7 +62,7 @@ function expectKeptRowsToMatchFreshOnes(TestableComponent $screen, array $before 'a commit unfolded' => fn () => $screen->tap('show-more-c3'), 'a thread opened' => fn () => $screen->tap('replies-c2')->assertSee('A reply in place'), 'a draft stashed' => function () use ($screen) { - app(Stash::class)->put(app(Account::class)->user()?->id, 'reply-c1', 'Half a reply'); + app(Stash::class)->put(app(Account::class)->user()?->id, 'reply-c1', new StashedDraft('Half a reply')); $screen->call('isRanked'); }, 'pastéis mode' => function () use ($screen) { diff --git a/tests/Feature/Screens/OutboxTest.php b/tests/Feature/Screens/OutboxTest.php index 584191bf..e5f90aa6 100644 --- a/tests/Feature/Screens/OutboxTest.php +++ b/tests/Feature/Screens/OutboxTest.php @@ -148,7 +148,7 @@ function pressToastAction(TestableComponent $screen, string $action, array $data // 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(app(Stash::class)->get($this->userId, 'commit')->body)->toBe('Lost in transit') ->and($screen->instance()->sheetOpen)->toBeFalse() ->and(array_map(fn ($shown): string => $shown->id, $screen->instance()->commits))->toBe(['c1']); @@ -196,7 +196,7 @@ function pressToastAction(TestableComponent $screen, string $action, array $data && $params['data'] === ['write' => $write->id]); expect($write->fresh()->status)->toBe(PendingWrite::Failed) - ->and(app(Stash::class)->get($this->userId, 'reply-c2'))->toBe('Hold on'); + ->and(app(Stash::class)->get($this->userId, 'reply-c2')->body)->toBe('Hold on'); pressToastAction($elsewhere, 'edit', ['write' => $write->id]) ->assertNavigatedTo('/notifications/commits/c2');