diff --git a/README.md b/README.md index 0a5dfe6..3524f82 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,6 @@ To make that instance work with the app on your phone, create a tunnel (ngrok, C These are the next things we want in the app. If you'd like to take one on, read [CONTRIBUTING.md](CONTRIBUTING.md) first. -- A native image viewer, instead of opening images in a browser sheet - Styling for livestreams - Search - A notifications centre that matches the web diff --git a/app/Devnull/Data/CommitImage.php b/app/Devnull/Data/CommitImage.php index 70635f3..c758f78 100644 --- a/app/Devnull/Data/CommitImage.php +++ b/app/Devnull/Data/CommitImage.php @@ -61,4 +61,17 @@ public function ratio(): float return round(max(self::TallestRatio, $this->width / $this->height), 4); } + + /** + * Width over height as the image really is, uncapped, for drawing it whole; + * null when devnull didn't send its size. + */ + public function naturalRatio(): ?float + { + if ($this->width === 0 || $this->height === 0) { + return null; + } + + return round($this->width / $this->height, 4); + } } diff --git a/app/Icons/Lucide.php b/app/Icons/Lucide.php index e209092..2a123ea 100644 --- a/app/Icons/Lucide.php +++ b/app/Icons/Lucide.php @@ -32,6 +32,7 @@ enum Lucide case Trash2; case CornerDownRight; case ChevronRight; + case ChevronLeft; case ChevronDown; case ArrowUpRight; case ArrowUp; @@ -192,6 +193,7 @@ public function ios(bool $filled = false): Ios|string return $this->custom($filled) ?? match ($this) { self::CornerDownRight => Ios::ArrowTurnDownRight, self::ChevronRight => Ios::ChevronRight, + self::ChevronLeft => Ios::ChevronLeft, self::ChevronDown => Ios::ChevronDown, self::ArrowUpRight => Ios::ArrowUpRight, self::Pin => Ios::Pin, @@ -209,6 +211,7 @@ public function android(bool $filled = false): AndroidSymbol|string return $this->custom($filled) ?? match ($this) { self::CornerDownRight => AndroidOutlined::SubdirectoryArrowRight, self::ChevronRight => AndroidOutlined::ChevronRight, + self::ChevronLeft => AndroidOutlined::ChevronLeft, self::ChevronDown => AndroidOutlined::ExpandMore, self::ArrowUpRight => AndroidOutlined::ArrowOutward, self::Pin => AndroidOutlined::PushPin, diff --git a/app/NativeComponents/Concerns/ShowsCommits.php b/app/NativeComponents/Concerns/ShowsCommits.php index c13f821..cdf12ab 100644 --- a/app/NativeComponents/Concerns/ShowsCommits.php +++ b/app/NativeComponents/Concerns/ShowsCommits.php @@ -5,6 +5,7 @@ use App\Devnull\Account; use App\Devnull\Client; use App\Devnull\Data\Commit; +use App\Devnull\Data\CommitImage; use App\Devnull\Data\Pasteis; use App\Devnull\Data\User; use App\Devnull\ErrorCode; @@ -12,6 +13,7 @@ use App\Devnull\ResponseCache; use Closure; use Native\Mobile\Attributes\On; +use Native\Mobile\Edge\SharedValue; use Native\Mobile\Events\Alert\ButtonPressed; use Native\Mobile\Facades\Browser; use Native\Mobile\Facades\Dialog; @@ -21,8 +23,9 @@ * The actions under a commit: the star, the pastel in pastéis mode, Quote, * Reply, and the More menu's Save commit, Share link, Pin to profile, Report * commit and Delete commit (Quote and Report open a sheet, see - * OpensCommitSheet), plus opening its link preview or images in the in-app - * browser, "Show more" on a long one, and voting on its poll (see + * OpensCommitSheet), plus opening its link preview in the in-app browser, its + * images full-screen in the app (see partials/image-viewer), "Show more" on a + * long one, and voting on its poll (see * VotesOnPolls). Taps name the commit rather than a URL, so a URL is never * rebuilt from an attribute. * @@ -49,6 +52,37 @@ trait ShowsCommits */ public ?string $deleting = null; + /** + * Whether the image viewer is up, over everything else on the screen, + * sheet included. + */ + public bool $viewerOpen = false; + + /** + * The images of the commit being viewed, as they were when it opened, so + * the viewer keeps what it shows while it slides away even if the commit + * leaves the screen. + * + * @var list + */ + public array $viewerImages = []; + + /** + * Which of the viewer's images is showing. + */ + public int $viewerIndex = 0; + + /** + * The zoom and pan of the image being viewed, driven on the phone by the + * viewer's gesture area. Fresh ones on every open and every move between + * images, so each starts at rest; nulled on close. + */ + public ?SharedValue $viewerZoom = null; + + public ?SharedValue $viewerPanX = null; + + public ?SharedValue $viewerPanY = null; + /** * Every commit the screen is showing. * @@ -194,9 +228,62 @@ public function openLinkPreview(string $id): void $this->openInBrowser($this->shownCommit($id)?->linkPreview?->url); } + /** + * Opens a commit's image full-screen in the app (see partials/image-viewer), + * from the one the row tapped: the image whole rather than cropped, pinch + * zoom between its whole self and four times, a drag to pan, and the + * chevrons under it to move between the commit's other images. An image the + * screen isn't showing, or one at no web address, opens nothing, as it did + * in the browser. + */ public function openImage(string $id, int $index = 0): void { - $this->openInBrowser(($this->shownCommit($id)?->images[$index] ?? null)?->url); + $images = $this->shownCommit($id)?->images ?? []; + $image = $images[$index] ?? null; + + if ($image === null || ! $this->isWebUrl($image->url)) { + return; + } + + $this->viewerImages = $images; + $this->viewerIndex = $index; + $this->restViewerImage(); + $this->viewerOpen = true; + } + + /** + * The chevron under the viewer: the commit's next image, at rest again. At + * the last image there's no chevron to press, and a next at no web address + * goes nowhere, as an image at one never opens. + */ + public function viewNextImage(): void + { + $this->showInViewedImage($this->viewerIndex + 1); + } + + /** + * The chevron under the viewer: the commit's previous image, at rest + * again. At the first image there's no chevron to press, and a previous at + * no web address goes nowhere either. + */ + public function viewPreviousImage(): void + { + $this->showInViewedImage($this->viewerIndex - 1); + } + + /** + * The viewer's close, back or outside tap, however the phone reports it: + * the viewer goes and forgets the image and what its gestures left behind, + * so the next one opens as this one did. + */ + public function closeImageViewer(): void + { + $this->viewerOpen = false; + $this->viewerImages = []; + $this->viewerIndex = 0; + $this->viewerZoom = null; + $this->viewerPanX = null; + $this->viewerPanY = null; } /** @@ -452,8 +539,46 @@ private function shownCommit(string $id): ?Commit private function openInBrowser(?string $url): void { - if ($url !== null && in_array(parse_url($url, PHP_URL_SCHEME), ['http', 'https'], true)) { + if ($this->isWebUrl($url)) { Browser::inApp($url); } } + + /** + * Only an http or https address is opened, in the browser or the viewer, so + * nothing else a row might carry can take the phone somewhere unexpected. + */ + private function isWebUrl(?string $url): bool + { + return $url !== null && in_array(parse_url($url, PHP_URL_SCHEME), ['http', 'https'], true); + } + + /** + * Puts the viewer's image back at rest: fresh shared values, which the + * phone seeds at one and none, so what the last image's gestures left + * behind can't carry over to it. + */ + private function restViewerImage(): void + { + $this->viewerZoom = SharedValue::make(1.0); + $this->viewerPanX = SharedValue::make(); + $this->viewerPanY = SharedValue::make(); + } + + /** + * Moves the viewer to one of its commit's images by its place among them, + * or nowhere when there's no image there or it's at no web address, so the + * viewer never comes to show one the phone shouldn't be asked for. + */ + private function showInViewedImage(int $index): void + { + $image = $this->viewerImages[$index] ?? null; + + if (! $this->viewerOpen || $image === null || ! $this->isWebUrl($image->url)) { + return; + } + + $this->viewerIndex = $index; + $this->restViewerImage(); + } } diff --git a/composer.json b/composer.json index 7ac7e9b..e50cf0f 100644 --- a/composer.json +++ b/composer.json @@ -31,12 +31,12 @@ "laravel/boost": "^2.0", "laravel/framework": "^13.0", "laravel/tinker": "^3.0", - "nativephp/mobile": "dev-main as 4.6.0", + "nativephp/mobile": "dev-feat/gesture-area-bounded as 4.6.0", "nativephp/mobile-browser": "^1.2", "nativephp/mobile-camera": "^1.1", "nativephp/mobile-secure-storage": "^1.1", "nativephp/mobile-share": "^1.1", - "nativephp/mobile-ui": "dev-main as 0.8.0", + "nativephp/mobile-ui": "dev-feat/gesture-area-bounded as 0.8.0", "s2br/nativephp-mobile-splashscreen": "^1.4" }, "require-dev": { diff --git a/composer.lock b/composer.lock index 586e575..b154e11 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": "8bcaeeb71989e08fe920116ba3e08bcd", + "content-hash": "a5dfa4b4bf563e858153d3744a994e82", "packages": [ { "name": "bacon/bacon-qr-code", @@ -2717,16 +2717,16 @@ }, { "name": "nativephp/mobile", - "version": "dev-main", + "version": "dev-feat/gesture-area-bounded", "source": { "type": "git", "url": "https://github.com/NativePHP/mobile-air.git", - "reference": "2189e72363ea00965c17d35d8b127a2ae77093ad" + "reference": "64e04c285d2daa7a0029e849444a74c388b66041" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/NativePHP/mobile-air/zipball/2189e72363ea00965c17d35d8b127a2ae77093ad", - "reference": "2189e72363ea00965c17d35d8b127a2ae77093ad", + "url": "https://api.github.com/repos/NativePHP/mobile-air/zipball/64e04c285d2daa7a0029e849444a74c388b66041", + "reference": "64e04c285d2daa7a0029e849444a74c388b66041", "shasum": "" }, "require": { @@ -2766,7 +2766,6 @@ "suggest": { "ext-zip": "Required by native:install to validate and extract the downloaded Android PHP binary archives" }, - "default-branch": true, "type": "library", "extra": { "laravel": { @@ -2834,10 +2833,10 @@ "nativephp" ], "support": { - "source": "https://github.com/NativePHP/mobile-air/tree/main", + "source": "https://github.com/NativePHP/mobile-air/tree/feat/gesture-area-bounded", "issues": "https://github.com/NativePHP/mobile-air/issues" }, - "time": "2026-10-24T01:35:48+00:00" + "time": "2026-10-24T02:10:34+00:00" }, { "name": "nativephp/mobile-browser", @@ -3061,16 +3060,16 @@ }, { "name": "nativephp/mobile-ui", - "version": "dev-main", + "version": "dev-feat/gesture-area-bounded", "source": { "type": "git", "url": "https://github.com/NativePHP/mobile-ui.git", - "reference": "0abe5d24dc6c8749ac27a651c66f6b8154459d61" + "reference": "d135f295e8d6684868fb9d48f7e7782243520bee" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/NativePHP/mobile-ui/zipball/0abe5d24dc6c8749ac27a651c66f6b8154459d61", - "reference": "0abe5d24dc6c8749ac27a651c66f6b8154459d61", + "url": "https://api.github.com/repos/NativePHP/mobile-ui/zipball/d135f295e8d6684868fb9d48f7e7782243520bee", + "reference": "d135f295e8d6684868fb9d48f7e7782243520bee", "shasum": "" }, "require": { @@ -3082,7 +3081,6 @@ "orchestra/testbench": "^10.0", "pestphp/pest": "^3.0" }, - "default-branch": true, "type": "nativephp-plugin", "extra": { "laravel": { @@ -3121,7 +3119,7 @@ ], "description": "Native SwiftUI and Jetpack Composer components for NativePHP", "support": { - "source": "https://github.com/NativePHP/mobile-ui/tree/main", + "source": "https://github.com/NativePHP/mobile-ui/tree/feat/gesture-area-bounded", "issues": "https://github.com/NativePHP/mobile-ui/issues" }, "funding": [ @@ -3130,7 +3128,7 @@ "url": "https://nativephp.com/sponsor" } ], - "time": "2026-10-24T01:25:19+00:00" + "time": "2026-10-24T02:05:19+00:00" }, { "name": "nesbot/carbon", @@ -11132,13 +11130,13 @@ "aliases": [ { "package": "nativephp/mobile", - "version": "dev-main", + "version": "dev-feat/gesture-area-bounded", "alias": "4.6.0", "alias_normalized": "4.6.0.0" }, { "package": "nativephp/mobile-ui", - "version": "dev-main", + "version": "dev-feat/gesture-area-bounded", "alias": "0.8.0", "alias_normalized": "0.8.0.0" } diff --git a/resources/views/native/partials/commit-sheet.blade.php b/resources/views/native/partials/commit-sheet.blade.php index a9238ed..4d399ee 100644 --- a/resources/views/native/partials/commit-sheet.blade.php +++ b/resources/views/native/partials/commit-sheet.blade.php @@ -181,3 +181,5 @@ class="w-full" @endif @endif + +@include('native.partials.image-viewer') diff --git a/resources/views/native/partials/image-viewer.blade.php b/resources/views/native/partials/image-viewer.blade.php new file mode 100644 index 0000000..91ad523 --- /dev/null +++ b/resources/views/native/partials/image-viewer.blade.php @@ -0,0 +1,56 @@ +{{-- + A commit's images full-screen, over everything else on the screen, sheet included (see ShowsCommits): the image + the row tapped, whole rather than cropped as it is in the row, on black. A pinch zooms between the image's whole + self and four times, around the fingers, and a drag pans it; both run on the phone with no frame trip, so the + picture follows a finger. Bounded by the picture's own shape, it can't be lost off screen: zoomed, its edges stop at + the screen's, and it stays centred on a side it fits; dragged past that it resists and springs back on release, so + at its whole self it always comes back to the middle. The modal's own close, and Android's back, close it. + + Several images in a commit move with the chevrons under the picture, which say their place among them, and each + move puts the picture back at rest, as closing and opening another image does. An image still loading, or one that + fails, shows whatever its platform's loader shows meanwhile — iOS its spinner, Android nothing — since the image + element tells the app nothing about either, there's nothing of the app's to draw here. + + Always in the tree, shown or not, like the sheet, so the phone can animate it away and say when it's gone. + Included beside the sheet, so every screen that shows commits has it once. +--}} + + @if ($viewerImages !== []) + @php($image = $viewerImages[$viewerIndex]) + + + + + + + @if (count($viewerImages) > 1) + + @if ($viewerIndex > 0) + + + + + + @endif + {{ $viewerIndex + 1 }} of {{ count($viewerImages) }} + @if ($viewerIndex < count($viewerImages) - 1) + + + + + + @endif + + @endif + + @endif + diff --git a/tests/Feature/Screens/ImageViewerTest.php b/tests/Feature/Screens/ImageViewerTest.php new file mode 100644 index 0000000..1862e9b --- /dev/null +++ b/tests/Feature/Screens/ImageViewerTest.php @@ -0,0 +1,308 @@ + 'http://devnull.test']); + signIn(); +}); + +afterEach(function () { + File::deleteDirectory(storage_path('app/draft-images')); +}); + +/** + * A commit as devnull sends it, with $count images, the first described as the web's alt text. + * + * @return array + */ +function imagedCommitJson(int $count = 1): array +{ + return commitJson(['id' => 'c1', 'images' => array_map( + fn (int $index): array => [ + 'id' => 'i'.($index + 1), + 'url' => 'https://img.test/'.($index + 1), + 'width' => 10, + 'height' => 10, + 'description' => $index === 0 ? 'A cat on a keyboard' : null, + ], + range(0, $count - 1), + )]); +} + +/** + * The Following log showing one commit with $count images. + */ +function logWithImages(int $count = 1, string $platform = 'ios'): TestableComponent +{ + Http::fake(['devnull.test/*' => Http::response(pageJson([imagedCommitJson($count)]))]); + + return Native::visit('/log', platform: $platform); +} + +/** + * The images of the imaged commit, for faking another screen that shows it. + * + * @return list> + */ +function imagedCommitImages(): array +{ + return imagedCommitJson()['images']; +} + +it('opens a commit\'s image full-screen in the app, not the browser', function (string $platform) { + $screen = logWithImages(platform: $platform); + + $screen->tap('image-i1'); + + $viewer = nodeByRef($screen, 'image-viewer'); + $gestures = nodeByRef($screen, 'viewer-gestures'); + $image = nodeByRef($screen, 'viewed-image'); + $viewing = $screen->instance(); + + expect($viewer['props'])->toMatchArray(['visible' => true, 'a11y_label' => 'Image viewer']) + ->and($viewer['props']['on_dismiss'])->not->toBeEmpty() + // The image whole, not cropped as it is in the row, read out by the description its author gave it. + ->and($image['props'])->toMatchArray(['src' => 'https://img.test/1', 'fit' => 1, 'alt' => 'A cat on a keyboard']) + // Pinch bounded one to four, and both pans, settled on the phone against the shared values this open made, + // kept on screen by the image's own shape. + ->and($gestures['props'])->toMatchArray(['pinch-min' => 1.0, 'pinch-max' => 4.0, 'bounded' => true, 'content-ratio' => 1.0]) + ->and($gestures['props']['pinch-id'])->toBe($viewing->viewerZoom->id) + ->and($gestures['props']['pan-x-id'])->toBe($viewing->viewerPanX->id) + ->and($gestures['props']['pan-y-id'])->toBe($viewing->viewerPanY->id) + ->and($image['props']['scale_sv'])->toBe((string) $viewing->viewerZoom) + ->and($image['props']['translate-x_sv'])->toBe((string) $viewing->viewerPanX) + ->and($image['props']['translate-y_sv'])->toBe((string) $viewing->viewerPanY) + // One image alone has no chevrons and no place among others. + ->and(findNode($screen->tree(), fn (array $node): bool => ($node['ref'] ?? null) === 'viewer-count'))->toBeNull(); + + $screen->assertNativeNotCalled('Browser.OpenInApp'); +})->with(['ios', 'android']); + +it('keeps an image of no known size on screen by the screen\'s own shape', function () { + Http::fake(['devnull.test/*' => Http::response(pageJson([commitJson(['id' => 'c1', 'images' => [ + ['id' => 'i1', 'url' => 'https://img.test/1'], + ]])]))]); + + $screen = Native::visit('/log')->tap('image-i1'); + + expect(nodeByRef($screen, 'viewer-gestures')['props'])->toHaveKey('bounded') + ->not->toHaveKey('content-ratio'); +}); + +it('bounds a wide image by its real shape, not the one the feed caps it at', function () { + Http::fake(['devnull.test/*' => Http::response(pageJson([commitJson(['id' => 'c1', 'images' => [ + ['id' => 'i1', 'url' => 'https://img.test/1', 'width' => 300, 'height' => 1200], + ]])]))]); + + $screen = Native::visit('/log')->tap('image-i1'); + + expect(nodeByRef($screen, 'viewer-gestures')['props']['content-ratio'])->toBe(0.25); +}); + +it('starts from the image the row tapped, with the chevrons under it for the commit\'s others', function () { + $screen = logWithImages(3)->tap('image-i3'); + + expect(nodeByRef($screen, 'viewed-image')['props'])->toMatchArray(['src' => 'https://img.test/3', 'alt' => 'Attached image 3 of 3']) + ->and($screen->instance()->viewerIndex)->toBe(2) + ->and(shownText($screen))->toContain('3 of 3') + // The first image has nothing before it, the last nothing after it, so only the chevron that goes somewhere is drawn. + ->and(nodeByRef($screen, 'viewed-previous')['props']['a11y_label'])->toBe('Previous image') + ->and(findNode($screen->tree(), fn (array $node): bool => ($node['ref'] ?? null) === 'viewed-next'))->toBeNull(); + + $screen->tap('viewed-previous')->tap('viewed-previous'); + + expect(nodeByRef($screen, 'viewed-image')['props'])->toMatchArray(['src' => 'https://img.test/1', 'alt' => 'A cat on a keyboard, image 1 of 3']) + ->and($screen->instance()->viewerIndex)->toBe(0) + ->and(shownText($screen))->toContain('1 of 3') + ->and(nodeByRef($screen, 'viewed-next')['props']['a11y_label'])->toBe('Next image') + ->and(findNode($screen->tree(), fn (array $node): bool => ($node['ref'] ?? null) === 'viewed-previous'))->toBeNull(); +}); + +it('moves between the commit\'s images with the chevrons, each image starting at rest', function () { + $screen = logWithImages(3)->tap('image-i1'); + + $atRest = nodeByRef($screen, 'viewer-gestures')['props']; + + $screen->tap('viewed-next'); + + $image = nodeByRef($screen, 'viewed-image'); + $moved = nodeByRef($screen, 'viewer-gestures')['props']; + + expect($image['props']['src'])->toBe('https://img.test/2') + ->and($screen->instance()->viewerIndex)->toBe(1) + // Fresh zoom and pans, so what the last image's pinch or drag left behind can't carry over to it. + ->and($moved['pinch-id'])->not->toBe($atRest['pinch-id']) + ->and($moved['pan-x-id'])->not->toBe($atRest['pan-x-id']) + ->and($moved['pan-y-id'])->not->toBe($atRest['pan-y-id']) + ->and($image['props']['scale'])->toBe(1.0); + + // Past the last image, the chevron is gone and the call goes nowhere. + $screen->tap('viewed-next')->call('viewNextImage'); + + expect($screen->instance()->viewerIndex)->toBe(2) + ->and(nodeByRef($screen, 'viewed-image')['props']['src'])->toBe('https://img.test/3'); +}); + +it('opens nothing for an image the screen isn\'t showing, a place with no image, or no web address', function () { + Http::fake(['devnull.test/*' => Http::response(pageJson([ + imagedCommitJson(1), + commitJson(['id' => 'c2', 'images' => [['id' => 'not-web', 'url' => 'file:///etc/passwd']]]), + ]))]); + + $screen = Native::visit('/log'); + + $screen->call('openImage', 'nope') + ->call('openImage', 'c1', 3) + ->call('openImage', 'c1', -1) + ->tap('image-not-web') + ->assertSet('viewerOpen', false) + ->assertSet('viewerImages', []) + ->assertNativeNotCalled('Browser.OpenInApp'); +}); + +it('closes on the phone\'s close or back and forgets everything, so the next image opens as this one did', function (string $platform) { + $screen = logWithImages(2, platform: $platform); + + $screen->tap('image-i2'); + $firstPinch = nodeByRef($screen, 'viewer-gestures')['props']['pinch-id']; + + $screen->dismissSheet('image-viewer'); + + $viewing = $screen->instance(); + + expect($viewing->viewerOpen)->toBeFalse() + ->and($viewing->viewerImages)->toBe([]) + ->and($viewing->viewerIndex)->toBe(0) + ->and($viewing->viewerZoom)->toBeNull() + ->and($viewing->viewerPanX)->toBeNull() + ->and($viewing->viewerPanY)->toBeNull() + // The modal itself stays in the tree, hidden, so the phone can animate it away and say when it's gone. + ->and(nodeByRef($screen, 'image-viewer')['props']['visible'])->toBeFalse() + ->and(findNode($screen->tree(), fn (array $node): bool => ($node['ref'] ?? null) === 'viewed-image'))->toBeNull(); + + $screen->tap('image-i1'); + + expect(nodeByRef($screen, 'viewed-image')['props']['src'])->toBe('https://img.test/1') + // A fresh pinch, seeded at one again, not the one the closed image was using. + ->and(nodeByRef($screen, 'viewer-gestures')['props']['pinch-id'])->not->toBe($firstPinch); +})->with(['ios', 'android']); + +it('goes nowhere when the next or previous image is at no web address, so the viewer never shows one', function () { + Http::fake(['devnull.test/*' => Http::response(pageJson([commitJson(['id' => 'c1', 'images' => [ + ['id' => 'i1', 'url' => 'https://img.test/1', 'width' => 10, 'height' => 10], + ['id' => 'i2', 'url' => 'file:///etc/passwd', 'width' => 10, 'height' => 10], + ['id' => 'i3', 'url' => 'https://img.test/3', 'width' => 10, 'height' => 10], + ]])]))]); + + $screen = Native::visit('/log'); + + // From the first, next is the unsafe image: the chevron goes nowhere. + $screen->tap('image-i1')->tap('viewed-next'); + + expect($screen->instance()->viewerIndex)->toBe(0) + ->and(nodeByRef($screen, 'viewed-image')['props']['src'])->toBe('https://img.test/1'); + + // From the last, previous is the unsafe image: the chevron goes nowhere either. + $screen->tap('image-i3')->tap('viewed-previous'); + + expect($screen->instance()->viewerIndex)->toBe(2) + ->and(nodeByRef($screen, 'viewed-image')['props']['src'])->toBe('https://img.test/3'); + + // Nothing the viewer shows is at any but a web address. + $shown = findNodes(nodeByRef($screen, 'image-viewer'), fn (array $node): bool => ($node['type'] ?? null) === 'image'); + + expect($shown)->not->toBeEmpty() + ->and(array_map(fn (array $node): string => $node['props']['src'] ?? '', $shown))->each->toStartWith('https://'); +}); + +it('keeps the browser for a commit\'s link preview, as before', function () { + Http::fake(['devnull.test/*' => Http::response(pageJson([commitJson([ + 'id' => 'c1', + 'body' => 'Read https://nativephp.com/docs', + 'link_preview' => ['url' => 'https://nativephp.com/docs', 'title' => 'Docs', 'ready' => true], + ])]))]); + + Native::visit('/log')->tap('link-preview-c1') + ->assertNativeCalled('Browser.OpenInApp', fn (array $params): bool => $params['url'] === 'https://nativephp.com/docs') + ->assertSet('viewerOpen', false); +}); + +it('hangs the viewer over every screen that shows commits', function (string $path, array $api) { + fakeApi($api); + + $screen = $path === '/profile/starred' + ? finishRefreshes(Native::visit($path, platform: 'ios')) + : Native::visit($path, platform: 'ios'); + + $screen->tap('image-i1'); + + expect(nodeByRef($screen, 'image-viewer')['props']['visible'])->toBeTrue() + ->and(nodeByRef($screen, 'viewed-image')['props']['src'])->toBe('https://img.test/1'); + + $screen->assertNativeNotCalled('Browser.OpenInApp'); +})->with([ + 'a commit\'s page' => ['/log/commits/c1', [ + 'GET /api/v1/commits/c1' => [commitPageJson(commitJson(['id' => 'c1', 'images' => imagedCommitImages()]))], + 'GET /api/v1/commits/c1/replies' => [pageJson([])], + ]], + 'a profile' => ['/log/users/ada', [ + 'GET /api/v1/users/ada' => [[ + 'data' => userWithCountsJson(['id' => 'u2', 'username' => 'ada'], commits: 1), + 'is_following' => false, + 'activity' => ['scale' => 'week', 'buckets' => []], + 'streaks' => ['longest' => 0, 'current' => 0], + 'pinned_commit' => null, + ]], + 'GET /api/v1/users/ada/commits' => [pageJson([commitJson(['id' => 'c1', 'author' => userJson(['id' => 'u2', 'username' => 'ada']), 'images' => imagedCommitImages()])])], + ]], + 'the starred page' => ['/profile/starred', [ + 'GET /api/v1/starred' => [pageJson([commitJson(['id' => 'c1', 'starred' => true, 'images' => imagedCommitImages()])], path: 'http://devnull.test/api/v1/starred')], + ]], +]); + +it('hangs over the sheet, and leaves a photo being written in the sheet alone, as before', function () { + serveApi([ + 'GET /api/v1/log/following' => [pageJson([imagedCommitJson(1)])], + 'POST /api/v1/commits/preview' => [['data' => ['body' => 'Look at this']]], + ]); + + $screen = Native::visit('/log', platform: 'ios'); + + // The composer in the sheet, previewing the photo just picked. + $screen->press('composeInSheet') + ->emitNative(MediaSelected::class, ['success' => true, 'files' => [['path' => testPhoto()]]]) + ->tap('sheet-preview'); + + // The photo being written is the draft's, not a shown commit's, so tapping it opens nothing — in the browser it + // opened nothing either. + $screen->tap('image-draft-0') + ->assertSet('viewerOpen', false) + ->assertSet('sheetOpen', true) + ->assertNativeNotCalled('Browser.OpenInApp'); + + // The viewer hangs beside the sheet in the tree, after it, with the sheet still up underneath — the order the + // platforms are given to draw them in. That it truly covers the sheet on a device is left to verify there; this + // is what the app can promise from here. + $screen->call('openImage', 'c1', 0); + + $refs = []; + $collect = function (array $node) use (&$collect, &$refs): void { + if (isset($node['ref'])) { + $refs[] = $node['ref']; + } + + foreach ($node['children'] ?? [] as $child) { + $collect($child); + } + }; + $collect($screen->tree()); + + expect(array_search('image-viewer', $refs, true))->toBeGreaterThan(array_search('sheet', $refs, true)) + ->and(nodeByRef($screen, 'image-viewer')['props']['visible'])->toBeTrue() + ->and($screen->instance()->sheetOpen)->toBeTrue(); +}); diff --git a/tests/Feature/Screens/LogTest.php b/tests/Feature/Screens/LogTest.php index 3e0d08a..4cae27c 100644 --- a/tests/Feature/Screens/LogTest.php +++ b/tests/Feature/Screens/LogTest.php @@ -304,7 +304,10 @@ && ($node['style']['bg_color'] ?? null) === config('native-ui.theme.light.wash')) ->assertElement('image', fn (array $node): bool => ($node['props']['src'] ?? null) === 'http://devnull.test/commit-images/i1') ->tap('image-i1') - ->assertNativeCalled('Browser.OpenInApp', fn (array $params): bool => $params['url'] === 'http://devnull.test/commit-images/i1'); + ->assertSet('viewerOpen', true) + ->assertElement('image', fn (array $node): bool => ($node['ref'] ?? null) === 'viewed-image' + && ($node['props']['src'] ?? null) === 'http://devnull.test/commit-images/i1') + ->assertNativeNotCalled('Browser.OpenInApp'); }); it('lays out several images in the web grid', function () { @@ -320,7 +323,10 @@ Native::visit('/log') ->assertElement('pressable', fn (array $node): bool => ($node['ref'] ?? null) === 'image-i3' && ($node['a11y']['label'] ?? $node['props']['a11y_label'] ?? null) !== null) ->tap('image-i3') - ->assertNativeCalled('Browser.OpenInApp', fn (array $params): bool => $params['url'] === 'https://img.test/3'); + ->assertSet('viewerIndex', 2) + ->assertElement('image', fn (array $node): bool => ($node['ref'] ?? null) === 'viewed-image' + && ($node['props']['src'] ?? null) === 'https://img.test/3') + ->assertNativeNotCalled('Browser.OpenInApp'); }); it('reads each image out by the description its author gave it, as the web does with alt text', function (array $images, array $labels) {