diff --git a/app/NativeComponents/Layouts/TabsLayout.php b/app/NativeComponents/Layouts/TabsLayout.php index 86dbb5e5..149bb3d8 100644 --- a/app/NativeComponents/Layouts/TabsLayout.php +++ b/app/NativeComponents/Layouts/TabsLayout.php @@ -79,7 +79,7 @@ public function tabBar(NativeComponent $screen): ?TabBar return TabBar::make() ->activeColor(self::accent()) ->labelVisibility('labeled') - ->add(Tab::link('Log', '/log', ios: Lucide::GitCommitVertical->ios(), android: Lucide::GitCommitVertical->android())) + ->add(Tab::link('Log', '/log', ios: Lucide::GitCommitVertical->ios(), android: Lucide::GitCommitVertical->android())->onReselect('backToTopAndFetchNewest')) ->add($notifications) ->add($remotes) ->add(Tab::link('Profile', '/profile', ios: Lucide::UserRound->ios(), android: Lucide::UserRound->android())); diff --git a/app/NativeComponents/Log.php b/app/NativeComponents/Log.php index d8d7f11c..d04eaf7e 100644 --- a/app/NativeComponents/Log.php +++ b/app/NativeComponents/Log.php @@ -394,6 +394,21 @@ public function backToTop(int $index): void $this->show(self::Logs[$index] ?? ''); } + /** + * A second tap on the Log tab in the bottom bar: back to the top of the + * log you're on, and the newest commits. + */ + public function backToTopAndFetchNewest(): void + { + $this->listKey++; + + if (! $this->isRanked()) { + $this->showNewCommits(); + } + + $this->load(); + } + /** * The log you go to keeps the list its first few commits were drawn in, * which can't have been scrolled, and fills it. The log you leave starts diff --git a/composer.json b/composer.json index 2c9c1ca0..b3c3dbd4 100644 --- a/composer.json +++ b/composer.json @@ -91,7 +91,11 @@ "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump", "@php artisan package:discover --ansi" ], + "post-install-cmd": [ + "@php scripts/apply-nativephp-patches.php" + ], "post-update-cmd": [ + "@php scripts/apply-nativephp-patches.php", "@php artisan vendor:publish --tag=laravel-assets --ansi --force" ], "post-root-package-install": [ diff --git a/patches/nativephp-mobile-tab-reselect-native.patch b/patches/nativephp-mobile-tab-reselect-native.patch new file mode 100644 index 00000000..071d5a76 --- /dev/null +++ b/patches/nativephp-mobile-tab-reselect-native.patch @@ -0,0 +1,178 @@ +--- a/resources/androidstudio/app/src/main/java/com/nativephp/mobile/ui/nativerender/NativeRootTabsRenderer.kt ++++ b/resources/androidstudio/app/src/main/java/com/nativephp/mobile/ui/nativerender/NativeRootTabsRenderer.kt +@@ -249,6 +249,18 @@ + val requestedId = tabIds[actualIdx] + val isSearchTab = tab.props.getBool("search") + selection = actualIdx ++ ++ val tabUrl = tab.props.getString("url", "") ++ val atTabRoot = tabUrl.isNotEmpty() && tabUrl == currentUri.substringBefore('?') ++ ++ if (!isSearchTab && actualIdx == activeTabIdx && pendingTabId == null) { ++ val reselect = tab.props.getCallbackId("on_reselect") ++ if (atTabRoot && reselect != 0) { ++ NativeElementBridge.sendPressEvent(reselect, tab.id) ++ } ++ return@activate ++ } ++ + val tapNavigates = actualIdx != activeTabIdx || pendingTabId != null + if (!isSearchTab && tapNavigates && tab.onPress != 0) { + if (tab.props.getString("url", "").isNotEmpty()) { +--- a/resources/xcode/NativePHP/NativeRender/NativeRootTabsRenderer.swift ++++ b/resources/xcode/NativePHP/NativeRender/NativeRootTabsRenderer.swift +@@ -1,4 +1,5 @@ + import SwiftUI ++import UIKit + + /// Native chrome renderer for the `native_root_tabs` sentinel. Uses + /// SwiftUI's `TabView` — system tab styling, selection +@@ -194,6 +195,21 @@ + + let visibleTabEntries = tabs.enumerated().compactMap { index, tab in + (!tab.props.getBool("search") || shouldShowSearchTab) ? (index, tab) : nil ++ } ++ ++ // A tap on the tab that's already selected: SwiftUI's TabView does ++ // not report it, so the observer below watches the system tab bar. ++ let canReselect = pendingTabId == nil ++ let visibleIndices = visibleTabEntries.map { $0.0 } ++ let reselect: (Int) -> Void = { visibleIndex in ++ guard canReselect, tabs.indices.contains(visibleIndex) else { return } ++ let tab = tabs[visibleIndex] ++ let tabUrl = tab.props.getString("url", default: "") ++ let currentPath = PerTabNavigationCoordinator.path(of: currentUri) ++ guard !tabUrl.isEmpty, tabUrl == currentPath else { return } ++ let callback = tab.props.getCallbackId("on_reselect") ++ guard callback != 0 else { return } ++ NativeElementBridge.sendPressEvent(callback, nodeId: tab.id) + } + let presentationIdentifier = node.props.getString("tab_presentation_id", default: "") + let requestsCustomPresentation = !presentationIdentifier.isEmpty && presentationIdentifier != "system" +@@ -420,6 +436,11 @@ + .onChange(of: selection) { newId in + activateSelection(newId, tabs: tabs, owningId: owningId) + } ++ .background( ++ TabBarReselectObserver(visibleIndices: visibleIndices, onReselect: reselect) ++ .frame(width: 1, height: 1) ++ .allowsHitTesting(false) ++ ) + } + + private func requestTabActivation(at index: Int, tabs: [NativeUINode], owningId: String) { +@@ -1440,5 +1461,114 @@ + } + .buttonStyle(.plain) + .disabled(tapCallback == 0) ++ } ++} ++ ++/// Watches the system tab bar for a tap on the already-selected item. ++/// SwiftUI's TabView does not report that tap, so this attaches a ++/// non-cancelling recognizer to the underlying UITabBar. It only ++/// observes; it does not replace the bar or its delegate. ++private struct TabBarReselectObserver: UIViewRepresentable { ++ let visibleIndices: [Int] ++ let onReselect: (Int) -> Void ++ ++ func makeCoordinator() -> Coordinator { ++ Coordinator(visibleIndices: visibleIndices, onReselect: onReselect) ++ } ++ ++ func makeUIView(context: Context) -> UIView { ++ let view = UIView(frame: .zero) ++ view.isUserInteractionEnabled = false ++ context.coordinator.attach(from: view) ++ ++ return view ++ } ++ ++ func updateUIView(_ uiView: UIView, context: Context) { ++ context.coordinator.visibleIndices = visibleIndices ++ context.coordinator.onReselect = onReselect ++ context.coordinator.attach(from: uiView) ++ } ++ ++ final class Coordinator: NSObject, UIGestureRecognizerDelegate { ++ var visibleIndices: [Int] ++ var onReselect: (Int) -> Void ++ private weak var tabBar: UITabBar? ++ private var recognizer: UITapGestureRecognizer? ++ ++ init(visibleIndices: [Int], onReselect: @escaping (Int) -> Void) { ++ self.visibleIndices = visibleIndices ++ self.onReselect = onReselect ++ } ++ ++ func attach(from view: UIView) { ++ guard tabBar == nil else { return } ++ ++ DispatchQueue.main.async { [weak self, weak view] in ++ guard let self, let view, let window = view.window, ++ let bar = Self.findTabBar(in: window) else { return } ++ ++ let tap = UITapGestureRecognizer(target: self, action: #selector(self.tapped(_:))) ++ tap.cancelsTouchesInView = false ++ tap.delegate = self ++ bar.addGestureRecognizer(tap) ++ ++ self.tabBar = bar ++ self.recognizer = tap ++ } ++ } ++ ++ @objc private func tapped(_ recognizer: UITapGestureRecognizer) { ++ guard let bar = recognizer.view as? UITabBar, ++ let items = bar.items, !items.isEmpty, ++ let selected = bar.selectedItem, ++ let selectedIndex = items.firstIndex(where: { $0 === selected }) else { return } ++ ++ let location = recognizer.location(in: bar) ++ ++ guard let tappedIndex = Self.tabIndex(at: location, in: bar, count: items.count), ++ tappedIndex == selectedIndex, ++ visibleIndices.indices.contains(tappedIndex) else { return } ++ ++ onReselect(visibleIndices[tappedIndex]) ++ } ++ ++ private static func tabIndex(at location: CGPoint, in bar: UITabBar, count: Int) -> Int? { ++ let buttons = bar.subviews.filter { ++ String(describing: type(of: $0)).contains("TabBarButton") ++ } ++ ++ if buttons.count == count, ++ let index = buttons.firstIndex(where: { $0.frame.contains(location) }) { ++ return index ++ } ++ ++ guard bar.bounds.width > 0 else { return nil } ++ ++ let index = Int((location.x / bar.bounds.width) * CGFloat(count)) ++ ++ return min(max(index, 0), count - 1) ++ } ++ ++ private static func findTabBar(in view: UIView) -> UITabBar? { ++ if let bar = view as? UITabBar, !bar.isHidden, let items = bar.items, !items.isEmpty { ++ return bar ++ } ++ ++ for subview in view.subviews { ++ if let found = findTabBar(in: subview) { ++ return found ++ } ++ } ++ ++ return nil ++ } ++ ++ func gestureRecognizer( ++ _ gestureRecognizer: UIGestureRecognizer, ++ shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer ++ ) -> Bool { ++ true ++ } + } + } diff --git a/patches/nativephp-mobile-tab-reselect.patch b/patches/nativephp-mobile-tab-reselect.patch new file mode 100644 index 00000000..f684b714 --- /dev/null +++ b/patches/nativephp-mobile-tab-reselect.patch @@ -0,0 +1,81 @@ +--- a/src/Edge/Element.php ++++ b/src/Edge/Element.php +@@ -55,6 +55,8 @@ + protected ?string $longPressMethod = null; + + protected ?string $doubleTapMethod = null; ++ ++ protected ?string $reselectMethod = null; + + protected ?string $pressDownMethod = null; + +@@ -632,6 +634,18 @@ + public function onDoubleTap(string $method): static + { + $this->doubleTapMethod = $method; ++ ++ return $this; ++ } ++ ++ /** ++ * Tab-reselect handler for bottom-nav items: fires when the tab is ++ * already selected and tapped again. Carried in the props dict as ++ * `on_reselect`, so it needs no binary wire-format change. ++ */ ++ public function onReselect(string $method): static ++ { ++ $this->reselectMethod = $method; + + return $this; + } +@@ -788,6 +802,10 @@ + + if ($this->doubleTapMethod !== null) { + $props['on_double_tap'] = $registry->register($this->doubleTapMethod); ++ } ++ ++ if ($this->reselectMethod !== null) { ++ $props['on_reselect'] = $registry->register($this->reselectMethod); + } + + if ($this->pressDownMethod !== null) { +--- a/src/Edge/Layouts/Builders/Tab.php ++++ b/src/Edge/Layouts/Builders/Tab.php +@@ -68,6 +68,8 @@ + + private ?string $pressMethod = null; + ++ private ?string $reselectMethod = null; ++ + private function __construct(string $id, string $label, string $url) + { + $this->id = $id; +@@ -162,6 +164,17 @@ + return $this; + } + ++ /** ++ * Fires when this tab is already the selected tab and is tapped again. ++ * The normal URL navigation still handles taps from other tabs. ++ */ ++ public function onReselect(string $method): self ++ { ++ $this->reselectMethod = $method; ++ ++ return $this; ++ } ++ + public function id(string $id): self + { + $this->id = $id; +@@ -284,6 +297,10 @@ + $item->onPress($this->pressMethod); + } + ++ if ($this->reselectMethod !== null) { ++ $item->onReselect($this->reselectMethod); ++ } ++ + return $item; + } + } diff --git a/scripts/apply-nativephp-patches.php b/scripts/apply-nativephp-patches.php new file mode 100644 index 00000000..c24b0da3 --- /dev/null +++ b/scripts/apply-nativephp-patches.php @@ -0,0 +1,118 @@ + $markers file => marker that proves it is patched + */ +$apply = function (string $patch, array $markers) use ($package): void { + $applied = true; + + foreach ($markers as $file => $marker) { + if (! is_file($file) || ! str_contains((string) file_get_contents($file), $marker)) { + $applied = false; + break; + } + } + + if ($applied) { + return; + } + + $command = sprintf( + 'patch -p1 --forward --batch -d %s < %s', + escapeshellarg($package), + escapeshellarg($patch), + ); + + exec($command, $output, $code); + + if ($code !== 0) { + fwrite(STDERR, "Could not apply {$patch}:\n".implode("\n", $output)."\n"); + exit(1); + } +}; + +$apply($phpPatch, [ + $package.'/src/Edge/Element.php' => 'reselectMethod', + $package.'/src/Edge/Layouts/Builders/Tab.php' => 'reselectMethod', +]); + +$nativeMarkers = [ + $package.'/resources/androidstudio/app/src/main/java/com/nativephp/mobile/ui/nativerender/NativeRootTabsRenderer.kt' => 'on_reselect', + $package.'/resources/xcode/NativePHP/NativeRender/NativeRootTabsRenderer.swift' => 'TabBarReselectObserver', +]; + +if (is_file($package.'/resources/xcode/NativePHP/NativeRender/NativeRootTabsRenderer.swift')) { + $apply($nativePatch, $nativeMarkers); +} + +$generated = [ + 'iOS' => [ + $root.'/nativephp/ios/NativePHP/NativeRender/NativeRootTabsRenderer.swift', + 'TabBarReselectObserver', + 'php artisan native:install ios --force', + ], + 'Android' => [ + $root.'/nativephp/android/app/src/main/java/com/nativephp/mobile/ui/nativerender/NativeRootTabsRenderer.kt', + 'on_reselect', + 'php artisan native:install android --force', + ], +]; + +foreach ($generated as $platform => [$file, $marker, $install]) { + if (is_file($file) && ! str_contains((string) file_get_contents($file), $marker)) { + fwrite(STDERR, "The patched {$platform} renderer is not in the generated native project yet. Run: {$install}\n"); + } +} + +echo "nativephp/mobile tab reselect patch is applied.\n"; diff --git a/tests/Feature/Edge/TabReselectTest.php b/tests/Feature/Edge/TabReselectTest.php new file mode 100644 index 00000000..dee4a689 --- /dev/null +++ b/tests/Feature/Edge/TabReselectTest.php @@ -0,0 +1,12 @@ +onReselect('backToTopAndFetchNewest'); + + $props = $tab->toElement()->getResolvedProps(new CallbackRegistry); + + expect($props['on_reselect'] ?? null)->toBeInt()->toBeGreaterThan(0); +}); diff --git a/tests/Feature/Screens/LogsTest.php b/tests/Feature/Screens/LogsTest.php index 87ecfc52..cd2a5769 100644 --- a/tests/Feature/Screens/LogsTest.php +++ b/tests/Feature/Screens/LogsTest.php @@ -604,3 +604,60 @@ function ada(): array ->assertSee('Kept following') ->assertSee('Kept latest'); }); + +it('takes the log back to the top and puts the newest commits in when the Log tab is selected again', function () { + holdRefreshes(); + keep('log/latest', pageJson([commitJson(['id' => 'c1', 'body' => 'Seen before'])])); + + $newer = array_map( + fn (int $n): array => commitJson(['id' => "n{$n}", 'body' => "New {$n}", 'author' => ada()]), + range(1, 3), + ); + + serveApi([ + 'GET /api/v1/log/following' => [pageJson([])], + 'GET /api/v1/log/latest' => [[...pageJson($newer), 'new_commits_count' => 3]], + 'GET /api/v1/me' => [['data' => userWithCountsJson(), 'unread_notifications_count' => 0]], + ]); + + $screen = finishRefreshes(tapLogTab(Native::visit('/log'), 'latest')) + ->assertSee('Seen before') + ->assertSee('3 new commits'); + + $tab = findNode( + $screen->tree(), + fn (array $node): bool => ($node['type'] ?? null) === 'bottom_nav_item' + && ($node['props']['label'] ?? null) === 'Log', + ); + + expect($tab['props']['on_reselect'] ?? null)->toBeInt()->toBeGreaterThan(0); + + $listKey = $screen->get('listKey'); + + $screen = finishRefreshes($screen->call('backToTopAndFetchNewest')) + ->assertSee('New 1') + ->assertDontSee('3 new commits'); + + expect($screen->get('listKey'))->toBeGreaterThan($listKey) + ->and(logRequestsSent())->toContain('log/latest'); +}); + +it('re-ranks Trending when the Log tab is selected again, without carrying the pill over', function () { + holdRefreshes(); + serveApi([ + 'GET /api/v1/log/trending' => [trendingJson([commitJson(['id' => 'a', 'body' => 'First'])], newest: 'a')], + 'GET /api/v1/log/following' => [pageJson([])], + 'GET /api/v1/me' => [['data' => userWithCountsJson(), 'unread_notifications_count' => 0]], + ]); + + $screen = finishRefreshes(tapLogTab(Native::visit('/log'), 'trending')); + $listKey = $screen->get('listKey'); + + $screen->call('backToTopAndFetchNewest'); + + expect($screen->get('listKey'))->toBe($listKey + 1); + + finishRefreshes($screen); + + expect(logRequestsSent())->toContain('log/trending'); +});