diff --git a/app/Devnull/Display.php b/app/Devnull/Display.php index 09d43f94..0c39eb6a 100644 --- a/app/Devnull/Display.php +++ b/app/Devnull/Display.php @@ -41,6 +41,15 @@ final class Display */ public const int ElbowLevel = 22; + /** + * Classes for a screen's scrolling column: as wide as the screen on a phone, but a + * comfortable 672pt line once the window reaches the expanded width (840pt, the inner + * display of a foldable held sideways), centred by its parent (give the parent items-center). + * A fixed width rather than max-w: EDGE stretches a w-full child across its parent and + * ignores items-center for it, and has no mx-auto. + */ + public const string ReadableColumn = 'expanded:w-[672px]'; + /** * How long ago a commit was made, as the web prints it beside the commit: * "now", "5m", "3h", "2d", then the date, in the same time zone as dayLabel(). diff --git a/app/Devnull/RowInputs.php b/app/Devnull/RowInputs.php index 0659a309..31b3625a 100644 --- a/app/Devnull/RowInputs.php +++ b/app/Devnull/RowInputs.php @@ -37,6 +37,7 @@ public static function for(Commit $commit, array $scope): array 'tombstoneReplies' => array_key_exists($commit->id, $scope['tombstoneReplies'] ?? []), 'shown' => [self::changing($commit), $commit->quotedCommit === null ? null : self::changing($commit->quotedCommit)], 'poll' => [$scope['pollChoices'][$commit->id] ?? null, in_array($commit->id, $scope['voting'] ?? [], true)], + 'selected' => ($scope['selectedCommit'] ?? null) === $commit->id, ]; } diff --git a/app/NativeComponents/CommitDetail.php b/app/NativeComponents/CommitDetail.php index f20c88a3..6d85dc84 100644 --- a/app/NativeComponents/CommitDetail.php +++ b/app/NativeComponents/CommitDetail.php @@ -77,6 +77,11 @@ class CommitDetail extends NativeComponent public string $id = ''; + public bool $embedded = false; + + /** @var array */ + public array $conversationData = []; + public ?Commit $commit = null; /** @var list */ @@ -169,10 +174,28 @@ class CommitDetail extends NativeComponent */ protected bool $forceFullFrames = true; + public function data(string $key, mixed $default = null): mixed + { + return $this->embedded ? ($this->conversationData[$key] ?? $default) : parent::data($key, $default); + } + + /** @param array $data */ + public function applyConversationIntent(array $data): void + { + $this->conversationData = $data; + if (($data['reply'] ?? false) === true) { + $this->focusReply = true; + } + if (($data['replies'] ?? false) === true) { + $this->repliesShown = true; + } + $this->editFromData(); + } + public function mount(): void { $this->focusReply = $this->data('reply', false) === true; - $this->repliesShown = $this->data('replies', false) === true; + $this->repliesShown = $this->embedded || $this->data('replies', false) === true; $draft = app(Stash::class)->get($this->me()?->id, $this->replyStashName()); $this->reply = $draft->body; $this->replyImages = $draft->images; @@ -434,6 +457,11 @@ protected function showCommitTurnedDown(PendingWrite $write): void $this->keepStagedReplies(); } + protected function opensComposer(): bool + { + return $this->embedded; + } + /** * A reply to this commit from the bar goes back into the bar, marked as * restored, when the bar's free. One from the sheet opens the sheet again, diff --git a/app/NativeComponents/Concerns/AttachesPhotos.php b/app/NativeComponents/Concerns/AttachesPhotos.php index d04c60fd..590ebbef 100644 --- a/app/NativeComponents/Concerns/AttachesPhotos.php +++ b/app/NativeComponents/Concerns/AttachesPhotos.php @@ -5,6 +5,7 @@ use App\Devnull\Data\DraftImage; use App\Devnull\DraftImages; use App\Devnull\Exceptions\InvalidDraftImage; +use App\NativeComponents\FeedWorkspace; use Native\Mobile\Attributes\On; use Native\Mobile\Events\Camera\PermissionDenied; use Native\Mobile\Events\Camera\PhotoTaken; @@ -59,6 +60,9 @@ abstract protected function photoFailed(?string $error): void; public function pickPhotos(): void { if ($this->hasRoomForPhotos()) { + if ($this->nativeParentComponent instanceof FeedWorkspace) { + $this->nativeParentComponent->claimPhotoPicker($this); + } Camera::pickImages('images', true, DraftImages::MaxImages - count($this->attachedPhotos()))->start(); } } @@ -66,6 +70,9 @@ public function pickPhotos(): void public function takePhoto(): void { if ($this->hasRoomForPhotos()) { + if ($this->nativeParentComponent instanceof FeedWorkspace) { + $this->nativeParentComponent->claimPhotoPicker($this); + } Camera::getPhoto()->start(); } } @@ -73,6 +80,10 @@ public function takePhoto(): void #[On(PhotoTaken::class)] public function photoTaken(string $path = ''): void { + if (! $this->ownsPhotoPicker()) { + return; + } + $this->attachPhotos([$path]); } @@ -82,17 +93,26 @@ public function photoTaken(string $path = ''): void #[On(MediaSelected::class)] public function photosPicked(array $files = []): void { + if (! $this->ownsPhotoPicker()) { + return; + } + $this->attachPhotos(array_map(fn (mixed $file): string => is_array($file) && is_string($file['path'] ?? null) ? $file['path'] : '', $files)); } #[On(PermissionDenied::class)] public function cameraDenied(): void { - if ($this->takesPhotos()) { + if ($this->ownsPhotoPicker() && $this->takesPhotos()) { $this->photoFailed(DraftImages::CameraDenied); } } + private function ownsPhotoPicker(): bool + { + return ! $this->nativeParentComponent instanceof FeedWorkspace || $this->nativeParentComponent->ownsPhotoPicker($this); + } + /** * The ✕ on a photo: it comes off the draft, and off the phone. */ diff --git a/app/NativeComponents/Concerns/OpensCommitSheet.php b/app/NativeComponents/Concerns/OpensCommitSheet.php index 19ac3922..90deb8e5 100644 --- a/app/NativeComponents/Concerns/OpensCommitSheet.php +++ b/app/NativeComponents/Concerns/OpensCommitSheet.php @@ -16,6 +16,7 @@ use App\Devnull\Timezone; use App\Devnull\Toasts; use App\Models\PendingWrite; +use App\NativeComponents\FeedWorkspace; use Carbon\CarbonImmutable; use Closure; use Native\Mobile\Facades\System; @@ -344,7 +345,7 @@ public function commitFromSheet(): void app(Stash::class)->put($me->id, $stashName, new StashedDraft); $this->sheetImages = []; $this->closeCommitSheet(); - $this->showCommitted($write->stagedCommit($me)); + $this->applyStagedCommit($write->stagedCommit($me)); $this->sendFromOutbox($write); } @@ -391,7 +392,11 @@ public function commitSheetClosed(): void $write = $held === null ? null : app(Outbox::class)->find($held); if ($write !== null && $write->isCommit() && $write->isFailed()) { - $this->openFailedCommit($write); + if ($this->nativeParentComponent instanceof FeedWorkspace) { + $this->nativeParentComponent->recoverOutboxDraft($write); + } else { + $this->openFailedCommit($write); + } return; } diff --git a/app/NativeComponents/Concerns/OpensInTab.php b/app/NativeComponents/Concerns/OpensInTab.php index 381c4991..83e3dbe4 100644 --- a/app/NativeComponents/Concerns/OpensInTab.php +++ b/app/NativeComponents/Concerns/OpensInTab.php @@ -2,7 +2,9 @@ namespace App\NativeComponents\Concerns; +use App\NativeComponents\FeedWorkspace; use App\NativeComponents\Layouts\TabsLayout; +use App\NativeComponents\Log; /** * Opens commits and profiles inside the tab they were tapped in, and never @@ -26,6 +28,12 @@ trait OpensInTab */ protected function openInTab(string $path, array $data = []): void { + if ($this->nativeParentComponent instanceof FeedWorkspace && str_starts_with($path, '/commits/')) { + $this->emit('open-conversation', substr($path, strlen('/commits/')), $data, $this instanceof Log); + + return; + } + $uri = $this->tabRoot().$path; if ($this->isShowing($uri)) { diff --git a/app/NativeComponents/Concerns/ShowsCommits.php b/app/NativeComponents/Concerns/ShowsCommits.php index 05407927..0128561a 100644 --- a/app/NativeComponents/Concerns/ShowsCommits.php +++ b/app/NativeComponents/Concerns/ShowsCommits.php @@ -16,6 +16,7 @@ use App\Devnull\SiteLink; use App\Devnull\Timezone; use App\Devnull\Toasts; +use App\NativeComponents\FeedWorkspace; use Closure; use Native\Mobile\Attributes\On; use Native\Mobile\Edge\SharedValue; @@ -221,7 +222,6 @@ public function toggleStar(string $id): void return; } - $this->showStar($id, ! $commit->starred); $this->queueStar($id, ! $commit->starred); } @@ -490,10 +490,19 @@ public function deleteCommit(string $id): void if ($deleted === true) { Toasts::say('rm '.$commit->shortHash.' → /dev/null', short: true); - $this->showDeleted($id); + if ($this->nativeParentComponent instanceof FeedWorkspace) { + $this->nativeParentComponent->conversationDeleted($id); + } else { + $this->showDeleted($id); + } } } + public function applyWorkspaceDeletion(string $id): void + { + $this->showDeleted($id); + } + /** * A commit you deleted leaves the screen as one deleted on its own page does * when you come back: the screen shows what the cache kept, then asks devnull. diff --git a/app/NativeComponents/Concerns/SyncsOutbox.php b/app/NativeComponents/Concerns/SyncsOutbox.php index 52b9a63e..50b700eb 100644 --- a/app/NativeComponents/Concerns/SyncsOutbox.php +++ b/app/NativeComponents/Concerns/SyncsOutbox.php @@ -17,6 +17,9 @@ use App\Devnull\Toasts; use App\Devnull\VoiceNotes; use App\Models\PendingWrite; +use App\NativeComponents\CommitDetail; +use App\NativeComponents\FeedWorkspace; +use App\NativeComponents\Log; use Native\Mobile\Attributes\On; use Native\Mobile\Facades\Browser; use Throwable; @@ -74,6 +77,9 @@ protected function queueStar(string $id, bool $starred): void $write = $outbox->add($starred ? PendingWrite::Star : PendingWrite::Unstar, $id, $starred ? [] : ['kept' => $cache->keptCommit($id)]); $cache->starChanged($id, $starred); + foreach ($this->outboxViews() as $view) { + $view->applyPendingStar($id, $starred); + } if ($write !== null && ! $outbox->starOnItsWay($id)) { $this->sendFromOutbox($write); @@ -129,6 +135,16 @@ protected function sendFromOutbox(PendingWrite $write): void #[On(Outbox::Answered)] public function outboxAnswered(string $id = '', string $status = 'finished', mixed $result = null, ?string $message = null): void { + if ($this->nativeParentComponent instanceof FeedWorkspace && $this instanceof CommitDetail) { + foreach ($this->outboxViews() as $view) { + if ($view instanceof Log) { + $view->outboxAnswered($id, $status, $result, $message); + + return; + } + } + } + $write = app(Outbox::class)->forTask($id); if ($write === null) { @@ -206,6 +222,10 @@ protected function stagedCommits(?string $parentId): array #[On(ToastActionPressed::class)] public function toastActionPressed(string $toastId = '', string $actionId = '', array $data = []): void { + if ($this->nativeParentComponent instanceof FeedWorkspace && $this->nativeParentComponent->foregroundPane() !== $this) { + return; + } + match ($actionId) { 'retry-star' => $this->retryStar($data), 'retry-message' => $this->retryMessage($data), @@ -234,7 +254,7 @@ private function writeWent(PendingWrite $write, array $json): void if (! $later && $data !== []) { app(ResponseCache::class)->commitReacted($data); app(ResponseCache::class)->commitStarred($data); - $this->showStar((string) $write->target, ($data['starred'] ?? false) === true, Commit::fromArray($data)); + $this->broadcastOutboxChange('star', $write, $data, ($data['starred'] ?? false) === true); } return; @@ -246,7 +266,7 @@ private function writeWent(PendingWrite $write, array $json): void if ($write->isCommit()) { app(ResponseCache::class)->commitAdded($data); - $this->showCommitWent($write, Commit::fromArray($data)); + $this->broadcastOutboxChange('commit-sent', $write, $data); return; } @@ -273,7 +293,7 @@ private function writeTurnedDown(PendingWrite $write, string $error, bool $reope } app(ResponseCache::class)->starChanged((string) $write->target, $write->wasStarred(), is_array($write->payload['kept'] ?? null) ? $write->payload['kept'] : null); - $this->showStar((string) $write->target, $write->wasStarred()); + $this->broadcastOutboxChange('star', $write, starred: $write->wasStarred()); Toasts::failed($error, ['label' => 'Retry', 'id' => 'retry-star', 'data' => ['commit' => $write->target, 'starred' => $starred]]); @@ -309,9 +329,12 @@ private function turnDownCommit(PendingWrite $write, string $error, bool $reopen $stash->put($this->me()?->id, $name, new StashedDraft($write->body())); } - $this->showCommitTurnedDown($write); + $this->broadcastOutboxChange('commit-rejected', $write); - if ($reopen && $this->reopenFailedCommit($write)) { + $recovered = $reopen && ($this->nativeParentComponent instanceof FeedWorkspace + ? $this->nativeParentComponent->recoverOutboxDraft($write) + : $this->reopenFailedCommit($write)); + if ($recovered) { Toasts::failed($error); return; @@ -320,6 +343,60 @@ private function turnDownCommit(PendingWrite $write, string $error, bool $reopen Toasts::failed($error, ['label' => 'Edit', 'id' => 'edit', 'data' => ['write' => $write->id]]); } + public function applyStagedCommit(Commit $commit): void + { + $this->showCommitted($commit); + + if ($this instanceof CommitDetail && $commit->parentId === null) { + foreach ($this->outboxViews() as $view) { + if ($view instanceof Log) { + $view->applyStagedCommit($commit); + } + } + } + } + + /** @return list */ + private function outboxViews(): array + { + return $this->nativeParentComponent instanceof FeedWorkspace + ? $this->nativeParentComponent->panes() + : [$this]; + } + + /** @param array $data */ + private function broadcastOutboxChange(string $state, PendingWrite $write, array $data = [], ?bool $starred = null): void + { + foreach ($this->outboxViews() as $view) { + $view->applyOutboxChange($state, $write, $data, $starred); + } + } + + /** + * Applies only view state; the single answering owner settles storage, + * cache, files, subsequent writes and toasts before broadcasting here. + * + * @param array $data + */ + public function applyOutboxChange(string $state, PendingWrite $write, array $data = [], ?bool $starred = null): void + { + match ($state) { + 'star' => $this->showStar((string) $write->target, $starred === true, $data === [] ? null : Commit::fromArray($data)), + 'commit-sent' => $this->showCommitWent($write, Commit::fromArray($data)), + 'commit-rejected' => $this->showCommitTurnedDown($write), + }; + } + + public function reopenOutboxDraft(PendingWrite $write): bool + { + return $this->reopenFailedCommit($write); + } + + public function applyPendingStar(string $id, bool $starred): void + { + $this->showStar($id, $starred); + } + /** * Signs a write as devnull takes it, here on the UI thread. */ @@ -349,7 +426,6 @@ private function retryStar(array $data): void return; } - $this->showStar($data['commit'], $data['starred']); $this->queueStar($data['commit'], $data['starred']); } @@ -391,6 +467,9 @@ private function editFromToast(array $data): void $write = is_numeric($data['write'] ?? null) ? app(Outbox::class)->find((int) $data['write']) : null; if ($write !== null && $write->isCommit() && $write->isFailed()) { + if ($this->nativeParentComponent instanceof FeedWorkspace) { + $this->nativeParentComponent->navigationRevision++; + } $this->editFailedCommit($write); } } diff --git a/app/NativeComponents/FeedWorkspace.php b/app/NativeComponents/FeedWorkspace.php new file mode 100644 index 00000000..70e5dfde --- /dev/null +++ b/app/NativeComponents/FeedWorkspace.php @@ -0,0 +1,208 @@ +}> */ + public array $conversations = []; + + public int $navigationRevision = 0; + + private int $nextSlot = 0; + + private ?NativeComponent $photoOwner = null; + + /** @return list */ + public function panes(): array + { + return array_values(array_filter( + $this->nativeChildComponents, + fn (NativeComponent $child): bool => $child instanceof Log || $child instanceof CommitDetail, + )); + } + + public function claimPhotoPicker(NativeComponent $owner): void + { + $this->photoOwner = $owner; + } + + public function ownsPhotoPicker(NativeComponent $owner): bool + { + return $this->photoOwner === $owner; + } + + public function foregroundPane(): Log|CommitDetail|null + { + $current = end($this->conversations); + + return $current === false + ? $this->feed() + : ($this->nativeChildComponents['commit-detail|key:conversation-'.$current['slot']] ?? null); + } + + public function recoverOutboxDraft(PendingWrite $write): bool + { + if ($this->foregroundPane()?->reopenOutboxDraft($write)) { + $this->navigationRevision++; + + return true; + } + + return false; + } + + #[On('open-conversation')] + public function openConversation(string $id, array $data = [], bool $fromFeed = false): void + { + $this->navigationRevision++; + $current = end($this->conversations); + + if ($current !== false && $current['id'] === $id) { + $index = array_key_last($this->conversations); + $this->conversations[$index]['data'] = $data; + $detail = $this->nativeChildComponents['commit-detail|key:conversation-'.$current['slot']] ?? null; + if ($detail instanceof CommitDetail) { + $detail->applyConversationIntent($data); + } + + return; + } + + if ($fromFeed) { + $this->conversations = []; + } + + $this->conversations[] = ['slot' => ++$this->nextSlot, 'id' => $id, 'data' => $data]; + } + + public function conversationDeleted(string $id): void + { + foreach ($this->conversations as $index => $conversation) { + if ($conversation['id'] === $id) { + $this->conversations = array_slice($this->conversations, 0, $index); + $this->navigationRevision++; + break; + } + } + + $retained = array_map(fn (array $conversation): string => 'commit-detail|key:conversation-'.$conversation['slot'], $this->conversations); + foreach ($this->nativeChildComponents as $key => $child) { + if ($child instanceof Log || ($child instanceof CommitDetail && in_array($key, $retained, true))) { + $child->applyWorkspaceDeletion($id); + } + } + } + + public function hostChanged(string $event): void + { + $change = json_decode($event, true); + + if (! is_array($change)) { + return; + } + + if (in_array($change['action'] ?? null, ['back', 'delete'], true)) { + $identities = array_map(fn (array $conversation): string => 'conversation-'.$conversation['slot'], $this->conversations); + $expected = $change['expected_history'] ?? null; + $matches = $expected === $identities; + if (($change['action'] ?? null) === 'back' && is_array($expected)) { + $matches = array_slice($expected, 0, count($identities)) === $identities; + } + if (! is_array($expected) || ($change['expected_revision'] ?? null) !== $this->navigationRevision || ($change['expected_depth'] ?? null) !== count($expected) || ! $matches) { + return; + } + } + + switch ($change['action'] ?? null) { + case 'back': + $depth = $change['depth'] ?? count($this->conversations) - 1; + if (is_int($depth) && $depth >= 0 && $depth < count($this->conversations)) { + $this->conversations = array_slice($this->conversations, 0, $depth); + $this->foregroundPane()?->onResume(); + } + break; + case 'compose': + $this->feed()?->composeInSheet(); + break; + case 'search': + $this->feed()?->openSearch(); + break; + case 'game': + $this->feed()?->playNataNullers(); + break; + case 'delete': + $current = end($this->conversations); + if ($current !== false) { + $detail = $this->nativeChildComponents['commit-detail|key:conversation-'.$current['slot']] ?? null; + if ($detail instanceof CommitDetail) { + $detail->confirmDelete($current['id']); + } + } + break; + } + } + + /** + * The commit of the conversation open from the log, which its row marks. + */ + public function selectedCommit(): ?string + { + return $this->conversations[0]['id'] ?? null; + } + + public function conversationCanDelete(int $slot): bool + { + $detail = $this->nativeChildComponents['commit-detail|key:conversation-'.$slot] ?? null; + + return $detail instanceof CommitDetail && $detail->isOwn(); + } + + public function onResume(): void + { + foreach ($this->nativeChildComponents as $child) { + $child->onResume(); + } + } + + #[Poll(30000)] + public function checkForNewCommits(): void + { + $this->feed()?->checkForNewCommits(); + } + + private function feed(): ?Log + { + foreach ($this->nativeChildComponents as $child) { + if ($child instanceof Log) { + return $child; + } + } + + return null; + } + + public function navTitle(): string + { + return 'devnull'; + } + + public function navigationOptions(): ?NavBarOptions + { + return NavBarOptions::make()->hidden(); + } + + public function render(): View + { + return view('native.feed-workspace'); + } +} diff --git a/app/NativeComponents/Layouts/TabsLayout.php b/app/NativeComponents/Layouts/TabsLayout.php index 4b7ce13d..6d6004bc 100644 --- a/app/NativeComponents/Layouts/TabsLayout.php +++ b/app/NativeComponents/Layouts/TabsLayout.php @@ -13,6 +13,7 @@ use Native\Mobile\Edge\Layouts\Builders\TabBar; use Native\Mobile\Edge\Layouts\NativeLayout; use Native\Mobile\Edge\NativeComponent; +use Native\Mobile\Facades\System; /** * The top-level sections: Log, Notifications, Remotes and your Profile, with @@ -63,6 +64,10 @@ public function navBar(NativeComponent $screen): ?NavBar ->title($screen->navTitle()) ->displayMode('inline'); + if (System::isIos()) { + $navBar->backgroundColor((string) theme('surface')); + } + if ($screen instanceof Log) { $logo = Image::make(self::logo())->height(28)->alt('devnull')->ref('wordmark'); diff --git a/app/NativeComponents/Log.php b/app/NativeComponents/Log.php index 050e79e2..0e853552 100644 --- a/app/NativeComponents/Log.php +++ b/app/NativeComponents/Log.php @@ -163,6 +163,13 @@ class Log extends NativeComponent public string $log = 'following'; + public bool $embedded = false; + + /** + * The commit of the conversation open from the log, whose row shows as selected. + */ + public ?string $selectedCommit = null; + /** * The list key of each log you're not on. The log you're on uses $listKey. * diff --git a/app/Providers/NativeServiceProvider.php b/app/Providers/NativeServiceProvider.php index b8dceea4..1ba23e10 100644 --- a/app/Providers/NativeServiceProvider.php +++ b/app/Providers/NativeServiceProvider.php @@ -3,6 +3,7 @@ namespace App\Providers; use Codingwithrk\NativephpSocialAuth\NativephpSocialAuthServiceProvider; +use Devnull\AdaptiveLayout\AdaptiveLayoutServiceProvider; use Devnull\Calls\CallsServiceProvider; use Illuminate\Support\ServiceProvider; use Native\Mobile\Providers\BrowserServiceProvider; @@ -52,6 +53,7 @@ public function plugins(): array { return [ NativeUIServiceProvider::class, + AdaptiveLayoutServiceProvider::class, BrowserServiceProvider::class, CameraServiceProvider::class, MicrophoneServiceProvider::class, diff --git a/composer.json b/composer.json index bfde9571..7452c988 100644 --- a/composer.json +++ b/composer.json @@ -11,6 +11,11 @@ ], "license": "MIT", "repositories": [ + { + "name": "adaptive-layout", + "type": "path", + "url": "packages/adaptive-layout" + }, { "name": "calls", "type": "path", @@ -29,6 +34,8 @@ "require": { "php": "^8.3", "codingwithrk/nativephp-social-auth": "^1.0", + "cweagans/composer-patches": "^1.7", + "devnull/adaptive-layout": "^1.0", "devnull/nativephp-calls": "^1.0", "laravel/boost": "^2.0", "laravel/framework": "^13.0", @@ -112,6 +119,18 @@ "extra": { "laravel": { "dont-discover": [] + }, + "composer-exit-on-patch-failure": true, + "patches": { + "nativephp/mobile": { + "Async callbacks retain their exact live child receiver within the dispatch root": "patches/nativephp-mobile-live-child-async.patch", + "Hidden navigation bars release the top layout and hit region for native child hosts": "patches/nativephp-mobile-hidden-nav-hit-area.patch" + }, + "nativephp/mobile-ui": { + "Pager: page by the ScrollView width and clip its sides, so a sideways bar (iPhone Duo rail) neither misplaces the tab underline nor shows the next page": "patches/nativephp-mobile-ui-pager-side-insets.patch", + "Pager: header-beside draws a horizontal pager's header in its own column beside the pages from a breakpoint up, split at the fold (iOS)": "patches/nativephp-mobile-ui-pager-header-beside.patch", + "Lists, scroll views and pagers run under the horizontal safe area beside them (the iPhone Duo's vertical bar, a notched iPhone held sideways) with their indicator at the screen edge, handing that inset back to their content as safe area (iOS); its pager hunks edit lines the header-beside patch adds, so it applies after that one": "patches/nativephp-mobile-ui-scroll-edge-under-bars.patch" + } } }, "config": { @@ -119,6 +138,7 @@ "preferred-install": "dist", "sort-packages": true, "allow-plugins": { + "cweagans/composer-patches": true, "pestphp/pest-plugin": true, "php-http/discovery": true } diff --git a/composer.lock b/composer.lock index e9cd6014..0500d86e 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "745d59f03866816f94bab0b20f027a25", + "content-hash": "4f5c6c2ddc01188a55eeaaabfb661e0c", "packages": [ { "name": "bacon/bacon-qr-code", @@ -341,6 +341,55 @@ ], "time": "2026-10-29T09:13:34+00:00" }, + { + "name": "cweagans/composer-patches", + "version": "1.7.3", + "source": { + "type": "git", + "url": "https://github.com/cweagans/composer-patches.git", + "reference": "e190d4466fe2b103a55467dfa83fc2fecfcaf2db" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/cweagans/composer-patches/zipball/e190d4466fe2b103a55467dfa83fc2fecfcaf2db", + "reference": "e190d4466fe2b103a55467dfa83fc2fecfcaf2db", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^1.0 || ^2.0", + "php": ">=5.3.0" + }, + "require-dev": { + "composer/composer": "~1.0 || ~2.0", + "phpunit/phpunit": "~4.6" + }, + "type": "composer-plugin", + "extra": { + "class": "cweagans\\Composer\\Patches" + }, + "autoload": { + "psr-4": { + "cweagans\\Composer\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Cameron Eagans", + "email": "me@cweagans.net" + } + ], + "description": "Provides a way to patch Composer packages.", + "support": { + "issues": "https://github.com/cweagans/composer-patches/issues", + "source": "https://github.com/cweagans/composer-patches/tree/1.7.3" + }, + "time": "2022-12-20T22:53:13+00:00", + "published-time": "2022-12-21T19:08:26+00:00" + }, { "name": "dasprid/enum", "version": "1.0.7", @@ -391,6 +440,43 @@ }, "time": "2025-09-16T12:23:56+00:00" }, + { + "name": "devnull/adaptive-layout", + "version": "1.0.0", + "dist": { + "type": "path", + "url": "packages/adaptive-layout", + "reference": "e16069926c3f6cc321e3f78e46f12d6e5ff7e9e5" + }, + "require": { + "nativephp/mobile": "^4.7", + "nativephp/mobile-ui": "^0.9", + "php": "^8.3" + }, + "type": "nativephp-plugin", + "extra": { + "laravel": { + "providers": [ + "Devnull\\AdaptiveLayout\\AdaptiveLayoutServiceProvider" + ] + }, + "nativephp": { + "manifest": "nativephp.json" + } + }, + "autoload": { + "psr-4": { + "Devnull\\AdaptiveLayout\\": "src/" + } + }, + "license": [ + "MIT" + ], + "description": "Retained iOS feed and conversation navigation for devnull", + "transport-options": { + "relative": true + } + }, { "name": "devnull/nativephp-calls", "version": "1.0.0", @@ -11579,5 +11665,5 @@ "php": "^8.3" }, "platform-dev": {}, - "plugin-api-version": "2.9.0" + "plugin-api-version": "2.11.0" } diff --git a/config/native-ui.php b/config/native-ui.php index 171b025b..edd8d940 100644 --- a/config/native-ui.php +++ b/config/native-ui.php @@ -62,6 +62,7 @@ 'on-surface-variant' => '#5b616b', 'outline' => '#d3d7dd', + 'outline-variant' => '#e1e4e8', // Rm Red. 'destructive' => '#c92a2a', @@ -130,6 +131,7 @@ 'on-surface-variant' => '#a1a1a6', 'outline' => '#36363a', + 'outline-variant' => '#2a2a2d', 'destructive' => '#ff6b6b', 'on-destructive' => '#111112', diff --git a/config/nativephp.php b/config/nativephp.php index 2e38a8c5..9b975365 100644 --- a/config/nativephp.php +++ b/config/nativephp.php @@ -477,8 +477,8 @@ 'iphone' => [ 'portrait' => true, 'upside_down' => false, - 'landscape_left' => false, - 'landscape_right' => false, + 'landscape_left' => true, + 'landscape_right' => true, ], 'android' => [ 'portrait' => true, diff --git a/packages/adaptive-layout/composer.json b/packages/adaptive-layout/composer.json new file mode 100644 index 00000000..8f6647e4 --- /dev/null +++ b/packages/adaptive-layout/composer.json @@ -0,0 +1,27 @@ +{ + "name": "devnull/adaptive-layout", + "description": "Retained iOS feed and conversation navigation for devnull", + "type": "nativephp-plugin", + "version": "1.0.0", + "license": "MIT", + "require": { + "php": "^8.3", + "nativephp/mobile": "^4.7", + "nativephp/mobile-ui": "^0.9" + }, + "autoload": { + "psr-4": { + "Devnull\\AdaptiveLayout\\": "src/" + } + }, + "extra": { + "laravel": { + "providers": [ + "Devnull\\AdaptiveLayout\\AdaptiveLayoutServiceProvider" + ] + }, + "nativephp": { + "manifest": "nativephp.json" + } + } +} diff --git a/packages/adaptive-layout/nativephp.json b/packages/adaptive-layout/nativephp.json new file mode 100644 index 00000000..688a7627 --- /dev/null +++ b/packages/adaptive-layout/nativephp.json @@ -0,0 +1,33 @@ +{ + "namespace": "AdaptiveLayout", + "platforms": ["ios"], + "android": { + "min_version": 26 + }, + "ios": { + "min_version": "18.2" + }, + "components": [ + { + "type": "adaptive_feed", + "element": "Devnull\\AdaptiveLayout\\Elements\\AdaptiveFeed", + "blade": "Devnull\\AdaptiveLayout\\Components\\AdaptiveFeed", + "ios_renderer": "AdaptiveFeedRenderer", + "self_closing": false + }, + { + "type": "adaptive_detail", + "element": "Devnull\\AdaptiveLayout\\Elements\\AdaptiveDetail", + "blade": "Devnull\\AdaptiveLayout\\Components\\AdaptiveDetail", + "ios_renderer": "AdaptiveDetailRenderer", + "self_closing": false + }, + { + "type": "adaptive_placeholder", + "element": "Devnull\\AdaptiveLayout\\Elements\\AdaptivePlaceholder", + "blade": "Devnull\\AdaptiveLayout\\Components\\AdaptivePlaceholder", + "ios_renderer": "AdaptiveDetailRenderer", + "self_closing": false + } + ] +} diff --git a/packages/adaptive-layout/resources/ios/AdaptiveFeedRenderer.swift b/packages/adaptive-layout/resources/ios/AdaptiveFeedRenderer.swift new file mode 100644 index 00000000..0f36a4ec --- /dev/null +++ b/packages/adaptive-layout/resources/ios/AdaptiveFeedRenderer.swift @@ -0,0 +1,667 @@ +import SwiftUI +import UIKit + +struct AdaptiveFeedRenderer: View { + let node: NativeUINode + @ObservedObject private var themeStore = NativeUITheme.shared + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + AdaptiveFeedHost(node: node) + .frame(maxWidth: .infinity, maxHeight: .infinity) + // The workspace fills the window and its pages inset themselves. + // On the outer display, nested navigation bars were observed to + // move into the vertical bar only when their controller reaches + // its edge; inset here, they stayed horizontal. + .ignoresSafeArea(.container, edges: .all) + .background(themeStore.resolve(for: colorScheme).surface) + .environment(\.nativeUITheme, themeStore.resolve(for: colorScheme)) + } +} + +// The detail and placeholder tags are descriptors consumed by the feed host, +// not a second router. +struct AdaptiveDetailRenderer: View { + let node: NativeUINode + + var body: some View { + ForEach(node.children) { child in + NodeView(node: child).equatable() + } + } +} + +private struct AdaptiveFeedHost: UIViewControllerRepresentable { + let node: NativeUINode + + func makeUIViewController(context: Context) -> AdaptiveWorkspaceController { + let controller = AdaptiveWorkspaceController() + controller.receive(node, environment: context.environment) + return controller + } + + func updateUIViewController(_ controller: AdaptiveWorkspaceController, context: Context) { + controller.receive(node, environment: context.environment) + } +} + +private final class AdaptivePaneInsets: ObservableObject { + @Published var bottom: CGFloat = 0 + @Published var horizontal = HorizontalInsets() + + struct HorizontalInsets: Equatable { + var leading: CGFloat = 0 + var trailing: CGFloat = 0 + } +} + +/// A stable root type for every retained hosting controller. Only the direct +/// bottom_bar sentinel is removed; all native node ids and ancestors survive. +private struct AdaptiveNodeRoot: View { + let content: NativeUINode + let bottom: NativeUINode? + let inherited: EnvironmentValues + @ObservedObject var paneInsets: AdaptivePaneInsets + + var body: some View { + GeometryReader { geometry in + GeometryReader { contentGeometry in + NodeView(node: content) + .equatable() + .environment(\.availableHeight, contentGeometry.size.height) + } + .modifier(AdaptiveBottomInset(bottom: bottom, clearance: max(0, paneInsets.bottom - geometry.safeAreaInsets.bottom))) + .environment(\.availableWidth, geometry.size.width) + .environment(\.availableHeight, geometry.size.height) + .environment(\.nativeSafeAreaTop, geometry.safeAreaInsets.top) + .environment(\.nativeSafeAreaBottom, geometry.safeAreaInsets.bottom) + } + .safeAreaPadding(.leading, paneInsets.horizontal.leading) + .safeAreaPadding(.trailing, paneInsets.horizontal.trailing) + // A navigation controller hands its pages the window's horizontal + // insets even in a column that stops short of the vertical bar. The + // workspace measures what each column actually overlaps instead, as + // safe area, so lists and backgrounds can still reach under the bar. + .ignoresSafeArea(.container, edges: .horizontal) + // UIHostingController is a new SwiftUI root, so it does not inherit the + // representable's theme, window breakpoints, locale, or custom keys. + .environment(\.self, inherited) + } +} + +private struct AdaptiveBottomInset: ViewModifier { + let bottom: NativeUINode? + let clearance: CGFloat + @Environment(\.colorScheme) private var colorScheme + + func body(content: Content) -> some View { + if let bottom { + let dark = colorScheme == .dark ? bottom.props.getColor("dark_bg_color", default: 0) : 0 + let background = dark != 0 ? dark : (bottom.style?.bgColor ?? 0) + content.safeAreaInset(edge: .bottom, spacing: 0) { + NodeView(node: bottom) + .equatable() + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity) + .padding(.bottom, clearance) + .background((background == 0 ? Color.clear : Color(argb: background)) + .ignoresSafeArea(edges: .bottom)) + } + } else { + content + } + } +} + +/// UIKit owns the navigation item; SwiftUI only owns the retained page content. +/// A hosting controller used directly as a navigation destination can replace +/// its title item when it moves between the two navigation controllers. +private final class AdaptivePageController: UIViewController { + private let hosting: UIHostingController + private let paneInsets: AdaptivePaneInsets + private var requestedBottomInset: CGFloat = 0 + private var requestedHorizontalInsets = AdaptivePaneInsets.HorizontalInsets() + var onSafeAreaChange: (() -> Void)? + private(set) var source: NativeUINode + private var contentNode: NativeUINode + private var bottomNode: NativeUINode? + + init(node: NativeUINode, detail: Bool, environment: EnvironmentValues) { + source = node + let root = detail ? (node.children.first ?? node) : node + let sentinel = detail ? root.children.first { $0.type == "bottom_bar" } : nil + let content = sentinel == nil ? root : root.copy(children: root.children.filter { $0.type != "bottom_bar" }) + let bottom = sentinel?.children.first + contentNode = content + bottomNode = bottom + let paneInsets = AdaptivePaneInsets() + self.paneInsets = paneInsets + hosting = UIHostingController(rootView: AdaptiveNodeRoot(content: content, bottom: bottom, inherited: environment, paneInsets: paneInsets)) + super.init(nibName: nil, bundle: nil) + navigationItem.largeTitleDisplayMode = .never + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .clear + hosting.view.backgroundColor = .clear + addChild(hosting) + hosting.view.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(hosting.view) + NSLayoutConstraint.activate([ + hosting.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + hosting.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + hosting.view.topAnchor.constraint(equalTo: view.topAnchor), + hosting.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + hosting.didMove(toParent: self) + } + + override func viewSafeAreaInsetsDidChange() { + super.viewSafeAreaInsetsDidChange() + onSafeAreaChange?() + } + + func reserveBottom(_ inset: CGFloat) { + guard abs(requestedBottomInset - inset) > 0.5 else { return } + requestedBottomInset = inset + // UIKit may be laying out during a SwiftUI update. Publish after + // that pass, and discard obsolete measurements from a transition. + DispatchQueue.main.async { [weak self] in + guard let self, self.requestedBottomInset == inset else { return } + self.paneInsets.bottom = inset + } + } + + func reserveHorizontal(leading: CGFloat, trailing: CGFloat) { + let insets = AdaptivePaneInsets.HorizontalInsets(leading: leading, trailing: trailing) + guard insets != requestedHorizontalInsets else { return } + requestedHorizontalInsets = insets + DispatchQueue.main.async { [weak self] in + guard let self, self.requestedHorizontalInsets == insets else { return } + self.paneInsets.horizontal = insets + } + } + + func update(_ node: NativeUINode, detail: Bool, environment: EnvironmentValues) { + if source !== node { + source = node + let root = detail ? (node.children.first ?? node) : node + let sentinel = detail ? root.children.first { $0.type == "bottom_bar" } : nil + contentNode = sentinel == nil ? root : root.copy(children: root.children.filter { $0.type != "bottom_bar" }) + bottomNode = sentinel?.children.first + } + // A resize never copies text from PHP or replaces a controller. The + // unchanged node references also let equatable NodeView skip echoes. + hosting.rootView = AdaptiveNodeRoot(content: contentNode, bottom: bottomNode, inherited: environment, paneInsets: paneInsets) + } +} + +private final class AdaptiveWordmarkView: UIImageView { + override var intrinsicContentSize: CGSize { CGSize(width: 120, height: 32) } + + override func sizeThatFits(_ size: CGSize) -> CGSize { intrinsicContentSize } +} + +/// A navigation controller gives its bar the window's horizontal insets even +/// in a column that stops short of the vertical bar. Limit them to what the +/// bar actually overlaps of its container's safe area. +private final class AdaptiveNavigationBar: UINavigationBar { + override var safeAreaInsets: UIEdgeInsets { + var insets = super.safeAreaInsets + guard let superview else { return insets } + let safeFrame = convert(superview.safeAreaLayoutGuide.layoutFrame, from: superview) + insets.left = max(0, min(bounds.width, safeFrame.minX - bounds.minX)) + insets.right = max(0, min(bounds.width, bounds.maxX - safeFrame.maxX)) + return insets + } +} + +private final class AdaptiveWorkspaceController: UIViewController, UINavigationControllerDelegate { + private let primary = UINavigationController(navigationBarClass: AdaptiveNavigationBar.self, toolbarClass: nil) + private let secondary = UINavigationController(navigationBarClass: AdaptiveNavigationBar.self, toolbarClass: nil) + private let divider = UIView() + private var pages: [Int: AdaptivePageController] = [:] + private var placeholder: AdaptivePageController? + private var appliedFoldGap: CGRect? + private var latest: NativeUINode? + private var inherited: EnvironmentValues? + private var wide: Bool? + private var applying = false + private var transitioning = false + private var expectedPrimary: [UIViewController] = [] + private var expectedSecondary: [UIViewController] = [] + private var history: [Int] = [] + private var historyIdentities: [String] = [] + private var logicalDepth = 0 + private var intentRevision = 0 + // Hold the completed native pop until PHP acknowledges it. Repeated old + // publishes must not immediately push the conversation back on screen. + private var awaitingHistory: [Int]? + private var poppedDepth: Int? + private var reconciling = false + private var revision: UInt64 = 0 + private var appliedRevision: UInt64? + private var logoName: String? + private var logoView: UIImageView? + + override func viewDidLoad() { + super.viewDidLoad() + for navigation in [primary, secondary] { + navigation.delegate = self + navigation.navigationBar.prefersLargeTitles = false + addChild(navigation) + view.addSubview(navigation.view) + navigation.didMove(toParent: self) + } + view.addSubview(divider) + secondary.view.isHidden = true + divider.isHidden = true + registerForTraitChanges([UITraitHorizontalSizeClass.self]) { (self: Self, _: UITraitCollection) in + self.view.setNeedsLayout() + } + if #available(iOS 27.1, *) { + // Hinge updates say nothing about when the division region turns + // active, and it can turn active just after one, so re-read the + // gap then and once more on the next run loop. + view.addInteraction(UIHingeInteraction { [weak self] _, _ in + self?.layOutIfFoldGapMoved() + DispatchQueue.main.async { [weak self] in self?.layOutIfFoldGapMoved() } + }) + } + } + + func receive(_ node: NativeUINode, environment: EnvironmentValues) { + latest = node + inherited = environment + revision &+= 1 + guard isViewLoaded else { return } + // Deliver state even during transitions: a sheet's dismissed false + // must reach its retained root before the next request to show it. + // Only navigation-stack mutations wait for reconciliation below. + for child in node.children { + pages[child.id]?.update(child, detail: child.type == "adaptive_detail", environment: environment) + } + if let placeholderNode = node.children.first(where: { $0.type == "adaptive_placeholder" }) { + placeholder?.update(placeholderNode, detail: false, environment: environment) + } + reconcile() + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + reconcile() + layoutColumns() + updateBottomBarClearance() + updateHorizontalInsets() + } + + override func viewSafeAreaInsetsDidChange() { + super.viewSafeAreaInsetsDidChange() + view.setNeedsLayout() + } + + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + super.viewWillTransition(to: size, with: coordinator) + // Duo can leave its system tab bar covering the entire container after + // resizing. Ask the owning container to lay out outside the transition + // callback, without overriding its frames or retrying child layout. + coordinator.animate(alongsideTransition: nil) { [weak self] _ in + DispatchQueue.main.async { [weak self] in + guard let self, self.viewIfLoaded?.window != nil, let tabs = self.tabBarController else { return } + tabs.view.setNeedsLayout() + tabs.view.layoutIfNeeded() + self.view.setNeedsLayout() + } + } + } + + private func updateBottomBarClearance() { + var bottomBar: UIView? + if let bar = tabBarController?.tabBar, !bar.isHidden, bar.alpha > 0, + bar.window === view.window { + let overlap = view.bounds.intersection(bar.convert(bar.bounds, to: view)) + // Only the horizontal bottom bar obstructs the reply field. The + // vertical bar is a horizontal safe-area inset of each column. + // Reject the full-window tab-bar frame seen during Duo resizing. + if !overlap.isNull, overlap.width > overlap.height, + overlap.height < view.bounds.height / 2 { + bottomBar = bar + } + } + for navigation in [primary, secondary] { + guard let page = navigation.topViewController as? AdaptivePageController, + page.isViewLoaded, page.view.window === view.window else { continue } + let required = bottomBar.map { bar in + max(0, page.view.bounds.maxY - bar.convert(bar.bounds, to: page.view).minY) + } ?? 0 + // SwiftUI subtracts its own live safe area from this reservation. + // Updating UIKit's additional insets is insufficient after a page + // moves: its hosting view can keep the previous parent's insets. + page.reserveBottom(required) + } + } + + private func updateHorizontalInsets() { + // The safe-area guide is solved lazily, and these columns are laid out + // by frame, so after a resize it can keep an intermediate size's frame. + let safe = view.bounds.inset(by: view.safeAreaInsets) + let isRightToLeft = view.effectiveUserInterfaceLayoutDirection == .rightToLeft + for navigation in [primary, secondary] where !navigation.view.isHidden { + let column = navigation.view.frame + let leading = max(0, min(column.width, safe.minX - column.minX)) + let trailing = max(0, min(column.width, column.maxX - safe.maxX)) + for case let page as AdaptivePageController in navigation.viewControllers { + page.reserveHorizontal(leading: isRightToLeft ? trailing : leading, trailing: isRightToLeft ? leading : trailing) + } + } + } + + private func layoutColumns() { + let bounds = view.bounds + guard wide == true else { + primary.view.frame = bounds + return + } + let fold = foldGap(in: bounds) + appliedFoldGap = fold + primary.view.frame = CGRect(x: bounds.minX, y: bounds.minY, width: max(0, fold.minX - bounds.minX), height: bounds.height) + secondary.view.frame = CGRect(x: fold.maxX, y: bounds.minY, width: max(0, bounds.maxX - fold.maxX), height: bounds.height) + let hairline = 1 / max(1, view.traitCollection.displayScale) + divider.frame = CGRect(x: fold.midX - hairline / 2, y: bounds.minY, width: hairline, height: bounds.height) + } + + private func layOutIfFoldGapMoved() { + guard wide == true, foldGap(in: view.bounds) != appliedFoldGap else { return } + view.setNeedsLayout() + } + + /// Where the columns part: the fold, for an even split around the hinge. + /// Flat, they meet on it. Partly folded, its division region is active + /// and they stop at its margins, keeping controls out of the crease. + /// A display without a fold across the split gets an even split. + /// PagerSplit in the patched nativephp/mobile-ui NativeUIPagerRenderer.swift + /// applies the same rule; keep the two alike. + private func foldGap(in bounds: CGRect) -> CGRect { + let middle = CGRect(x: bounds.minX + (bounds.width / 2).rounded(), y: bounds.minY, width: 0, height: bounds.height) + guard #available(iOS 27.1, *) else { return middle } + let divisions = view.reservedRegions(kind: .division, options: .includeInactive) + .filter { $0.frame.height > $0.frame.width && $0.frame.minX > bounds.minX && $0.frame.maxX < bounds.maxX } + guard let division = divisions.min(by: { abs($0.frame.midX - bounds.midX) < abs($1.frame.midX - bounds.midX) }) else { + return middle + } + return division.isActive ? division.frame : CGRect(x: division.frame.midX, y: bounds.minY, width: 0, height: bounds.height) + } + + private func reconcile() { + guard !reconciling, !applying, !transitioning, + primary.transitionCoordinator == nil, secondary.transitionCoordinator == nil, + let latest, let inherited, view.bounds.width > 0, view.bounds.height > 0, + let feed = latest.children.first(where: { $0.type != "adaptive_detail" && $0.type != "adaptive_placeholder" }) else { return } + // Regular width alone would split the inner display in portrait into + // two skinny columns. Like Apple's arrangements, also weigh the aspect + // ratio: only a window wider than it is tall gets two columns. The + // window's shape, unlike the view's, survives keyboard resizing. + let window = view.window?.bounds ?? view.bounds + let wantsWide = traitCollection.horizontalSizeClass == .regular && window.width > window.height + // Root-view assignment can schedule another UIKit layout. A bounds-only + // layout in the same mode must not assign all hosting roots again. + guard appliedRevision != revision || wide != wantsWide else { return } + reconciling = true + defer { reconciling = false } + appliedRevision = revision + + let details = latest.children.filter { $0.type == "adaptive_detail" } + let requestedDepth = max(0, latest.props.getInt("depth")) + let requestedIntent = latest.props.getInt("intent_revision") + let currentHistory = details.map(\.id) + if let awaitingHistory, let poppedDepth { + // Two completed pops can outrun PHP. An intermediate ancestor + // acknowledgement must not repush the second screen just popped. + let ancestor = currentHistory.count <= awaitingHistory.count + && currentHistory == Array(awaitingHistory.prefix(currentHistory.count)) + if requestedIntent != intentRevision || !ancestor || requestedDepth <= poppedDepth { + self.awaitingHistory = nil + self.poppedDepth = nil + } + } + history = currentHistory + historyIdentities = details.map { $0.props.getString("identity", default: String($0.id)) } + logicalDepth = requestedDepth + intentRevision = requestedIntent + // PHP owns history. Truncate to its explicit depth; never invent a + // detail screen while children are still arriving asynchronously. + let visibleDepth = min(details.count, poppedDepth ?? requestedDepth) + let activeDetails = Array(details.prefix(visibleDepth)) + let migrating = wide != wantsWide + let oldSelection = (wide == true ? secondary.topViewController : primary.topViewController) + let focused = migrating ? view.adaptiveFirstResponder() : nil + + for (node, detail) in [(feed, false)] + details.map({ ($0, true) }) { + if pages[node.id] == nil { + let page = AdaptivePageController(node: node, detail: detail, environment: inherited) + page.onSafeAreaChange = { [weak self] in self?.view.setNeedsLayout() } + pages[node.id] = page + } + } + if let placeholderNode = latest.children.first(where: { $0.type == "adaptive_placeholder" }), placeholder == nil { + placeholder = AdaptivePageController(node: placeholderNode, detail: false, environment: inherited) + } + guard let feedPage = pages[feed.id] else { return } + let detailPages: [UIViewController] = activeDetails.compactMap { pages[$0.id] } + let emptyDetail: [UIViewController] = placeholder.map { [$0] } ?? [] + let primaryStack: [UIViewController] = wantsWide ? [feedPage] : [feedPage] + detailPages + let secondaryStack: [UIViewController] = wantsWide ? (detailPages.isEmpty ? emptyDetail : detailPages) : [] + + applying = true + if migrating { + // Release both old parents BEFORE adding any page to the other + // nav controller. UINavigationController cannot share children. + primary.setViewControllers([], animated: false) + secondary.setViewControllers([], animated: false) + } + wide = wantsWide + // Establish destination visibility and bounds before attaching retained + // hosting roots, rather than first mounting them at the old geometry. + secondary.view.isHidden = !wantsWide + divider.isHidden = !wantsWide + layoutColumns() + expectedPrimary = primaryStack + expectedSecondary = secondaryStack + if !sameControllers(primary.viewControllers, primaryStack) { + primary.setViewControllers(primaryStack, animated: false) + } + if !sameControllers(secondary.viewControllers, secondaryStack) { + secondary.setViewControllers(secondaryStack, animated: false) + } + if migrating { + // Removing an item from its old bar can detach the title view + // after it has been assigned to the destination. Rebuild only + // the chrome, once both retained stacks have their new parents. + logoName = nil + for page in pages.values { page.navigationItem.titleView = nil } + } + configureBars(feed: feed, details: details, environment: inherited) + primary.view.layoutIfNeeded() + secondary.view.layoutIfNeeded() + updateHorizontalInsets() + applying = false + let validIDs = Set([feed.id] + currentHistory) + pages = pages.filter { validIDs.contains($0.key) } + + let selection = wantsWide ? secondary.topViewController : primary.topViewController + if migrating, oldSelection === selection, let focused { + // Best effort only. Do not steal a newly acquired focus or write + // any text/selection value; SwiftUI/UIKit may discard the field. + DispatchQueue.main.async { [weak self, weak focused, weak selection] in + guard let self, let focused, let selection, + (self.wide == true ? self.secondary.topViewController : self.primary.topViewController) === selection, + focused.window != nil, focused.canBecomeFirstResponder, + focused.window?.adaptiveFirstResponder() == nil else { return } + focused.becomeFirstResponder() + } + } + } + + private func sameControllers(_ left: [UIViewController], _ right: [UIViewController]) -> Bool { + left.count == right.count && zip(left, right).allSatisfy { $0.0 === $0.1 } + } + + func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) { + guard !applying, animated else { return } + transitioning = true + } + + func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) { + guard !applying else { return } + transitioning = false + let expected = navigationController === primary ? expectedPrimary : expectedSecondary + let actual = navigationController.viewControllers + // didShow fires after a cancelled gesture too: only a STRICT prefix + // removal is a committed user pop. Resize mutations are synchronous + // and guarded by applying, so cannot produce a back event. + if actual.count < expected.count, + sameControllers(actual, Array(expected.prefix(actual.count))) { + let depth = navigationController === primary ? max(0, actual.count - 1) : actual.count + awaitingHistory = history + poppedDepth = depth + if navigationController === primary { + expectedPrimary = actual + } else { + expectedSecondary = actual + } + send("back", depth: depth) + } + // The coordinator may still be non-nil in didShow. Wait one turn; + // updates received during the gesture remain in latest until here. + DispatchQueue.main.async { [weak self] in self?.reconcile() } + } + + private func configureBars(feed: NativeUINode, details: [NativeUINode], environment: EnvironmentValues) { + let tokens = environment.nativeUITheme + view.backgroundColor = UIColor(tokens.surface) + divider.backgroundColor = UIColor(tokens.outlineVariant) + let appearance = UINavigationBarAppearance() + appearance.configureWithOpaqueBackground() + appearance.backgroundColor = UIColor(tokens.surface) + for navigation in [primary, secondary] { + navigation.navigationBar.tintColor = UIColor(tokens.onSurface) + navigation.navigationBar.standardAppearance = appearance + navigation.navigationBar.scrollEdgeAppearance = appearance + } + if let placeholder { + // Nothing sits in the empty detail's bar, so it draws no edge. + let bare = appearance.copy() + bare.shadowColor = .clear + placeholder.navigationItem.standardAppearance = bare + placeholder.navigationItem.scrollEdgeAppearance = bare + } + guard let feedPage = pages[feed.id] else { return } + feedPage.title = "Feed" + if #available(iOS 27.2, *) { + feedPage.navigationItem.titleAlignment = .leading + } + let name = environment.colorScheme == .dark ? "logo-dark@3x.png" : "logo-light@3x.png" + if name != logoName { + logoName = name + if let image = UIImage(contentsOfFile: AppUpdateManager.shared.getAppPath() + "/public/images/" + name) { + let imageView = AdaptiveWordmarkView(image: image) + imageView.contentMode = .scaleAspectFit + imageView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + imageView.widthAnchor.constraint(equalToConstant: imageView.intrinsicContentSize.width), + imageView.heightAnchor.constraint(equalToConstant: imageView.intrinsicContentSize.height), + ]) + imageView.isAccessibilityElement = true + imageView.accessibilityLabel = "devnull" + imageView.isUserInteractionEnabled = true + imageView.addGestureRecognizer(UILongPressGestureRecognizer(target: self, action: #selector(AdaptiveWorkspaceController.playGame(_:)))) + logoView = imageView + } else { + logoView = nil + } + } + feedPage.navigationItem.titleView = logoView + if feedPage.navigationItem.rightBarButtonItems == nil { + let compose = UIBarButtonItem(image: UIImage(systemName: "square.and.pencil"), style: .plain, target: self, action: #selector(AdaptiveWorkspaceController.compose)) + compose.accessibilityLabel = "Compose" + let search = UIBarButtonItem(image: UIImage(systemName: "magnifyingglass"), style: .plain, target: self, action: #selector(AdaptiveWorkspaceController.search)) + search.accessibilityLabel = "Search" + feedPage.navigationItem.rightBarButtonItems = [compose, search] + } + for detail in details { + guard let page = pages[detail.id] else { continue } + let title = detail.props.getString("title", default: "Commit") + let subtitle = detail.props.getString("subtitle", default: "") + if page.title != title || page.navigationItem.titleView?.accessibilityLabel != title + ", " + subtitle { + page.title = title + let heading = UILabel() + heading.text = title + heading.font = .preferredFont(forTextStyle: .headline) + heading.adjustsFontForContentSizeCategory = true + let hash = UILabel() + hash.text = subtitle + hash.font = .preferredFont(forTextStyle: .caption1) + hash.adjustsFontForContentSizeCategory = true + hash.lineBreakMode = .byTruncatingMiddle + let stack = UIStackView(arrangedSubviews: [heading, hash]) + stack.axis = .vertical + stack.alignment = .center + stack.isAccessibilityElement = true + stack.accessibilityLabel = title + ", " + subtitle + page.navigationItem.titleView = stack + } + if let stack = page.navigationItem.titleView as? UIStackView { + (stack.arrangedSubviews.first as? UILabel)?.textColor = UIColor(tokens.onSurface) + (stack.arrangedSubviews.last as? UILabel)?.textColor = UIColor(tokens.onSurfaceVariant) + } + var actions: [UIBarButtonItem] = [] + if detail.props.getBool("can_delete") { + let delete = UIBarButtonItem(image: UIImage(systemName: "trash"), style: .plain, target: self, action: #selector(AdaptiveWorkspaceController.deleteConversation)) + delete.accessibilityLabel = "Delete commit" + actions.append(delete) + } + page.navigationItem.rightBarButtonItems = actions + } + } + + @objc private func compose() { send("compose") } + @objc private func search() { send("search") } + @objc private func playGame(_ gesture: UILongPressGestureRecognizer) { + if gesture.state == .began { send("game") } + } + @objc private func deleteConversation() { send("delete") } + + private func send(_ action: String, depth: Int? = nil) { + guard let latest else { return } + let callback = latest.props.getCallbackId("on_change") + guard callback != 0 else { return } + // These describe the last APPLIED PHP history, not `latest`: a newer + // publish may already be queued during an interactive pop. PHP must + // compare both before accepting a delayed back notification. + var payload: [String: Any] = [ + "action": action, + "expected_depth": logicalDepth, + "expected_revision": intentRevision, + "expected_history": historyIdentities, + "expected_node_ids": history, + ] + if let depth { payload["depth"] = depth } + guard let bytes = try? JSONSerialization.data(withJSONObject: payload), + let text = String(data: bytes, encoding: .utf8) else { return } + NativeElementBridge.sendTextChangeEvent(callback, nodeId: latest.id, text: text) + } +} + +private extension UIView { + func adaptiveFirstResponder() -> UIView? { + if isFirstResponder { return self } + for child in subviews { + if let responder = child.adaptiveFirstResponder() { return responder } + } + return nil + } +} diff --git a/packages/adaptive-layout/src/AdaptiveLayoutServiceProvider.php b/packages/adaptive-layout/src/AdaptiveLayoutServiceProvider.php new file mode 100644 index 00000000..5bcb67b4 --- /dev/null +++ b/packages/adaptive-layout/src/AdaptiveLayoutServiceProvider.php @@ -0,0 +1,7 @@ + 'Commit', 'subtitle' => '']; + + public static function make(): static + { + return new static; + } + + public function title(string $title): static + { + $this->componentProps['title'] = $title; + + return $this; + } + + public function subtitle(string $subtitle): static + { + $this->componentProps['subtitle'] = $subtitle; + + return $this; + } + + public function identity(string $identity): static + { + $this->componentProps['identity'] = $identity; + + return $this; + } + + public function applyAttributes(array $attrs): void + { + if (isset($attrs['title'])) { + $this->title((string) $attrs['title']); + } + + if (isset($attrs['subtitle'])) { + $this->subtitle((string) $attrs['subtitle']); + } + + if (isset($attrs['identity'])) { + $this->identity((string) $attrs['identity']); + } + + $this->componentProps['can_delete'] = (bool) ($attrs['can-delete'] ?? $attrs['can_delete'] ?? false); + } + + protected function resolveProps(CallbackRegistry $registry): array + { + return $this->componentProps; + } +} diff --git a/packages/adaptive-layout/src/Elements/AdaptiveFeed.php b/packages/adaptive-layout/src/Elements/AdaptiveFeed.php new file mode 100644 index 00000000..7fc4df55 --- /dev/null +++ b/packages/adaptive-layout/src/Elements/AdaptiveFeed.php @@ -0,0 +1,58 @@ + 0, 'intent_revision' => 0]; + + public static function make(): static + { + return new static; + } + + public function depth(int $depth): static + { + $this->componentProps['depth'] = max(0, $depth); + + return $this; + } + + public function onChange(string $method): static + { + $this->componentProps['on_change'] = $method; + + return $this; + } + + public function applyAttributes(array $attrs): void + { + if (isset($attrs['depth'])) { + $this->depth((int) $attrs['depth']); + } + + if (isset($attrs['intent-revision'])) { + $this->componentProps['intent_revision'] = (int) $attrs['intent-revision']; + } + + if (isset($attrs['_change'])) { + $this->onChange($attrs['_change']); + } + } + + protected function resolveProps(CallbackRegistry $registry): array + { + $props = $this->componentProps; + + if (isset($props['on_change'])) { + $props['on_change'] = $registry->register($props['on_change']); + } + + return $props; + } +} diff --git a/packages/adaptive-layout/src/Elements/AdaptivePlaceholder.php b/packages/adaptive-layout/src/Elements/AdaptivePlaceholder.php new file mode 100644 index 00000000..327b6fca --- /dev/null +++ b/packages/adaptive-layout/src/Elements/AdaptivePlaceholder.php @@ -0,0 +1,25 @@ +hidden()`, + /// folded onto the chrome sentinel as `hide_nav_bar`. The enclosing + /// `NavigationStack` survives — push / pop and per-URI caching keep +-/// working; only the toolbar chrome disappears. Unlike the tab bar, +-/// hiding the nav bar releases its space automatically, and the +-/// status-bar safe area stays intact. ++/// working; only the toolbar chrome disappears. Hidden-bar screens own ++/// their top safe area. Extend the destination's layout and hit region ++/// together so a nested native navigation bar remains tappable after a ++/// resize, rather than only drawing above an inset parent. + /// + /// Note: SwiftUI disables the interactive edge-swipe-back gesture while + /// the bar is hidden — screens that hide it on a pushed level should +@@ -985,7 +986,9 @@ + + func body(content: Content) -> some View { + if hidden { +- content.toolbar(.hidden, for: .navigationBar) ++ content ++ .toolbar(.hidden, for: .navigationBar) ++ .ignoresSafeArea(.container, edges: .top) + } else { + content + } diff --git a/patches/nativephp-mobile-live-child-async.patch b/patches/nativephp-mobile-live-child-async.patch new file mode 100644 index 00000000..794ebc84 --- /dev/null +++ b/patches/nativephp-mobile-live-child-async.patch @@ -0,0 +1,24 @@ +--- a/src/Edge/NativeComponent.php ++++ b/src/Edge/NativeComponent.php +@@ -2655,10 +2655,18 @@ + $callback = app($callback); + } + +- // Bind the closure to this live component so finished()/failed() can +- // mutate state via $this. Static closures run without $this. ++ // A live child's callback must retain its original receiver. The ++ // dispatch-root check above still guards the screen session; exact ++ // tree membership also rejects detached or same-key replacements. + if ($callback instanceof \Closure && ! (new \ReflectionFunction($callback))->isStatic()) { +- $callback = \Closure::bind($callback, $this, static::class); ++ $owner = (new \ReflectionFunction($callback))->getClosureThis(); ++ if ($owner instanceof self) { ++ if (! in_array($owner, iterator_to_array($this->componentTree(), false), true)) { ++ return; ++ } ++ } else { ++ $callback = \Closure::bind($callback, $this, static::class); ++ } + } + + if ($failed) { diff --git a/patches/nativephp-mobile-ui-pager-header-beside.patch b/patches/nativephp-mobile-ui-pager-header-beside.patch new file mode 100644 index 00000000..5db1826e --- /dev/null +++ b/patches/nativephp-mobile-ui-pager-header-beside.patch @@ -0,0 +1,417 @@ +--- a/src/Elements/Pager.php ++++ b/src/Elements/Pager.php +@@ -3,8 +3,10 @@ + namespace Native\Mobile\UI\Elements; + + use Illuminate\Support\Str; ++use InvalidArgumentException; + use Native\Mobile\Edge\CallbackRegistry; + use Native\Mobile\Edge\Element; ++use Native\Mobile\Edge\TailwindParser; + use Native\Mobile\UI\Concerns\ResolvesColorValues; + use Native\Mobile\UI\Theme; + +@@ -71,6 +73,8 @@ + * that scrolls up with the page showing while the tabs pin under the nav + * bar: see {@see PagerHeader}. A {@see PagerOverlay} hangs under the tabs + * over the pages. Neither counts as a page, and neither is windowed. ++ * `header-beside="expanded"` moves the header beside the pages once the ++ * window is that wide (see headerBeside()). + * + * The page index rides the tab-change transport, so a single feed is + * capped at 32,767 pages per screen. +@@ -126,6 +130,10 @@ + if ($settle !== null) { + $this->reportOnSettle(filter_var($settle, FILTER_VALIDATE_BOOLEAN)); + } ++ $beside = $attrs['header_beside'] ?? $attrs['headerBeside'] ?? $attrs['header-beside'] ?? null; ++ if ($beside !== null) { ++ $this->headerBeside($beside); ++ } + $cb = $attrs['on_page_change'] ?? $attrs['onPageChange'] ?? $attrs['on-page-change'] ?? null; + if ($cb !== null) { + $this->onPageChange($cb); +@@ -196,7 +204,29 @@ + public function reportOnSettle(bool $value = true): static + { + $this->pagerProps['report_on_settle'] = $value; ++ ++ return $this; ++ } ++ ++ /** ++ * From a breakpoint (`expanded`, `lg`, … as the responsive classes name ++ * them) or a width in points up, the header scrolls by itself in a ++ * leading column and the tabs and pages fill the trailing one, split at ++ * a foldable's fold or evenly elsewhere. Narrower, it is above the tabs ++ * as usual. Native decides against the live window width, as it does ++ * for the classes, so the header is sent once for either layout. iOS ++ * only so far: Android keeps the header above the tabs at any width. ++ */ ++ public function headerBeside(string|int|float $from): static ++ { ++ $width = is_numeric($from) ? (float) $from : TailwindParser::breakpointMinWidth($from); + ++ if ($width === null) { ++ throw new InvalidArgumentException("Unknown breakpoint [{$from}] for header-beside."); ++ } ++ ++ $this->pagerProps['header_beside_from'] = $width; ++ + return $this; + } + +--- a/src/Elements/PagerHeader.php ++++ b/src/Elements/PagerHeader.php +@@ -26,6 +26,10 @@ + * always finds the header at the height it left it. Once the header has + * gone, each page keeps its own place. A page too short to scroll the + * header away gets room at its end to do so. ++ * ++ * With `header-beside` on the pager, a wide enough window has the header in ++ * a column of its own beside the pages instead, and the pages' lists draw ++ * only their own rows (see {@see Pager::headerBeside()}). + */ + class PagerHeader extends Element + { +--- a/resources/ios/NativeUIPagerRenderer.swift ++++ b/resources/ios/NativeUIPagerRenderer.swift +@@ -26,6 +26,11 @@ + struct NativeUIPagerRenderer: View { + let node: NativeUINode + ++ /// The window width responsive variants resolve against, so the header ++ /// moves beside the pages at the same width an `expanded:` class applies. ++ @Environment(\.windowWidth) private var windowWidth ++ @Environment(\.availableWidth) private var availableWidth ++ + var body: some View { + let p = node.props + let horizontal = p.getBool("horizontal") +@@ -49,6 +54,14 @@ + return map + }() + ++ // Only a horizontal pager's pages scroll up and down under a header. ++ let header = horizontal ? node.children.first { $0.type == "pager_header" } : nil ++ let besideFrom = CGFloat(p.getFloat("header_beside_from")) ++ // As NodeView resolves the classes: the window has no width yet on ++ // the first frame. ++ let width = windowWidth > 0 ? windowWidth : availableWidth ++ let beside = header != nil && besideFrom > 0 && width >= besideFrom ++ + PagerBody( + nodeId: node.id, + count: pageCount, +@@ -60,8 +73,8 @@ + pageByIndex: pageByIndex, + placeholders: p.getStringList("placeholders"), + tabs: PagerTabs(props: p), +- // Only a horizontal pager's pages scroll up and down under a header. +- header: horizontal ? node.children.first { $0.type == "pager_header" } : nil, ++ header: beside ? nil : header, ++ besideHeader: beside ? header : nil, + overlay: node.children.first { $0.type == "pager_overlay" }, + background: PagerColor(light: node.style?.bgColor, dark: p.has("dark_bg_color") ? p.getColor("dark_bg_color", default: 0) : nil), + a11yLabel: p.getString("a11y_label") +@@ -89,6 +102,9 @@ + let tabs: PagerTabs? + /// The `pager_header`, which each page's list draws above the tabs. + let header: NativeUINode? ++ /// The `pager_header` instead drawn in a column of its own beside the ++ /// pages, once the window is wide enough (`header_beside_from`). ++ let besideHeader: NativeUINode? + /// The `pager_overlay`, laid over the pages under the tabs. + let overlay: NativeUINode? + /// The pager's own background, behind the tabs a header pins. +@@ -116,6 +132,8 @@ + @State private var progress: PagerProgress + /// Where the header is, and each page's list, when there's a header. + @State private var headerScroll = PagerHeaderScroll() ++ /// Where the header beside the pages ends and the pages begin. ++ @State private var split = PagerSplit.even(0) + /// How a horizontal pager gets back on its page after iOS resizes it + /// while the app is in the background (see PagerResume). + @State private var resume: PagerResume +@@ -127,7 +145,7 @@ + /// long feed never fetches stills it may never reach. + static let placeholderRadius = 4 + +- init(nodeId: Int, count: Int, loaded: Int, requestedPage: Int, horizontal: Bool, reportOnSettle: Bool, cbId: Int, pageByIndex: [Int: NativeUINode], placeholders: [String], tabs: PagerTabs?, header: NativeUINode?, overlay: NativeUINode?, background: PagerColor, a11yLabel: String) { ++ init(nodeId: Int, count: Int, loaded: Int, requestedPage: Int, horizontal: Bool, reportOnSettle: Bool, cbId: Int, pageByIndex: [Int: NativeUINode], placeholders: [String], tabs: PagerTabs?, header: NativeUINode?, besideHeader: NativeUINode?, overlay: NativeUINode?, background: PagerColor, a11yLabel: String) { + self.nodeId = nodeId + self.count = count + self.loaded = loaded +@@ -139,6 +157,7 @@ + self.placeholders = placeholders + self.tabs = tabs + self.header = header ++ self.besideHeader = besideHeader + self.overlay = overlay + self.background = background + self.a11yLabel = a11yLabel +@@ -150,27 +169,47 @@ + _resume = State(initialValue: PagerResume(page: requestedPage, count: count)) + } + ++ /// One layout whether the header is beside the pages, in them or absent, ++ /// so crossing `header_beside_from` (unfolding, Split View) shows or ++ /// hides the column and the tab row around the same paging scroll view ++ /// instead of rebuilding it and every page's list. + var body: some View { +- if header != nil { +- // The pages' lists draw the header and the tabs themselves, so +- // they scroll with the page (see NativeUIPagerHeader). +- pager +- .overlay(alignment: .top) { overlayView } +- .modifier(PagerA11yLabelModifier(label: a11yLabel)) +- .onGeometryChange(for: CGRect.self) { $0.frame(in: .global) } action: { frame in +- headerScroll.pageWidth = frame.width +- headerScroll.pagerMinY = frame.minY +- } +- } else { ++ HStack(spacing: 0) { ++ if let besideHeader { ++ // The header scrolls by itself in the leading column, and ++ // the tabs and pages fill the trailing one. ++ ScrollView(.vertical) { ++ PagerChildren(node: besideHeader) ++ .frame(maxWidth: .infinity, alignment: .leading) ++ } ++ .frame(width: split.leading) ++ Color.clear ++ .frame(width: split.gap) ++ .accessibilityHidden(true) ++ } + VStack(spacing: 0) { +- if let tabs { ++ // Under a header the pages' lists draw the header and the ++ // tabs themselves, so they scroll with the page (see ++ // NativeUIPagerHeader). ++ if let tabs, header == nil { + PagerTabStrip(tabs: tabs, progress: progress, select: selectTab) + } + pager + .overlay(alignment: .top) { overlayView } + .modifier(PagerA11yLabelModifier(label: a11yLabel)) ++ .onGeometryChange(for: CGRect.self) { $0.frame(in: .global) } action: { frame in ++ headerScroll.pageWidth = frame.width ++ headerScroll.pagerMinY = frame.minY ++ } + } + } ++ .onGeometryChange(for: PagerSplit.self) { PagerSplit(in: $0) } action: { split = $0 } ++ // Beside the pages, the header leaves their lists, which stop ++ // reporting where they are but keep their scroll views. Forgotten ++ // now, each is found again where it is once the header is back. ++ .onChange(of: header == nil) { _, gone in ++ if gone { headerScroll.reset() } ++ } + } + + @ViewBuilder +@@ -584,7 +623,7 @@ + Group { + if let child = pageByIndex[index] { + NodeView(node: child).equatable() +- .environment(\.nativeUIPagerHeader, header.map { pagerHeader(page: index, node: $0) }) ++ .environment(\.nativeUIPagerHeader, (header ?? besideHeader).map { pagerHeader(page: index, node: $0) }) + } else if index >= loaded { + ProgressView() + .controlSize(.large) +@@ -606,8 +645,10 @@ + + /// What page `index` gets of the header: the same object for the life + /// of the pager, so a render only redraws the parts of it that changed. ++ /// A page gets it beside the pages too, with nothing to draw, so its ++ /// list is the same list either way and keeps its place. + private func pagerHeader(page index: Int, node: NativeUINode) -> NativeUIPagerHeader { +- headerScroll.header(page: index, node: node, tabs: tabs, background: background, progress: progress, select: selectTab) ++ headerScroll.header(page: index, node: node, inList: header != nil, tabs: tabs, background: background, progress: progress, select: selectTab) + } + + /// A tap on a tab: scroll there, and the page is reported when the +@@ -880,7 +921,39 @@ + } + + // MARK: - Header ++ ++/// Where a pager splits into its header and its pages: at a foldable's fold, ++/// for an even split around the hinge. Flat, the columns meet on it. Partly ++/// folded, its division region is active and they stop at its margins, ++/// keeping controls out of the crease. Without a fold across the pager the ++/// columns share the width evenly. foldGap(in:) in devnull/adaptive-layout's ++/// AdaptiveFeedRenderer.swift applies the same rule; keep the two alike. ++private struct PagerSplit: Equatable { ++ var leading: CGFloat ++ var gap: CGFloat ++ ++ static func even(_ width: CGFloat) -> PagerSplit { ++ PagerSplit(leading: (width / 2).rounded(), gap: 0) ++ } ++ ++ init(leading: CGFloat, gap: CGFloat) { ++ self.leading = leading ++ self.gap = gap ++ } + ++ init(in proxy: GeometryProxy) { ++ let width = proxy.size.width ++ self = .even(width) ++ guard #available(iOS 27.1, *) else { return } ++ let folds = proxy.reservedRegions(kind: .division, options: .includeInactive) ++ .filter { $0.frame.height > $0.frame.width && $0.frame.minX > 0 && $0.frame.maxX < width } ++ guard let fold = folds.min(by: { abs($0.frame.midX - width / 2) < abs($1.frame.midX - width / 2) }) else { return } ++ self = fold.isActive ++ ? PagerSplit(leading: fold.frame.minX, gap: fold.frame.width) ++ : PagerSplit(leading: fold.frame.midX, gap: 0) ++ } ++} ++ + /// A colour as PHP sent it, light and dark, nil where it sent none. + struct PagerColor: Equatable { + let light: Int? +@@ -911,15 +984,19 @@ + final class NativeUIPagerHeader { + let page: Int + var node: NativeUINode ++ /// Whether the page's list draws the header and the tabs. Beside the ++ /// pages, the pager draws them itself and the list only its own rows. ++ var inList: Bool + fileprivate var tabs: PagerTabs? + var background: PagerColor + fileprivate let progress: PagerProgress + @ObservationIgnored fileprivate var select: (Int) -> Void + let scroll: PagerHeaderScroll + +- fileprivate init(page: Int, node: NativeUINode, tabs: PagerTabs?, background: PagerColor, progress: PagerProgress, select: @escaping (Int) -> Void, scroll: PagerHeaderScroll) { ++ fileprivate init(page: Int, node: NativeUINode, inList: Bool, tabs: PagerTabs?, background: PagerColor, progress: PagerProgress, select: @escaping (Int) -> Void, scroll: PagerHeaderScroll) { + self.page = page + self.node = node ++ self.inList = inList + self.tabs = tabs + self.background = background + self.progress = progress +@@ -994,15 +1071,16 @@ + weak var view: UIScrollView? + } + +- fileprivate func header(page: Int, node: NativeUINode, tabs: PagerTabs?, background: PagerColor, progress: PagerProgress, select: @escaping (Int) -> Void) -> NativeUIPagerHeader { ++ fileprivate func header(page: Int, node: NativeUINode, inList: Bool, tabs: PagerTabs?, background: PagerColor, progress: PagerProgress, select: @escaping (Int) -> Void) -> NativeUIPagerHeader { + guard let header = headers[page] else { +- let header = NativeUIPagerHeader(page: page, node: node, tabs: tabs, background: background, progress: progress, select: select, scroll: self) ++ let header = NativeUIPagerHeader(page: page, node: node, inList: inList, tabs: tabs, background: background, progress: progress, select: select, scroll: self) + headers[page] = header + return header + } + + // Only what changed, so an unchanged header doesn't redraw. + if header.node !== node { header.node = node } ++ if header.inList != inList { header.inList = inList } + if header.tabs != tabs { header.tabs = tabs } + if header.background != background { header.background = background } + header.select = select +@@ -1032,9 +1110,25 @@ + scrollViews[page] = WeakScrollView(view: scrollView) + offsets[page] = scrollView.contentOffset.y + scrollView.adjustedContentInset.top + +- if page != leading { ++ if page == leading { ++ // The header follows this list from where it is, and any list ++ // found before it is brought level with it. ++ collapse = min(offsets[page] ?? 0, headerHeight) ++ for other in scrollViews.keys where other != page { ++ align(other) ++ } ++ } else if scrollViews[leading]?.view != nil { + align(page) + } ++ } ++ ++ /// Forget every list and how far the header had gone, so each list is ++ /// found again as it is. ++ func reset() { ++ collapse = 0 ++ stripMaxY = [:] ++ offsets = [:] ++ scrollViews = [:] + } + + /// A page's list scrolled to `y`, its distance from its own top. +@@ -1073,17 +1167,27 @@ + } + + /// The header as a page's list draws it: its first row. ++/// ++/// With the header beside the pages the row stays, drawing nothing, as does ++/// the section the tabs head: taking either out reloads the whole list, ++/// which loses its place. + struct NativeUIPagerHeaderRow: View { + let header: NativeUIPagerHeader + + var body: some View { +- PagerChildren(node: header.node) +- .frame(maxWidth: .infinity, alignment: .leading) +- .onGeometryChange(for: CGFloat.self) { $0.size.height } action: { height in +- if header.scroll.headerHeight != height { header.scroll.headerHeight = height } +- } +- .background(PagerHeaderScrollViewFinder { header.scroll.attach($0, page: header.page) }) +- .modifier(PagerStationary(header: header)) ++ if header.inList { ++ PagerChildren(node: header.node) ++ .frame(maxWidth: .infinity, alignment: .leading) ++ .onGeometryChange(for: CGFloat.self) { $0.size.height } action: { height in ++ if header.scroll.headerHeight != height { header.scroll.headerHeight = height } ++ } ++ .background(PagerHeaderScrollViewFinder { header.scroll.attach($0, page: header.page) }) ++ .modifier(PagerStationary(header: header)) ++ } else { ++ Color.clear ++ .frame(height: 0) ++ .accessibilityHidden(true) ++ } + } + } + +@@ -1096,6 +1200,16 @@ + @Environment(\.colorScheme) private var colorScheme + + var body: some View { ++ if header.inList { ++ strip ++ } else { ++ Color.clear ++ .frame(height: 0) ++ .accessibilityHidden(true) ++ } ++ } ++ ++ private var strip: some View { + header.strip + .frame(maxWidth: .infinity) + .background(header.background.resolve(colorScheme, fallback: themeStore.resolve(for: colorScheme).surface)) +@@ -1129,10 +1243,13 @@ + func body(content: Content) -> some View { + if let header { + content +- // Section headers get a minimum height and space above. ++ // Section headers get a minimum height and space above, and ++ // rows a minimum height, which the header's row beside the ++ // pages must not have. + .environment(\.defaultMinListHeaderHeight, 0) ++ .environment(\.defaultMinListRowHeight, 0) + .listSectionSpacing(0) +- .contentMargins(.bottom, room, for: .scrollContent) ++ .contentMargins(.bottom, header.inList ? room : 0, for: .scrollContent) + .onScrollGeometryChange(for: PagerListGeometry.self) { geometry in + PagerListGeometry( + y: geometry.contentOffset.y + geometry.contentInsets.top, +@@ -1142,6 +1259,8 @@ + container: geometry.containerSize.height + ) + } action: { _, geometry in ++ guard header.inList else { return } ++ + header.scroll.scrolled(page: header.page, y: geometry.y) + + // The room this list needs to scroll the header away, past diff --git a/patches/nativephp-mobile-ui-pager-side-insets.patch b/patches/nativephp-mobile-ui-pager-side-insets.patch new file mode 100644 index 00000000..7ceeee79 --- /dev/null +++ b/patches/nativephp-mobile-ui-pager-side-insets.patch @@ -0,0 +1,565 @@ +--- a/resources/ios/NativeUIPagerRenderer.swift ++++ b/resources/ios/NativeUIPagerRenderer.swift +@@ -98,6 +98,8 @@ + /// Programmatic-jump binding only. Reading it back is useless here: + /// under `.paging` it goes nil the moment the pager moves. + @State private var position: Int? ++ /// A same-ID tap still needs an explicit scroll when geometry is off-grid. ++ @State private var tabRequest = 0 + /// Page nearest the leading edge, derived from the scroll geometry. + @State private var leading: Int + /// Pages reported to PHP that it has not echoed back yet, oldest first. +@@ -114,6 +116,10 @@ + @State private var progress: PagerProgress + /// Where the header is, and each page's list, when there's a header. + @State private var headerScroll = PagerHeaderScroll() ++ /// How a horizontal pager gets back on its page after iOS resizes it ++ /// while the app is in the background (see PagerResume). ++ @State private var resume: PagerResume ++ @Environment(\.scenePhase) private var scenePhase + + /// Loaded-but-unshipped pages this far from the current one get their + /// placeholder image; the rest stay empty. Wide enough that a fast +@@ -141,6 +147,7 @@ + _reported = State(initialValue: [requestedPage]) + _lastReported = State(initialValue: requestedPage) + _progress = State(initialValue: PagerProgress(CGFloat(requestedPage))) ++ _resume = State(initialValue: PagerResume(page: requestedPage, count: count)) + } + + var body: some View { +@@ -185,6 +192,7 @@ + // both size pages against the extended container and drift by + // the inset on every swipe.) + GeometryReader { geo in ++ ScrollViewReader { proxy in + // A horizontal pager runs under a bar at the bottom of the + // screen (a tab bar), as a screen's own list does. Its pages + // take the full height and hand the bar's inset back to their +@@ -197,6 +205,11 @@ + .scrollTargetLayout() + } + .ignoresSafeArea(.container, edges: horizontal ? .bottom : []) ++ // Beside a sideways bar (an iPhone Duo's rail) the ScrollView ++ // also draws into the bar's inset, so the next page showed ++ // under it. Clip the sides only: the pages still run under a ++ // bar at the bottom. ++ .clipShape(PagerSideClip(horizontal: horizontal)) + .scrollTargetBehavior(.paging) + .scrollPosition(id: $position) + // On a non-lazy stack SwiftUI ignores the initial value of +@@ -207,46 +220,98 @@ + .scrollIndicators(.hidden) + .scrollBounceBehavior(.basedOnSize) + .contentMargins(0) +- // `visibleRect` is inset-adjusted: its origin is 0 on page 0 +- // and its length is the viewport, whatever the bar insets are. ++ // `visibleRect` is inset-adjusted: its origin is 0 on page 0. ++ // Its width is not a page, though: beside a sideways bar it ++ // takes in the bar's inset too, so horizontally a page is the ++ // ScrollView's own width, which `.paging` steps by. + // Before the first layout the pager has no size. That is no + // page yet, not page 0: reported as page 0, PHP would think the + // user had left the start page. +- .onScrollGeometryChange(for: Int?.self) { geometry in ++ // Rounded selection and fractional progress share one sample, ++ // so recovery ownership is checked before either can escape. ++ .onScrollGeometryChange(for: PagerPosition?.self) { geometry in + let visible = geometry.visibleRect +- let length = horizontal ? visible.width : visible.height +- guard length > 0, count > 0 else { return nil } +- let offset = horizontal ? visible.minX : visible.minY +- return min(count - 1, max(0, Int((offset / length).rounded()))) +- } action: { _, page in +- guard let page else { return } ++ let length = horizontal ? geometry.bounds.width : visible.height ++ guard length > 0 else { return nil } ++ return PagerPosition(pages: (horizontal ? visible.minX : visible.minY) / length, length: length) ++ } action: { _, value in ++ if observe(value, proxy: proxy) { return } ++ guard let value else { return } ++ if resume.phase == .idle, resume.userScrolling { ++ // Idle had no size; this is its first measurable finish. ++ resume.settled() ++ reconcile(value) ++ resume.armed = horizontal && !resume.active ++ return ++ } ++ if horizontal, resume.active, !resume.armed, let target = resume.target { ++ // A timeout cannot cancel a scroll already sent to UIKit. ++ // Keep following its fresh geometry, including a late ack. ++ if value.isOn(target) { ++ resume.settled() ++ } ++ reconcile(value) ++ return ++ } ++ if resume.holds(horizontal: horizontal) { ++ // Not the user's doing: iOS sizing the app while it's in ++ // the background, or the pager being put back. Nothing ++ // follows it until the pager is back on its page. ++ resume.armed = resume.armed || !resume.active ++ if resume.active, resume.armed { ++ restore(proxy) ++ } ++ return ++ } ++ guard resume.count > 0 else { return } ++ let page = min(resume.count - 1, max(0, Int(value.pages.rounded()))) + leading = page +- // Report the moment a page becomes the nearest one, mid-swipe, +- // so PHP's round-trip overlaps the animation instead of +- // starting after it. Idle re-reports the same page (no-op). +- // With `report_on_settle` only idle reports, so the frame +- // the report causes never lands during the animation. +- if !reportOnSettle { ++ // Report mid-swipe unless PHP requested settle-only reports. ++ if !reportOnSettle, !(horizontal && resume.target != nil) { + report(page) + } +- // A header follows the page nearest the leading edge. + headerScroll.lead(page) ++ if tabs != nil { ++ progress.value = value.pages ++ } + } +- // The same position, unrounded, for the tab row. Nil until the +- // pager has a size, so the row doesn't flash the first tab. +- .onScrollGeometryChange(for: CGFloat?.self) { geometry in +- let visible = geometry.visibleRect +- let length = horizontal ? visible.width : visible.height +- guard length > 0 else { return nil } +- return (horizontal ? visible.minX : visible.minY) / length +- } action: { _, value in +- if tabs != nil, let value { +- progress.value = value ++ .onScrollPhaseChange { _, phase, context in ++ scrollPhaseChanged(phase, geometry: context.geometry, proxy: proxy) ++ } ++ .onChange(of: scenePhase, initial: true) { _, phase in ++ resume.active = phase == .active ++ // Work queued before the app left the foreground is stale. ++ resume.suspend() ++ if !resume.active { ++ // Background sizing belongs to warm-resume, not an active ++ // resize's deadline. Keep its accepted selection and hold. ++ resume.resize = nil ++ } ++ if resume.active, resume.armed, horizontal { ++ // Hold the pager until it's checked: the geometry it ++ // came back with is not a page the user chose. ++ resume.restoring = true ++ restore(proxy) + } + } +- .onScrollPhaseChange { _, phase in +- guard phase == .idle else { return } +- report(leading) ++ .onChange(of: count) { _, count in ++ resume.count = count ++ resume.accepted = min(resume.accepted, max(count - 1, 0)) ++ if count == 0 { ++ resume.settled() ++ resume.phase = .idle ++ return ++ } ++ if let target = resume.target { ++ resume.target = min(target, count - 1) ++ } ++ if let resize = resume.resize { ++ resize.page = min(resize.page, count - 1) ++ } ++ resume.suspend() ++ if resume.active, resume.armed, horizontal { ++ restore(proxy) ++ } + } + .onChange(of: requestedPage) { _, page in + // An echo of something we reported: drop it and everything +@@ -261,9 +326,230 @@ + } + reported = [page] + lastReported = page ++ resume.chosen(page) + position = page ++ if horizontal { ++ verifySelection(proxy) ++ } ++ } ++ .onChange(of: tabRequest) { _, _ in ++ verifySelection(proxy) ++ } ++ } ++ } ++ } ++ ++ /// Tracking is only a finger down. Keep the recovery until that finger ++ /// actually moves, or check it again when the touch ends without a drag. ++ private func scrollPhaseChanged(_ phase: ScrollPhase, geometry: ScrollGeometry, proxy: ScrollViewProxy) { ++ resume.phase = phase ++ if phase == .tracking { ++ resume.suspend() ++ return ++ } ++ if phase == .interacting { ++ resume.settled() ++ resume.userScrolling = true ++ return ++ } ++ guard phase == .idle else { return } ++ let finishedSwipe = resume.userScrolling ++ let visible = geometry.visibleRect ++ let length = horizontal ? geometry.bounds.width : visible.height ++ guard length > 0, resume.count > 0 else { ++ if horizontal, resume.armed, resume.target != nil { ++ resume.restoring = true ++ } ++ return ++ } ++ let final = PagerPosition(pages: (horizontal ? visible.minX : visible.minY) / length, length: length) ++ if observe(final, proxy: proxy) { return } ++ resume.userScrolling = false ++ if finishedSwipe { ++ resume.settled() ++ reconcile(final) ++ // A real swipe can finish after the scene became inactive. ++ // Accept it now; subsequent system resizes must not replace it. ++ resume.armed = horizontal && !resume.active ++ } else if horizontal, resume.armed, let target = resume.target { ++ if resume.active, final.isOn(target) { ++ resume.settled() ++ reconcile(final) ++ } else if resume.active { ++ restore(proxy) ++ } ++ } else if resume.holds(horizontal: horizontal) { ++ if resume.active, resume.armed { ++ restore(proxy) ++ } ++ } else { ++ if horizontal, let target = resume.target, final.isOn(target) { ++ resume.settled() ++ } ++ reconcile(final) ++ } ++ } ++ ++ /// Classify a sample before it can change selection, header or PHP. Width ++ /// changes belong to layout, unless a genuine swipe already owns the move. ++ /// A quiet width groups the burst; it does not forget its selected page. ++ private func observe(_ value: PagerPosition?, proxy: ScrollViewProxy) -> Bool { ++ resume.position = value ++ resume.geometryVersion += 1 ++ guard horizontal, let value else { return false } ++ let changed = resume.width.map { abs($0 - value.length) > 0.5 } ?? false ++ resume.width = value.length ++ guard resume.active, !resume.userScrolling, resume.count > 0 else { return false } ++ if changed { ++ let page = resume.resize?.page ?? (resume.armed ? resume.target : nil) ?? resume.accepted ++ // Quiet is not an acknowledgement. Churn cannot refill a live ++ // or exhausted repair; only a completed selection can start anew. ++ if resume.resize == nil || (resume.resize!.stable && !resume.resize!.checking && !resume.resize!.exhausted) { ++ let resize = PagerResize(page: page) ++ resume.resize = resize ++ } ++ let resize = resume.resize! ++ resize.version += 1 ++ resize.stable = false ++ let version = resize.version ++ resume.suspend() ++ DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(250)) { ++ guard resume.resize === resize, resize.version == version else { return } ++ resize.stable = true ++ if resume.active, resume.armed, !resize.exhausted { ++ restore(proxy) ++ } ++ } ++ } ++ guard let resize = resume.resize, !resize.exhausted else { return false } ++ if !resize.checking { ++ resize.checking = true ++ resize.timerVersion += 1 ++ let timer = resize.timerVersion ++ // Bound each outstanding repair burst, not passive selection ++ // ownership. Acknowledgement cancels this timer, not its budget. ++ DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(2)) { ++ guard resume.resize === resize, resize.timerVersion == timer, !resize.exhausted else { return } ++ resize.overdue = true ++ if resume.active, resume.phase == .idle, resume.armed { ++ failResume() ++ } + } + } ++ resume.accepted = min(resize.page, resume.count - 1) ++ resume.target = resume.accepted ++ resume.armed = true ++ resume.restoring = true ++ if resize.stable || resize.overdue, resume.phase == .idle { ++ restore(proxy) ++ } ++ return true ++ } ++ ++ /// Reconcile from one geometry sample, not separately delivered rounded ++ /// and fractional callbacks. `report` de-duplicates a page PHP knows. ++ private func reconcile(_ position: PagerPosition) { ++ guard resume.count > 0 else { return } ++ let page = min(max(Int(position.pages.rounded()), 0), resume.count - 1) ++ resume.accepted = page ++ leading = page ++ headerScroll.lead(page) ++ if tabs != nil { ++ progress.value = position.pages ++ } ++ report(page) ++ } ++ ++ /// Every explicit horizontal selection gets a check, including a binding ++ /// assignment that does not move anything. Give an ordinary animation time ++ /// to start; its idle callback owns completion if it is still running. ++ private func verifySelection(_ proxy: ScrollViewProxy) { ++ guard resume.active, resume.armed else { return } ++ if resume.phase == .idle, let position = resume.position, position.isOn(resume.accepted) { ++ resume.settled(preservingResize: true) ++ reconcile(position) ++ return ++ } ++ let generation = resume.generation ++ DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(250)) { ++ guard generation == resume.generation, resume.active, resume.armed, resume.phase == .idle else { return } ++ restore(proxy) ++ } ++ } ++ ++ /// Puts the pager back on the page it was meant to be on, once the app ++ /// is in front again: one check at a time, on the next turn of the run ++ /// loop, dropped if anything newer happened first. Each of two attempts ++ /// waits for new geometry, with a bounded timeout if layout never replies. ++ private func restore(_ proxy: ScrollViewProxy) { ++ let generation = resume.generation ++ guard resume.pending != generation else { return } ++ resume.pending = generation ++ DispatchQueue.main.async { ++ if resume.pending == generation { ++ resume.pending = nil ++ } ++ guard generation == resume.generation, resume.active, resume.armed, resume.phase == .idle else { return } ++ if let resize = resume.resize { ++ if resize.exhausted { return } ++ if resize.overdue { ++ failResume() ++ return ++ } ++ if !resize.stable { return } ++ } ++ guard resume.count > 0 else { ++ resume.settled() ++ return ++ } ++ guard let position = resume.position else { ++ // The next valid geometry sample continues this idle check. ++ resume.restoring = true ++ return ++ } ++ let page = min(max(resume.accepted, 0), resume.count - 1) ++ if position.isOn(page) { ++ resume.settled(preservingResize: true) ++ reconcile(position) ++ return ++ } ++ if let waiting = resume.waitingForGeometry, resume.geometryVersion <= waiting { ++ return ++ } ++ if (resume.resize?.attempts ?? resume.attempts) >= 2 { ++ failResume() ++ return ++ } ++ resume.restoring = true ++ resume.target = page ++ resume.attempts += 1 ++ resume.resize?.attempts += 1 ++ resume.waitingForGeometry = resume.geometryVersion ++ let attempt = resume.attempts ++ var transaction = Transaction() ++ transaction.disablesAnimations = true ++ withTransaction(transaction) { proxy.scrollTo(page, anchor: .leading) } ++ DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(250)) { ++ guard generation == resume.generation, resume.armed, resume.attempts == attempt else { return } ++ failResume() ++ } ++ } ++ } ++ ++ /// A failed correction is not a successful restore. Release the hold, ++ /// reconcile PHP and native selection with the actual nearest page. Keep ++ /// ownership of the issued scroll until it acknowledges or newer intent ++ /// supersedes it: timing out cannot cancel a native scroll already issued. ++ private func failResume() { ++ NSLog("NativeUI pager resume correction failed after \(resume.attempts) attempt(s)") ++ let target = resume.target ++ resume.resize?.exhausted = true ++ resume.settled(preservingResize: true) ++ resume.target = target ++ if let position = resume.position { ++ reconcile(position) ++ resume.resize?.page = resume.accepted ++ } + } + + /// The start page as a point of the scroll range. Every page is one +@@ -330,13 +616,23 @@ + private func selectTab(_ index: Int) { + guard index >= 0, index < count else { return } + +- if index == leading, abs(progress.value - CGFloat(index)) < 0.01 { ++ resume.chosen(index) ++ if index == leading, abs(progress.value - CGFloat(index)) < 0.01, resume.position?.isOn(index) == true { ++ resume.settled() + if let cb = tabs?.reselectCbId, cb != 0 { + NativeElementBridge.sendTabChangeEvent(cb, nodeId: nodeId, index: index) + } + return + } + ++ if horizontal, resume.active, resume.phase == .idle, let actual = resume.position, actual.isOn(index) { ++ resume.settled() ++ reconcile(actual) ++ return ++ } ++ if horizontal { ++ tabRequest += 1 ++ } + withAnimation { position = index } + } + +@@ -476,6 +772,7 @@ + row(theme) + .padding(.horizontal, tabs.edgePadding) + } ++ .clipShape(PagerSideClip(horizontal: true)) + .scrollIndicators(.hidden) + .scrollPosition($rowPosition) + .onGeometryChange(for: CGFloat.self) { $0.size.width } action: { viewportWidth = $0 } +@@ -941,3 +1238,122 @@ + } + } + } ++ ++/// Clips a horizontal pager at its sides only, leaving a page's height of ++/// room above and below, so its pages can still draw under a bar at the ++/// bottom. A vertical pager gets that much room on every side as well. ++private struct PagerSideClip: Shape { ++ let horizontal: Bool ++ ++ func path(in rect: CGRect) -> Path { ++ Path(horizontal ++ ? rect.insetBy(dx: 0, dy: -rect.height) ++ : rect.insetBy(dx: -rect.width, dy: -rect.height)) ++ } ++} ++ ++/// Where the pager is, in pages, and how long a page is. ++private struct PagerPosition: Equatable { ++ let pages: CGFloat ++ let length: CGFloat ++ ++ /// Within half a point of `page`. ++ func isOn(_ page: Int) -> Bool { ++ abs(pages - CGFloat(page)) * length <= 0.5 ++ } ++} ++ ++/// A horizontal pager's way back to its page after iOS resizes it in the ++/// background (on an iPhone Duo beside its side rail, the pager came back ++/// between two pages). A class, so none of this redraws the pages. ++private final class PagerResume { ++ /// The page the pager is meant to be on: where it last came to rest on ++ /// a page, a tapped tab, or a page PHP jumped to. ++ var accepted: Int ++ var count: Int ++ /// The latest scroll geometry; nil while the pager has no size. ++ var position: PagerPosition? ++ /// Last measurable width survives zero-size layout samples. ++ var width: CGFloat? ++ var resize: PagerResize? ++ var active = true ++ var phase: ScrollPhase = .idle ++ var userScrolling = false ++ var geometryVersion = 0 ++ var waitingForGeometry: Int? ++ /// An explicit selection or issued correction still owns its completion, ++ /// even after its retry timeout. New user/PHP intent replaces this target. ++ var target: Int? ++ /// The pager moved while the app wasn't in front, so it may be off its page. ++ var armed = false ++ /// The pager is being put back; its moves are not the user's. ++ var restoring = false ++ var attempts = 0 ++ /// Bumped by anything that makes queued work stale. ++ var generation = 0 ++ /// The generation a check is queued for. ++ var pending: Int? ++ ++ init(page: Int, count: Int) { ++ accepted = page ++ self.count = count ++ } ++ ++ /// Geometry changes aren't the user's: the app isn't in front, or the ++ /// pager is being put back, and nobody is scrolling. ++ func holds(horizontal: Bool) -> Bool { ++ horizontal && (!active || restoring) && !userScrolling ++ } ++ ++ /// A tab tap or a PHP jump: the pager goes there instead. In the ++ /// background its move is only seen once the app is in front again. ++ func chosen(_ page: Int) { ++ let holding = restoring || !active ++ settled() ++ accepted = page ++ target = page ++ armed = true ++ restoring = holding ++ } ++ ++ /// Cancel queued checks and timeouts without losing the recovery intent. ++ func suspend() { ++ generation += 1 ++ waitingForGeometry = nil ++ attempts = 0 ++ } ++ ++ /// The user scrolled: whatever was pending is theirs to decide. ++ func settled(preservingResize: Bool = false) { ++ suspend() ++ restoring = false ++ armed = false ++ userScrolling = false ++ target = nil ++ if preservingResize { ++ resize?.checking = false ++ resize?.overdue = false ++ resize?.timerVersion += 1 ++ } else { ++ resize = nil ++ } ++ } ++} ++ ++/// Selection survives an acknowledgement; command budget does not reset on ++/// alignment, tracking or duplicate geometry. A later width burst gets a new ++/// episode, while a same-width late clamp still belongs to this one. ++private final class PagerResize { ++ var page: Int ++ var stable = false ++ var exhausted = false ++ var attempts = 0 ++ var version = 0 ++ var checking = false ++ var overdue = false ++ var timerVersion = 0 ++ ++ init(page: Int) { ++ self.page = page ++ } ++} diff --git a/patches/nativephp-mobile-ui-scroll-edge-under-bars.patch b/patches/nativephp-mobile-ui-scroll-edge-under-bars.patch new file mode 100644 index 00000000..9a11671a --- /dev/null +++ b/patches/nativephp-mobile-ui-scroll-edge-under-bars.patch @@ -0,0 +1,140 @@ +--- a/resources/ios/NativeUIListRenderer.swift ++++ b/resources/ios/NativeUIListRenderer.swift +@@ -200,6 +200,7 @@ + // Only this list draws the header, not one in its rows. + .environment(\.nativeUIPagerHeader, nil) + .modifier(ListAutoScrollModifier(request: autoScrollTo, targetID: autoScrollTarget, anchor: .top)) ++ .modifier(NativeUIScrollEdgeExtension()) + } + } + +--- a/resources/ios/NativeUIPagerRenderer.swift ++++ b/resources/ios/NativeUIPagerRenderer.swift +@@ -182,6 +182,7 @@ + PagerChildren(node: besideHeader) + .frame(maxWidth: .infinity, alignment: .leading) + } ++ .modifier(NativeUIScrollEdgeExtension()) + .frame(width: split.leading) + Color.clear + .frame(width: split.gap) +@@ -197,7 +198,12 @@ + pager + .overlay(alignment: .top) { overlayView } + .modifier(PagerA11yLabelModifier(label: a11yLabel)) +- .onGeometryChange(for: CGRect.self) { $0.frame(in: .global) } action: { frame in ++ .onGeometryChange(for: CGRect.self) { proxy in ++ // A page also spans the bar insets at its sides (see `pager`). ++ let sides = horizontal ? proxy.safeAreaInsets.leading + proxy.safeAreaInsets.trailing : 0 ++ let frame = proxy.frame(in: .global) ++ return CGRect(x: frame.minX, y: frame.minY, width: frame.width + sides, height: frame.height) ++ } action: { frame in + headerScroll.pageWidth = frame.width + headerScroll.pagerMinY = frame.minY + } +@@ -238,17 +244,25 @@ + // content as safe area, so a list in a page draws under the + // bar and still scrolls its last row clear of it. Paging is + // sideways, so the extra height can't make the pages drift. +- let bottomInset = horizontal ? geo.safeAreaInsets.bottom : 0 ++ // ++ // It runs under a bar at its side (an iPhone Duo's vertical bar) ++ // the same way, so a page's list can put its scroll indicator at ++ // the screen's edge while its rows stay clear of the bar. Each ++ // page spans the insets, so `.paging` still steps by one page. ++ let insets = horizontal ++ ? EdgeInsets(top: 0, leading: geo.safeAreaInsets.leading, bottom: geo.safeAreaInsets.bottom, trailing: geo.safeAreaInsets.trailing) ++ : EdgeInsets() + ScrollView(axis) { +- pages(size: CGSize(width: geo.size.width, height: geo.size.height + bottomInset), bottomInset: bottomInset) ++ pages(size: CGSize(width: geo.size.width + insets.leading + insets.trailing, height: geo.size.height + insets.bottom), insets: insets) + .scrollTargetLayout() + } +- .ignoresSafeArea(.container, edges: horizontal ? .bottom : []) +- // Beside a sideways bar (an iPhone Duo's rail) the ScrollView +- // also draws into the bar's inset, so the next page showed +- // under it. Clip the sides only: the pages still run under a +- // bar at the bottom. ++ // A ScrollView draws past its sides, so the next page showed ++ // beside the pager. Clip the sides only, at the edges the pages ++ // run to: inside the safe-area extension, which lays this out ++ // over the side insets. The pages still run under a bar at the ++ // bottom. + .clipShape(PagerSideClip(horizontal: horizontal)) ++ .ignoresSafeArea(.container, edges: horizontal ? [.bottom, .horizontal] : []) + .scrollTargetBehavior(.paging) + .scrollPosition(id: $position) + // On a non-lazy stack SwiftUI ignores the initial value of +@@ -610,15 +624,15 @@ + /// stack expensive, so they only exist within `placeholderRadius` of + /// the current page; further slots are clear until scrolled towards. + @ViewBuilder +- private func pages(size: CGSize, bottomInset: CGFloat) -> some View { ++ private func pages(size: CGSize, insets: EdgeInsets) -> some View { + if horizontal { +- HStack(spacing: 0) { pageViews(size: size, bottomInset: bottomInset) } ++ HStack(spacing: 0) { pageViews(size: size, insets: insets) } + } else { +- VStack(spacing: 0) { pageViews(size: size, bottomInset: bottomInset) } ++ VStack(spacing: 0) { pageViews(size: size, insets: insets) } + } + } + +- private func pageViews(size: CGSize, bottomInset: CGFloat) -> some View { ++ private func pageViews(size: CGSize, insets: EdgeInsets) -> some View { + ForEach(0.. some View { ++ content ++ .safeAreaPadding(.leading, insets.leading) ++ .safeAreaPadding(.trailing, insets.trailing) ++ .ignoresSafeArea(.container, edges: .horizontal) ++ .onGeometryChange(for: EdgeInsets.self) { proxy in ++ EdgeInsets(top: 0, leading: proxy.safeAreaInsets.leading, bottom: 0, trailing: proxy.safeAreaInsets.trailing) ++ } action: { insets = $0 } + } + } +--- a/resources/ios/NativeUIVirtualListRenderer.swift ++++ b/resources/ios/NativeUIVirtualListRenderer.swift +@@ -56,6 +56,7 @@ + // Environment-propagating, so it reaches the List inside the body + // without threading another property through it. + .scrollIndicators(node.props.getBool("shows_indicators", default: true) ? .automatic : .hidden) ++ .modifier(NativeUIScrollEdgeExtension()) + } + } + diff --git a/resources/views/native/add-org-members.blade.php b/resources/views/native/add-org-members.blade.php index 384211e7..70a92749 100644 --- a/resources/views/native/add-org-members.blade.php +++ b/resources/views/native/add-org-members.blade.php @@ -14,7 +14,7 @@ @include('native.partials.people-search', ['placeholder' => 'Find people by name or @handle', 'autofocus' => true]) @include('native.partials.picked-people') - + @include('native.partials.people-results') @@ -24,7 +24,7 @@ - + Share history diff --git a/resources/views/native/commit-detail.blade.php b/resources/views/native/commit-detail.blade.php index c633a3d0..473a4b58 100644 --- a/resources/views/native/commit-detail.blade.php +++ b/resources/views/native/commit-detail.blade.php @@ -6,110 +6,112 @@ @include('native.partials.stargazers-sheet') - - @if (! $commit && $error === null) - - - - @endif - - {{-- - The commit is the main thing, near the top: larger, in full ink, with its git date and its replies behind - a toggle in its action row. The commit it replies to shows above it, and what that replies to waits - behind "↑ N earlier commits", since EDGE can't open the page scrolled down to the commit. Opened, the - ancestors, the commit and the rest of its series share one rail, so someone replying to themselves shows - their avatar once. The replies hang under it on the thread's dashed lines, with the rail ending in a dot - above them, five at a time with "Show more" under the last. A part of a series with more parts after it - has its replies right under it, beside the rail running on, and the later parts follow. Everything - around the commit is muted, and still opens its own page. A deleted reply's own replies open in place, - one level in, five at a time too, since a deleted commit has no page. - --}} - @if ($earlierCount > 0) - @include('native.partials.earlier-commits', ['count' => $earlierCount, 'lane' => $earlierLane]) - @endif + + + @if (! $commit && $error === null) + + + + @endif - @foreach ($rows as $row) - @php($isFocused = $row->commit->id === $commit?->id) - @php($hasThread = $isFocused && $repliesShown && $replies !== []) - @include('native.partials.commit-row', [ - 'commit' => $row->commit, - 'previousLane' => $row->previousLane, - 'continuesRun' => $row->continuesRun, - 'continuesNext' => $isFocused ? ! $loop->last : null, - 'threadBelow' => $hasThread, - 'focused' => $isFocused, - 'muted' => ! $isFocused, - 'seriesExpanded' => true, - 'withTail' => ! $loop->last, - ]) - @if ($isFocused && $repliesShown && $replies === [] && ! $repliesLoaded && $error === null) - - - + {{-- + The commit is the main thing, near the top: larger, in full ink, with its git date and its replies behind + a toggle in its action row. The commit it replies to shows above it, and what that replies to waits + behind "↑ N earlier commits", since EDGE can't open the page scrolled down to the commit. Opened, the + ancestors, the commit and the rest of its series share one rail, so someone replying to themselves shows + their avatar once. The replies hang under it on the thread's dashed lines, with the rail ending in a dot + above them, five at a time with "Show more" under the last. A part of a series with more parts after it + has its replies right under it, beside the rail running on, and the later parts follow. Everything + around the commit is muted, and still opens its own page. A deleted reply's own replies open in place, + one level in, five at a time too, since a deleted commit has no page. + --}} + @if ($earlierCount > 0) + @include('native.partials.earlier-commits', ['count' => $earlierCount, 'lane' => $earlierLane]) @endif - @if ($hasThread) - @php($railLane = $loop->last ? null : $commit->lane()) - @foreach ($replies as $index => $threadReply) - @php($nested = $tombstoneReplies[$threadReply->id] ?? []) - @php($nestedMore = ($tombstoneCursors[$threadReply->id] ?? null) !== null) - @php($hasNext = ! $loop->last || $repliesCursor !== null) - @php($replyRow = [ - 'reply' => $threadReply, - 'lane' => $commit->lane(), - 'previousLane' => $index === 0 ? null : $replies[$index - 1]->lane(), - 'hasNext' => $hasNext, - 'railLane' => $railLane, - 'tombstones' => true, - 'muted' => true, - 'thread' => $commit, - 'more' => $loop->last && $nested === [] && $repliesCursor !== null ? 'loadMoreReplies' : null, - 'last' => $loop->last && $nested === [], - ]) - @php($rowScope = array_diff_key(get_defined_vars(), ['__data' => 1, '__path' => 1])) - @php($replyRows->include('reply-'.$threadReply->id, \App\Devnull\RowInputs::for($threadReply, $rowScope), 'native.partials.thread-reply', $replyRow, $rowScope)) - @foreach ($nested as $nestedIndex => $nestedReply) - @php($nestedRow = [ - 'reply' => $nestedReply, - 'lane' => \App\Devnull\Lane::Rail, - 'previousLane' => $nestedIndex === 0 ? null : $nested[$nestedIndex - 1]->lane(), - 'hasNext' => ! $loop->last || $nestedMore, + + @foreach ($rows as $row) + @php($isFocused = $row->commit->id === $commit?->id) + @php($hasThread = $isFocused && $repliesShown && $replies !== []) + @include('native.partials.commit-row', [ + 'commit' => $row->commit, + 'previousLane' => $row->previousLane, + 'continuesRun' => $row->continuesRun, + 'continuesNext' => $isFocused ? ! $loop->last : null, + 'threadBelow' => $hasThread, + 'focused' => $isFocused, + 'muted' => ! $isFocused, + 'seriesExpanded' => true, + 'withTail' => ! $loop->last, + ]) + @if ($isFocused && $repliesShown && $replies === [] && ! $repliesLoaded && $error === null) + + + + @endif + @if ($hasThread) + @php($railLane = $loop->last ? null : $commit->lane()) + @foreach ($replies as $index => $threadReply) + @php($nested = $tombstoneReplies[$threadReply->id] ?? []) + @php($nestedMore = ($tombstoneCursors[$threadReply->id] ?? null) !== null) + @php($hasNext = ! $loop->last || $repliesCursor !== null) + @php($replyRow = [ + 'reply' => $threadReply, + 'lane' => $commit->lane(), + 'previousLane' => $index === 0 ? null : $replies[$index - 1]->lane(), + 'hasNext' => $hasNext, 'railLane' => $railLane, - 'indent' => 32, - 'spine' => $hasNext ? $threadReply->lane() : null, + 'tombstones' => true, 'muted' => true, - 'thread' => $nestedMore ? $threadReply : $commit, - 'more' => match (true) { - ! $loop->last => null, - $nestedMore => "loadMoreTombstoneReplies('{$threadReply->id}')", - $loop->parent->last && $repliesCursor !== null => 'loadMoreReplies', - default => null, - }, - 'last' => $loop->last && $loop->parent->last, + 'thread' => $commit, + 'more' => $loop->last && $nested === [] && $repliesCursor !== null ? 'loadMoreReplies' : null, + 'last' => $loop->last && $nested === [], ]) @php($rowScope = array_diff_key(get_defined_vars(), ['__data' => 1, '__path' => 1])) - @php($replyRows->include('reply-'.$nestedReply->id, \App\Devnull\RowInputs::for($nestedReply, $rowScope), 'native.partials.thread-reply', $nestedRow, $rowScope)) + @php($replyRows->include('reply-'.$threadReply->id, \App\Devnull\RowInputs::for($threadReply, $rowScope), 'native.partials.thread-reply', $replyRow, $rowScope)) + @foreach ($nested as $nestedIndex => $nestedReply) + @php($nestedRow = [ + 'reply' => $nestedReply, + 'lane' => \App\Devnull\Lane::Rail, + 'previousLane' => $nestedIndex === 0 ? null : $nested[$nestedIndex - 1]->lane(), + 'hasNext' => ! $loop->last || $nestedMore, + 'railLane' => $railLane, + 'indent' => 32, + 'spine' => $hasNext ? $threadReply->lane() : null, + 'muted' => true, + 'thread' => $nestedMore ? $threadReply : $commit, + 'more' => match (true) { + ! $loop->last => null, + $nestedMore => "loadMoreTombstoneReplies('{$threadReply->id}')", + $loop->parent->last && $repliesCursor !== null => 'loadMoreReplies', + default => null, + }, + 'last' => $loop->last && $loop->parent->last, + ]) + @php($rowScope = array_diff_key(get_defined_vars(), ['__data' => 1, '__path' => 1])) + @php($replyRows->include('reply-'.$nestedReply->id, \App\Devnull\RowInputs::for($nestedReply, $rowScope), 'native.partials.thread-reply', $nestedRow, $rowScope)) + @endforeach @endforeach - @endforeach - @endif - @endforeach + @endif + @endforeach - @if ($commit && $replyCount === 0 && $repliesLoaded && $series === []) - - No replies yet. - - @endif + @if ($commit && $replyCount === 0 && $repliesLoaded && $series === []) + + No replies yet. + + @endif - @if ($commit && $related !== []) - @include('native.partials.related-commits') - @endif + @if ($commit && $related !== []) + @include('native.partials.related-commits') + @endif - {{-- - A pushed screen runs under its bottom bar, so the page clears the reply bar itself (see - Display::replyBarClearance): the last reply and its actions end above the bar, keyboard up or down. - --}} - @if ($commit) - - @endif + {{-- + A pushed screen clears its overlaid reply bar itself (see Display::replyBarClearance). + The embedded workspace reserves the actual bar height natively, so it only needs the final gap. + --}} + @if ($commit) + + @endif + @@ -125,51 +127,57 @@ the field on Android. --}} - + {{-- - The people to mention keep a slot above the field, empty and no height when there's no one, so the - field's row never moves. Android rebuilds a row that moves among its siblings, field and all, which - drops the keyboard. - --}} - @php($mentioning = ($mention['field'] ?? null) === 'reply' && ($mentionPeople !== [] || $mentionOrganizations !== [])) - - @include('native.partials.mention-suggestions', ['field' => 'reply', 'label' => 'Mention a developer']) - - - @include('native.partials.composer-plate', [ - 'ref' => 'reply', - 'field' => 'native.partials.reply-field', - 'tools' => [['ref' => 'reply-expand', 'icon' => \App\Icons\Lucide::Maximize2, 'label' => 'Reply with photos or a preview', 'press' => "replyInSheet('{$commit->id}')"]], - ]) - @include('native.partials.body-counter', ['body' => $reply, 'ref' => 'reply-budget']) - 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. - Photos attached in the sheet show as a count on the left, which opens the sheet on them, and Reply - sends them. + The readable width goes on a child: Android seats the bar's root at the leading edge of a + full-width box, so the root stays full width and centres this. --}} - @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 + + {{-- + The people to mention keep a slot above the field, empty and no height when there's no one, so the + field's row never moves. Android rebuilds a row that moves among its siblings, field and all, which + drops the keyboard. + --}} + @php($mentioning = ($mention['field'] ?? null) === 'reply' && ($mentionPeople !== [] || $mentionOrganizations !== [])) + + @include('native.partials.mention-suggestions', ['field' => 'reply', 'label' => 'Mention a developer']) - @endif + + @include('native.partials.composer-plate', [ + 'ref' => 'reply', + 'field' => 'native.partials.reply-field', + 'tools' => [['ref' => 'reply-expand', 'icon' => \App\Icons\Lucide::Maximize2, 'label' => 'Reply with photos or a preview', 'press' => "replyInSheet('{$commit->id}')"]], + ]) + @include('native.partials.body-counter', ['body' => $reply, 'ref' => 'reply-budget']) + 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. + 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 + @endif diff --git a/resources/views/native/conversation.blade.php b/resources/views/native/conversation.blade.php index c01b14b7..9d466716 100644 --- a/resources/views/native/conversation.blade.php +++ b/resources/views/native/conversation.blade.php @@ -81,7 +81,7 @@ --}} - + @if ($canPush) {{-- The members to mention, what you're replying to and the photos on the draft each keep a slot, diff --git a/resources/views/native/feed-workspace.blade.php b/resources/views/native/feed-workspace.blade.php new file mode 100644 index 00000000..d6710972 --- /dev/null +++ b/resources/views/native/feed-workspace.blade.php @@ -0,0 +1,15 @@ + + + + + + + @include('native.partials.empty', ['ref' => 'no-conversation', 'command' => 'no commit selected', 'message' => 'Choose a commit in the log to read its conversation here.']) + + + @foreach ($conversations as $conversation) + + + + @endforeach + diff --git a/resources/views/native/new-org.blade.php b/resources/views/native/new-org.blade.php index 44fc6974..351dd3b6 100644 --- a/resources/views/native/new-org.blade.php +++ b/resources/views/native/new-org.blade.php @@ -24,7 +24,7 @@ class="w-full text-theme-ink" @include('native.partials.people-search', ['placeholder' => 'Find people by name or @handle', 'autofocus' => true]) @include('native.partials.picked-people') - + @include('native.partials.people-results') @@ -34,7 +34,7 @@ class="w-full text-theme-ink" - + {{ $pickError ?? '' }} diff --git a/resources/views/native/new-remote.blade.php b/resources/views/native/new-remote.blade.php index b3c96031..2e41cb33 100644 --- a/resources/views/native/new-remote.blade.php +++ b/resources/views/native/new-remote.blade.php @@ -8,7 +8,7 @@ @include('native.partials.people-search', ['placeholder' => 'Message a developer', 'autofocus' => true]) - + @if ($term === '') diff --git a/resources/views/native/notifications.blade.php b/resources/views/native/notifications.blade.php index 512cbd34..9789442e 100644 --- a/resources/views/native/notifications.blade.php +++ b/resources/views/native/notifications.blade.php @@ -1,4 +1,4 @@ - + @include('native.partials.error') @include('native.partials.refresh-failed') @include('native.partials.push-prompt') @@ -16,7 +16,7 @@ ref="notifications-pager" horizontal report-on-settle - class="w-full flex-1 bg-theme-surface" + class="w-full flex-1 bg-theme-surface {{ \App\Devnull\Display::ReadableColumn }}" :count="count($types)" :page="$pageIndex" :from="$pagesFrom" diff --git a/resources/views/native/onboarding.blade.php b/resources/views/native/onboarding.blade.php index 563a8b8f..f88f8ebf 100644 --- a/resources/views/native/onboarding.blade.php +++ b/resources/views/native/onboarding.blade.php @@ -1,13 +1,23 @@ @use('App\Icons\Ios') @use('App\Icons\Android') - - {{-- The lockup, two thirds of the screen wide at its own 15:4, centred in the space above the buttons. --}} - - +{{-- + On a phone, the lockup over the buttons. Once the window is expanded (a foldable's inner display held sideways) the + lockup and the buttons sit side by side as two equal halves, with the gap between them in the middle of the + window. +--}} + + {{-- + The lockup, two thirds wide at its own 15:4, centred in its space. Side by side it has to fit its half + less the buttons' 24pt margin on each side. iOS resolves a percentage against the window, not the parent, and a + variant can't swap a width for `w-full`, so there it's 40% of the window, which fits that room at every expanded + width. Android resolves it against the parent, which is already the half, so it keeps two thirds. + --}} + + - + @if ($error) {{ $error }} @endif diff --git a/resources/views/native/org-members.blade.php b/resources/views/native/org-members.blade.php index 7f8cbf4b..43291dcd 100644 --- a/resources/views/native/org-members.blade.php +++ b/resources/views/native/org-members.blade.php @@ -45,7 +45,7 @@ class="w-full text-theme-ink" @endif @foreach ($rows as $row) @if ($isMaintainer || ! $loop->first) - + @endif @include('native.partials.org-member-row', $row + ['showsSlot' => $isMaintainer]) @endforeach @@ -58,7 +58,7 @@ class="w-full text-theme-ink" @foreach (range(1, 3) as $skeleton) @if (! $loop->first) - + @endif diff --git a/resources/views/native/partials/achievements-sheet.blade.php b/resources/views/native/partials/achievements-sheet.blade.php index 76e0f646..9d7ae925 100644 --- a/resources/views/native/partials/achievements-sheet.blade.php +++ b/resources/views/native/partials/achievements-sheet.blade.php @@ -49,7 +49,7 @@ @endforeach - + @if ($achievementsTab === 'badges') @@ -112,7 +112,7 @@ @endif - + XP: 10/commit · 15/day streak · 5/star received · 5/reply · 2/star given diff --git a/resources/views/native/partials/achievements.blade.php b/resources/views/native/partials/achievements.blade.php index 6afc70b1..24db5c91 100644 --- a/resources/views/native/partials/achievements.blade.php +++ b/resources/views/native/partials/achievements.blade.php @@ -49,4 +49,4 @@ @endif - + diff --git a/resources/views/native/partials/activity.blade.php b/resources/views/native/partials/activity.blade.php index 79553704..f1ff0dcc 100644 --- a/resources/views/native/partials/activity.blade.php +++ b/resources/views/native/partials/activity.blade.php @@ -50,4 +50,4 @@ More - + diff --git a/resources/views/native/partials/commit-row.blade.php b/resources/views/native/partials/commit-row.blade.php index 209350f2..a5782409 100644 --- a/resources/views/native/partials/commit-row.blade.php +++ b/resources/views/native/partials/commit-row.blade.php @@ -3,6 +3,8 @@ The author's avatar is the node on the rail. A commit carrying on its author's run ($continuesRun) gets a lane dot instead. The segment above the node takes the previous author's lane, the one below takes this author's. $focused draws the commit a page is about: a larger body, its git date, and no link to itself. + In the iOS workspace the row of the conversation open from the log ($selectedCommit) takes the wash; a selected + thread reply's row does the same (partials/thread-reply). Rows in a list carry no press-* feedback. EDGE draws it with a zero-distance DragGesture (NodePressFeedbackModifier in nativephp/mobile), and on iOS that gesture keeps the list from scrolling when a drag starts on the row. @@ -17,7 +19,7 @@ @include('native.partials.commit-row-content') @else - + @include('native.partials.commit-row-content') @endif diff --git a/resources/views/native/partials/commit-sheet.blade.php b/resources/views/native/partials/commit-sheet.blade.php index 97a5b0b1..2222c980 100644 --- a/resources/views/native/partials/commit-sheet.blade.php +++ b/resources/views/native/partials/commit-sheet.blade.php @@ -63,7 +63,7 @@ @include('native.partials.sheet-bar-button', ['ref' => 'sheet-commit', 'icon' => \App\Icons\Lucide::Check, 'label' => match (true) { $schedulingThis => 'Schedule commit', $replying => 'Reply', default => 'Commit' }, 'main' => true, 'loading' => $sendingSchedule, 'press' => $sendingSchedule || ! $canCommit || ($schedulingThis && ! $hasScheduledTime) ? null : 'commitFromSheet']) @endif - + @if ($sheetError) @@ -130,7 +130,7 @@ class="w-full text-theme-ink" {{-- Pinned under the scroll view, so it rides just above the keyboard, and above the home indicator without it. --}} - + @unless ($issueCommand) @include('native.partials.composer-tools') @@ -151,9 +151,9 @@ class="w-full text-theme-ink" - + {{ $sheetCommit->plainText() }} - + diff --git a/resources/views/native/partials/commit-tombstone.blade.php b/resources/views/native/partials/commit-tombstone.blade.php index 8d6e0929..47dd9a4d 100644 --- a/resources/views/native/partials/commit-tombstone.blade.php +++ b/resources/views/native/partials/commit-tombstone.blade.php @@ -15,7 +15,7 @@ @include('native.partials.series-position') @endif - + @include('native.partials.rail', ['previous' => $previousLane, 'lane' => \App\Devnull\Lane::Rail, 'tail' => $withTail ? \App\Devnull\Lane::Rail : null, 'node' => 'tombstone']) diff --git a/resources/views/native/partials/connection-loading.blade.php b/resources/views/native/partials/connection-loading.blade.php index 2d96c6e5..d5badc09 100644 --- a/resources/views/native/partials/connection-loading.blade.php +++ b/resources/views/native/partials/connection-loading.blade.php @@ -11,5 +11,5 @@ - + diff --git a/resources/views/native/partials/connection.blade.php b/resources/views/native/partials/connection.blade.php index a6307f22..faa96792 100644 --- a/resources/views/native/partials/connection.blade.php +++ b/resources/views/native/partials/connection.blade.php @@ -38,5 +38,5 @@ @endif - + diff --git a/resources/views/native/partials/day-marker.blade.php b/resources/views/native/partials/day-marker.blade.php index ace93ea0..391d797e 100644 --- a/resources/views/native/partials/day-marker.blade.php +++ b/resources/views/native/partials/day-marker.blade.php @@ -5,7 +5,7 @@ {{ $label }} - + @include('native.partials.rail', ['previous' => $lane]) diff --git a/resources/views/native/partials/earlier-commits.blade.php b/resources/views/native/partials/earlier-commits.blade.php index a33b471a..ca5a8183 100644 --- a/resources/views/native/partials/earlier-commits.blade.php +++ b/resources/views/native/partials/earlier-commits.blade.php @@ -16,7 +16,7 @@ {{ $label }} - + @include('native.partials.dashed-line', ['position' => 'top-0 h-[13]', 'left' => 31.5, 'lane' => \App\Devnull\Lane::Rail]) diff --git a/resources/views/native/partials/empty.blade.php b/resources/views/native/partials/empty.blade.php index afa3478f..29b69915 100644 --- a/resources/views/native/partials/empty.blade.php +++ b/resources/views/native/partials/empty.blade.php @@ -1,5 +1,5 @@ {{-- Empty states speak in git's voice: a mono line, then a plain sentence or two, and maybe a link on its own line. --}} - + {{ $command }} {{ $message }} @isset($link) diff --git a/resources/views/native/partials/following-empty.blade.php b/resources/views/native/partials/following-empty.blade.php index 286befff..fae6421c 100644 --- a/resources/views/native/partials/following-empty.blade.php +++ b/resources/views/native/partials/following-empty.blade.php @@ -30,7 +30,7 @@ Follow @unless ($loop->last) - + @endunless @endforeach diff --git a/resources/views/native/partials/last-visit-marker.blade.php b/resources/views/native/partials/last-visit-marker.blade.php index a5290c8e..6426dca7 100644 --- a/resources/views/native/partials/last-visit-marker.blade.php +++ b/resources/views/native/partials/last-visit-marker.blade.php @@ -9,11 +9,11 @@ HEAD@{last visit} @foreach (range(1, 24) as $dash) - + @endforeach - + @include('native.partials.rail', ['previous' => $lane]) diff --git a/resources/views/native/partials/live-stream-sheet.blade.php b/resources/views/native/partials/live-stream-sheet.blade.php index d3dd87c2..f886ef25 100644 --- a/resources/views/native/partials/live-stream-sheet.blade.php +++ b/resources/views/native/partials/live-stream-sheet.blade.php @@ -25,7 +25,7 @@ Watch now @if ($liveStreamWithProfile) - + @include('native.partials.rail-avatar', ['user' => $streamer, 'lane' => $streamer->lane(), 'size' => 48]) diff --git a/resources/views/native/partials/log-page.blade.php b/resources/views/native/partials/log-page.blade.php index ebee83bc..1e2073d9 100644 --- a/resources/views/native/partials/log-page.blade.php +++ b/resources/views/native/partials/log-page.blade.php @@ -12,11 +12,11 @@ Every page keeps pull to refresh, which can only be pulled on the log you're on anyway: iOS builds a new list when a list gains or loses it, and the new one starts at the top. --}} - + - + {{-- The Rail Gray rail runs the whole inbox, through each node. --}} diff --git a/resources/views/native/partials/organization-header.blade.php b/resources/views/native/partials/organization-header.blade.php index 295a56b4..f303a8d1 100644 --- a/resources/views/native/partials/organization-header.blade.php +++ b/resources/views/native/partials/organization-header.blade.php @@ -86,5 +86,5 @@ @endif - + diff --git a/resources/views/native/partials/pinned-bar.blade.php b/resources/views/native/partials/pinned-bar.blade.php index bb9c3c8a..b6700889 100644 --- a/resources/views/native/partials/pinned-bar.blade.php +++ b/resources/views/native/partials/pinned-bar.blade.php @@ -14,6 +14,6 @@ - + diff --git a/resources/views/native/partials/pinned-sheet.blade.php b/resources/views/native/partials/pinned-sheet.blade.php index 35e9fa1f..0ceb222b 100644 --- a/resources/views/native/partials/pinned-sheet.blade.php +++ b/resources/views/native/partials/pinned-sheet.blade.php @@ -12,7 +12,7 @@ - + @foreach ($pinned as $message) @include('native.partials.pinned-message', ['message' => $message, 'key' => 'pin-'.$message->id, 'first' => $loop->first]) diff --git a/resources/views/native/partials/poll-editor.blade.php b/resources/views/native/partials/poll-editor.blade.php index 1e1f1807..f07ffacb 100644 --- a/resources/views/native/partials/poll-editor.blade.php +++ b/resources/views/native/partials/poll-editor.blade.php @@ -36,7 +36,7 @@ class="flex-1" @endif - + diff --git a/resources/views/native/partials/profile-header.blade.php b/resources/views/native/partials/profile-header.blade.php index 78c88ffc..f73fc2ae 100644 --- a/resources/views/native/partials/profile-header.blade.php +++ b/resources/views/native/partials/profile-header.blade.php @@ -131,7 +131,7 @@ @endif - + @if (! $sketched) @include('native.partials.activity', ['activity' => $profile->activity, 'lane' => $user->lane()]) diff --git a/resources/views/native/partials/push-prompt.blade.php b/resources/views/native/partials/push-prompt.blade.php index 317d40f4..f89c0427 100644 --- a/resources/views/native/partials/push-prompt.blade.php +++ b/resources/views/native/partials/push-prompt.blade.php @@ -27,5 +27,5 @@ - + @endif diff --git a/resources/views/native/partials/rail-loading.blade.php b/resources/views/native/partials/rail-loading.blade.php index f2807820..90f1f96d 100644 --- a/resources/views/native/partials/rail-loading.blade.php +++ b/resources/views/native/partials/rail-loading.blade.php @@ -17,7 +17,7 @@ - + @include('native.partials.rail', ['tail' => \App\Devnull\Lane::Rail, 'endsWithDot' => true, 'lane' => \App\Devnull\Lane::Rail, 'node' => 'skeleton']) diff --git a/resources/views/native/partials/rail.blade.php b/resources/views/native/partials/rail.blade.php index f64abf05..770c2c99 100644 --- a/resources/views/native/partials/rail.blade.php +++ b/resources/views/native/partials/rail.blade.php @@ -70,7 +70,8 @@ @php($nodeSize = $node === 'avatar' ? $avatarSize : 48) @php($nodeAt = 'absolute top-['.($center - intdiv($nodeSize, 2)).'] left-['.(32 - intdiv($nodeSize, 2)).']') @if ($node === 'avatar' && $press) - name} is live" : "{$user->name}'s profile" }}"> + @php($avatarLabel = $user->isLive() ? "{$user->name} is live" : "{$user->name}'s profile") + @include('native.partials.rail-avatar', ['user' => $user, 'lane' => $lane, 'size' => $avatarSize]) @elseif ($node === 'avatar' || $node === 'staged') diff --git a/resources/views/native/partials/refresh-failed.blade.php b/resources/views/native/partials/refresh-failed.blade.php index 8dbf3cd7..310300cf 100644 --- a/resources/views/native/partials/refresh-failed.blade.php +++ b/resources/views/native/partials/refresh-failed.blade.php @@ -8,6 +8,6 @@ warning: couldn't refresh Retry - + @endif diff --git a/resources/views/native/partials/remote-row-loading.blade.php b/resources/views/native/partials/remote-row-loading.blade.php index 427e51dd..d8ba43f1 100644 --- a/resources/views/native/partials/remote-row-loading.blade.php +++ b/resources/views/native/partials/remote-row-loading.blade.php @@ -8,6 +8,6 @@ - + diff --git a/resources/views/native/partials/remote-row.blade.php b/resources/views/native/partials/remote-row.blade.php index 9c67f07e..bbbdf04e 100644 --- a/resources/views/native/partials/remote-row.blade.php +++ b/resources/views/native/partials/remote-row.blade.php @@ -80,6 +80,6 @@ class="w-full" - + diff --git a/resources/views/native/partials/remotes-page.blade.php b/resources/views/native/partials/remotes-page.blade.php index 7f65fd99..4a67b6a2 100644 --- a/resources/views/native/partials/remotes-page.blade.php +++ b/resources/views/native/partials/remotes-page.blade.php @@ -22,7 +22,7 @@ class="w-full flex-1 bg-theme-surface" @include('native.partials.empty', match ($page['tab']) { 'stashed' => ['command' => 'nothing stashed', 'message' => 'Stash a conversation from its menu. It pops back into Open when someone writes.'], 'fetched' => ['command' => 'no requests fetched', 'message' => 'First messages from people you don’t follow wait here until you merge or drop them.'], - default => ['command' => 'no conversations yet', 'message' => 'Start one with New remote, or from a profile with Remote.'], + default => ['command' => 'no conversations yet', 'message' => 'Start one with New remote, the pencil button, or from a profile with Remote.'], }) @endif diff --git a/resources/views/native/partials/sheet-bar-button.blade.php b/resources/views/native/partials/sheet-bar-button.blade.php index a554285b..2d645dfc 100644 --- a/resources/views/native/partials/sheet-bar-button.blade.php +++ b/resources/views/native/partials/sheet-bar-button.blade.php @@ -13,12 +13,13 @@ @php($iconColour = $filled ? 'text-theme-on-'.$myColour : ($press === null ? 'text-theme-ink-muted' : 'text-theme-ink')) @if ($press !== null) - + @else - + @php($mutedLabel = $loading ? $label : $label.', unavailable') + @if ($loading) @else diff --git a/resources/views/native/partials/streaks.blade.php b/resources/views/native/partials/streaks.blade.php index dfb1342a..9a038c69 100644 --- a/resources/views/native/partials/streaks.blade.php +++ b/resources/views/native/partials/streaks.blade.php @@ -10,5 +10,5 @@ @endif - + @endif diff --git a/resources/views/native/partials/thread-reply.blade.php b/resources/views/native/partials/thread-reply.blade.php index 6a1d0a7c..df577d77 100644 --- a/resources/views/native/partials/thread-reply.blade.php +++ b/resources/views/native/partials/thread-reply.blade.php @@ -53,7 +53,7 @@ @include('native.partials.thread-more', ['commit' => $thread ?? $reply]) @endif @if ($last && ! $muted) - + @endif @include('native.partials.thread-links') @@ -64,7 +64,7 @@ @else @php($commit = $reply) - + @@ -92,7 +92,7 @@ @include('native.partials.thread-more', ['commit' => $thread ?? $reply]) @endif @if ($last && ! $muted) - + @endif @include('native.partials.thread-links') diff --git a/resources/views/native/partials/timezone-sheet.blade.php b/resources/views/native/partials/timezone-sheet.blade.php index f4769719..5f81ec1e 100644 --- a/resources/views/native/partials/timezone-sheet.blade.php +++ b/resources/views/native/partials/timezone-sheet.blade.php @@ -15,7 +15,7 @@ @forelse ($timezones['zones'] as $zone) @if (! $loop->first) - + @endif diff --git a/resources/views/native/partials/who-to-follow.blade.php b/resources/views/native/partials/who-to-follow.blade.php index 0482fed4..4f81df78 100644 --- a/resources/views/native/partials/who-to-follow.blade.php +++ b/resources/views/native/partials/who-to-follow.blade.php @@ -83,12 +83,12 @@ @unless ($loop->last) - + @endunless @endforeach @endif - + diff --git a/resources/views/native/profile.blade.php b/resources/views/native/profile.blade.php index efd5934b..c6713850 100644 --- a/resources/views/native/profile.blade.php +++ b/resources/views/native/profile.blade.php @@ -10,7 +10,7 @@ The pager or the list stays alone in here, so the banners above coming and going never add a node beside it. Keyed so nothing above it, like the image viewer opening, gives it a new id and the pager a new list at the top. --}} - + @if ($profile && ! $unavailable) @php($user = $profile->user) {{-- @@ -19,6 +19,11 @@ so it scrolls up with it until the tabs pin under the nav bar. It stays where it is while you swipe, and the pages keep level with it. + Once the window is expanded (a foldable's inner display) the pager on iOS draws the header beside the pages + instead: who they are, their activity and their level scroll by themselves in the leading column, and the tabs + and their commits fill the trailing one. The columns meet at the fold, or halve the width without one. Android + keeps it above the tabs. The header is sent once either way, so every control is on screen once. + The pill hangs under the tabs wherever they are. Keyed so the banners above coming and going don't make it a new pager, with new lists. --}} @@ -27,6 +32,7 @@ ref="profile-pager" horizontal report-on-settle + header-beside="expanded" class="w-full flex-1 bg-theme-surface" :count="count($pages)" :page="$pageIndex" diff --git a/resources/views/native/remotes.blade.php b/resources/views/native/remotes.blade.php index d7f7dff2..f09e0630 100644 --- a/resources/views/native/remotes.blade.php +++ b/resources/views/native/remotes.blade.php @@ -4,7 +4,7 @@ in a full-width split, the underline in your lane follows your finger, and a tap on the tab you're on takes it back to its top. While you have Remotes off, the tab shows the web's page for /remotes then, its 404, word for word. --}} - + @if ($off) fatal: path '/remotes' does not exist in 'HEAD' @@ -22,7 +22,7 @@ ref="remotes-pager" horizontal report-on-settle - class="w-full flex-1 bg-theme-surface" + class="w-full flex-1 bg-theme-surface {{ \App\Devnull\Display::ReadableColumn }}" :count="count($tabLabels)" :page="$pageIndex" :from="$pagesFrom" diff --git a/resources/views/native/safety.blade.php b/resources/views/native/safety.blade.php index 9080687e..56943fbf 100644 --- a/resources/views/native/safety.blade.php +++ b/resources/views/native/safety.blade.php @@ -18,7 +18,7 @@ @foreach (range(1, 2) as $skeleton) @if (! $loop->first) - + @endif @@ -39,7 +39,7 @@ @foreach ($people[$key] as $person) @if (! $loop->first) - + @endif diff --git a/resources/views/native/scheduled-commits.blade.php b/resources/views/native/scheduled-commits.blade.php index 06f07ac1..64fa03c7 100644 --- a/resources/views/native/scheduled-commits.blade.php +++ b/resources/views/native/scheduled-commits.blade.php @@ -21,7 +21,7 @@ Times in {{ $timezone }} - + @if (! $loaded) @if ($error === null) @@ -31,7 +31,7 @@ - + @endforeach @endif @elseif ($scheduled === []) @@ -81,7 +81,7 @@ {{ $item->isDue($now) ? 'Waiting for the scheduler. If submission fails, it is retried automatically.' : 'Private until submitted. You can commit it now or cancel it before submission.' }} @endif - + @endforeach @if ($nextPage !== null) diff --git a/resources/views/native/search.blade.php b/resources/views/native/search.blade.php index 29f914c8..663f1740 100644 --- a/resources/views/native/search.blade.php +++ b/resources/views/native/search.blade.php @@ -3,18 +3,18 @@ developers whose name or handle matches, then the commits nearest in meaning, on a rail of their own as the web's results page draws them. A term with nothing near it answers in git's voice. --}} - + @include('native.partials.error') @include('native.partials.refresh-failed') @include('native.partials.commit-sheet') @include('native.partials.live-stream-sheet') - + @include('native.partials.people-search', ['placeholder' => 'Search developers or commits', 'autofocus' => $autofocus, 'a11yLabel' => 'Search developers or commits']) - + - + @if ($term === '') diff --git a/resources/views/native/starred.blade.php b/resources/views/native/starred.blade.php index 61117acc..20ce9d17 100644 --- a/resources/views/native/starred.blade.php +++ b/resources/views/native/starred.blade.php @@ -18,7 +18,7 @@ Commits you starred, newest first. - + @if (! $loaded && $error === null) diff --git a/resources/views/native/this-device.blade.php b/resources/views/native/this-device.blade.php index 5f228385..f71a111b 100644 --- a/resources/views/native/this-device.blade.php +++ b/resources/views/native/this-device.blade.php @@ -33,7 +33,7 @@ @foreach ($fields as $field) @if (! $loop->first) - + @endif {{ $field['label'] }} @@ -134,7 +134,7 @@ @endif @if ($phoneTimezone !== null) - + this phone {{ \App\Devnull\Timezone::offset($phoneTimezone) }} {{ $phoneTimezone }} @@ -331,7 +331,7 @@ {{ $fingerprint ?: 'null' }} @if ($remote !== null) - + remote {{ $remote }} diff --git a/routes/mobile.php b/routes/mobile.php index 764b9a27..d032c561 100644 --- a/routes/mobile.php +++ b/routes/mobile.php @@ -8,6 +8,7 @@ use App\NativeComponents\CommitDetail; use App\NativeComponents\Connections; use App\NativeComponents\Conversation; +use App\NativeComponents\FeedWorkspace; use App\NativeComponents\Launch; use App\NativeComponents\Layouts\TabsLayout; use App\NativeComponents\Log; @@ -26,6 +27,7 @@ use App\NativeComponents\Starred; use App\NativeComponents\ThisDevice; use Illuminate\Support\Facades\Route; +use Native\Mobile\Facades\System; Route::native('/', Launch::class); Route::native('/onboarding', Onboarding::class); @@ -44,7 +46,7 @@ } Route::nativeGroup(TabsLayout::class, function (): void { - Route::native('/log', Log::class); + Route::native('/log', System::isIos() ? FeedWorkspace::class : Log::class); Route::native('/notifications', Notifications::class); Route::native('/remotes', Remotes::class); Route::native('/remotes/new', NewRemote::class); diff --git a/tests/Feature/Screens/CommitDetailTest.php b/tests/Feature/Screens/CommitDetailTest.php index fc0da284..a92bdf27 100644 --- a/tests/Feature/Screens/CommitDetailTest.php +++ b/tests/Feature/Screens/CommitDetailTest.php @@ -1,10 +1,13 @@ and(nodeByRef($screen, 'reply-plate')['props']['glass'] ?? null)->toBe(1); }); +it("keeps the reply bar's root full width and centres a readable column in it, for Android's full-width bar box", function (string $platform) { + fakeThread(); + + $screen = Native::visit('/log/commits/c2', platform: $platform); + $bar = nodeByRef($screen, 'reply-bar'); + $content = nodeByRef($screen, 'reply-content'); + $variant = json_decode($content['props']['_variants'], true)[0]; + + expect($bar['props'])->not->toHaveKey('_variants') + ->and($bar['layout'])->toMatchArray(['width' => 'fill', 'align_items' => AlignItems::Center->value]) + ->and($variant)->toMatchArray(['min' => TailwindParser::breakpointMinWidth('expanded'), 'layout' => TailwindParser::parse(Display::ReadableColumn)['variants']['expanded']]) + ->and(childRefs($screen, 'reply-bar'))->toBe([null, 'reply-content']) + ->and(childRefs($screen, 'reply-content'))->toContain('reply-mention-slot', 'reply-row'); +})->with(['ios', 'android']); + it('ends the page above the reply bar, and further as the draft grows', function () { fakeThread(replies: [replyJson(['id' => 'r1', 'parent_id' => 'c2', 'body' => 'The last reply'])]); $screen = Native::visit('/log/commits/c2', platform: 'ios'); - $scroller = findNode($screen->tree(), fn (array $node): bool => ($node['type'] ?? null) === 'scroll_view'); - $page = $scroller['children'][0]['children']; + $page = childRefs($screen, 'thread-page'); // The clearance is the last thing on the page, under the last reply: the bar's line and the home indicator. - expect(end($page)['ref'] ?? null)->toBe('reply-bar-clearance') + expect(end($page))->toBe('reply-bar-clearance') ->and(nodeByRef($screen, 'reply-bar-clearance')['layout']['height'])->toEqual(24 + 53 + 34); $screen->input('reply-input', "Three lines\nof a\ndraft"); diff --git a/tests/Feature/Screens/ComposeSheetTest.php b/tests/Feature/Screens/ComposeSheetTest.php index d0f319ee..7158888c 100644 --- a/tests/Feature/Screens/ComposeSheetTest.php +++ b/tests/Feature/Screens/ComposeSheetTest.php @@ -291,3 +291,21 @@ function sheetCommitDisabled(TestableComponent $screen): bool Http::assertNotSent(fn (Request $request): bool => $request->method() === 'POST'); }); + +it("names the sheet's round buttons for VoiceOver, with nothing else announced inside them", function () { + $screen = composeSheetOnLog()->assertSee('New commit'); + $cancel = nodeByRef($screen, 'sheet-cancel'); + $commit = nodeByRef($screen, 'sheet-commit'); + + expect($cancel['type'])->toBe('pressable') + ->and($cancel['props'])->toMatchArray(['a11y_label' => 'Cancel']) + ->and($commit['props']['a11y_label'])->toBe('Commit, unavailable') + // On iOS every ref'd node becomes its own accessibility container (NodeIdentityModifier), so one ref is one announcement. + ->and(findNodes($cancel, fn (array $node): bool => isset($node['ref'])))->toHaveCount(1) + ->and(findNodes($commit, fn (array $node): bool => isset($node['ref'])))->toHaveCount(1); + + $screen = $screen->input('sheet-body', 'Shipped'); + + expect(nodeByRef($screen, 'sheet-commit'))->toMatchArray(['type' => 'pressable']) + ->and(nodeByRef($screen, 'sheet-commit')['props'])->toMatchArray(['a11y_label' => 'Commit']); +}); diff --git a/tests/Feature/Screens/FeedWorkspaceTest.php b/tests/Feature/Screens/FeedWorkspaceTest.php new file mode 100644 index 00000000..80a40750 --- /dev/null +++ b/tests/Feature/Screens/FeedWorkspaceTest.php @@ -0,0 +1,486 @@ + 'http://devnull.test']); + signIn(); +}); + +function serveWorkspace(): void +{ + serveApi(function (Request $request) { + $path = parse_url($request->url(), PHP_URL_PATH); + + if (preg_match('~/commits/([^/]+)$~', $path, $matches)) { + return Http::response(commitPageJson(commitJson(['id' => $matches[1], 'body' => 'Conversation '.$matches[1]]))); + } + + if (str_contains($path, '/log/')) { + return Http::response(pageJson([commitJson(['id' => 'c2', 'body' => 'Browsable feed'])])); + } + + return Http::response(pageJson([])); + }); +} + +it('selects the native Log route for the platform present at application boot', function (string $platform, string $component) { + try { + System::rememberPlatform($platform); + $this->refreshApplication(); + + expect(NativeRouter::resolve('/log')['class'])->toBe($component); + } finally { + System::rememberPlatform(null); + $this->refreshApplication(); + } +})->with([ + 'iOS retained workspace' => ['ios', FeedWorkspace::class], + 'Android feed' => ['android', Log::class], +]); + +it('keeps the feed live beside conversation history and ignores obsolete native back', function () { + serveWorkspace(); + $screen = Native::test(FeedWorkspace::class, platform: 'ios')->assertSee('Browsable feed'); + expect(treeShows(nodeByRef($screen, 'no-conversation'), 'no commit selected'))->toBeTrue(); + + $screen->call('openConversation', 'c2', ['replies' => true], true) + ->assertSee('Conversation c2') + ->assertSee('Browsable feed') + ->call('openConversation', 'c3') + ->assertSee('Conversation c3'); + $oldBack = json_encode(['action' => 'back', 'depth' => 1, 'expected_depth' => 2, 'expected_revision' => 2, 'expected_history' => ['conversation-1', 'conversation-2']]); + + $screen->call('openConversation', 'c4', [], true)->call('hostChanged', $oldBack); + + expect(array_column($screen->get('conversations'), 'id'))->toBe(['c4']); + $screen->assertSee('Conversation c4')->assertSee('Browsable feed')->assertDontSee('Conversation c3'); + + $screen->call('hostChanged', json_encode(['action' => 'back', 'depth' => 0, 'expected_depth' => 1, 'expected_revision' => 3, 'expected_history' => ['conversation-3']])); + expect($screen->get('conversations'))->toBe([]); + $screen->assertSee('Browsable feed')->assertDontSee('Conversation c4'); +}); + +it('marks the feed row whose conversation is open beside it', function () { + serveWorkspace(); + $screen = Native::test(FeedWorkspace::class, platform: 'ios'); + $background = fn (): string => strtolower(nodeByRef($screen, 'commit-c2')['style']['bg_color']); + $surface = $background(); + + $screen->tap('commit-c2'); + expect($background())->toBe(strtolower((string) theme('wash'))); + + $screen->call('openConversation', 'c3'); + expect($background())->toBe(strtolower((string) theme('wash'))); + + $screen->call('hostChanged', json_encode(['action' => 'back', 'depth' => 0, 'expected_depth' => 2, 'expected_revision' => 2, 'expected_history' => ['conversation-1', 'conversation-2']])); + expect($background())->toBe($surface)->not->toBe(strtolower((string) theme('wash'))); +}); + +it('opens the existing game from the retained feed logo when it is installed', function () { + skipWithoutSkia(); + serveWorkspace(); + + Native::test(FeedWorkspace::class, platform: 'ios') + ->call('hostChanged', json_encode(['action' => 'game'])) + ->assertNavigatedTo(Game::Route); +}); + +it('refreshes the retained destination after native back without losing its draft', function () { + $body = 'Parent before opening another conversation'; + serveApi(function (Request $request) use (&$body) { + $path = parse_url($request->url(), PHP_URL_PATH); + + return Http::response(match ($path) { + '/api/v1/commits/c2' => commitPageJson(commitJson(['id' => 'c2', 'body' => $body])), + '/api/v1/commits/c3' => commitPageJson(commitJson(['id' => 'c3', 'body' => 'Deeper conversation'])), + default => pageJson([]), + }); + }); + $screen = Native::test(FeedWorkspace::class, platform: 'ios')->call('openConversation', 'c2'); + $parent = $screen->instance()->foregroundPane(); + $screen->input('reply-input', 'Keep this parent draft')->call('openConversation', 'c3'); + $body = 'Parent refreshed after returning'; + + $screen->call('hostChanged', json_encode(['action' => 'back', 'depth' => 1, 'expected_depth' => 2, 'expected_revision' => 2, 'expected_history' => ['conversation-1', 'conversation-2']])) + ->assertSee('Parent refreshed after returning')->assertSee('Keep this parent draft'); + + expect($screen->instance()->foregroundPane())->toBe($parent); +}); + +it('refreshes the live feed when going back from its selected conversation', function () { + $body = 'Feed before opening a conversation'; + serveApi(function (Request $request) use (&$body) { + $path = parse_url($request->url(), PHP_URL_PATH); + + return Http::response(match ($path) { + '/api/v1/log/following' => pageJson([commitJson(['id' => 'c2', 'body' => $body])]), + '/api/v1/commits/c2' => commitPageJson(commitJson(['id' => 'c2', 'body' => 'Selected conversation'])), + default => pageJson([]), + }); + }); + $screen = Native::test(FeedWorkspace::class, platform: 'ios')->tap('commit-c2'); + $body = 'Feed refreshed after going back'; + + $screen->call('hostChanged', json_encode(['action' => 'back', 'depth' => 0, 'expected_depth' => 1, 'expected_revision' => 1, 'expected_history' => ['conversation-1']])) + ->assertSee('Feed refreshed after going back'); + + expect($screen->get('conversations'))->toBe([]); +}); + +it('ignores the retained logo shortcut when the game is disabled', function () { + config(['devnull.nata_nullers' => false]); + serveWorkspace(); + + Native::test(FeedWorkspace::class, platform: 'ios') + ->call('hostChanged', json_encode(['action' => 'game'])) + ->assertNoNavigation(); +}); + +it('applies held child refreshes and stashes the selected conversation draft', function () { + serveWorkspace(); + holdRefreshes(); + $screen = Native::test(FeedWorkspace::class, platform: 'ios'); + finishRefreshes($screen); + + $screen->call('openConversation', 'c2', ['replies' => true], true); + finishRefreshes($screen)->assertSee('Conversation c2'); + $screen->input('reply-input', 'A draft for c2'); + + expect(app(Stash::class)->get(userJson()['id'], 'reply-c2')->body)->toBe('A draft for c2'); + $screen->call('openConversation', 'c3', [], true); + finishRefreshes($screen)->assertSee('Conversation c3')->assertDontSee('Conversation c2'); + $screen->call('openConversation', 'c2', [], true); + finishRefreshes($screen)->assertSee('A draft for c2'); +}); + +it('keeps all three feed tabs interactive without replacing the selected conversation', function () { + serveWorkspace(); + $screen = Native::test(FeedWorkspace::class, platform: 'ios'); + $pager = nodeByRef($screen, 'log-pager')['id']; + $screen->tap('commit-c2')->input('reply-input', 'Keep this conversation draft'); + + foreach ([1, 2, 0] as $page) { + $screen->changeTab('showPage', $page) + ->assertSee('Conversation c2') + ->assertSee('Keep this conversation draft') + ->assertNoNavigation(); + + expect(nodeByRef($screen, 'log-pager')['id'])->toBe($pager); + expect(nodeByRef($screen, 'log-pager')['props']['page'])->toBe($page); + expect(array_column($screen->get('conversations'), 'id'))->toBe(['c2']); + } +}); + +it('retries a child server failure and recovers an API error on resume', function () { + holdRefreshes(); + $attempts = 0; + serveApi(function (Request $request) use (&$attempts) { + $path = parse_url($request->url(), PHP_URL_PATH); + if ($path === '/api/v1/commits/c2') { + $attempts++; + + return match ($attempts) { + 1 => Http::response(['message' => 'Temporary failure'], 503), + 2 => Http::response(['message' => 'Conversation unavailable'], 422), + default => Http::response(commitPageJson(commitJson(['id' => 'c2', 'body' => 'Recovered conversation']))), + }; + } + + return Http::response(pageJson([])); + }); + $screen = finishRefreshes(Native::test(FeedWorkspace::class, platform: 'ios')); + finishRefreshes($screen->call('openConversation', 'c2', [], true)) + ->assertSee('Conversation unavailable'); + + expect($attempts)->toBe(2); + finishRefreshes($screen->call('onResume'))->assertSee('Recovered conversation')->assertDontSee('Conversation unavailable'); + expect($attempts)->toBe(3); +}); + +it('drops a detached conversation completion rather than caching or displaying its result', function () { + serveWorkspace(); + holdRefreshes(); + $screen = finishRefreshes(Native::test(FeedWorkspace::class, platform: 'ios')); + $screen->call('openConversation', 'c2', [], true) + ->call('openConversation', 'c3', [], true); + + finishRefreshes($screen)->assertSee('Conversation c3')->assertDontSee('Conversation c2'); + expect(kept('commits/c2'))->toBeNull(); + expect(kept('commits/c3')['data']['body'])->toBe('Conversation c3'); +}); + +it('accepts consecutive ancestor back events but rejects a stale delete and divergent back', function () { + serveWorkspace(); + $screen = Native::test(FeedWorkspace::class, platform: 'ios') + ->call('openConversation', 'c2') + ->call('openConversation', 'c3') + ->call('openConversation', 'c4'); + $event = ['expected_depth' => 3, 'expected_revision' => 3, 'expected_history' => ['conversation-1', 'conversation-2', 'conversation-3']]; + $screen->call('hostChanged', json_encode([...$event, 'action' => 'back', 'depth' => 2])) + ->call('hostChanged', json_encode([...$event, 'action' => 'back', 'depth' => 1])) + ->call('hostChanged', json_encode([...$event, 'action' => 'delete'])) + ->assertNativeNotCalled('Dialog.Alert'); + expect(array_column($screen->get('conversations'), 'id'))->toBe(['c2']); + + $screen->call('openConversation', 'c5', [], true) + ->call('hostChanged', json_encode([...$event, 'action' => 'back', 'depth' => 0])); + expect(array_column($screen->get('conversations'), 'id'))->toBe(['c5']); +}); + +it('settles a held reply once and updates the live detail or restores its rejected draft', function (bool $accepted) { + holdRefreshes(); + serveApi([ + 'GET /api/v1/log/following' => [pageJson([commitJson(['id' => 'c2', 'body' => 'Reply here'])])], + 'GET /api/v1/commits/c2' => [commitPageJson(commitJson(['id' => 'c2', 'body' => 'Reply here']))], + 'GET /api/v1/commits/c2/replies' => [pageJson([])], + 'GET /api/v1/commits/c2/related' => [pageJson([])], + 'POST /api/v1/commits' => $accepted + ? [['data' => replyJson(['id' => 'r-real', 'parent_id' => 'c2', 'body' => 'Held reply'])], 201] + : [['message' => 'Reply rejected'], 422], + ]); + $screen = finishRefreshes(Native::test(FeedWorkspace::class, platform: 'ios'))->tap('commit-c2'); + finishRefreshes($screen)->input('reply-input', 'Held reply')->tap('send-reply')->assertSee('Held reply'); + expect(PendingWrite::query()->count())->toBe(1); + + finishRefreshes($screen)->assertSee('Reply here'); + expect(PendingWrite::query()->count())->toBe(0); + if ($accepted) { + expect(nodeByRef($screen, 'thread-reply-r-real'))->not->toBeNull(); + expect(app(Stash::class)->get(userJson()['id'], 'reply-c2')->body)->toBe(''); + } else { + $screen->assertSee('Held reply')->assertSee('stash@{0}'); + expect(app(Stash::class)->get(userJson()['id'], 'reply-c2')->body)->toBe('Held reply'); + } +})->with([true, false]); + +it('opens Edit for the already selected conversation without replacing its native identity', function () { + serveWorkspace(); + $screen = Native::test(FeedWorkspace::class, platform: 'ios')->tap('commit-c2'); + $detail = findNode($screen->tree(), fn (array $node): bool => ($node['type'] ?? null) === 'adaptive_detail')['id']; + $write = app(Outbox::class)->add(PendingWrite::Commit, null, ['body' => 'Recovered from Edit', 'parent_id' => 'c2', 'composer' => 'bar', 'stash' => 'reply-c2']); + app(Outbox::class)->failed($write, 'Try editing'); + + $screen->emitNative(ToastActionPressed::class, ['actionId' => 'edit', 'data' => ['write' => $write->id]]) + ->assertSee('Recovered from Edit')->assertSee('stash@{0}'); + expect(findNode($screen->tree(), fn (array $node): bool => ($node['type'] ?? null) === 'adaptive_detail')['id'])->toBe($detail); + expect(array_column($screen->get('conversations'), 'id'))->toBe(['c2']); + expect(PendingWrite::query()->count())->toBe(0); +}); + +it('recovers and resubmits a rejected feed composer through the selected pane', function (int $page, bool $quote) { + holdRefreshes(); + serveApi([ + 'GET /api/v1/log/following' => [pageJson([commitJson(['id' => 'c2', 'body' => 'Browse during submission'])])], + 'GET /api/v1/log/latest' => [pageJson([commitJson(['id' => 'c2', 'body' => 'Browse during submission'])])], + 'GET /api/v1/log/trending' => [pageJson([commitJson(['id' => 'c2', 'body' => 'Browse during submission'])])], + 'GET /api/v1/commits/c2' => [commitPageJson(commitJson(['id' => 'c2', 'body' => 'Browse during submission']))], + 'GET /api/v1/commits/c2/replies' => [pageJson([])], + 'GET /api/v1/commits/c2/related' => [pageJson([])], + 'POST /api/v1/commits' => [['message' => 'Controlled rejection'], 422], + ]); + $screen = finishRefreshes(Native::test(FeedWorkspace::class, platform: 'ios')); + finishRefreshes($screen->changeTab('showPage', $page)); + if ($quote) { + $screen->tap('quote-c2'); + } else { + $screen->call('hostChanged', json_encode(['action' => 'compose'])); + } + $screen->input('sheet-body', 'Rejected feed draft')->tap('sheet-commit') + ->call('openConversation', 'c2', [], true); + + finishRefreshes($screen, newestFirst: true); + $detail = findNode($screen->tree(), fn (array $node): bool => ($node['type'] ?? null) === 'adaptive_detail'); + expect(findNode($detail, fn (array $node): bool => ($node['ref'] ?? null) === 'sheet')['props']['visible'])->toBeTrue(); + expect(nodeByRef($screen, 'sheet')['props']['visible'])->toBeFalse(); + $screen->assertSee('Rejected feed draft'); + + serveApi([ + 'POST /api/v1/commits' => [['data' => commitJson(['id' => 'c-new', 'body' => 'Rejected feed draft'])], 201], + ]); + $screen->input('reply-input', 'Keep my unrelated inline draft'); + $commit = findNode($detail, fn (array $node): bool => ($node['ref'] ?? null) === 'sheet-commit'); + $screen->fireEvent('hostChanged', TestableComponent::EVENT_PRESS, ['callback_id' => $commit['on_press']]); + $staged = PendingWrite::query()->sole(); + expect(nodeByRef($screen, 'commit-'.PendingWrite::stagedId($staged->client_id)))->not->toBeNull(); + $screen->assertSee('Keep my unrelated inline draft'); + + finishRefreshes($screen); + expect(nodeByRef($screen, 'commit-c-new'))->not->toBeNull(); + expect(PendingWrite::query()->count())->toBe(0); + $screen->assertSee('Keep my unrelated inline draft'); +})->with([[0, false], [1, false], [2, false], [2, true]]); + +it('invalidates old native commands when a rejected sheet reopens after dismissal', function (string $action) { + holdRefreshes(); + serveWorkspace(); + $screen = finishRefreshes(Native::test(FeedWorkspace::class, platform: 'ios'))->tap('commit-c2'); + finishRefreshes($screen)->input('reply-input', 'Fast rejected reply')->tap('reply-expand')->tap('sheet-commit'); + $old = ['action' => $action, 'depth' => 0, 'expected_depth' => 1, 'expected_revision' => 1, 'expected_history' => ['conversation-1']]; + serveApi(['POST /api/v1/commits' => [['message' => 'Fast rejection'], 422]]); + finishRefreshes($screen); + $detail = findNode($screen->tree(), fn (array $node): bool => ($node['type'] ?? null) === 'adaptive_detail'); + $sheet = findNode($detail, fn (array $node): bool => ($node['ref'] ?? null) === 'sheet'); + expect($sheet['props']['visible'])->toBeFalse(); + + $screen->fireEvent('hostChanged', TestableComponent::EVENT_SHEET_DISMISS, ['callback_id' => $sheet['props']['on_dismiss']]) + ->call('hostChanged', json_encode($old)) + ->assertSee('Fast rejected reply')->assertNativeNotCalled('Dialog.Alert'); + + expect(array_column($screen->get('conversations'), 'id'))->toBe(['c2']); + expect($screen->get('navigationRevision'))->toBe(2); + $detail = findNode($screen->tree(), fn (array $node): bool => ($node['type'] ?? null) === 'adaptive_detail'); + expect(findNode($detail, fn (array $node): bool => ($node['ref'] ?? null) === 'sheet')['props']['visible'])->toBeTrue(); +})->with(['back', 'delete']); + +it('rejects old native commands after a newer same-conversation intent', function (string $action) { + serveWorkspace(); + $screen = Native::test(FeedWorkspace::class, platform: 'ios') + ->call('openConversation', 'c2') + ->call('openConversation', 'c3'); + $detail = findNodes($screen->tree(), fn (array $node): bool => ($node['type'] ?? null) === 'adaptive_detail')[1]['id']; + $event = ['action' => $action, 'depth' => 1, 'expected_depth' => 2, 'expected_revision' => 2, 'expected_history' => ['conversation-1', 'conversation-2']]; + + $screen->call('openConversation', 'c3', ['reply' => true])->call('hostChanged', json_encode($event)) + ->assertNativeNotCalled('Dialog.Alert'); + + expect(array_column($screen->get('conversations'), 'id'))->toBe(['c2', 'c3']); + expect(findNodes($screen->tree(), fn (array $node): bool => ($node['type'] ?? null) === 'adaptive_detail')[1]['id'])->toBe($detail); + expect($screen->tree()['props']['intent_revision'])->toBe(3); + if ($action === 'delete') { + $screen->call('hostChanged', json_encode([...$event, 'expected_revision' => 3])) + ->assertNativeCalled('Dialog.Alert', fn (array $params): bool => $params['title'] === 'Delete this commit?'); + } +})->with(['back', 'delete']); + +it('synchronizes successful deletion from either pane without replacing the feed', function (bool $fromDetail) { + $deleted = false; + serveApi(function (Request $request) use (&$deleted) { + if ($request->method() === 'DELETE') { + $deleted = true; + + return Http::response(null, 204); + } + $commit = commitJson(['id' => 'c2', 'body' => 'Delete from either pane']); + $path = parse_url($request->url(), PHP_URL_PATH); + + return Http::response(str_ends_with($path, '/commits/c2') ? commitPageJson($commit) : pageJson(str_contains($path, '/log/') && ! $deleted ? [$commit] : [])); + }); + $screen = Native::test(FeedWorkspace::class, platform: 'ios')->tap('commit-c2'); + $feed = findNode($screen->tree(), fn (array $node): bool => ($node['type'] ?? null) === 'pager')['id']; + if ($fromDetail) { + $screen->call('hostChanged', json_encode(['action' => 'delete', 'expected_depth' => 1, 'expected_revision' => 1, 'expected_history' => ['conversation-1']])); + } else { + $screen->press('delete-c2'); + } + $screen->emitNative(ButtonPressed::class, ['label' => 'Delete', 'id' => 'delete-commit'])->assertDontSee('Delete from either pane'); + + expect($screen->get('conversations'))->toBe([]); + expect(findNode($screen->tree(), fn (array $node): bool => ($node['type'] ?? null) === 'pager')['id'])->toBe($feed); + Http::assertSent(fn (Request $request): bool => $request->method() === 'DELETE' && str_ends_with($request->url(), '/commits/c2')); +})->with([true, false]); + +it('keeps held cross-pane stars and toast retry consistent before transport settles', function () { + holdRefreshes(); + $commit = commitJson(['id' => 'c2', 'body' => 'Star in either pane', 'author' => userJson(['id' => 'u2', 'handle' => 'ada'])]); + serveApi(function (Request $request) use ($commit) { + if ($request->method() !== 'GET') { + return Http::response(['message' => 'Try again'], 422); + } + $path = parse_url($request->url(), PHP_URL_PATH); + + return Http::response(str_ends_with($path, '/commits/c2') ? commitPageJson($commit) : pageJson(str_contains($path, '/log/') ? [$commit] : [])); + }); + $screen = finishRefreshes(Native::test(FeedWorkspace::class, platform: 'ios'))->tap('commit-c2'); + finishRefreshes($screen); + $starLabels = fn (): array => array_column(array_column(findNodes($screen->tree(), fn (array $node): bool => ($node['ref'] ?? null) === 'star-c2'), 'props'), 'a11y_label'); + + $screen->tap('star-c2'); + expect($starLabels())->toBe(['Unstar commit, 1 star', 'Unstar commit, 1 star']); + $detailStar = findNodes($screen->tree(), fn (array $node): bool => ($node['ref'] ?? null) === 'star-c2')[1]; + $screen->fireEvent('hostChanged', TestableComponent::EVENT_PRESS, ['callback_id' => $detailStar['on_press']]); + expect($starLabels())->toBe(['Star commit', 'Star commit']); + expect(PendingWrite::query()->orderBy('id')->pluck('kind')->all())->toBe([PendingWrite::Star, PendingWrite::Unstar]); + finishRefreshes($screen); + + $screen->emitNative(ToastActionPressed::class, ['actionId' => 'retry-star', 'data' => ['commit' => 'c2', 'starred' => true]]); + expect($starLabels())->toBe(['Unstar commit, 1 star', 'Unstar commit, 1 star']); + expect(PendingWrite::query()->count())->toBe(1); + finishRefreshes($screen); + expect($starLabels())->toBe(['Star commit', 'Star commit']); +}); + +it('attaches a photo only to the pane that opened the picker', function (bool $fromDetail, bool $fromCamera) { + serveWorkspace(); + $screen = Native::test(FeedWorkspace::class, platform: 'ios')->tap('commit-c2'); + $workspace = $screen->instance(); + $feed = array_find($workspace->panes(), fn (Log|CommitDetail $pane): bool => $pane instanceof Log); + $detail = $workspace->foregroundPane(); + $feed->composeInSheet(); + $detail->replyInSheet('c2'); + $owner = $fromDetail ? $detail : $feed; + $other = $fromDetail ? $feed : $detail; + + if ($fromCamera) { + $owner->takePhoto(); + $screen->emitNative(PhotoTaken::class, ['path' => testPhoto()]); + } else { + $owner->pickPhotos(); + photosPicked($screen, [testPhoto()]); + } + + try { + expect($owner->sheetImages)->toHaveCount(1) + ->and($other->sheetImages)->toBe([]) + ->and($other->sheetError)->toBeNull(); + } finally { + app(DraftImages::class)->discard($owner->sheetImages); + } +})->with([[false, false], [false, true], [true, false], [true, true]]); + +it('ignores a detached conversation picker result rather than attaching it to another pane', function () { + serveWorkspace(); + $screen = Native::test(FeedWorkspace::class, platform: 'ios')->tap('commit-c2'); + $screen->instance()->foregroundPane()->replyInSheet('c2'); + $screen->instance()->foregroundPane()->pickPhotos(); + + $screen->call('openConversation', 'c3', [], true); + $screen->instance()->foregroundPane()->replyInSheet('c3'); + photosPicked($screen, [testPhoto()]); + + foreach ($screen->instance()->panes() as $pane) { + expect($pane->sheetImages)->toBe([]); + } +}); + +it('shows a camera permission error only on the pane that requested it', function () { + serveWorkspace(); + $screen = Native::test(FeedWorkspace::class, platform: 'ios')->tap('commit-c2'); + $workspace = $screen->instance(); + $feed = array_find($workspace->panes(), fn (Log|CommitDetail $pane): bool => $pane instanceof Log); + $feed->composeInSheet(); + $workspace->foregroundPane()->replyInSheet('c2'); + $feed->takePhoto(); + + $screen->emitNative(PermissionDenied::class, ['action' => 'camera']); + + expect($feed->sheetError)->toBe(DraftImages::CameraDenied) + ->and($workspace->foregroundPane()->sheetError)->toBeNull(); +}); diff --git a/tests/Feature/Screens/LiveAvatarTest.php b/tests/Feature/Screens/LiveAvatarTest.php index b7a214eb..f9b2d9aa 100644 --- a/tests/Feature/Screens/LiveAvatarTest.php +++ b/tests/Feature/Screens/LiveAvatarTest.php @@ -81,6 +81,15 @@ function railRingColour(TestableComponent $screen, string $ref): string ->and(railRingColour($screen, 'commit-c1'))->toBe(strtolower(config('native-ui.theme.light.lane-2'))); }); +it("announces an author's avatar by name, and as live while they stream", function (?array $live, string $label) { + $avatar = findNode(nodeByRef(logByStreamer($live), 'commit-c1'), fn (array $node): bool => ($node['props']['a11y_label'] ?? null) === $label); + + expect($avatar)->not->toBeNull(); +})->with([ + 'not live' => [null, "Ada's profile"], + 'live' => [fn (): array => adaStream(now()->addHour()->toIso8601ZuluString()), 'Ada is live'], +]); + it('keeps the lane ring once the stream has ended', function (?array $live) { $screen = logByStreamer($live); diff --git a/tests/Feature/Screens/OnboardingTest.php b/tests/Feature/Screens/OnboardingTest.php index 9027e23b..8a6b7a31 100644 --- a/tests/Feature/Screens/OnboardingTest.php +++ b/tests/Feature/Screens/OnboardingTest.php @@ -17,6 +17,7 @@ use Native\Mobile\Edge\Enums\JustifyContent; use Native\Mobile\Edge\NativeRouter; use Native\Mobile\Edge\NavigationIntent; +use Native\Mobile\Edge\TailwindParser; use Native\Mobile\System; use Native\Mobile\Testing\FakeBridge; use Native\Mobile\Testing\Native; @@ -142,6 +143,32 @@ function sendBack(TestableComponent $screen, array $query): TestableComponent } }); +it("keeps the lockup inside its half, with the buttons' margin, once the window is wide enough to put them side by side", function (float $window) { + signInBridge(testSeed()); + + $screen = Native::visit('/onboarding', platform: 'ios'); + $variant = fn (array $node): array => json_decode($node['props']['_variants'], true)[0]; + $logo = $variant(nodeByRef($screen, 'logo')); + $margin = $variant(nodeByRef($screen, 'logo-space'))['layout']['padding'][1]; + $half = ($window - $variant($screen->tree())['layout']['gap']) / 2; + + expect($logo['min'])->toEqual(TailwindParser::breakpointMinWidth('expanded')) + ->and($margin)->toEqual(nodeByRef($screen, 'actions')['layout']['padding'][1]) + ->and(floatval($logo['layout']['width']) / 100 * $window)->toBeLessThanOrEqual($half - 2 * $margin); +})->with([ + 'the narrowest expanded window' => fn (): float => TailwindParser::breakpointMinWidth('expanded'), + 'the inner display held sideways' => 951, +]); + +it("leaves Android's lockup at two thirds of its half, since there a percentage is of the parent and not the window", function () { + signInBridge(testSeed()); + + $logo = nodeByRef(Native::visit('/onboarding', platform: 'android'), 'logo'); + + expect($logo['props'])->not->toHaveKey('_variants') + ->and($logo['layout']['width'])->toBe('67%'); +}); + it('keeps the same key across launches', function () { signInBridge(testSeed()); diff --git a/tests/Feature/Screens/ProfilePagerTest.php b/tests/Feature/Screens/ProfilePagerTest.php index 38d67ec0..e0d0045e 100644 --- a/tests/Feature/Screens/ProfilePagerTest.php +++ b/tests/Feature/Screens/ProfilePagerTest.php @@ -1,6 +1,7 @@ changeTab('showPage', -1)->assertSet('tab', 'commits'); }); -it('draws the header once, for the pager to put above its tabs, and hangs the pill under them', function () { +it('draws the header once, for the pager to put above its tabs or beside them once the window is expanded, and hangs the pill under them', function () { holdRefreshes(); keepProfile('ada', [commitJson(['id' => 'c1'])], [commitJson(['id' => 'r1'])]); @@ -91,7 +92,10 @@ function profileList(TestableComponent $screen, string $tab): array $hasRef = fn (string $ref): Closure => fn (array $node): bool => ($node['ref'] ?? null) === $ref; expect(array_column($pager['children'], 'type'))->toBe(['pager_header', 'column', 'column', 'pager_overlay']) + ->and($pager['props']['header_beside_from'])->toBe(TailwindParser::breakpointMinWidth('expanded')) ->and(findNode($pager['children'][0], $hasRef('profile-header')))->not->toBeNull() + ->and(findNodes($screen->tree(), $hasRef('profile-header')))->toHaveCount(1) + ->and(findNodes($screen->tree(), $hasRef('follow')))->toHaveCount(1) ->and(findNode(showingProfile($screen), $hasRef('profile-header')))->toBeNull() ->and(findNode(profileList($screen, 'replies'), $hasRef('profile-header')))->toBeNull() ->and(findNode($pager['children'][3], $hasRef('new-commits-slot')))->not->toBeNull(); diff --git a/tests/TestCase.php b/tests/TestCase.php index 80975918..623e7061 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -8,6 +8,7 @@ use Illuminate\Support\Facades\File; use Native\Mobile\AsyncTask; use Native\Mobile\Support\AsyncTaskTransport; +use Native\Mobile\System; abstract class TestCase extends BaseTestCase { @@ -36,6 +37,7 @@ protected function setUp(): void protected function tearDown(): void { AsyncTask::clearFake(); + System::rememberPlatform(null); File::deleteDirectory(AsyncTaskTransport::directory()); parent::tearDown();