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');