From 28590fb6bc5be3fc03f4c3feb15e2ac92141e77c Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Sun, 18 Oct 2026 23:28:20 +0100 Subject: [PATCH] Drop the inline composer from the Log Typing fast into the composer at the top of the Log lost letters and put them out of order. The field sat in the Log's list, and every sync of the draft redrew the whole log, so the field kept getting text that was a few keystrokes old. The compose sheet and the reply box don't have the problem. The compose button in the nav bar already opens a composer in a sheet from anywhere in the log, like Twitter's. So the inline field goes, along with its draft, budget, Commit button and staged avatar. The new commits pill is now the first row of the list. The sheet keeps the web's commit draft stash. Co-Authored-By: Claude Opus 5.5 --- app/Devnull/Stash.php | 4 +- .../Concerns/OpensCommitSheet.php | 21 +- app/NativeComponents/Log.php | 148 +----------- .../views/native/partials/composer.blade.php | 48 ---- .../views/native/partials/log-page.blade.php | 4 - tests/Feature/Screens/ComposeSheetTest.php | 38 +-- tests/Feature/Screens/ComposerTest.php | 224 ------------------ tests/Feature/Screens/DraftStashTest.php | 88 +------ tests/Feature/Screens/LaneButtonsTest.php | 4 +- tests/Feature/Screens/LogCacheTest.php | 10 +- tests/Feature/Screens/LogTest.php | 12 - tests/Feature/Screens/LogsTest.php | 8 +- 12 files changed, 37 insertions(+), 572 deletions(-) delete mode 100644 resources/views/native/partials/composer.blade.php delete mode 100644 tests/Feature/Screens/ComposerTest.php diff --git a/app/Devnull/Stash.php b/app/Devnull/Stash.php index 899db42..cd2ed6d 100644 --- a/app/Devnull/Stash.php +++ b/app/Devnull/Stash.php @@ -7,8 +7,8 @@ /** * What you wrote and didn't send, per user, as the web's useStash keeps it. * - * Drafts go by the web's names: 'commit' for the Log's composer, which its - * sheet shares, 'quote-{id}' for a quote of a commit and 'reply-{id}' for a + * 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 * Account::forget). diff --git a/app/NativeComponents/Concerns/OpensCommitSheet.php b/app/NativeComponents/Concerns/OpensCommitSheet.php index b6db9f0..d919c90 100644 --- a/app/NativeComponents/Concerns/OpensCommitSheet.php +++ b/app/NativeComponents/Concerns/OpensCommitSheet.php @@ -20,9 +20,9 @@ * 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. + * 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. * * The sheet keeps its commit while it slides away, so it doesn't empty on the * way down, and lets go of it once iOS says it's gone, however it was closed. @@ -202,22 +202,7 @@ public function hideCommitSheet(): void */ 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 - { - // } /** diff --git a/app/NativeComponents/Log.php b/app/NativeComponents/Log.php index 294fe7f..43ed179 100644 --- a/app/NativeComponents/Log.php +++ b/app/NativeComponents/Log.php @@ -28,25 +28,19 @@ /** * The log: the web's three of them, Following (you and the people you follow), - * Latest (everyone, newest first) and Trending (ranked by stars and replies), - * under a composer you type into where it is, as on the web. + * Latest (everyone, newest first) and Trending (ranked by stars and replies). * - * EDGE tells PHP nothing about focus, and gives PHP no way to focus a field - * or scroll a list. So a touch on the field is taken as being in it, and a new - * key on the list is how it gets back to the top. + * EDGE gives PHP no way to scroll a list, so a new key on the list is how it + * gets back to the top. * - * Nor does it say where a list is scrolled, so the nav bar's compose button - * can't tell whether the composer is on screen. It always opens the composer - * 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. + * The Log has no composer of its own. The nav bar's compose button opens one + * in a sheet (see OpensCommitSheet), which leaves the log where you were in it, + * and the commit goes on top. A field in the list was redrawn with the whole + * log every time it synced, and fast typing lost and reordered letters. * * 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 + * Commits newer than anything showing wait at the top of the list in the new * commits pill (see HoldsNewCommits), which says how many devnull counted * since the newest one showing (?since=). Scrolled down, it pins under the tabs. * @@ -144,31 +138,8 @@ class Log extends NativeComponent */ public ?string $lastVisitAt = null; - /** - * The commit you're writing. It stays when the keyboard goes, until it's committed. - */ - public string $body = ''; - - /** - * Whether the composer shows its budget and Commit button: from the moment you're - * in it until you commit, or leave it empty and the log reloads. - */ - public bool $composing = false; - - /** - * Mounts a fresh field when it changes, so a committed field starts clean. - */ - 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(); @@ -181,7 +152,6 @@ public function mount(): void public function onResume(): void { $this->forgetRefreshes(); - $this->closeEmptyComposer(); $this->forgetDeleted(); $this->forgetHidden(); $this->showKept(); @@ -323,8 +293,6 @@ public function isRanked(): bool */ public function load(): void { - $this->closeEmptyComposer(); - if ($this->isRanked()) { $this->rank(); @@ -432,88 +400,7 @@ public function followSuggested(string $username): void } /** - * A touch on the composer's field puts you in it. - */ - 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); - } - - public function canCommit(): bool - { - $length = mb_strlen(trim($this->body)); - - return $length >= Commit::MinLength && $length <= Commit::MaxLength; - } - - /** - * Commits the draft and puts it at the top of the log. Pressing Commit has - * already put the keyboard away. The draft stays if devnull says no. - */ - public function commit(): void - { - if (! $this->canCommit()) { - return; - } - - $commit = $this->attempt(fn ($devnull) => $devnull->createCommit(trim($this->body))); - - if ($commit === null) { - return; - } - - $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. + * Puts a commit you just made in the compose sheet at the top of the log. */ protected function showCommitted(Commit $commit): void { @@ -546,20 +433,6 @@ protected function updateShownCommits(Closure $update): void $this->threads = array_map(fn (array $replies): array => array_map($update, $replies), $this->threads); } - /** - * An empty composer you've left closes again. Nothing says when you leave it, - * so this waits for the log to reload: on returning to it, or pulling to refresh. - */ - private function closeEmptyComposer(): void - { - if (! $this->composing || $this->body !== '') { - return; - } - - $this->composing = false; - $this->composerKey++; - } - /** * The log as it was kept last time, if there is one. Coming back to it, it's * laid over what's showing like any fresh page, so a change made elsewhere @@ -772,9 +645,6 @@ public function render(): View 'myColour' => $this->myColour(), 'viewer' => $viewer, 'pasteis' => app(Account::class)->pasteis(), - 'composerOpen' => $this->composing || $this->body !== '', - 'remaining' => $this->remaining(), - 'canCommit' => $this->canCommit(), 'stashed' => app(Stash::class)->names($viewer?->id), 'newCommitsLabel' => $this->newCommitsLabel(), 'pages' => $pages, diff --git a/resources/views/native/partials/composer.blade.php b/resources/views/native/partials/composer.blade.php deleted file mode 100644 index f9f60ae..0000000 --- a/resources/views/native/partials/composer.blade.php +++ /dev/null @@ -1,48 +0,0 @@ -{{-- - The top of the log, like the web's composer: your avatar staged on the rail beside "What did you ship?", which you - type into where it is. Once you're in it, or it holds a draft, Commit sits under it, with the web's counter once - few of the 4,000 characters are left. The ring is dashed until the commit is long enough to make, then solid. - - 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. ---}} - - - - - - - - - - @if ($composerOpen) - - @if ($draftRestored) - @include('native.partials.stash-label', ['ref' => 'stash', 'drop' => 'dropStash']) - - @endif - @include('native.partials.body-counter', ['body' => $body, 'ref' => 'budget']) - Commit - - @endif - - - - - - @include('native.partials.rail', ['lane' => $me->lane(), 'user' => $me, 'node' => $canCommit ? 'avatar' : 'staged', 'tail' => 'staged', 'tailDashes' => $composerOpen ? 6 : 2]) - diff --git a/resources/views/native/partials/log-page.blade.php b/resources/views/native/partials/log-page.blade.php index 92092df..9dcfa7c 100644 --- a/resources/views/native/partials/log-page.blade.php +++ b/resources/views/native/partials/log-page.blade.php @@ -19,10 +19,6 @@ class="w-full flex-1 bg-theme-surface" :on-end-reached="$page['showing'] ? 'loadMore' : null" :end-reached-buffer="5" > - @if ($viewer) - @include('native.partials.composer', ['me' => $viewer]) - @endif - @include('native.partials.new-commits-pill', ['label' => $newCommitsLabel, 'press' => 'showNewCommits', 'shown' => $page['showing'] && $newCommits !== []]) @if (! $page['loaded'] && $page['error'] === null) diff --git a/tests/Feature/Screens/ComposeSheetTest.php b/tests/Feature/Screens/ComposeSheetTest.php index 22547ad..f3f2aae 100644 --- a/tests/Feature/Screens/ComposeSheetTest.php +++ b/tests/Feature/Screens/ComposeSheetTest.php @@ -50,9 +50,8 @@ function sheetCommitDisabled(TestableComponent $screen): bool 'autofocus' => true, 'value' => '', ]) - // The same list, so it stays scrolled where it was, and the composer at its top stays as it was. - ->and(nodeByRef($screen, 'log')['id'])->toBe($list) - ->and($screen->instance()->composing)->toBeFalse(); + // The same list, so it stays scrolled where it was. + ->and(nodeByRef($screen, 'log')['id'])->toBe($list); }); it('keeps Commit disabled until the commit is long enough, and short enough', function () { @@ -139,39 +138,6 @@ function sheetCommitDisabled(TestableComponent $screen): bool Http::assertNotSent(fn (Request $request): bool => $request->method() === 'POST'); }); -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' => '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('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'); - - 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') - ->dismissSheet('sheet') - ->assertSet('body', ''); - - 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 () { fakeApi(['GET /api/v1/log/following' => [pageJson([commitJson(['id' => 'c1', 'author' => userJson(['id' => 'u2', 'username' => 'ada'])])])]]); diff --git a/tests/Feature/Screens/ComposerTest.php b/tests/Feature/Screens/ComposerTest.php deleted file mode 100644 index c99125e..0000000 --- a/tests/Feature/Screens/ComposerTest.php +++ /dev/null @@ -1,224 +0,0 @@ - 'http://devnull.test']); - signIn(); -}); - -function composerBudget(): Closure -{ - return fn (string $text, string $color): Closure => fn (array $node): bool => ($node['ref'] ?? null) === 'budget' - && ($node['text'] ?? $node['props']['text'] ?? null) === $text - && ($node['props']['color'] ?? null) === config("native-ui.theme.light.{$color}"); -} - -function commitButton(bool $disabled): Closure -{ - return fn (array $node): bool => ($node['ref'] ?? null) === 'commit' && ($node['props']['disabled'] ?? false) === $disabled; -} - -/** - * The composer's field, as the native side would mount it. - * - * @return array - */ -function composerField(TestableComponent $screen): array -{ - return nodeByRef($screen, 'body'); -} - -it('is a field at the top of the log, with no budget or Commit until you are in it', function () { - Http::fake(['devnull.test/*' => Http::response(pageJson([commitJson()]))]); - - Native::visit('/log') - ->assertElement('bare_text_input', fn (array $node): bool => ($node['ref'] ?? null) === 'body' - && ($node['props']['placeholder'] ?? null) === 'What did you ship?' - && ($node['props']['max_length'] ?? null) === 4000 - && ($node['props']['multiline'] ?? false) - && ! isset($node['props']['autofocus'])) - ->assertMissingElement('text', fn (array $node): bool => ($node['ref'] ?? null) === 'budget') - ->assertMissingElement('button', fn (array $node): bool => ($node['ref'] ?? null) === 'commit'); -}); - -it('opens where it is when you tap into it, with a disabled Commit, no countdown yet, and no navigation', function () { - Http::fake(['devnull.test/*' => Http::response(pageJson([commitJson()]))]); - - Native::visit('/log') - ->pressUp('composer-field') - ->assertNoNavigation() - ->assertSet('composing', true) - ->assertMissingElement('text', fn (array $node): bool => ($node['ref'] ?? null) === 'budget') - ->assertElement('button', commitButton(disabled: true)); -}); - -it('stays open once it has a draft', function () { - Http::fake(['devnull.test/*' => Http::response(pageJson([commitJson()]))]); - - Native::visit('/log') - ->input('body', 'Shipped it') - ->assertElement('button', commitButton(disabled: false)); -}); - -it('counts down like the web only once 200 characters are left, amber from 20 and red at none', function () { - Http::fake(['devnull.test/*' => Http::response(pageJson([commitJson()]))]); - - Native::visit('/log') - ->pressUp('composer-field') - ->input('body', str_repeat('x', 3799)) - ->assertMissingElement('text', fn (array $node): bool => ($node['ref'] ?? null) === 'budget') - ->input('body', str_repeat('x', 3800)) - ->assertElement('text', composerBudget()('200', 'ink-muted')) - ->assertElement('text', fn (array $node): bool => ($node['ref'] ?? null) === 'budget' && ($node['props']['font_name'] ?? $node['props']['font'] ?? null) !== null) - ->input('body', str_repeat('x', 3980)) - ->assertElement('text', composerBudget()('20', 'pending')) - ->input('body', str_repeat('x', 4000)) - ->assertElement('text', composerBudget()('0', 'destructive')) - ->assertElement('button', commitButton(disabled: false)); -}); - -it('shows the fold icon once a commit is long enough for lists to show only its start', function () { - Http::fake(['devnull.test/*' => Http::response(pageJson([commitJson()]))]); - - $fold = fn (array $node): bool => ($node['ref'] ?? null) === 'budget-fold' && ($node['props']['name'] ?? null) === 'custom:fold-vertical'; - - Native::visit('/log', platform: 'ios') - ->pressUp('composer-field') - ->input('body', str_repeat('x', 280)) - ->assertMissingElement('icon', $fold) - ->input('body', str_repeat('x', 281)) - ->assertElement('icon', $fold) - ->input('body', implode("\n", range(1, 9))) - ->assertElement('icon', $fold); -}); - -it('enables Commit once the commit is long enough', function () { - Http::fake(['devnull.test/*' => Http::response(pageJson([commitJson()]))]); - - Native::visit('/log') - ->pressUp('composer-field') - ->input('body', 'hi') - ->assertElement('button', commitButton(disabled: true)) - ->input('body', ' hi ') - ->assertElement('button', commitButton(disabled: true)) - ->input('body', 'hi!') - ->assertElement('button', commitButton(disabled: false)); -}); - -it('stages your avatar on the rail in a dashed ring, solid once the commit is long enough', function () { - Http::fake(['devnull.test/*' => Http::response(pageJson([commitJson(['author' => userJson(['id' => 'u2', 'username' => 'ada', 'lane' => 1])])]))]); - - $lane = config('native-ui.theme.light.lane-3'); - $solidRing = fn (array $node): bool => ($node['type'] ?? null) === 'stack' && ($node['layout']['width'] ?? null) === 48.0 && ($node['style']['bg_color'] ?? null) === $lane; - $stagedRing = fn (array $node): bool => ($node['ref'] ?? null) === 'staged-avatar'; - - Native::visit('/log') - ->assertElement('column', $stagedRing) - ->assertMissingElement('stack', $solidRing) - ->input('body', 'hi') - ->assertElement('column', $stagedRing) - ->input('body', 'hi!') - ->assertMissingElement('column', $stagedRing) - ->assertElement('stack', $solidRing); -}); - -it('commits without leaving the log, then clears the composer and puts the commit at the top', function () { - fakeApi([ - 'GET /api/v1/log/following' => [pageJson([commitJson(['id' => 'c1', 'body' => 'An older commit', 'author' => userJson(['id' => 'u2', 'username' => 'ada'])])])], - 'POST /api/v1/commits' => [['data' => commitJson(['id' => '0199e5c4-1111-7000-8000-00000000beef', 'body' => 'Shipped the mobile app.'])], 201], - ]); - - $screen = Native::visit('/log')->pressUp('composer-field')->input('body', ' Shipped the mobile app. '); - $draftField = composerField($screen)['id']; - - $screen->tap('commit') - ->assertNoNavigation() - ->assertNativeNotCalled('Dialog.Toast') - ->assertSet('sheetOpen', false) - ->assertSet('body', '') - ->assertSet('composing', false) - ->assertSee('Shipped the mobile app.') - ->assertMissingElement('text', fn (array $node): bool => ($node['ref'] ?? null) === 'budget') - ->assertMissingElement('button', fn (array $node): bool => ($node['ref'] ?? null) === 'commit'); - - // A fresh, empty field: the committed one is gone, so the next tap into it opens it again. - expect(composerField($screen)['id'])->not->toBe($draftField) - ->and(composerField($screen)['props']['value'] ?? null)->toBe('') - ->and(array_map(fn ($commit): string => $commit->id, $screen->instance()->commits))->toBe(['0199e5c4-1111-7000-8000-00000000beef', 'c1']); - - $json = json_encode($screen->tree()); - expect(strpos($json, 'commit-0199e5c4-1111-7000-8000-00000000beef'))->toBeLessThan(strpos($json, 'commit-c1')); - - Http::assertSent(fn (Request $request): bool => $request->method() === 'POST' && $request->body() === '{"body":"Shipped the mobile app."}'); -}); - -it('marks the new commit as pending on its row', function () { - fakeApi([ - 'GET /api/v1/log/following' => [pageJson([])], - 'POST /api/v1/commits' => [['data' => commitJson(['id' => 'abc1234', 'body' => 'Needs a look first', 'status' => 'pending'])], 201], - ]); - - Native::visit('/log') - ->input('body', 'Needs a look first') - ->tap('commit') - ->assertNativeNotCalled('Dialog.Toast') - ->assertNoNavigation() - ->assertSee('Needs a look first') - ->assertSee('pending'); -}); - -it('keeps the draft and shows the reason when the server says no', function () { - fakeApi([ - 'GET /api/v1/log/following' => [pageJson([])], - 'POST /api/v1/commits' => [['message' => 'Too Many Attempts.'], 429], - ]); - - Native::visit('/log') - ->input('body', 'One more') - ->tap('commit') - ->assertSee('Too Many Attempts.') - ->assertSet('body', 'One more') - ->assertElement('button', commitButton(disabled: false)) - ->assertNoNavigation(); -}); - -it('sends nothing shorter than three characters', function () { - fakeApi(['GET /api/v1/log/following' => [pageJson([])]]); - - Native::visit('/log')->input('body', 'ok')->call('commit')->assertSet('body', 'ok'); - - Http::assertNotSent(fn (Request $request): bool => $request->method() === 'POST'); -}); - -it('syncs the draft on a short debounce so a burst of typing arrives whole', function () { - Http::fake(['devnull.test/*' => Http::response(pageJson([]))]); - - // In live mode a burst of keystrokes can overflow the native event queue, - // and the input never resends the dropped tail. Debounce sends one trailing value. - Native::visit('/log')->assertElement('bare_text_input', fn (array $node): bool => ($node['ref'] ?? null) === 'body' - && ($node['props']['sync_mode'] ?? null) === 'debounce' - && ($node['props']['debounce_ms'] ?? null) === 150); -}); - -it('closes an empty composer when the log reloads, and keeps one with a draft open', function () { - Http::fake(['devnull.test/*' => Http::response(pageJson([]))]); - - Native::visit('/log') - ->pressUp('composer-field') - ->assertSet('composing', true) - ->call('load') - ->assertSet('composing', false) - ->assertMissingElement('button', fn (array $node): bool => ($node['ref'] ?? null) === 'commit'); - - Native::visit('/log') - ->pressUp('composer-field') - ->input('body', 'Still thinking') - ->call('load') - ->assertSet('composing', true) - ->assertSet('body', 'Still thinking') - ->assertElement('button', commitButton(disabled: false)); -}); diff --git a/tests/Feature/Screens/DraftStashTest.php b/tests/Feature/Screens/DraftStashTest.php index fc50ea6..53941ce 100644 --- a/tests/Feature/Screens/DraftStashTest.php +++ b/tests/Feature/Screens/DraftStashTest.php @@ -72,79 +72,19 @@ function stashLabel(TestableComponent $screen, string $ref): array // ── 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'); + $screen = Native::visit('/log', platform: 'ios')->press('composeInSheet'); - expect(nodeByRef($screen, 'body')['props']['value'])->toBe('') - ->and(showsRef($screen, 'stash'))->toBeFalse(); + expect(nodeByRef($screen, 'sheet-body')['props']['value'])->toBe('') + ->and(showsRef($screen, 'sheet-stash'))->toBeFalse(); }); // ── The compose sheet ────────────────────────── -it("stashes the compose sheet as the composer's draft, and forgets it once it's committed", function () { +it("stashes the compose sheet's draft, and forgets it once it's committed", function () { serveStashLog(); $screen = Native::visit('/log', platform: 'ios') @@ -153,21 +93,19 @@ function stashLabel(TestableComponent $screen, string $ref): array 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'); + $screen->dismissSheet('sheet')->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(); + expect(stashed('commit'))->toBe(''); + + $screen->press('composeInSheet'); + + expect(nodeByRef($screen, 'sheet-body')['props']['value'])->toBe('') + ->and(showsRef($screen, 'sheet-stash'))->toBeFalse(); }); it("drops the sheet's restored draft", function () { @@ -181,10 +119,6 @@ function stashLabel(TestableComponent $screen, string $ref): array expect(stashed('commit'))->toBe('') ->and(showsRef($screen, 'sheet-stash'))->toBeFalse(); - - $screen->dismissSheet('sheet'); - - expect(nodeByRef($screen, 'body')['props']['value'])->toBe(''); }); // ── Quotes ───────────────────────────────────── diff --git a/tests/Feature/Screens/LaneButtonsTest.php b/tests/Feature/Screens/LaneButtonsTest.php index d3640f9..e01a4e0 100644 --- a/tests/Feature/Screens/LaneButtonsTest.php +++ b/tests/Feature/Screens/LaneButtonsTest.php @@ -53,7 +53,6 @@ function laneLog(): TestableComponent } dataset('main buttons', [ - 'Commit, in the composer' => [fn (): TestableComponent => laneLog()->pressUp('composer-field'), 'commit'], 'Commit, in the compose sheet' => [fn (): TestableComponent => laneLog()->press('composeInSheet'), 'sheet-commit'], 'Commit, quoting a commit' => [fn (): TestableComponent => laneLog()->tap('quote-c1'), 'sheet-commit'], 'Send report' => [fn (): TestableComponent => laneLog()->tap('report-c1'), 'send-report'], @@ -180,8 +179,7 @@ function laneLog(): TestableComponent expect(buttonProps($report, 'report-done')['size'] ?? null)->toBe('lg'); - expect(buttonProps(laneLog()->pressUp('composer-field'), 'commit'))->not->toHaveKey('size') - ->and(buttonProps(laneLog()->tap('quote-c1'), 'sheet-commit'))->not->toHaveKey('size'); + expect(buttonProps(laneLog()->tap('quote-c1'), 'sheet-commit'))->not->toHaveKey('size'); }); it('makes Unlink Apple ID large too', function () { diff --git a/tests/Feature/Screens/LogCacheTest.php b/tests/Feature/Screens/LogCacheTest.php index e1e7e13..641417b 100644 --- a/tests/Feature/Screens/LogCacheTest.php +++ b/tests/Feature/Screens/LogCacheTest.php @@ -284,7 +284,7 @@ function shownCommitIds(TestableComponent $screen): array 'POST /api/v1/commits' => [['data' => commitJson(['id' => 'c9', 'body' => 'Just shipped'])], 201], ]); - Native::visit('/log')->pressUp('composer-field')->input('body', 'Just shipped')->tap('commit'); + Native::visit('/log')->press('composeInSheet')->input('sheet-body', 'Just shipped')->tap('sheet-commit'); holdRefreshes(); @@ -322,7 +322,7 @@ function shownCommitIds(TestableComponent $screen): array $commit->goBack()->assertDontSee('Delete me')->assertSee('Keep me'); }); -it('puts the pill in the list between the composer and the commits, in a header that pins, there before it shows', function () { +it('puts the pill at the top of the list, above the commits, in a header that pins, there before it shows', function () { holdRefreshes(); keep('log/following', pageJson([commitJson(['id' => 'c1'])])); fakeFreshLog([commitJson(['id' => 'c2', 'body' => 'Newer']), commitJson(['id' => 'c1'])]); @@ -334,11 +334,11 @@ function shownCommitIds(TestableComponent $screen): array return array_map(fn (array $node): string => $node['ref'] ?? $node['type'], $parent['children']); }; - $inTheList = fn (): array => array_slice(array_values(array_filter(childRefs($screen, 'log'), fn (?string $ref): bool => $ref !== null && ! str_starts_with($ref, 'day-'))), 0, 3); + $inTheList = fn (): array => array_slice(array_values(array_filter(childRefs($screen, 'log'), fn (?string $ref): bool => $ref !== null && ! str_starts_with($ref, 'day-'))), 0, 2); // Hidden and empty until there are new commits, but already in its place, so showing it never changes the list's shape. expect($besideTheList())->toBe(['log']) - ->and($inTheList())->toBe(['composer', 'new-commits-slot', 'commit-c1']) + ->and($inTheList())->toBe(['new-commits-slot', 'commit-c1']) ->and(nodeByRef($screen, 'new-commits-slot')['type'])->toBe('sticky_header') ->and(nodeByRef($screen, 'new-commits-slot')['layout']['display'] ?? null)->toBe(1) ->and(nodeByRef($screen, 'new-commits-slot')['children'] ?? [])->toBe([]); @@ -346,7 +346,7 @@ function shownCommitIds(TestableComponent $screen): array finishRefreshes($screen)->assertSee('1 new commit'); expect($besideTheList())->toBe(['log']) - ->and($inTheList())->toBe(['composer', 'new-commits-slot', 'commit-c1']) + ->and($inTheList())->toBe(['new-commits-slot', 'commit-c1']) ->and(nodeByRef($screen, 'new-commits-slot')['layout']['display'] ?? null)->toBeNull() ->and(findNode(nodeByRef($screen, 'new-commits-slot'), fn (array $node): bool => ($node['ref'] ?? null) === 'new-commits'))->not->toBeNull(); }); diff --git a/tests/Feature/Screens/LogTest.php b/tests/Feature/Screens/LogTest.php index 2fba789..51e78c4 100644 --- a/tests/Feature/Screens/LogTest.php +++ b/tests/Feature/Screens/LogTest.php @@ -502,18 +502,6 @@ ->assertNoNavigation(); }); -it('starts the log with your avatar staged on the rail, like the web composer', function () { - Http::fake(['devnull.test/*' => Http::response(pageJson([commitJson()]))]); - - $screen = Native::visit('/log')->assertSee('What did you ship?'); - - // A dashed ring in your lane: sixteen short dashes, each turned to follow the circle. - $dashes = findNodes(nodeByRef($screen, 'staged-avatar'), fn (array $node): bool => isset($node['props']['rotate'])); - - expect($dashes)->toHaveCount(16) - ->and(array_unique(array_map(fn (array $dash): ?string => $dash['style']['bg_color'] ?? null, $dashes)))->toBe([config('native-ui.theme.light.lane-3')]); -}); - it('shows an author once per run, and starts a new run at each day', function () { $this->travelTo(now()->setDate(2026, 10, 15)->setTime(12, 0)); $ada = userJson(['id' => 'u2', 'username' => 'ada', 'name' => 'Ada', 'lane' => 1]); diff --git a/tests/Feature/Screens/LogsTest.php b/tests/Feature/Screens/LogsTest.php index 1932011..57f87a3 100644 --- a/tests/Feature/Screens/LogsTest.php +++ b/tests/Feature/Screens/LogsTest.php @@ -305,7 +305,7 @@ function ada(): array $screen = Native::visit('/log'); $row = nodeByRef($screen, 'commit-c1')['id']; - $screen->pressUp('composer-field')->input('body', 'Fresh from the composer')->tap('commit'); + $screen->press('composeInSheet')->input('sheet-body', 'Fresh from the composer')->tap('sheet-commit'); expect(shownLogIds($screen))->toBe(['mine', 'c1']) ->and(nodeByRef($screen, 'commit-c1')['id'])->toBe($row); @@ -527,9 +527,9 @@ function ada(): array ]); $screen = tapLogTab(Native::visit('/log'), 'trending') - ->pressUp('composer-field') - ->input('body', 'Fresh from the composer') - ->tap('commit'); + ->press('composeInSheet') + ->input('sheet-body', 'Fresh from the composer') + ->tap('sheet-commit'); expect(shownLogIds($screen))->toBe(['mine', 'a']) ->and(dayMarkers($screen))->toBe([Timeline::JustNow, Timeline::TopCommits])