From 057918e27c751a757ef4e8a4dc65e1b7bed44de2 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Sun, 18 Oct 2026 17:11:26 +0100 Subject: [PATCH 1/2] Keep unsent drafts on the phone, as the web's stash does The Log's composer, the compose sheet, quotes and replies now stash what you type, per user, under the web's names: commit, quote-{id} and reply-{id}. The sheet shares the composer's draft, as the web's dialog does. A draft that comes back shows the web's stash@{0} and drop. Sending, emptying the field, drop, signing out and deleting the account forget it. Quote and Reply mark a commit with a stashed draft with a dot, since the web's stash@{0} doesn't fit the action row. Co-Authored-By: Claude Opus 5.5 --- app/Devnull/Account.php | 4 +- app/Devnull/Display.php | 10 +- app/Devnull/Stash.php | 88 +++++ app/NativeComponents/CommitDetail.php | 43 ++- .../Concerns/OpensCommitSheet.php | 85 ++++- app/NativeComponents/Log.php | 52 +++ app/NativeComponents/Profile.php | 2 + .../views/native/commit-detail.blade.php | 8 +- .../native/partials/commit-actions.blade.php | 21 +- .../native/partials/commit-sheet.blade.php | 7 +- .../views/native/partials/composer.blade.php | 6 + .../native/partials/stash-label.blade.php | 13 + tests/Feature/Devnull/StashTest.php | 69 ++++ tests/Feature/Screens/ComposeSheetTest.php | 36 +- tests/Feature/Screens/DraftStashTest.php | 331 ++++++++++++++++++ tests/Feature/Screens/QuoteTest.php | 7 +- 16 files changed, 755 insertions(+), 27 deletions(-) create mode 100644 app/Devnull/Stash.php create mode 100644 resources/views/native/partials/stash-label.blade.php create mode 100644 tests/Feature/Devnull/StashTest.php create mode 100644 tests/Feature/Screens/DraftStashTest.php diff --git a/app/Devnull/Account.php b/app/Devnull/Account.php index cf5b237..8f33e67 100644 --- a/app/Devnull/Account.php +++ b/app/Devnull/Account.php @@ -72,11 +72,13 @@ public function rememberPasteis(?Pasteis $pasteis): void } /** - * Signs this phone out: who it was, the badge, its pastéis, and every answer kept for a quick start. + * Signs this phone out: who it was, the badge, its pastéis, every answer kept for a quick start, and your unsent + * drafts. */ public function forget(): void { app(ResponseCache::class)->flush(); + app(Stash::class)->forget($this->user()?->id); Cache::forget(self::CacheKey); Cache::forget(self::UnreadKey); diff --git a/app/Devnull/Display.php b/app/Devnull/Display.php index 1aa7038..de251c8 100644 --- a/app/Devnull/Display.php +++ b/app/Devnull/Display.php @@ -23,6 +23,11 @@ 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). + */ + public const int ReplyBarStashLabelHeight = 28; + /** * The strip under the home indicator on Face ID iPhones. EDGE doesn't * hand pushed screens the real inset (it's 0 inside a tab), so this errs @@ -206,9 +211,9 @@ public static function elbowDashes(float $from, float $to): array * 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. + * leaves a little more room. A restored draft adds the row that marks it. */ - public static function replyBarClearance(string $draft): int + public static function replyBarClearance(string $draft, bool $restored = false): int { $lines = 0; @@ -218,6 +223,7 @@ public static function replyBarClearance(string $draft): int return self::ReplyBarHeight + (min(self::ReplyBarMaxLines, $lines) - 1) * self::ReplyBarLineHeight + + ($restored ? self::ReplyBarStashLabelHeight : 0) + self::HomeIndicatorHeight; } diff --git a/app/Devnull/Stash.php b/app/Devnull/Stash.php new file mode 100644 index 0000000..899db42 --- /dev/null +++ b/app/Devnull/Stash.php @@ -0,0 +1,88 @@ +drafts($userId)[$name] ?? ''; + } + + /** + * Keeps $body under $name. An empty body forgets the draft. + */ + public function put(?string $userId, string $name, string $body): void + { + if ($userId === null) { + return; + } + + $drafts = $this->drafts($userId); + + if ($body === '' && ! array_key_exists($name, $drafts)) { + return; + } + + unset($drafts[$name]); + + if ($body !== '') { + $drafts = array_slice([...$drafts, $name => $body], -self::MaxDrafts, preserve_keys: true); + } + + $drafts === [] + ? Cache::forget($this->key($userId)) + : Cache::forever($this->key($userId), $drafts); + } + + /** + * The names of every draft kept, for the Quote and Reply buttons that mark one. + * + * @return list + */ + public function names(?string $userId): array + { + return array_map(strval(...), array_keys($this->drafts($userId))); + } + + public function forget(?string $userId): void + { + if ($userId !== null) { + Cache::forget($this->key($userId)); + } + } + + /** + * @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 !== '') : []; + } + + private function key(string $userId): string + { + return "devnull.stash.{$userId}"; + } +} diff --git a/app/NativeComponents/CommitDetail.php b/app/NativeComponents/CommitDetail.php index e71b610..2cb4102 100644 --- a/app/NativeComponents/CommitDetail.php +++ b/app/NativeComponents/CommitDetail.php @@ -7,6 +7,7 @@ use App\Devnull\Data\CommitThread; use App\Devnull\Data\Page; use App\Devnull\Display; +use App\Devnull\Stash; use App\Devnull\Timeline; use App\Icons\Lucide; use App\NativeComponents\Concerns\OpensInTab; @@ -30,6 +31,10 @@ * * It opens on the commit as it was last time, or opened from a row, on what * the row knew of it, then asks devnull for the rest behind it and fills it in. + * + * The reply is stashed as you type, per commit, as on the web (see Stash), so + * leaving the page keeps it, and it's in the reply bar again, marked as + * restored, when you come back to the commit. */ class CommitDetail extends NativeComponent { @@ -71,6 +76,11 @@ class CommitDetail extends NativeComponent public string $reply = ''; + /** + * Whether the reply was stashed when the page opened, which the bar marks until you empty it or drop it. + */ + public bool $replyRestored = false; + /** * Whether the ancestors show above the commit, rather than "↑ N earlier commits". */ @@ -95,6 +105,8 @@ class CommitDetail extends NativeComponent public function mount(): void { $this->focusReply = $this->data('reply', false) === true; + $this->reply = app(Stash::class)->get($this->me()?->id, $this->replyStashName()); + $this->replyRestored = $this->reply !== ''; $this->showKept(); $this->revalidate(); } @@ -229,10 +241,32 @@ public function sendReply(): void } $this->reply = ''; + $this->updatedReply(); $this->replies = [...$this->replies, $reply]; $this->repliesShown = true; } + /** + * Stashes the reply as it changes. An empty field forgets it, and isn't restored any more. + */ + public function updatedReply(): void + { + app(Stash::class)->put($this->me()?->id, $this->replyStashName(), $this->reply); + + if ($this->reply === '') { + $this->replyRestored = false; + } + } + + /** + * The web's drop, beside a restored reply: it's thrown away, and the field starts again empty. + */ + public function dropReplyStash(): void + { + $this->reply = ''; + $this->updatedReply(); + } + /** * The commit this page is about takes you back where you came from. Any * other of yours leaves the page as it would coming back to it. @@ -315,6 +349,11 @@ private function takeReplies(array $json): void $this->repliesLoaded = true; } + private function replyStashName(): string + { + return 'reply-'.$this->id; + } + private function threadPath(): string { return 'commits/'.rawurlencode($this->id); @@ -337,10 +376,12 @@ public function isOwn(): bool public function render(): View { $earlier = $this->earlierShown ? [] : $this->ancestors; + $viewer = $this->me(); return view('native.commit-detail', [ 'myColour' => $this->myColour(), - 'viewer' => $this->me(), + 'viewer' => $viewer, + 'stashed' => app(Stash::class)->names($viewer?->id), 'pasteis' => app(Account::class)->pasteis(), 'rows' => $this->commit === null ? [] : Timeline::thread( [...($this->earlierShown ? $this->ancestors : []), $this->commit, ...$this->series], diff --git a/app/NativeComponents/Concerns/OpensCommitSheet.php b/app/NativeComponents/Concerns/OpensCommitSheet.php index 24004f4..b6db9f0 100644 --- a/app/NativeComponents/Concerns/OpensCommitSheet.php +++ b/app/NativeComponents/Concerns/OpensCommitSheet.php @@ -5,6 +5,7 @@ use App\Devnull\Client; use App\Devnull\Data\Commit; use App\Devnull\ReportReason; +use App\Devnull\Stash; use Closure; /** @@ -18,9 +19,13 @@ * it. Once devnull has the commit the sheet goes, and a screen that lists * your new commits puts it in place (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 as the Log's composer's draft, + * which the sheet shares, as the web's dialog does. Opened again, it starts + * from the draft, marked as restored. It's forgotten once it's sent. + * * The sheet keeps its commit while it slides away, so it doesn't empty on the - * way down, and forgets it and any draft once iOS says it's gone, however it - * was closed. + * way down, and lets go of it once iOS says it's gone, however it was closed. */ trait OpensCommitSheet { @@ -52,6 +57,11 @@ trait OpensCommitSheet */ public string $sheetBody = ''; + /** + * Whether the sheet opened on a stashed draft, which it marks until you empty it or drop it. + */ + public bool $sheetRestored = false; + /** * Why devnull didn't take the report or the commit, shown in the sheet. */ @@ -113,6 +123,7 @@ public function quoteCommit(string $id): void $this->sheetCommit = $commit; $this->sheetView = 'quote'; $this->sheetOpen = true; + $this->restoreSheetDraft(); } /** @@ -123,6 +134,34 @@ public function composeInSheet(): void $this->forgetCommitSheet(); $this->sheetView = 'compose'; $this->sheetOpen = true; + $this->restoreSheetDraft(); + } + + /** + * Stashes the draft as it changes. An empty field forgets it, and isn't restored any more. + */ + public function updatedSheetBody(): void + { + $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; + } + } + + /** + * The web's drop, beside a restored draft: it's thrown away, and the field starts again empty. + */ + public function dropSheetStash(): void + { + $this->sheetBody = ''; + $this->updatedSheetBody(); } /** @@ -132,9 +171,10 @@ public function composeInSheet(): void public function commitFromSheet(): void { $quoted = $this->sheetView === 'quote' ? $this->sheetCommit : null; + $stashName = $this->sheetStashName(); $body = trim($this->sheetBody); - if (($quoted === null && $this->sheetView !== 'compose') || mb_strlen($body) < Commit::MinLength || mb_strlen($body) > Commit::MaxLength) { + if ($stashName === null || mb_strlen($body) < Commit::MinLength || mb_strlen($body) > Commit::MaxLength) { return; } @@ -144,6 +184,7 @@ public function commitFromSheet(): void return; } + app(Stash::class)->put($this->me()?->id, $stashName, ''); $this->sheetOpen = false; $this->showCommitted($commit); } @@ -157,11 +198,26 @@ public function hideCommitSheet(): void } /** - * iOS says the sheet has gone, swiped away or closed from here. What was drafted in it goes with it. + * iOS says the sheet has gone, swiped away or closed from here. What was drafted in it stays stashed. */ public function commitSheetClosed(): void { + $composed = $this->sheetView === 'compose'; + $this->forgetCommitSheet(); + + if ($composed) { + $this->composeSheetClosed(); + } + } + + /** + * The sheet's new commit has gone, sent or stashed. The Log's composer shares its draft, so it takes it as the + * sheet left it. + */ + protected function composeSheetClosed(): void + { + // } /** @@ -194,9 +250,30 @@ private function forgetCommitSheet(): void $this->reportReason = ''; $this->reportDetails = ''; $this->sheetBody = ''; + $this->sheetRestored = false; $this->sheetError = null; } + /** + * The draft the sheet stashes, by the web's name for it, or null when it isn't a composer. + */ + private function sheetStashName(): ?string + { + return match (true) { + $this->sheetView === 'compose' => 'commit', + $this->sheetView === 'quote' && $this->sheetCommit !== null => 'quote-'.$this->sheetCommit->id, + default => null, + }; + } + + private function restoreSheetDraft(): void + { + $name = $this->sheetStashName(); + + $this->sheetBody = $name === null ? '' : app(Stash::class)->get($this->me()?->id, $name); + $this->sheetRestored = $this->sheetBody !== ''; + } + /** * Like attempt(), but a failure shows in the sheet rather than where the screen prints its errors. * diff --git a/app/NativeComponents/Log.php b/app/NativeComponents/Log.php index da42a12..294fe7f 100644 --- a/app/NativeComponents/Log.php +++ b/app/NativeComponents/Log.php @@ -9,6 +9,7 @@ use App\Devnull\LastVisit; use App\Devnull\ResponseCache; use App\Devnull\ShownTabs; +use App\Devnull\Stash; use App\Devnull\Timeline; use App\Icons\Android; use App\Icons\Ios; @@ -39,6 +40,10 @@ * in a sheet (see OpensCommitSheet), which leaves the log where you were in * it, and the commit goes on top as one from the composer does. * + * The composer's draft is stashed as you type, as on the web (see Stash), so + * it's there again when the Log opens, marked as restored. The sheet shares + * it, as the web's dialog does, and the composer takes what the sheet left. + * * It opens on the log as it was last time, then asks devnull again in the * background, and every 30 seconds while it's showing, as the web polls. * Commits newer than anything showing wait under the composer in the new @@ -155,8 +160,15 @@ class Log extends NativeComponent */ public int $composerKey = 0; + /** + * Whether the draft in the composer was stashed when it opened, which it marks until you empty it or drop it. + */ + public bool $draftRestored = false; + public function mount(): void { + $this->body = app(Stash::class)->get($this->me()?->id, 'commit'); + $this->draftRestored = $this->body !== ''; $this->log = app(ShownTabs::class)->get($this->me()?->id, 'log', self::Logs, 'following'); $this->lastVisitAt = $this->lastVisitSeenAt(); $this->showKept(); @@ -427,6 +439,28 @@ public function composerFocused(): void $this->composing = true; } + /** + * Stashes the draft as it changes. An empty field forgets it, and isn't restored any more. + */ + public function updatedBody(): void + { + app(Stash::class)->put($this->me()?->id, 'commit', $this->body); + + if ($this->body === '') { + $this->draftRestored = false; + } + } + + /** + * The web's drop, beside a restored draft: it's thrown away, and a fresh field takes its place. + */ + public function dropStash(): void + { + $this->body = ''; + $this->updatedBody(); + $this->composerKey++; + } + public function remaining(): int { return Commit::MaxLength - mb_strlen($this->body); @@ -456,11 +490,28 @@ public function commit(): void } $this->body = ''; + $this->updatedBody(); $this->composing = false; $this->composerKey++; $this->showCommitted($commit); } + /** + * The sheet wrote to the composer's stash, so the composer takes the draft as the sheet left it, in a fresh field. + */ + protected function composeSheetClosed(): void + { + $draft = app(Stash::class)->get($this->me()?->id, 'commit'); + + if ($draft === $this->body) { + return; + } + + $this->body = $draft; + $this->draftRestored = $draft !== ''; + $this->composerKey++; + } + /** * Puts a commit you just made at the top of the log, from the composer or the sheet. */ @@ -724,6 +775,7 @@ public function render(): View 'composerOpen' => $this->composing || $this->body !== '', 'remaining' => $this->remaining(), 'canCommit' => $this->canCommit(), + 'stashed' => app(Stash::class)->names($viewer?->id), 'newCommitsLabel' => $this->newCommitsLabel(), 'pages' => $pages, 'pageIndex' => array_search($this->log, self::Logs, true), diff --git a/app/NativeComponents/Profile.php b/app/NativeComponents/Profile.php index ad0cd17..1eb848a 100644 --- a/app/NativeComponents/Profile.php +++ b/app/NativeComponents/Profile.php @@ -10,6 +10,7 @@ use App\Devnull\ErrorCode; use App\Devnull\Exceptions\ApiException; use App\Devnull\ShownTabs; +use App\Devnull\Stash; use App\Devnull\Timeline; use App\Icons\Lucide; use App\NativeComponents\Concerns\ExpandsThreads; @@ -721,6 +722,7 @@ public function render(): View 'isMe' => $this->isMe(), 'newCommitsLabel' => $this->newCommitsLabel(), 'viewer' => $this->me(), + 'stashed' => app(Stash::class)->names($this->me()?->id), 'pages' => $pages, 'pageIndex' => array_search($this->tab, self::Tabs, true), 'tabs' => array_values(self::Labels), diff --git a/resources/views/native/commit-detail.blade.php b/resources/views/native/commit-detail.blade.php index b8e1ce2..0bf6538 100644 --- a/resources/views/native/commit-detail.blade.php +++ b/resources/views/native/commit-detail.blade.php @@ -94,7 +94,7 @@ Display::replyBarClearance): the last reply and its actions end above the bar, keyboard up or down. --}} @if ($commit) - + @endif @@ -124,6 +124,12 @@ class="w-full py-2 text-theme-ink" @include('native.partials.body-counter', ['body' => $reply, 'ref' => 'reply-budget']) Reply + {{-- 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']) + + @endif @endif diff --git a/resources/views/native/partials/commit-actions.blade.php b/resources/views/native/partials/commit-actions.blade.php index a0cb72c..a497e86 100644 --- a/resources/views/native/partials/commit-actions.blade.php +++ b/resources/views/native/partials/commit-actions.blade.php @@ -17,6 +17,10 @@ published commit it gives one more per tap; on your own, or one that isn't published, it's just the count. It only shows in pastéis mode ($pasteis). + A commit with a quote or a reply you started and didn't send marks that button, as the web does with stash@{0} + beside its label. That doesn't fit the row, so here it's a dot after the icon or label, inside the button's 44pt. + The screen passes the names of the drafts it has kept ($stashed, see App\Devnull\Stash). + Each action is at least 44pt wide and 36pt tall to tap, as Apple asks, with the row pulled in by the extra height so the commit keeps its rhythm. EDGE makes a pressable's whole box tappable, so the box is the target. Off the log, "› N replies" sits beside Reply, and both won't fit on a phone with their labels, so Reply is just its icon there. @@ -36,6 +40,9 @@ @php($iconSize = $compact ? 14 : 16) @php($labelSize = $compact ? 'text-xs' : 'text-[13px]') @php($viewer ??= app(\App\Devnull\Account::class)->user()) +@php($stashed ??= []) +@php($hasQuoteStash = in_array('quote-'.$commit->id, $stashed, true)) +@php($hasReplyStash = in_array('reply-'.$commit->id, $stashed, true)) @php($isOwn = $commit->author->id === $viewer?->id) @php($canReact = $commit->isApproved() || $isOwn) @php($hasThread = $expandable && $commit->replyPreview !== null) @@ -80,20 +87,26 @@ @endif @if ($withQuote && $canReact) - - + + - + @if ($hasQuoteStash) + + @endif + @endif @if ($withReply) - + @unless ($showsReplies) Reply @endunless + @if ($hasReplyStash) + + @endif @endif diff --git a/resources/views/native/partials/commit-sheet.blade.php b/resources/views/native/partials/commit-sheet.blade.php index 599ea69..983703f 100644 --- a/resources/views/native/partials/commit-sheet.blade.php +++ b/resources/views/native/partials/commit-sheet.blade.php @@ -2,7 +2,8 @@ The sheet under a commit (see OpensCommitSheet). Quote opens it as a composer: the field, focused, with the commit being quoted under it as a static card, then the counter and Commit, which waits for a body long enough to make. If devnull says no, why shows here and the draft stays. The Log's compose button opens the same composer for a new - commit, without the card. + commit, without the card. A draft stashed last time comes back in it, with the web's stash@{0} and drop on the left + of Commit's row. Report commit, in someone else's published commit's More menu, opens it as the web's Report dialog: the private, not-hidden line, the commit's body clamped to three lines, the four reasons, optional details, and Send report, @@ -43,6 +44,10 @@ class="w-full text-theme-ink" @endif + @if ($sheetRestored) + @include('native.partials.stash-label', ['ref' => 'sheet-stash', 'drop' => 'dropSheetStash']) + + @endif @include('native.partials.body-counter', ['body' => $sheetBody, 'ref' => 'sheet-budget']) Commit diff --git a/resources/views/native/partials/composer.blade.php b/resources/views/native/partials/composer.blade.php index f6d782d..f9f60ae 100644 --- a/resources/views/native/partials/composer.blade.php +++ b/resources/views/native/partials/composer.blade.php @@ -6,6 +6,8 @@ The field is keyed, so a new $composerKey (or a new list key) mounts a fresh one, clean after a commit. A touch on it is how the screen learns you're in it, since a field reports neither focus nor, on iOS, a caret in an empty field. The nav bar's compose button opens a composer in the sheet instead (see OpensCommitSheet). + + A draft stashed last time comes back in it, with the web's stash@{0} and drop on the left of Commit's row. --}} @@ -29,6 +31,10 @@ class="w-full text-theme-ink" @if ($composerOpen) + @if ($draftRestored) + @include('native.partials.stash-label', ['ref' => 'stash', 'drop' => 'dropStash']) + + @endif @include('native.partials.body-counter', ['body' => $body, 'ref' => 'budget']) Commit diff --git a/resources/views/native/partials/stash-label.blade.php b/resources/views/native/partials/stash-label.blade.php new file mode 100644 index 0000000..b455da5 --- /dev/null +++ b/resources/views/native/partials/stash-label.blade.php @@ -0,0 +1,13 @@ +{{-- + The web's StashLabel, beside a draft that was stashed and has come back: stash@{0} in mono, then drop, which throws + the draft away. $drop is what drop presses, and drop is a full 44pt target. +--}} +@php($size ??= 'text-[13px]') + + {{ 'stash@{0}' }} + + + drop + + + diff --git a/tests/Feature/Devnull/StashTest.php b/tests/Feature/Devnull/StashTest.php new file mode 100644 index 0000000..a76a8fc --- /dev/null +++ b/tests/Feature/Devnull/StashTest.php @@ -0,0 +1,69 @@ +put('u1', 'commit', 'Half a thought'); + $stash->put('u1', 'reply-c2', 'A reply in progress'); + $stash->put('u2', 'commit', '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') + ->and($stash->names('u1'))->toBe(['commit', 'reply-c2']); +}); + +it('forgets a draft once it is emptied', function () { + $stash = app(Stash::class); + + $stash->put('u1', 'commit', 'Half a thought'); + $stash->put('u1', 'commit', ''); + + expect($stash->get('u1', 'commit'))->toBe('') + ->and($stash->names('u1'))->toBe([]); +}); + +it('keeps nothing when nobody is signed in', function () { + $stash = app(Stash::class); + + $stash->put(null, 'commit', 'Half a thought'); + + expect($stash->get(null, 'commit'))->toBe('') + ->and($stash->names(null))->toBe([]); +}); + +it('lets the draft written longest ago go once there are too many', function () { + $stash = app(Stash::class); + + foreach (range(1, Stash::MaxDrafts) as $number) { + $stash->put('u1', "reply-c{$number}", "Reply {$number}"); + } + + // Writing a draft again makes it the newest. + $stash->put('u1', 'reply-c1', 'Reply 1, again'); + $stash->put('u1', 'commit', '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'); +}); + +it("forgets the signed-in user's drafts when the phone signs out", function () { + signIn(); + $me = app(Account::class)->user()?->id; + $stash = app(Stash::class); + + $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'); + + app(Account::class)->forget(); + + expect($stash->names($me))->toBe([]) + ->and($stash->get('u2', 'commit'))->toBe('Someone else on this phone'); +}); diff --git a/tests/Feature/Screens/ComposeSheetTest.php b/tests/Feature/Screens/ComposeSheetTest.php index 99a8319..22547ad 100644 --- a/tests/Feature/Screens/ComposeSheetTest.php +++ b/tests/Feature/Screens/ComposeSheetTest.php @@ -120,9 +120,11 @@ function sheetCommitDisabled(TestableComponent $screen): bool ->and(array_map(fn (Commit $commit): string => $commit->id, $screen->instance()->commits))->toBe(['c1']); }); -it('forgets the draft when the sheet is swiped away', function () { +it('keeps the draft when the sheet is swiped away, and opens on it again, marked as restored', function () { $screen = composeSheetOnLog()->input('sheet-body', 'Half a thought'); + expect(findNode($screen->tree(), fn (array $node): bool => ($node['ref'] ?? null) === 'sheet-stash'))->toBeNull(); + $screen->dismissSheet('sheet'); expect(nodeByRef($screen, 'sheet')['props']['visible'])->toBeFalse() @@ -130,30 +132,44 @@ function sheetCommitDisabled(TestableComponent $screen): bool $screen->press('composeInSheet'); - expect(nodeByRef($screen, 'sheet-body')['props']['value'])->toBe('') - ->and(sheetCommitDisabled($screen))->toBeTrue(); + expect(nodeByRef($screen, 'sheet-body')['props']['value'])->toBe('Half a thought') + ->and(nodeByRef($screen, 'sheet-stash'))->not->toBeEmpty() + ->and(sheetCommitDisabled($screen))->toBeFalse(); Http::assertNotSent(fn (Request $request): bool => $request->method() === 'POST'); }); -it('leaves a draft in the composer at the top of the log alone', function () { +it('shares its draft with the composer at the top of the log, as the web dialog does', function () { fakeApi([ 'GET /api/v1/log/following' => [pageJson([])], - 'POST /api/v1/commits' => [['data' => commitJson(['id' => 'mine', 'body' => 'Shipped from the sheet.'])], 201], + 'POST /api/v1/commits' => [['data' => commitJson(['id' => 'mine', 'body' => 'Half a thought, shipped.'])], 201], ]); $screen = Native::visit('/log', platform: 'ios') ->input('body', 'Half a thought') ->press('composeInSheet'); - expect(nodeByRef($screen, 'sheet-body')['props']['value'])->toBe(''); + expect(nodeByRef($screen, 'sheet-body')['props']['value'])->toBe('Half a thought'); + + // Closed, the composer takes the draft as the sheet left it. + $screen->input('sheet-body', 'Half a thought, and more') + ->dismissSheet('sheet'); - $screen->input('sheet-body', 'Shipped from the sheet.') + expect(nodeByRef($screen, 'body')['props']['value'])->toBe('Half a thought, and more') + ->and(nodeByRef($screen, 'stash'))->not->toBeEmpty(); + + // Sent from the sheet, it's gone from both. + $screen->press('composeInSheet') + ->input('sheet-body', 'Half a thought, shipped.') ->tap('sheet-commit') - ->assertSet('body', 'Half a thought'); + ->dismissSheet('sheet') + ->assertSet('body', ''); - expect(nodeByRef($screen, 'body')['props']['value'])->toBe('Half a thought') - ->and(nodeByRef($screen, 'commit')['props']['disabled'] ?? false)->toBeFalse(); + expect(nodeByRef($screen, 'body')['props']['value'])->toBe(''); + + $screen->press('composeInSheet'); + + expect(nodeByRef($screen, 'sheet-body')['props']['value'])->toBe(''); }); it('only commits from a sheet that has a composer in it', function () { diff --git a/tests/Feature/Screens/DraftStashTest.php b/tests/Feature/Screens/DraftStashTest.php new file mode 100644 index 0000000..d2529f1 --- /dev/null +++ b/tests/Feature/Screens/DraftStashTest.php @@ -0,0 +1,331 @@ + 'http://devnull.test']); + signIn(); +}); + +/** + * Ada, someone else whose commits you can quote and reply to. + * + * @return array + */ +function stashAda(): array +{ + return userJson(['id' => 'u2', 'username' => 'ada', 'name' => 'Ada']); +} + +/** + * A Following log with two commits of Ada's, c1 and c2, that answers a new commit with one of yours. + */ +function serveStashLog(): void +{ + fakeApi([ + 'GET /api/v1/log/following' => [pageJson([ + commitJson(['id' => 'c2', 'body' => 'Another commit of Ada\'s', 'author' => stashAda()]), + commitJson(['id' => 'c1', 'body' => 'Required reading.', 'author' => stashAda()]), + ])], + 'POST /api/v1/commits' => [['data' => commitJson(['id' => 'mine', 'body' => 'Sent.'])], 201], + 'GET /api/v1/commits/c2' => [commitPageJson(commitJson(['id' => 'c2', 'body' => 'Another commit of Ada\'s', 'author' => stashAda()]))], + 'GET /api/v1/commits/c2/replies' => [pageJson([])], + ]); +} + +function stashed(string $name): string +{ + return app(Stash::class)->get(app(Account::class)->user()?->id, $name); +} + +function stash(string $name, string $body): void +{ + app(Stash::class)->put(app(Account::class)->user()?->id, $name, $body); +} + +function showsRef(TestableComponent $screen, string $ref): bool +{ + return findNode($screen->tree(), fn (array $node): bool => ($node['ref'] ?? null) === $ref) !== null; +} + +/** + * The text of the label a restored draft shows, and the a11y label of its drop. + * + * @return array{0: ?string, 1: ?string} + */ +function stashLabel(TestableComponent $screen, string $ref): array +{ + $label = findNode(nodeByRef($screen, $ref), fn (array $node): bool => ($node['type'] ?? null) === 'text'); + $drop = nodeByRef($screen, "{$ref}-drop"); + + return [$label['text'] ?? $label['props']['text'] ?? null, $drop['props']['a11y_label'] ?? $drop['a11y_label'] ?? null]; +} + +// ── The Log's composer ───────────────────────── + +it("stashes the Log's composer as you type, and brings it back marked as restored when the Log opens again", function () { + serveStashLog(); + + $screen = Native::visit('/log', platform: 'ios')->input('body', 'Half a thought'); + + expect(stashed('commit'))->toBe('Half a thought') + ->and(showsRef($screen, 'stash'))->toBeFalse(); + + $screen = Native::visit('/log', platform: 'ios'); + + expect(nodeByRef($screen, 'body')['props']['value'])->toBe('Half a thought') + ->and(stashLabel($screen, 'stash'))->toBe(['stash@{0}', 'Discard the restored draft']) + ->and(nodeByRef($screen, 'commit')['props']['disabled'] ?? false)->toBeFalse(); +}); + +it("forgets the composer's stash once the commit is made", function () { + serveStashLog(); + stash('commit', 'Sent.'); + + $screen = Native::visit('/log', platform: 'ios') + ->tap('commit') + ->assertSet('body', ''); + + expect(stashed('commit'))->toBe('') + ->and(showsRef($screen, 'stash'))->toBeFalse(); + + expect(nodeByRef(Native::visit('/log', platform: 'ios'), 'body')['props']['value'])->toBe(''); +}); + +it('forgets the stash when the field is emptied, and stops marking the draft as restored', function () { + serveStashLog(); + stash('commit', 'Half a thought'); + + $screen = Native::visit('/log', platform: 'ios')->input('body', ''); + + expect(stashed('commit'))->toBe('') + ->and(showsRef($screen, 'stash'))->toBeFalse(); + + $screen->input('body', 'A new thought'); + + expect(stashed('commit'))->toBe('A new thought') + ->and(showsRef($screen, 'stash'))->toBeFalse(); +}); + +it('drops a restored draft, leaving a fresh empty field', function () { + serveStashLog(); + stash('commit', 'Half a thought'); + + $screen = Native::visit('/log', platform: 'ios') + ->tap('stash-drop') + ->assertSet('body', '') + ->assertSet('composerKey', 1); + + expect(stashed('commit'))->toBe('') + ->and(nodeByRef($screen, 'body')['props']['value'])->toBe('') + ->and(showsRef($screen, 'stash'))->toBeFalse(); + + Http::assertNotSent(fn (Request $request): bool => $request->method() === 'POST'); +}); + +it('keeps your drafts to you', function () { + serveStashLog(); + app(Stash::class)->put('u2', 'commit', "Ada's draft"); + + $screen = Native::visit('/log', platform: 'ios'); + + expect(nodeByRef($screen, 'body')['props']['value'])->toBe('') + ->and(showsRef($screen, 'stash'))->toBeFalse(); +}); + +// ── The compose sheet ────────────────────────── + +it("stashes the compose sheet as the composer's draft, and forgets it once it's committed", function () { + serveStashLog(); + + $screen = Native::visit('/log', platform: 'ios') + ->press('composeInSheet') + ->input('sheet-body', 'Written in the sheet'); + + expect(stashed('commit'))->toBe('Written in the sheet'); + + $screen->dismissSheet('sheet'); + + expect(nodeByRef($screen, 'body')['props']['value'])->toBe('Written in the sheet') + ->and(nodeByRef($screen, 'stash'))->not->toBeEmpty(); + + $screen->press('composeInSheet'); + + expect(nodeByRef($screen, 'sheet-body')['props']['value'])->toBe('Written in the sheet') + ->and(stashLabel($screen, 'sheet-stash'))->toBe(['stash@{0}', 'Discard the restored draft']); + + $screen->tap('sheet-commit')->dismissSheet('sheet'); + + expect(stashed('commit'))->toBe('') + ->and(nodeByRef($screen, 'body')['props']['value'])->toBe('') + ->and(showsRef($screen, 'stash'))->toBeFalse(); +}); + +it("drops the sheet's restored draft", function () { + serveStashLog(); + stash('commit', 'Half a thought'); + + $screen = Native::visit('/log', platform: 'ios') + ->press('composeInSheet') + ->tap('sheet-stash-drop') + ->assertSet('sheetBody', ''); + + expect(stashed('commit'))->toBe('') + ->and(showsRef($screen, 'sheet-stash'))->toBeFalse(); + + $screen->dismissSheet('sheet'); + + expect(nodeByRef($screen, 'body')['props']['value'])->toBe(''); +}); + +// ── Quotes ───────────────────────────────────── + +it('stashes a quote per commit quoted, and marks Quote on that commit', function () { + serveStashLog(); + + $screen = Native::visit('/log', platform: 'ios') + ->tap('quote-c1') + ->input('sheet-body', 'This, all of it.') + ->dismissSheet('sheet'); + + expect(stashed('quote-c1'))->toBe('This, all of it.') + ->and(stashed('commit'))->toBe('') + ->and(showsRef($screen, 'quote-stash-c1'))->toBeTrue() + ->and(nodeByRef($screen, 'quote-c1')['props']['a11y_label'])->toBe('Quote, you have an unsent quote here') + ->and(showsRef($screen, 'quote-stash-c2'))->toBeFalse() + ->and(nodeByRef($screen, 'quote-c2')['props']['a11y_label'])->toBe('Quote'); + + // Another commit's quote starts empty. + $screen->tap('quote-c2'); + + expect(nodeByRef($screen, 'sheet-body')['props']['value'])->toBe('') + ->and(showsRef($screen, 'sheet-stash'))->toBeFalse(); + + $screen->dismissSheet('sheet')->tap('quote-c1'); + + expect(nodeByRef($screen, 'sheet-body')['props']['value'])->toBe('This, all of it.') + ->and(stashLabel($screen, 'sheet-stash'))->toBe(['stash@{0}', 'Discard the restored draft']); +}); + +it("forgets a quote's stash once it's sent", function () { + serveStashLog(); + stash('quote-c1', 'This, all of it.'); + + $screen = Native::visit('/log', platform: 'ios') + ->tap('quote-c1') + ->tap('sheet-commit') + ->dismissSheet('sheet'); + + Http::assertSent(fn (Request $request): bool => $request->method() === 'POST' + && $request->body() === '{"body":"This, all of it.","quoted_commit_id":"c1"}'); + + expect(stashed('quote-c1'))->toBe('') + ->and(showsRef($screen, 'quote-stash-c1'))->toBeFalse(); +}); + +it('keeps a quote that devnull said no to', function () { + fakeApi([ + 'GET /api/v1/log/following' => [pageJson([commitJson(['id' => 'c1', 'author' => stashAda()])])], + 'POST /api/v1/commits' => [['message' => 'Too Many Attempts.'], 429], + ]); + + Native::visit('/log', platform: 'ios') + ->tap('quote-c1') + ->input('sheet-body', 'This, all of it.') + ->tap('sheet-commit'); + + expect(stashed('quote-c1'))->toBe('This, all of it.'); +}); + +it('marks Quote on a profile with a stashed quote', function () { + fakeApi([ + 'GET /api/v1/users/ada' => [['data' => userWithCountsJson(['id' => 'u2', 'username' => 'ada']), 'is_following' => false, 'activity' => ['scale' => 'week', 'buckets' => []], 'streaks' => ['longest' => 0, 'current' => 0], 'pinned_commit' => null]], + 'GET /api/v1/users/ada/commits' => [pageJson([commitJson(['id' => 'c1', 'author' => stashAda()])])], + ]); + stash('quote-c1', 'This, all of it.'); + + expect(showsRef(Native::visit('/log/users/ada', platform: 'ios'), 'quote-stash-c1'))->toBeTrue(); +}); + +// ── Replies ──────────────────────────────────── + +it('stashes a reply per commit, and brings it back in the reply bar marked as restored', function () { + serveStashLog(); + + $screen = Native::visit('/log/commits/c2')->input('reply-input', 'A reply in progress'); + $clearance = nodeByRef($screen, 'reply-bar-clearance')['layout']['height']; + + expect(stashed('reply-c2'))->toBe('A reply in progress') + ->and(showsRef($screen, 'reply-stash'))->toBeFalse(); + + $screen = Native::visit('/log/commits/c2'); + + expect(nodeByRef($screen, 'reply-input')['props']['value'])->toBe('A reply in progress') + ->and(stashLabel($screen, 'reply-stash'))->toBe(['stash@{0}', 'Discard the restored draft']) + ->and(nodeByRef($screen, 'send-reply')['props']['disabled'] ?? false)->toBeFalse() + // The page ends above the row that marks it, too. + ->and(nodeByRef($screen, 'reply-bar-clearance')['layout']['height'])->toEqual($clearance + Display::ReplyBarStashLabelHeight); +}); + +it("forgets a reply's stash once it's sent", function () { + serveStashLog(); + stash('reply-c2', 'Sent.'); + + $screen = Native::visit('/log/commits/c2') + ->tap('send-reply') + ->assertSet('reply', ''); + + expect(stashed('reply-c2'))->toBe('') + ->and(showsRef($screen, 'reply-stash'))->toBeFalse(); +}); + +it('drops a restored reply', function () { + serveStashLog(); + stash('reply-c2', 'A reply in progress'); + + $screen = Native::visit('/log/commits/c2') + ->tap('reply-stash-drop') + ->assertSet('reply', ''); + + expect(stashed('reply-c2'))->toBe('') + ->and(nodeByRef($screen, 'reply-input')['props']['value'])->toBe('') + ->and(showsRef($screen, 'reply-stash'))->toBeFalse(); +}); + +it('marks Reply on a commit with a stashed reply', function () { + serveStashLog(); + stash('reply-c2', 'A reply in progress'); + + $screen = Native::visit('/log', platform: 'ios'); + + expect(showsRef($screen, 'reply-stash-c2'))->toBeTrue() + ->and(nodeByRef($screen, 'reply-to-c2')['props']['a11y_label'])->toBe('Reply, you have an unsent reply here') + ->and(showsRef($screen, 'reply-stash-c1'))->toBeFalse() + ->and(nodeByRef($screen, 'reply-to-c1')['props']['a11y_label'])->toBe('Reply'); +}); + +// ── Signing out ──────────────────────────────── + +it('forgets every draft when you sign out or delete your account', function (string $button, string $alert) { + serveApi(['GET /api/v1/me' => [currentUserJson()], 'DELETE /api/v1/me' => [null, 204]]); + stash('commit', 'Half a thought'); + stash('quote-c1', 'This, all of it.'); + stash('reply-c2', 'A reply in progress'); + $me = app(Account::class)->user()?->id; + + Native::visit('/profile/device') + ->emitNative(ButtonPressed::class, ['index' => 1, 'label' => $button, 'id' => $alert]) + ->assertReplacedWith('/onboarding'); + + expect(app(Stash::class)->names($me))->toBe([]); +})->with([ + 'sign out' => ['Sign out', 'sign-out'], + 'delete account' => ['Delete', 'delete-account'], +]); diff --git a/tests/Feature/Screens/QuoteTest.php b/tests/Feature/Screens/QuoteTest.php index a4fffc1..d4d91cf 100644 --- a/tests/Feature/Screens/QuoteTest.php +++ b/tests/Feature/Screens/QuoteTest.php @@ -165,7 +165,7 @@ function quoteButtonDisabled(TestableComponent $screen): bool ->and(array_map(fn (Commit $commit): string => $commit->id, $screen->instance()->commits))->toBe(['c1']); }); -it('forgets the draft when the sheet is swiped away', function () { +it('keeps the draft when the sheet is swiped away, and opens on it again, marked as restored', function () { $screen = quoteSheetOnLog()->input('sheet-body', 'Half a thought'); $screen->dismissSheet('sheet'); @@ -177,8 +177,9 @@ function quoteButtonDisabled(TestableComponent $screen): bool $screen->tap('quote-c1'); - expect(nodeByRef($screen, 'sheet-body')['props']['value'])->toBe('') - ->and(quoteButtonDisabled($screen))->toBeTrue(); + expect(nodeByRef($screen, 'sheet-body')['props']['value'])->toBe('Half a thought') + ->and(nodeByRef($screen, 'sheet-stash'))->not->toBeEmpty() + ->and(quoteButtonDisabled($screen))->toBeFalse(); Http::assertNotSent(fn (Request $request): bool => $request->method() === 'POST'); }); From 765a892c58a8b82d4c7f73c7a87361738a965739 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Sun, 18 Oct 2026 22:45:17 +0100 Subject: [PATCH 2/2] Mark stashed drafts on Saved's rows too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Saved joined the tabs after the stash was written, so its rows had no dots on Quote and Reply. It passes the drafts' names like the other screens. A test also checks the dots stay inside their buttons in pastéis mode, beside the pastel. Co-Authored-By: Claude Opus 5.5 --- app/NativeComponents/Saved.php | 5 +++- tests/Feature/Screens/DraftStashTest.php | 34 ++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/app/NativeComponents/Saved.php b/app/NativeComponents/Saved.php index 79d43c2..b78d2d6 100644 --- a/app/NativeComponents/Saved.php +++ b/app/NativeComponents/Saved.php @@ -6,6 +6,7 @@ use App\Devnull\Data\Page; use App\Devnull\ResponseCache; use App\Devnull\ShownTabs; +use App\Devnull\Stash; use App\Devnull\Timeline; use App\NativeComponents\Concerns\ExpandsThreads; use App\NativeComponents\Concerns\OpensInTab; @@ -233,9 +234,11 @@ private static function ids(array $commits): array public function render(): View { $lastCommit = $this->commits[array_key_last($this->commits) ?? -1] ?? null; + $viewer = $this->me(); return view('native.saved', [ - 'viewer' => $this->me(), + 'viewer' => $viewer, + 'stashed' => app(Stash::class)->names($viewer?->id), 'myColour' => $this->myColour(), 'rows' => Timeline::feed($this->commits), 'lastCommit' => $lastCommit, diff --git a/tests/Feature/Screens/DraftStashTest.php b/tests/Feature/Screens/DraftStashTest.php index d2529f1..fc50ea6 100644 --- a/tests/Feature/Screens/DraftStashTest.php +++ b/tests/Feature/Screens/DraftStashTest.php @@ -1,8 +1,10 @@ and(nodeByRef($screen, 'reply-to-c1')['props']['a11y_label'])->toBe('Reply'); }); +it('marks Quote and Reply on a saved commit with a stashed draft', function () { + fakeApi(['GET /api/v1/saved' => [pageJson([commitJson(['id' => 'c1', 'is_saved' => true, 'author' => stashAda()])], path: 'http://devnull.test/api/v1/saved')]]); + stash('quote-c1', 'This, all of it.'); + stash('reply-c1', 'A reply in progress'); + + $screen = finishRefreshes(Native::visit('/saved', platform: 'ios')); + + expect(showsRef($screen, 'quote-stash-c1'))->toBeTrue() + ->and(showsRef($screen, 'reply-stash-c1'))->toBeTrue(); +}); + +it('keeps the dots inside Quote and Reply in pastéis mode, beside the pastel, the Reply label and the replies link', function () { + app(Account::class)->rememberPasteis(new Pasteis(Pasteis::Daily, CarbonImmutable::parse('2026-10-18T00:00:00Z'))); + fakeApi(['GET /api/v1/log/following' => [pageJson([ + commitJson(['id' => 'c2', 'replies_count' => 3, 'author' => stashAda()]), + commitJson(['id' => 'c1', 'author' => stashAda()]), + ])]]); + stash('quote-c2', 'This, all of it.'); + stash('reply-c2', 'A reply in progress'); + stash('reply-c1', 'Another reply in progress'); + + $screen = Native::visit('/log', platform: 'ios'); + + // The dots sit inside the buttons, so the row has the same actions as without them. + expect(array_values(array_filter(childRefs($screen, 'actions-c2'))))->toBe(['star-c2', 'pastel-c2', 'quote-c2', 'reply-to-c2', 'replies-c2', 'more-c2']) + ->and(array_values(array_filter(childRefs($screen, 'actions-c1'))))->toBe(['star-c1', 'pastel-c1', 'quote-c1', 'reply-to-c1', 'more-c1']) + ->and(showsRef($screen, 'quote-stash-c2'))->toBeTrue() + ->and(showsRef($screen, 'reply-stash-c2'))->toBeTrue() + ->and(showsRef($screen, 'reply-stash-c1'))->toBeTrue() + ->and(findNode(nodeByRef($screen, 'reply-to-c1'), fn (array $node): bool => ($node['type'] ?? null) === 'text')['props']['text'] ?? null)->toBe('Reply'); +}); + // ── Signing out ──────────────────────────────── it('forgets every draft when you sign out or delete your account', function (string $button, string $alert) {