From 6750bf55a0169d5484a1e2515ec6d29b6b531755 Mon Sep 17 00:00:00 2001 From: Ricardo Valfreixo Date: Thu, 22 Oct 2026 19:57:52 +0200 Subject: [PATCH 1/5] View commit images natively with zoom and gallery controls --- app/Icons/Lucide.php | 3 + .../Concerns/ShowsCommits.php | 133 +++++++- .../native/partials/commit-sheet.blade.php | 5 +- .../native/partials/image-viewer.blade.php | 54 ++++ tests/Feature/Screens/ImageViewerTest.php | 286 ++++++++++++++++++ tests/Feature/Screens/LogTest.php | 10 +- 6 files changed, 484 insertions(+), 7 deletions(-) create mode 100644 resources/views/native/partials/image-viewer.blade.php create mode 100644 tests/Feature/Screens/ImageViewerTest.php 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/resources/views/native/partials/commit-sheet.blade.php b/resources/views/native/partials/commit-sheet.blade.php index 00ce8fb..e09b020 100644 --- a/resources/views/native/partials/commit-sheet.blade.php +++ b/resources/views/native/partials/commit-sheet.blade.php @@ -27,7 +27,8 @@ they upload, and no body at all will do. It's full height for the forms, and short for the receipts. It's always on the screen, shown or not, so iOS can - slide it away and say when it's gone. + slide it away and say when it's gone. Beside it hangs the image viewer (see partials/image-viewer), always in the + tree too, which covers the sheet when a commit's images are opened from under it. --}} @if ($sheetCommit || in_array($sheetView, ['compose', 'issued'], true)) @@ -175,3 +176,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..98b754a --- /dev/null +++ b/resources/views/native/partials/image-viewer.blade.php @@ -0,0 +1,54 @@ +{{-- + 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, bounded on the phone itself so it can't be flung past it, and a drag pans it; both run on the + phone with no frame trip, so the picture follows a finger. 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..1118378 --- /dev/null +++ b/tests/Feature/Screens/ImageViewerTest.php @@ -0,0 +1,286 @@ + '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. + ->and($gestures['props'])->toMatchArray(['pinch-min' => 1.0, 'pinch-max' => 4.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('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 b8f164a..2a8ea26 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) { From 19dc53c0b1d684b18f65b2c51db3057b70e684d6 Mon Sep 17 00:00:00 2001 From: Ricardo Valfreixo Date: Thu, 22 Oct 2026 20:14:17 +0200 Subject: [PATCH 2/5] Use released camera plugin instead of deleted branch --- README.md | 2 +- composer.json | 2 +- composer.lock | 21 +++++++-------------- 3 files changed, 9 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index b0eeb21..70ba828 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ For now the app tracks unreleased branches, which Composer pulls straight from G - button variants named after a theme colour ([#137](https://github.com/NativePHP/mobile-ui/pull/137)), - a sticky header for lists ([#138](https://github.com/NativePHP/mobile-ui/pull/138)), - a pager header that scrolls with the page showing ([#139](https://github.com/NativePHP/mobile-ui/pull/139)). -- `nativephp/mobile-camera` on `fix/present-over-sheets`, the plugin's `main` plus presenting the camera and the photo library from the topmost view controller, so they open over the composer sheet ([NativePHP/mobile-camera#17](https://github.com/NativePHP/mobile-camera/pull/17)). `main` also has the HEIC to JPEG conversion and the persistent storage fix that 1.0.4 lacks. +- `nativephp/mobile-camera` on tagged `1.1.0`, which presents the camera and the photo library from the topmost view controller, so they open over the composer sheet ([NativePHP/mobile-camera#17](https://github.com/NativePHP/mobile-camera/pull/17)), with the topmost picker fix ([NativePHP/mobile-camera#15](https://github.com/NativePHP/mobile-camera/pull/15)) and the HEIC to JPEG conversion and the persistent storage fix that 1.0.4 lacks. Once those are released, the app goes back to tagged versions and the combined branches can be deleted. diff --git a/composer.json b/composer.json index 297eef0..d211d03 100644 --- a/composer.json +++ b/composer.json @@ -37,7 +37,7 @@ "laravel/tinker": "^3.0", "nativephp/mobile": "dev-combined/dev-null-app as 4.6.0", "nativephp/mobile-browser": "^1.2", - "nativephp/mobile-camera": "dev-fix/present-over-sheets as 1.0.5", + "nativephp/mobile-camera": "^1.1", "nativephp/mobile-secure-storage": "^1.1", "nativephp/mobile-share": "^1.1", "nativephp/mobile-ui": "dev-combined/dev-null-app as 0.8.0", diff --git a/composer.lock b/composer.lock index 52cc95a..421621a 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": "dd9f8ac9c92e7eb0fa3d9f0ecacf7bee", + "content-hash": "2a5c0cde3030efb5e9c1f0586f3632b3", "packages": [ { "name": "bacon/bacon-qr-code", @@ -2895,16 +2895,16 @@ }, { "name": "nativephp/mobile-camera", - "version": "dev-fix/present-over-sheets", + "version": "1.1.0", "source": { "type": "git", "url": "https://github.com/NativePHP/mobile-camera.git", - "reference": "e838409c0368bb2d1dd33be8679602e5560196ae" + "reference": "8a7d215413fb0979cb0c86f9c8499f883a671077" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/NativePHP/mobile-camera/zipball/e838409c0368bb2d1dd33be8679602e5560196ae", - "reference": "e838409c0368bb2d1dd33be8679602e5560196ae", + "url": "https://api.github.com/repos/NativePHP/mobile-camera/zipball/8a7d215413fb0979cb0c86f9c8499f883a671077", + "reference": "8a7d215413fb0979cb0c86f9c8499f883a671077", "shasum": "" }, "require": { @@ -2952,10 +2952,10 @@ ], "description": "Camera plugin for NativePHP Mobile (Photo capture, Video recording, Gallery picker)", "support": { - "source": "https://github.com/NativePHP/mobile-camera/tree/fix/present-over-sheets", + "source": "https://github.com/NativePHP/mobile-camera/tree/1.1.0", "issues": "https://github.com/NativePHP/mobile-camera/issues" }, - "time": "2026-10-19T19:09:59+00:00" + "time": "2026-10-22T15:11:58+00:00" }, { "name": "nativephp/mobile-secure-storage", @@ -11143,12 +11143,6 @@ "alias": "4.6.0", "alias_normalized": "4.6.0.0" }, - { - "package": "nativephp/mobile-camera", - "version": "dev-fix/present-over-sheets", - "alias": "1.0.5", - "alias_normalized": "1.0.5.0" - }, { "package": "nativephp/mobile-ui", "version": "dev-combined/dev-null-app", @@ -11159,7 +11153,6 @@ "minimum-stability": "stable", "stability-flags": { "nativephp/mobile": 20, - "nativephp/mobile-camera": 20, "nativephp/mobile-ui": 20 }, "prefer-stable": true, From a3f35ddfeb9ea5b0c08ebb151baf8d721ce272bc Mon Sep 17 00:00:00 2001 From: Ricardo Valfreixo Date: Thu, 22 Oct 2026 20:22:30 +0200 Subject: [PATCH 3/5] Remove completed native image viewer roadmap item --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 70ba828..64b66b4 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 From 8d865506b67c46bf2df4372fc6c7147157b86e51 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Sat, 24 Oct 2026 02:54:37 +0100 Subject: [PATCH 4/5] Keep the viewed image on screen: bounded pan, springing back to centre at 1x --- app/Devnull/Data/CommitImage.php | 13 ++++++++++ .../native/partials/image-viewer.blade.php | 8 +++--- tests/Feature/Screens/ImageViewerTest.php | 26 +++++++++++++++++-- 3 files changed, 42 insertions(+), 5 deletions(-) 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/resources/views/native/partials/image-viewer.blade.php b/resources/views/native/partials/image-viewer.blade.php index 98b754a..91ad523 100644 --- a/resources/views/native/partials/image-viewer.blade.php +++ b/resources/views/native/partials/image-viewer.blade.php @@ -1,8 +1,10 @@ {{-- 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, bounded on the phone itself so it can't be flung past it, and a drag pans it; both run on the - phone with no frame trip, so the picture follows a finger. The modal's own close, and Android's back, close it. + 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 @@ -16,7 +18,7 @@ @if ($viewerImages !== []) @php($image = $viewerImages[$viewerIndex]) - + 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. - ->and($gestures['props'])->toMatchArray(['pinch-min' => 1.0, 'pinch-max' => 4.0]) + // 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) @@ -82,6 +83,27 @@ function imagedCommitImages(): array $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'); From fd4b9bdcc256a86bb78ce0ebdd315ff078a5ea5d Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Sat, 24 Oct 2026 03:13:27 +0100 Subject: [PATCH 5/5] Require the bounded gesture area branches of mobile-air and mobile-ui Both are drafts (NativePHP/mobile-air#603, NativePHP/mobile-ui#173). Swap back to dev-main once they're merged. Co-Authored-By: Claude Opus 5.5 --- composer.json | 8 ++++---- composer.lock | 36 +++++++++++++++++++----------------- 2 files changed, 23 insertions(+), 21 deletions(-) diff --git a/composer.json b/composer.json index fbb3f3a..e50cf0f 100644 --- a/composer.json +++ b/composer.json @@ -13,11 +13,11 @@ "repositories": [ { "type": "vcs", - "url": "https://github.com/ricardovfreixo/mobile-air" + "url": "https://github.com/NativePHP/mobile-air" }, { "type": "vcs", - "url": "https://github.com/ricardovfreixo/mobile-ui" + "url": "https://github.com/NativePHP/mobile-ui" }, { "name": "nativephp-plugins", @@ -31,12 +31,12 @@ "laravel/boost": "^2.0", "laravel/framework": "^13.0", "laravel/tinker": "^3.0", - "nativephp/mobile": "dev-fix/root-tab-reselect 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-fix/list-root-tab-scroll-target 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 bbcd030..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": "abab2d6147983d77671c0ba0837c35f3", + "content-hash": "a5dfa4b4bf563e858153d3744a994e82", "packages": [ { "name": "bacon/bacon-qr-code", @@ -2717,16 +2717,16 @@ }, { "name": "nativephp/mobile", - "version": "dev-fix/root-tab-reselect", + "version": "dev-feat/gesture-area-bounded", "source": { "type": "git", - "url": "https://github.com/ricardovfreixo/mobile-air.git", - "reference": "f06d13ccc80e597b86600e442a2dd2407ac0ab5e" + "url": "https://github.com/NativePHP/mobile-air.git", + "reference": "64e04c285d2daa7a0029e849444a74c388b66041" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ricardovfreixo/mobile-air/zipball/f06d13ccc80e597b86600e442a2dd2407ac0ab5e", - "reference": "f06d13ccc80e597b86600e442a2dd2407ac0ab5e", + "url": "https://api.github.com/repos/NativePHP/mobile-air/zipball/64e04c285d2daa7a0029e849444a74c388b66041", + "reference": "64e04c285d2daa7a0029e849444a74c388b66041", "shasum": "" }, "require": { @@ -2833,9 +2833,10 @@ "nativephp" ], "support": { - "source": "https://github.com/ricardovfreixo/mobile-air/tree/fix/root-tab-reselect" + "source": "https://github.com/NativePHP/mobile-air/tree/feat/gesture-area-bounded", + "issues": "https://github.com/NativePHP/mobile-air/issues" }, - "time": "2026-10-23T13:20:45+00:00" + "time": "2026-10-24T02:10:34+00:00" }, { "name": "nativephp/mobile-browser", @@ -3059,16 +3060,16 @@ }, { "name": "nativephp/mobile-ui", - "version": "dev-fix/list-root-tab-scroll-target", + "version": "dev-feat/gesture-area-bounded", "source": { "type": "git", - "url": "https://github.com/ricardovfreixo/mobile-ui.git", - "reference": "28753b7ea337349243330c1f531ed61f386a2c6d" + "url": "https://github.com/NativePHP/mobile-ui.git", + "reference": "d135f295e8d6684868fb9d48f7e7782243520bee" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ricardovfreixo/mobile-ui/zipball/28753b7ea337349243330c1f531ed61f386a2c6d", - "reference": "28753b7ea337349243330c1f531ed61f386a2c6d", + "url": "https://api.github.com/repos/NativePHP/mobile-ui/zipball/d135f295e8d6684868fb9d48f7e7782243520bee", + "reference": "d135f295e8d6684868fb9d48f7e7782243520bee", "shasum": "" }, "require": { @@ -3118,7 +3119,8 @@ ], "description": "Native SwiftUI and Jetpack Composer components for NativePHP", "support": { - "source": "https://github.com/ricardovfreixo/mobile-ui/tree/fix/list-root-tab-scroll-target" + "source": "https://github.com/NativePHP/mobile-ui/tree/feat/gesture-area-bounded", + "issues": "https://github.com/NativePHP/mobile-ui/issues" }, "funding": [ { @@ -3126,7 +3128,7 @@ "url": "https://nativephp.com/sponsor" } ], - "time": "2026-10-23T12:43:42+00:00" + "time": "2026-10-24T02:05:19+00:00" }, { "name": "nesbot/carbon", @@ -11128,13 +11130,13 @@ "aliases": [ { "package": "nativephp/mobile", - "version": "dev-fix/root-tab-reselect", + "version": "dev-feat/gesture-area-bounded", "alias": "4.6.0", "alias_normalized": "4.6.0.0" }, { "package": "nativephp/mobile-ui", - "version": "dev-fix/list-root-tab-scroll-target", + "version": "dev-feat/gesture-area-bounded", "alias": "0.8.0", "alias_normalized": "0.8.0.0" }