diff --git a/resources/views/native/partials/commit-sheet.blade.php b/resources/views/native/partials/commit-sheet.blade.php index f656da4..a9238ed 100644 --- a/resources/views/native/partials/commit-sheet.blade.php +++ b/resources/views/native/partials/commit-sheet.blade.php @@ -29,8 +29,12 @@ It's full height for the forms, and short for the receipts. It's always on the screen, shown or not, so iOS can slide it away and say when it's gone. + + The sheet and its field are keyed, so nothing drawn above the field (Commit turning on, the issue badge, a banner + behind the sheet) gives it a new id. iOS and Android would build a new field for a new id, and the keyboard would + go down with the old one. --}} - + @if ($sheetCommit || in_array($sheetView, ['compose', 'issued'], true)) @if (in_array($sheetView, ['quote', 'compose', 'reply'], true)) @php($quoting = $sheetView === 'quote') @@ -66,6 +70,7 @@ @include('native.partials.composer-preview') @else and(nodeByRef($screen, 'thread-scroll')['id'])->not->toBe($before); }); +it('keeps the reply field the same view while the thread above it changes, so the keyboard stays up', function () { + fakeThread(['replies_count' => 1], replies: [replyJson(['id' => 'r1', 'parent_id' => 'c2', 'body' => 'First reply', 'author' => userJson(['username' => 'grace', 'name' => 'Grace'])])]); + + $screen = Native::visit('/log/commits/c2')->tap('earlier-commits')->input('reply-input', 'Nice'); + $field = idsDownTo($screen->tree(), 'reply-input'); + + // Tapping into the bar, which folds the earlier commits away above it. + $screen->pressUp('reply-field')->assertDontSee('The parent commit'); + expect(idsDownTo($screen->tree(), 'reply-input'))->toBe($field); + + // More of the thread drawn above it. + $screen->set('repliesShown', true)->assertSee('First reply'); + expect(idsDownTo($screen->tree(), 'reply-input'))->toBe($field); + + // A refresh failing, which puts its banner at the top of the screen. + $screen->set('refreshFailed', true); + expect(nodeByRef($screen, 'refresh-failed'))->not->toBeEmpty() + ->and(idsDownTo($screen->tree(), 'reply-input'))->toBe($field); +}); + it('runs the rail from the folded ancestors into the commit, which shows its avatar', function () { $ada = userJson(['id' => 'u2', 'username' => 'ada', 'name' => 'Ada', 'lane' => 1]); fakeApi([ diff --git a/tests/Feature/Screens/ComposeSheetTest.php b/tests/Feature/Screens/ComposeSheetTest.php index 9638209..319ca25 100644 --- a/tests/Feature/Screens/ComposeSheetTest.php +++ b/tests/Feature/Screens/ComposeSheetTest.php @@ -214,6 +214,26 @@ function sheetCommitDisabled(TestableComponent $screen): bool ->and(sheetButtonCircle($android, 'sheet-cancel')['props'])->not->toHaveKey('glass'); }); +it('keeps the field the same view while what is drawn above it changes, so the keyboard stays up', function () { + $screen = composeSheetOnLog(); + $field = idsDownTo($screen->tree(), 'sheet-body'); + + // Commit turning on, which wraps its circle in a pressable. + $screen->input('sheet-body', 'Shipped'); + expect(sheetCommitDisabled($screen))->toBeFalse() + ->and(idsDownTo($screen->tree(), 'sheet-body'))->toBe($field); + + // The issue badge over the field. + $screen->input('sheet-body', '/feature Dark mode'); + expect(nodeByRef($screen, 'sheet-open-issue'))->not->toBeEmpty() + ->and(idsDownTo($screen->tree(), 'sheet-body'))->toBe($field); + + // A refresh failing behind the sheet, which puts its banner above it. + $screen->set('refreshFailed', true); + expect(nodeByRef($screen, 'refresh-failed'))->not->toBeEmpty() + ->and(idsDownTo($screen->tree(), 'sheet-body'))->toBe($field); +}); + 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/ConversationTest.php b/tests/Feature/Screens/ConversationTest.php index c2264bf..7864d17 100644 --- a/tests/Feature/Screens/ConversationTest.php +++ b/tests/Feature/Screens/ConversationTest.php @@ -106,6 +106,20 @@ function serveRemoteThread(array $thread, ?array $poll = null, int $pushStatus = ->and(sentTo('PUT', 'remotes/@ada/read'))->toBe(['{"through":2}']); }); +it('keeps the composer the same view while messages arrive above it, so the keyboard stays up', function () { + serveRemoteThread( + threadJson([remoteMessageJson(['id' => 1, 'body' => 'first'])], ['read_through_id' => 1]), + threadJson([remoteMessageJson(['id' => 2, 'body' => 'second'])]), + ); + + $screen = finishRefreshes(Native::visit('/remotes/conversations/ada'))->input('message-input', 'on my way'); + $field = idsDownTo($screen->tree(), 'message-input'); + + finishRefreshes($screen->call('poll'))->assertSee('second'); + + expect(idsDownTo($screen->tree(), 'message-input'))->toBe($field); +}); + it('waits longer between polls once nobody has written or typed for a minute', function () { serveRemoteThread(threadJson([remoteMessageJson(['id' => 1])], ['read_through_id' => 1])); diff --git a/tests/Pest.php b/tests/Pest.php index 9ea9bd6..5d3a50e 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -697,6 +697,28 @@ function findNodes(array $node, callable $matches): array return $found; } +/** + * The ids of every node from the root down to the one with the given ref. iOS and Android know a view by its id at + * each level, so a new id anywhere on the way down builds a new field, and the keyboard goes with the old one. + * + * @param array $node + * @return list|null + */ +function idsDownTo(array $node, string $ref): ?array +{ + if (($node['ref'] ?? null) === $ref) { + return [$node['id']]; + } + + foreach ($node['children'] ?? [] as $child) { + if (($ids = idsDownTo($child, $ref)) !== null) { + return [$node['id'], ...$ids]; + } + } + + return null; +} + /** * The rendered node with the given ref. *