From 2394ab3dd5ac5a45e32429d0a3aab2d2619496ff Mon Sep 17 00:00:00 2001 From: li0nd3v Date: Tue, 17 Mar 2026 18:28:26 +0100 Subject: [PATCH 1/5] refactor: browser readiness for new nav --- mobile/app/DAppBrowser.tsx | 430 +++++++++++++++--------------- mobile/app/DAppBrowserTabItem.tsx | 99 +++++-- mobile/app/DAppBrowserTabs.tsx | 100 ++++++- mobile/assets/images/home.svg | 3 + 4 files changed, 393 insertions(+), 239 deletions(-) create mode 100644 mobile/assets/images/home.svg diff --git a/mobile/app/DAppBrowser.tsx b/mobile/app/DAppBrowser.tsx index d7065d1f7..16fc93e0d 100644 --- a/mobile/app/DAppBrowser.tsx +++ b/mobile/app/DAppBrowser.tsx @@ -13,8 +13,8 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import { Image as ExpoImage } from 'expo-image'; import { Gesture, GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler'; import Animated, { useAnimatedStyle, useSharedValue, withTiming, withSpring, interpolate, runOnJS } from 'react-native-reanimated'; +import { SafeAreaView } from 'react-native-safe-area-context'; import { ThemedText } from '@/components/ThemedText'; -import GradientScreen from '@/components/GradientScreen'; import { BrowserBridge } from '@/src/class/browser-bridge'; import { BackgroundExecutor } from '@/src/modules/background-executor'; import { AccountNumberContext } from '@shared/hooks/AccountNumberContext'; @@ -25,6 +25,7 @@ import { getNetworkImageAsset } from '@/utils/networkAssets'; import { ActionPopupButton } from '@/components/ActionPopupButton'; import { DAppBrowserTabs } from './DAppBrowserTabs'; import { useWebViewPreviewManager } from './hooks/useWebViewPreviewManager'; +import PlatformBlurView from '@/components/PlatformBlurView'; export const BROWSER_CONSTANTS = { ANIMATION: { @@ -36,7 +37,7 @@ export const BROWSER_CONSTANTS = { }, TIMEOUTS: { SCREENSHOT_DELAY: 500, - POST_LOAD_CAPTURE: 1000, + POST_LOAD_CAPTURE: 1500, LOADING_TIMEOUT: 10000, }, MODAL: { @@ -57,6 +58,8 @@ export const BROWSER_CONSTANTS = { }, } as const; +const homeIcon = require('@/assets/images/home.svg'); + const getHomeUrl = (network: string): string => `https://layerztec.github.io/website/explore/?network=${network}`; // to test: https://metamask.github.io/test-dapp/ & https://eip6963.org/ const getTabTitle = (url: string): string => { @@ -271,6 +274,7 @@ const DAppBrowser: React.FC = () => { })); const panGesture = Gesture.Pan() + .enabled(false) .onStart(() => { gestureStartPosition.value = modalTranslateY.value; }) @@ -350,17 +354,10 @@ const DAppBrowser: React.FC = () => { useEffect(() => { navigation.setOptions({ - headerShown: showTabsOverview, - title: 'Tabs', - headerBackVisible: false, - headerTransparent: true, - headerBlurEffect: 'dark', - headerTintColor: 'white', - headerStyle: { - backgroundColor: 'transparent', - }, + // Tabs overlay has its own header; keep native header hidden to avoid double headers. + headerShown: false, }); - }, [showTabsOverview, navigation]); + }, [navigation]); useFocusEffect( useCallback(() => { @@ -607,16 +604,14 @@ const DAppBrowser: React.FC = () => { await new Promise((resolve) => setTimeout(resolve, delay)); } - // Check if ref is ready, with retry logic + // Check if ref is ready, with small bounded retry (mount/layout race) + const retryDelaysMs = [100, 200, 400]; let containerRef = tabContainerRefs.current[tabId]; - if (!containerRef?.current) { - // Wait a bit for ref to be set - await new Promise((resolve) => setTimeout(resolve, 100)); + for (let attempt = 0; attempt <= retryDelaysMs.length; attempt++) { + if (containerRef?.current) break; + if (attempt === retryDelaysMs.length) return null; + await new Promise((resolve) => setTimeout(resolve, retryDelaysMs[attempt])); containerRef = tabContainerRefs.current[tabId]; - - if (!containerRef?.current) { - return null; - } } try { @@ -672,16 +667,29 @@ const DAppBrowser: React.FC = () => { ? { ...currentTab, screenshot: storedScreenshot, + timestamp: Date.now(), } : currentTab ); }); + return; } else { + // If we already have a screenshot, don't force a reload/capture just because the manifest is missing. + // This avoids replacing a working image with a failing one. + if (hasScreenshot) { + return; + } + // If no stored screenshot, mark tab as needing one and trigger reload if (!tabsNeedingScreenshotsRef.current.has(tabId)) { tabsNeedingScreenshotsRef.current.add(tabId); - // Try forcing the WebView to load by triggering a reload + // Try a short capture attempt first (sometimes reload isn't needed) + setTimeout(() => { + captureTabScreenshot(tabId, 0).catch(() => {}); + }, 300); + + // Then try forcing the WebView to load by triggering a reload (fallback) const webviewRef = tabWebViewRefs.current[tabId]; if (webviewRef?.current) { webviewRef.current.reload(); @@ -689,9 +697,13 @@ const DAppBrowser: React.FC = () => { } } }, - [screenshots] + [screenshots, captureTabScreenshot] ); + const invalidateTabPreview = useCallback((tabId: string) => { + setTabs((prev) => prev.map((t) => (t.id === tabId ? { ...t, screenshot: undefined, timestamp: Date.now() } : t))); + }, []); + useEffect(() => { const restoreTabs = async () => { const restored = await loadTabs(); @@ -861,11 +873,26 @@ const DAppBrowser: React.FC = () => { tabsOpacity.value = withTiming(1, { duration: BROWSER_CONSTANTS.ANIMATION.STANDARD }); addressBarTranslateY.value = withTiming(-120, { duration: BROWSER_CONSTANTS.ANIMATION.STANDARD }); - // Capture current tab screenshot + // Capture current tab screenshot only if missing (don't replace an existing preview on overlay open) if (activeTabId) { - captureTabScreenshot(activeTabId).catch((error) => globalThis.handleError?.(error, 'captureTabScreenshot')); + const active = tabs.find((t) => t.id === activeTabId); + if (!active?.screenshot) { + captureTabScreenshot(activeTabId).catch((error) => globalThis.handleError?.(error, 'captureTabScreenshot')); + } } + // Refresh previews for the first visible cards (helps after cache prune / restore) + const topN = 6; + tabs.slice(0, topN).forEach((tab, index) => { + if (tab.screenshot) return; + setTimeout( + () => { + ensureTabPreview(tab.id, false).catch((error) => globalThis.handleError?.(error, 'ensureTabPreview')); + }, + 250 + index * 200 + ); + }); + // Ensure all tabs have screenshots (stagger to avoid overwhelming the system) tabs.forEach((tab, index) => { if (!tab.screenshot) { @@ -981,34 +1008,6 @@ const DAppBrowser: React.FC = () => { webviewRef.current?.injectJavaScript(`window.location.href = '${historyItem.url}';`); }; - const goForward = () => { - const tab = tabs.find((t) => t.id === activeTabId); - if (!tab || tab.historyIndex >= tab.history.length - 1) return; - - const newIndex = tab.historyIndex + 1; - const historyItem = tab.history[newIndex]; - - isManualNavigation.current = true; - lastManualNavigationUrl.current = historyItem.url; - setTabs((prev) => - prev.map((t) => - t.id === activeTabId - ? { - ...t, - historyIndex: newIndex, - url: historyItem.url, - title: historyItem.title, - canGoBack: newIndex > 0, - canGoForward: newIndex < t.history.length - 1, - } - : t - ) - ); - - setAddressBarValue(historyItem.url, { ensureStartVisible: true }); - webviewRef.current?.injectJavaScript(`window.location.href = '${historyItem.url}';`); - }; - const goToHistoryItem = (index: number) => { const tab = tabs.find((t) => t.id === activeTabId); if (!tab || index < 0 || index >= tab.history.length) return; @@ -1042,12 +1041,6 @@ const DAppBrowser: React.FC = () => { return tab.history.slice(0, tab.historyIndex).reverse(); }; - const getForwardHistory = () => { - const tab = tabs.find((t) => t.id === activeTabId); - if (!tab) return []; - return tab.history.slice(tab.historyIndex + 1); - }; - const injectAutofillScript = useCallback((address: string) => { const script = ` (function() { @@ -1215,21 +1208,21 @@ const DAppBrowser: React.FC = () => { if (error) { return ( - + {error} - + ); } if (!js) { return ( - + Loading DApp browser... - + ); } @@ -1238,15 +1231,18 @@ const DAppBrowser: React.FC = () => { - + - - + + + + + { ) : ( - - - + )} - {}, - variant: 'section', - children: Autofill, - }, - { - onClick: async () => { - const next = !autofillEnabled; - setAutofillEnabled(next); - await AsyncStorage.setItem(BROWSER_CONSTANTS.STORAGE.AUTOFILL_BTC_DISABLED_KEY, next ? '' : 'true'); - if (next && btcAddress) { - injectAutofillScript(btcAddress); - } + {!isAddressInputFocused && ( + {}, + variant: 'section', + children: Page, }, - children: ( - - - - Autofill Bitcoin Address + { + onClick: onRefresh, + children: ( + + + + Refresh + - Automatically fill BTC address fields on websites. - - ), - }, - { - onClick: () => {}, - variant: 'section', - children: Clipboard, - }, - { - onClick: async () => { - if (!btcAddress) return; - await Clipboard.setStringAsync(btcAddress); + ), }, - children: ( - - - - Copy Bitcoin Address + { + onClick: () => {}, + variant: 'section', + children: Autofill, + }, + { + onClick: async () => { + const next = !autofillEnabled; + setAutofillEnabled(next); + await AsyncStorage.setItem(BROWSER_CONSTANTS.STORAGE.AUTOFILL_BTC_DISABLED_KEY, next ? '' : 'true'); + if (next && btcAddress) { + injectAutofillScript(btcAddress); + } + }, + children: ( + + + + Autofill Bitcoin Address + + Automatically fill BTC address fields on websites. - {btcAddress ? {btcAddress} : null} - - ), - }, - ]} - > - {}, + variant: 'section', + children: Clipboard, + }, + { + onClick: async () => { + if (!btcAddress) return; + await Clipboard.setStringAsync(btcAddress); + }, + children: ( + + + + Copy Bitcoin Address + + {btcAddress ? {btcAddress} : null} + + ), + }, + ]} > - - - + + + + + )} - router.back()} testID="BrowserCloseButton"> - - + {!isAddressInputFocused && ( + + + {tabs.length} + + + )} - {showAddressSuggestions && addressSuggestions.length > 0 && ( + {!isAddressInputFocused && showAddressSuggestions && addressSuggestions.length > 0 && ( {addressSuggestions.map((suggestion, index) => ( { })} - - - - - {!showTabsOverview && ( - <> - - {activeTab?.canGoBack && getBackHistory().length > 0 ? ( - - - - - - - - - {getBackHistory().map((item, index) => { - const historyIndex = (activeTab?.historyIndex || 0) - index - 1; - return goToHistoryItem(historyIndex)} />; - })} - - - - ) : ( - - - - )} - - - - {activeTab?.canGoForward && getForwardHistory().length > 0 ? ( - - - - - - - - - {getForwardHistory().map((item, index) => { - const historyIndex = (activeTab?.historyIndex || 0) + index + 1; - return goToHistoryItem(historyIndex)} />; - })} - - - - ) : ( - - - - )} + + + {showTabsOverview && ( + + + + + + + Add new + - - )} - - - - - - + - - - - - - - - {tabs.length} + + + + - - - - - + + + + )} + ); @@ -1552,6 +1515,11 @@ const styles = StyleSheet.create({ gestureRootView: { flex: 1, }, + blackScreen: { + flex: 1, + backgroundColor: '#000', + position: 'relative', + }, flex1: { flex: 1, }, @@ -1609,7 +1577,6 @@ const styles = StyleSheet.create({ }, addressBarContainer: { position: 'relative', - zIndex: 10, }, addressBarWrapper: { flex: 1, @@ -1652,6 +1619,10 @@ const styles = StyleSheet.create({ fontSize: 14, color: 'rgba(255, 255, 255, 0.9)', }, + addressBackButton: { + padding: 4, + marginRight: 8, + }, stopButton: { padding: 4, marginLeft: 8, @@ -1675,11 +1646,11 @@ const styles = StyleSheet.create({ fontSize: 14, color: 'rgba(255, 255, 255, 0.9)', }, - closeButton: { + topRightButton: { width: 40, height: 40, borderRadius: 20, - backgroundColor: 'rgba(255, 255, 255, 0.2)', + backgroundColor: 'transparent', justifyContent: 'center', alignItems: 'center', }, @@ -1691,9 +1662,9 @@ const styles = StyleSheet.create({ justifyContent: 'center', alignItems: 'center', }, - networkIcon: { - width: 24, - height: 24, + homeIcon: { + width: 16, + height: 16, }, contentContainer: { flex: 1, @@ -1701,7 +1672,7 @@ const styles = StyleSheet.create({ }, webviewContainer: { flex: 1, - backgroundColor: 'white', + backgroundColor: 'black', }, tabContainer: { flex: 1, @@ -1752,6 +1723,11 @@ const styles = StyleSheet.create({ alignItems: 'center', paddingVertical: 16, paddingHorizontal: 24, + }, + bottomSafeArea: { + backgroundColor: 'transparent', + }, + bottomBlur: { backgroundColor: 'rgba(255, 255, 255, 0.1)', }, navigationLeft: { @@ -1784,12 +1760,32 @@ const styles = StyleSheet.create({ justifyContent: 'center', }, addTabButton: { - width: 48, - height: 48, - borderRadius: 24, - backgroundColor: 'rgba(255, 255, 255, 0.2)', + paddingHorizontal: 16, + paddingVertical: 8, + borderRadius: 6, + backgroundColor: 'rgba(255, 255, 255, 0.15)', justifyContent: 'center', alignItems: 'center', + flexDirection: 'row', + gap: 4, + }, + addTabButtonIcon: { + marginRight: 6, + }, + addTabButtonText: { + fontSize: 14, + color: 'rgba(255, 255, 255, 0.95)', + fontWeight: '600', + }, + closeOverviewButton: { + width: 40, + height: 40, + borderRadius: 20, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'rgba(255, 255, 255, 0.12)', + borderWidth: StyleSheet.hairlineWidth, + borderColor: 'rgba(255, 255, 255, 0.16)', }, tabsOverviewIcon: { width: 24, diff --git a/mobile/app/DAppBrowserTabItem.tsx b/mobile/app/DAppBrowserTabItem.tsx index a49940964..82702388c 100644 --- a/mobile/app/DAppBrowserTabItem.tsx +++ b/mobile/app/DAppBrowserTabItem.tsx @@ -20,14 +20,18 @@ interface BrowserTab { interface DAppBrowserTabItemProps { tab: BrowserTab; index: number; + isVisible: boolean; onPress: () => void; onClose: () => void; getTabTitle: (url: string) => string; onEnsurePreview: (forceReload?: boolean) => void; + onInvalidatePreview?: () => void; } -export const DAppBrowserTabItem: React.FC = ({ tab, index, onPress, onClose, onEnsurePreview }) => { +export const DAppBrowserTabItem: React.FC = ({ tab, index, isVisible, onPress, onClose, onEnsurePreview, onInvalidatePreview }) => { const [imageError, setImageError] = useState(false); + const [hasLoaded, setHasLoaded] = useState(false); + const [reloadToken, setReloadToken] = useState(0); const getDomainName = (url: string): string => { try { @@ -38,13 +42,33 @@ export const DAppBrowserTabItem: React.FC = ({ tab, ind } }; + const getFaviconUrl = (url: string): string | null => { + try { + const domain = getDomainName(url); + if (!domain) { + return null; + } + return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(domain)}&sz=64`; + } catch { + return null; + } + }; + useEffect(() => { // Reset error state when screenshot changes if (tab.screenshot) { setImageError(false); + setHasLoaded(false); + setReloadToken((v) => v + 1); } }, [tab.screenshot]); + useEffect(() => { + if (isVisible) { + setImageError(false); + } + }, [isVisible]); + useEffect(() => { // Ensure preview is loaded for tabs without screenshots if (!tab.screenshot) { @@ -53,16 +77,31 @@ export const DAppBrowserTabItem: React.FC = ({ tab, ind // eslint-disable-next-line react-hooks/exhaustive-deps }, [tab.screenshot, tab.id]); + useEffect(() => { + if (!tab.screenshot || imageError || hasLoaded) return; + const timeout = setTimeout(() => { + if (!hasLoaded) { + setReloadToken((v) => v + 1); + } + }, 1500); + return () => clearTimeout(timeout); + }, [tab.screenshot, imageError, hasLoaded]); + return ( - #{index + 1} - - {tab.title} - + + {getFaviconUrl(tab.url) ? ( + + ) : ( + + {getDomainName(tab.url).charAt(0).toUpperCase()} + + )} + - + @@ -70,10 +109,13 @@ export const DAppBrowserTabItem: React.FC = ({ tab, ind {tab.screenshot && !imageError ? ( { + setHasLoaded(true); + }} onError={() => { setImageError(true); // Try to reload from storage if the screenshot URI is invalid @@ -87,7 +129,7 @@ export const DAppBrowserTabItem: React.FC = ({ tab, ind )} - {tab.url || 'Untitled Tab'} + {tab.title || getDomainName(tab.url) || 'Untitled Tab'} @@ -117,20 +159,47 @@ const styles = StyleSheet.create({ flex: 1, marginRight: 8, }, - tabCardNumber: { - fontSize: 12, - fontWeight: '700', - color: 'rgba(255, 255, 255, 0.6)', - marginRight: 6, - }, tabCardTitle: { fontSize: 14, fontWeight: '600', color: 'white', flex: 1, }, + faviconWrapper: { + width: 18, + height: 18, + borderRadius: 9, + overflow: 'hidden', + alignItems: 'center', + justifyContent: 'center', + }, + favicon: { + width: 18, + height: 18, + borderRadius: 9, + }, + faviconFallback: { + width: 18, + height: 18, + borderRadius: 9, + backgroundColor: 'rgba(255, 255, 255, 0.16)', + alignItems: 'center', + justifyContent: 'center', + }, + faviconFallbackText: { + fontSize: 10, + color: 'rgba(255, 255, 255, 0.9)', + fontWeight: '600', + }, tabCardCloseButton: { - padding: 4, + width: 22, + height: 22, + borderRadius: 11, + backgroundColor: 'rgba(255, 255, 255, 0.14)', + borderWidth: StyleSheet.hairlineWidth, + borderColor: 'rgba(255, 255, 255, 0.18)', + alignItems: 'center', + justifyContent: 'center', }, tabCardPreview: { flex: 1, diff --git a/mobile/app/DAppBrowserTabs.tsx b/mobile/app/DAppBrowserTabs.tsx index adbecbdad..5c6bdd2de 100644 --- a/mobile/app/DAppBrowserTabs.tsx +++ b/mobile/app/DAppBrowserTabs.tsx @@ -1,7 +1,11 @@ -import React from 'react'; -import { View, ScrollView, StyleSheet } from 'react-native'; +import React, { useCallback, useRef } from 'react'; +import { View, ScrollView, StyleSheet, Platform, ActionSheetIOS, UIManager, findNodeHandle } from 'react-native'; import Animated from 'react-native-reanimated'; +import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; +import { Ionicons } from '@expo/vector-icons'; +import { ThemedText } from '@/components/ThemedText'; import { DAppBrowserTabItem } from './DAppBrowserTabItem'; +import Pressable from '../components/Pressable'; interface BrowserTab { id: string; @@ -22,23 +26,80 @@ interface DAppBrowserTabsProps { activeTabId: string; animatedStyle: any; pointerEvents: 'auto' | 'none'; + isVisible: boolean; onSwitchTab: (tabId: string) => void; onCloseTab: (tabId: string) => void; getTabTitle: (url: string) => string; onEnsurePreview: (tabId: string, forceReload?: boolean) => void | Promise; + onInvalidatePreview: (tabId: string) => void; + onCloseAllTabs: () => void; } -export const DAppBrowserTabs: React.FC = ({ tabs, animatedStyle, pointerEvents, onSwitchTab, onCloseTab, getTabTitle, onEnsurePreview }) => { +export const DAppBrowserTabs: React.FC = ({ + tabs, + animatedStyle, + pointerEvents, + isVisible, + onSwitchTab, + onCloseTab, + getTabTitle, + onEnsurePreview, + onInvalidatePreview, + onCloseAllTabs, +}) => { + const insets = useSafeAreaInsets(); + const menuAnchorRef = useRef(null); + + const openTabsMenu = useCallback(() => { + const node = findNodeHandle(menuAnchorRef.current); + const popup = (UIManager as any).showPopupMenu as undefined | ((reactTag: number, items: string[], error: () => void, success: (eventName: string, index?: number) => void) => void); + + if (node && typeof popup === 'function') { + popup( + node, + ['Close all tabs'], + () => {}, + (eventName: string, index?: number) => { + if (eventName !== 'itemSelected') return; + if (index === 0) onCloseAllTabs(); + } + ); + return; + } + + // Fallback (should be rare): use system action sheet if popup menu isn't available. + if (Platform.OS === 'ios') { + ActionSheetIOS.showActionSheetWithOptions( + { + options: ['Cancel', 'Close all tabs'], + cancelButtonIndex: 0, + destructiveButtonIndex: 1, + userInterfaceStyle: 'dark', + }, + (buttonIndex) => { + if (buttonIndex === 1) onCloseAllTabs(); + } + ); + } + }, [onCloseAllTabs]); + return ( - - + + + Tabs + + + + + {tabs.map((tab, index) => ( { onSwitchTab(tab.id); }} @@ -49,11 +110,14 @@ export const DAppBrowserTabs: React.FC = ({ tabs, animated onEnsurePreview={(forceReload) => { void onEnsurePreview(tab.id, forceReload); }} + onInvalidatePreview={() => { + onInvalidatePreview(tab.id); + }} /> ))} - + ); }; @@ -73,10 +137,32 @@ const styles = StyleSheet.create({ flex: 1, backgroundColor: 'rgba(0, 0, 0, 0.85)', }, + header: { + paddingHorizontal: 20, + paddingTop: 8, + paddingBottom: 8, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + title: { + fontSize: 34, + fontWeight: '700', + lineHeight: 42, + color: 'rgba(255, 255, 255, 0.95)', + letterSpacing: -0.6, + }, + headerMenuButton: { + width: 32, + height: 32, + borderRadius: 16, + alignItems: 'center', + justifyContent: 'center', + }, tabsOverviewContent: { flex: 1, paddingHorizontal: 20, - paddingTop: 100, + paddingTop: 8, }, tabsGridContainer: { paddingBottom: 20, diff --git a/mobile/assets/images/home.svg b/mobile/assets/images/home.svg new file mode 100644 index 000000000..f7b1093d1 --- /dev/null +++ b/mobile/assets/images/home.svg @@ -0,0 +1,3 @@ + + + From 1a229cbfc496545dc1546fa9c43ba2572835145b Mon Sep 17 00:00:00 2001 From: li0nd3v Date: Fri, 20 Mar 2026 11:42:04 +0100 Subject: [PATCH 2/5] feat: new explorer view --- mobile/app/DAppBrowser.tsx | 330 ++++++++++---- .../components/Explorer/ExplorerContent.tsx | 380 ++++++++++++++++ mobile/components/Explorer/ExplorerView.tsx | 426 ++++++++++++++++++ 3 files changed, 1043 insertions(+), 93 deletions(-) create mode 100644 mobile/components/Explorer/ExplorerContent.tsx create mode 100644 mobile/components/Explorer/ExplorerView.tsx diff --git a/mobile/app/DAppBrowser.tsx b/mobile/app/DAppBrowser.tsx index 16fc93e0d..8c1b84103 100644 --- a/mobile/app/DAppBrowser.tsx +++ b/mobile/app/DAppBrowser.tsx @@ -20,12 +20,15 @@ import { BackgroundExecutor } from '@/src/modules/background-executor'; import { AccountNumberContext } from '@shared/hooks/AccountNumberContext'; import { NetworkContext } from '@shared/hooks/NetworkContext'; import { useFocusEffect } from '@react-navigation/native'; -import { NETWORK_BITCOIN } from '@shared/types/networks'; +import { NETWORK_BITCOIN, NETWORK_CITREA } from '@shared/types/networks'; import { getNetworkImageAsset } from '@/utils/networkAssets'; import { ActionPopupButton } from '@/components/ActionPopupButton'; import { DAppBrowserTabs } from './DAppBrowserTabs'; import { useWebViewPreviewManager } from './hooks/useWebViewPreviewManager'; import PlatformBlurView from '@/components/PlatformBlurView'; +import ExplorerContent, { ExplorerCategory } from '@/components/Explorer/ExplorerContent'; +import { getPartnersList } from '@shared/models/partners-list'; +import type { PartnerInfo } from '@shared/types/partner-info'; export const BROWSER_CONSTANTS = { ANIMATION: { @@ -153,11 +156,18 @@ const DAppBrowser: React.FC = () => { const [js, setJs] = useState(null); const [error, setError] = useState(null); const params = useLocalSearchParams(); + const [viewMode, setViewMode] = useState<'explorer' | 'browser'>(() => (params.url ? 'browser' : 'explorer')); + const [explorerCategory, setExplorerCategory] = useState('bitcoin'); + const viewModeRef = useRef(viewMode); + useEffect(() => { + viewModeRef.current = viewMode; + }, [viewMode]); + const explorerPlaceholder = 'Search on Bitcoin'; const initialUrl = params.url || getHomeUrl(network); const [tabs, setTabs] = useState([]); const [activeTabId, setActiveTabId] = useState(''); const [isRestoringTabs, setIsRestoringTabs] = useState(true); - const [addressInput, setAddressInput] = useState(initialUrl); + const [addressInput, setAddressInput] = useState(() => (params.url ? initialUrl : '')); const [showTabsOverview, setShowTabsOverview] = useState(false); const [isLoading, setIsLoading] = useState(false); const [isAddressInputFocused, setIsAddressInputFocused] = useState(false); @@ -402,6 +412,7 @@ const DAppBrowser: React.FC = () => { }, []); useEffect(() => { + if (viewMode !== 'browser') return; (async () => { try { // eslint-disable-next-line @typescript-eslint/no-require-imports @@ -476,7 +487,7 @@ const DAppBrowser: React.FC = () => { setError('Failed to load DApp browser script: ' + error.message); } })(); - }, [network, setAddressBarValue]); + }, [viewMode, network, setAddressBarValue]); const isPurgingRef = useRef(false); const hasPurgedRef = useRef(false); @@ -713,13 +724,21 @@ const DAppBrowser: React.FC = () => { setActiveTabId(restored.activeTabId); const activeTab = restored.tabs.find((t) => t.id === restored.activeTabId); if (activeTab) { - setAddressBarValue(activeTab.url, { ensureStartVisible: true }); + if (viewModeRef.current === 'browser') { + setAddressBarValue(activeTab.url, { ensureStartVisible: true }); + } else { + setAddressInput(''); + } } } else { const initialTab = createHomeTab(network); setTabs([initialTab]); setActiveTabId(initialTab.id); - setAddressBarValue(initialTab.url, { ensureStartVisible: true }); + if (viewModeRef.current === 'browser') { + setAddressBarValue(initialTab.url, { ensureStartVisible: true }); + } else { + setAddressInput(''); + } } setIsRestoringTabs(false); @@ -755,6 +774,7 @@ const DAppBrowser: React.FC = () => { useEffect(() => { const handleAppStateChange = async (nextAppState: AppStateStatus) => { + if (viewMode !== 'browser') return; if (nextAppState === 'background' || nextAppState === 'inactive') { if (activeTabId) { await captureTabScreenshot(activeTabId); @@ -764,7 +784,7 @@ const DAppBrowser: React.FC = () => { const subscription = AppState.addEventListener('change', handleAppStateChange); return () => subscription.remove(); - }, [activeTabId, captureTabScreenshot]); + }, [activeTabId, captureTabScreenshot, viewMode]); useEffect(() => { if (params.url && !isRestoringTabs && tabs.length > 0 && lastHandledUrl.current !== params.url) { @@ -784,22 +804,50 @@ const DAppBrowser: React.FC = () => { } }, [params.url, isRestoringTabs, tabs, setAddressBarValue]); - const redirectActiveTabToHome = () => { - const homeUrl = getHomeUrl(network); - const homeTitle = 'layerztec.github.io'; - - updateActiveTab({ - url: homeUrl, - title: homeTitle, - history: [{ url: homeUrl, title: homeTitle }], - historyIndex: 0, - canGoBack: false, - canGoForward: false, - }); - setAddressBarValue(homeUrl, { ensureStartVisible: true }); + const handleHomePress = () => { + // "Home" should always go to Explorer-only. + setViewMode('explorer'); + setAddressInput(''); + setShowTabsOverview(false); + setShowAddressSuggestions(false); + setIsAddressInputFocused(false); + addressInputRef.current?.blur(); + }; + + const openWebAppInNewTab = async (url: string) => { + if (!url) return; + + // Explorer UX: submitting/opening should dismiss keyboard. + if (addressInputRef.current?.isFocused()) { + addressInputRef.current.blur(); + } + setIsAddressInputFocused(false); + setShowAddressSuggestions(false); + + // If we're already in browser mode, capture the current tab preview before switching. + if (viewMode === 'browser' && activeTabId) { + await captureTabScreenshot(activeTabId, 50).catch((error) => globalThis.handleError?.(error, 'captureTabScreenshot')); + } + + const newTab = createBrowserTab(url); + setTabs((prev) => [...prev, newTab]); + setActiveTabId(newTab.id); + setShowAddressSuggestions(false); + setShowTabsOverview(false); + + setAddressBarValue(newTab.url, { ensureStartVisible: true }); + setViewMode('browser'); }; const createNewTab = async () => { + if (viewModeRef.current !== 'browser') { + setViewMode('browser'); + } + if (addressInputRef.current?.isFocused()) { + addressInputRef.current.blur(); + } + setIsAddressInputFocused(false); + setShowAddressSuggestions(false); if (activeTabId) { await captureTabScreenshot(activeTabId, 50).catch((error) => globalThis.handleError?.(error, 'captureTabScreenshot')); } @@ -816,6 +864,14 @@ const DAppBrowser: React.FC = () => { }; const closeTab = (tabId: string) => { + if (viewModeRef.current !== 'browser') { + setViewMode('browser'); + } + if (addressInputRef.current?.isFocused()) { + addressInputRef.current.blur(); + } + setIsAddressInputFocused(false); + setShowAddressSuggestions(false); screenshots.remove(tabId); if (tabs.length === 1) { @@ -842,6 +898,14 @@ const DAppBrowser: React.FC = () => { }; const switchTab = async (tabId: string) => { + if (viewModeRef.current !== 'browser') { + setViewMode('browser'); + } + if (addressInputRef.current?.isFocused()) { + addressInputRef.current.blur(); + } + setIsAddressInputFocused(false); + setShowAddressSuggestions(false); if (activeTabId === tabId) { hideTabsOverview(); return; @@ -934,6 +998,9 @@ const DAppBrowser: React.FC = () => { text: 'Close All', style: 'destructive', onPress: () => { + if (viewModeRef.current !== 'browser') { + setViewMode('browser'); + } const newTab = createHomeTab(network); setTabs([newTab]); @@ -981,6 +1048,7 @@ const DAppBrowser: React.FC = () => { }; const goBack = () => { + if (viewMode !== 'browser') return; const tab = tabs.find((t) => t.id === activeTabId); if (!tab || tab.historyIndex <= 0) return; @@ -1206,7 +1274,7 @@ const DAppBrowser: React.FC = () => { [activeTabId, isAddressInputFocused, setAddressBarValue] ); - if (error) { + if (error && viewMode === 'browser') { return ( @@ -1216,7 +1284,7 @@ const DAppBrowser: React.FC = () => { ); } - if (!js) { + if (!js && viewMode === 'browser') { return ( @@ -1235,12 +1303,17 @@ const DAppBrowser: React.FC = () => { - + - + { onBlur={() => { setIsAddressInputFocused(false); setShowAddressSuggestions(false); - if (activeTab?.url) { + if (viewMode === 'browser' && activeTab?.url) { setAddressBarValue(activeTab.url, { ensureStartVisible: true }); } }} onSubmitEditing={() => { + if (viewMode === 'explorer') { + const raw = addressInput.trim(); + if (!raw) return; + + // If the user typed a URL, open it directly (keeps browser behavior intact). + let urlCandidate = raw; + if (!urlCandidate.startsWith('http://') && !urlCandidate.startsWith('https://')) { + urlCandidate = 'https://' + urlCandidate; + } + if (isValidUrl(urlCandidate)) { + void openWebAppInNewTab(urlCandidate); + return; + } + + // Otherwise treat it as an app search and open the first match. + const q = raw.toLowerCase(); + const partners = [...getPartnersList(NETWORK_BITCOIN), ...getPartnersList(NETWORK_CITREA)]; + const first = partners.find((p) => `${p.name} ${p.description ?? ''}`.toLowerCase().includes(q)); + if (first?.url) { + void openWebAppInNewTab(first.url); + } + return; + } + let url = addressInput.trim(); if (!url) return; @@ -1280,16 +1377,24 @@ const DAppBrowser: React.FC = () => { } }} returnKeyType="go" - keyboardType="url" + keyboardType={viewMode === 'explorer' ? 'default' : 'url'} autoCapitalize="none" autoCorrect={false} - placeholder="Enter URL" + placeholder={viewMode === 'explorer' ? explorerPlaceholder : 'Enter URL'} placeholderTextColor="rgba(255, 255, 255, 0.5)" selectTextOnFocus={true} testID="DappBrowserAddressBar" /> {isAddressInputFocused ? ( - setAddressInput('')}> + { + setAddressInput(''); + setIsAddressInputFocused(false); + setShowAddressSuggestions(false); + addressInputRef.current?.blur(); + }} + > ) : isLoading ? ( @@ -1374,14 +1479,23 @@ const DAppBrowser: React.FC = () => { {!isAddressInputFocused && ( - + { + toggleTabsOverview(); + }} + onLongPress={() => { + handleCloseAllTabs(); + }} + testID="BrowserTabsOverviewButton" + > {tabs.length} )} - {!isAddressInputFocused && showAddressSuggestions && addressSuggestions.length > 0 && ( + {viewMode === 'browser' && !isAddressInputFocused && showAddressSuggestions && addressSuggestions.length > 0 && ( {addressSuggestions.map((suggestion, index) => ( { - - - - - - - - {tabs.map((tab) => { - const isActive = tab.id === activeTabId; - const containerStyles: StyleProp[] = [styles.tabContainer]; - - if (isActive) { - containerStyles.push(styles.tabContainerActive); - } else { - containerStyles.push(styles.tabContainerHidden); - } + {isAddressInputFocused && ( + { + setIsAddressInputFocused(false); + setShowAddressSuggestions(false); + addressInputRef.current?.blur(); + }} + /> + )} - return ( - { - if (ref) { - const wasPresent = !!tabContainerRefs.current[tab.id]; - tabContainerRefs.current[tab.id] = { current: ref }; - if (!wasPresent) { - } - } - }} - collapsable={false} - style={containerStyles} - > - {tab.screenshot && } - { - if (ref) { - if (!tabWebViewRefs.current[tab.id]) { - tabWebViewRefs.current[tab.id] = { current: ref }; - } - if (isActive) { - webviewRef.current = ref; - browserBridgeRef.current = new BrowserBridge(ref); + + + {viewMode === 'browser' ? ( + <> + + + + + + {tabs.map((tab) => { + const isActive = tab.id === activeTabId; + const containerStyles: StyleProp[] = [styles.tabContainer]; + + if (isActive) { + containerStyles.push(styles.tabContainerActive); + } else { + containerStyles.push(styles.tabContainerHidden); + } + + return ( + { + if (ref) { + const wasPresent = !!tabContainerRefs.current[tab.id]; + tabContainerRefs.current[tab.id] = { current: ref }; + if (!wasPresent) { + } } - } - }} - originWhitelist={['https://*', 'http://*', 'about:blank', 'about:srcdoc']} - allowsInlineMediaPlayback={true} - source={{ uri: tab.url }} - onMessage={isActive ? handleMessage : undefined} - onNavigationStateChange={isActive ? handleNavigationStateChange : undefined} - onLoadProgress={isActive ? handleLoadProgress : undefined} - onLoadEnd={ - isActive - ? handleActiveTabLoadEnd - : () => { - handleInactiveTabLoad(tab.id); + }} + collapsable={false} + style={containerStyles} + > + {tab.screenshot && } + { + if (ref) { + if (!tabWebViewRefs.current[tab.id]) { + tabWebViewRefs.current[tab.id] = { current: ref }; + } + if (isActive) { + webviewRef.current = ref; + browserBridgeRef.current = new BrowserBridge(ref); + } } - } - injectedJavaScriptBeforeContentLoaded={js} - style={styles.webviewVisible} - incognito={false} - scrollEnabled={!isAddressInputFocused} - /> - - ); - })} - + }} + originWhitelist={['https://*', 'http://*', 'about:blank', 'about:srcdoc']} + allowsInlineMediaPlayback={true} + source={{ uri: tab.url }} + onMessage={isActive ? handleMessage : undefined} + onNavigationStateChange={isActive ? handleNavigationStateChange : undefined} + onLoadProgress={isActive ? handleLoadProgress : undefined} + onLoadEnd={ + isActive + ? handleActiveTabLoadEnd + : () => { + handleInactiveTabLoad(tab.id); + } + } + injectedJavaScriptBeforeContentLoaded={js ?? undefined} + style={styles.webviewVisible} + incognito={false} + scrollEnabled={!isAddressInputFocused} + /> + + ); + })} + + + ) : ( + { + void openWebAppInNewTab(url); + }} + /> + )} @@ -1577,6 +1715,12 @@ const styles = StyleSheet.create({ }, addressBarContainer: { position: 'relative', + zIndex: 2, + }, + dismissKeyboardOverlay: { + ...StyleSheet.absoluteFillObject, + backgroundColor: 'transparent', + zIndex: 1, }, addressBarWrapper: { flex: 1, diff --git a/mobile/components/Explorer/ExplorerContent.tsx b/mobile/components/Explorer/ExplorerContent.tsx new file mode 100644 index 000000000..b29158327 --- /dev/null +++ b/mobile/components/Explorer/ExplorerContent.tsx @@ -0,0 +1,380 @@ +import React, { useMemo, useRef } from 'react'; +import { ScrollView, StyleSheet, View } from 'react-native'; +import { useRouter } from 'expo-router'; + +import Pressable from '@/components/Pressable'; +import { ThemedText } from '@/components/ThemedText'; +import SectionContainer from '@/components/SectionContainer'; +import { Image as ExpoImage } from 'expo-image'; + +import { getPartnersList } from '@shared/models/partners-list'; +import type { PartnerInfo } from '@shared/types/partner-info'; +import { getTokenInfo } from '@shared/models/token-list'; +import { YIELD_TOKEN_DEFINITIONS_BY_NETWORK } from '@shared/hooks/useYieldDiscovery'; +import type { Networks } from '@shared/types/networks'; +import { NETWORK_BITCOIN, NETWORK_CITREA } from '@shared/types/networks'; +import type { TokenInfo } from '@shared/types/token-info'; + +export type ExplorerCategory = 'all' | 'bitcoin' | 'lightning' | 'arkade' | 'citrea'; + +const getCategoryLabel = (category: ExplorerCategory): string => { + switch (category) { + case 'all': + return 'All'; + case 'bitcoin': + return 'Bitcoin'; + case 'lightning': + return 'Lightning'; + case 'arkade': + return 'Arkade'; + case 'citrea': + return 'Citrea'; + default: + return 'Bitcoin'; + } +}; + +const getPartnersForCategory = (category: ExplorerCategory): PartnerInfo[] => { + switch (category) { + case 'bitcoin': + return getPartnersList(NETWORK_BITCOIN); + // MVP: match website behavior for `network=lightning` which returns the same as bitcoin partners. + case 'lightning': + return getPartnersList(NETWORK_BITCOIN); + case 'arkade': + return []; + case 'citrea': + return getPartnersList(NETWORK_CITREA); + case 'all': + return [...getPartnersForCategory('bitcoin'), ...getPartnersForCategory('citrea')]; + default: + return []; + } +}; + +export type ExplorerContentProps = { + category: ExplorerCategory; + query: string; + onChangeCategory: (category: ExplorerCategory) => void; + onOpenWebApp: (url: string) => void; +}; + +export default function ExplorerContent({ category, query, onChangeCategory, onOpenWebApp }: ExplorerContentProps) { + const router = useRouter(); + const basePartners = useMemo(() => getPartnersForCategory(category), [category]); + const allPartners = useMemo(() => [...getPartnersList(NETWORK_BITCOIN), ...getPartnersList(NETWORK_CITREA)], []); + + const filteredPartners = useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) return basePartners; + return allPartners.filter((p) => { + const haystack = `${p.name} ${p.description ?? ''}`.toLowerCase(); + return haystack.includes(q); + }); + }, [allPartners, basePartners, query]); + + // Improvement: highlight is random and must not depend on category/pill. + const highlightPartnerRef = useRef(null); + if (highlightPartnerRef.current === null && allPartners.length > 0) { + const idx = Math.floor(Math.random() * allPartners.length); + highlightPartnerRef.current = allPartners[idx] ?? null; + } + const highlightPartner = highlightPartnerRef.current; + + const availableEarnItems = useMemo(() => { + const entries = Object.entries(YIELD_TOKEN_DEFINITIONS_BY_NETWORK) as [Networks, { tokenId: string; apr: string; url: string }[]][]; + const items: { token: TokenInfo; apr: string; url: string }[] = []; + + for (const [network, defs] of entries) { + // Currently MVP yields are stored per network in the shared hook. + // We still surface them here as “available”. + for (const def of defs) { + try { + const token = getTokenInfo(def.tokenId); + items.push({ token, apr: def.apr, url: def.url }); + } catch { + // If token info is missing from the token list, skip it. + continue; + } + } + } + + return items; + }, []); + + return ( + + + router.push('/YieldList')}> + + {availableEarnItems.slice(0, 3).map((item) => ( + + + {item.token.logoURI ? : } + + + {item.token.symbol} + + + up to {item.apr} + + + ))} + + + + + + + + + + + {highlightPartner?.name ?? 'Explore partners'} + + {highlightPartner?.description ?? 'Discover services and apps across networks.'} + + + + + + + + + Bitcoin apps + + + {( + [ + { key: 'all', label: 'See all' }, + { key: 'bitcoin', label: 'Bitcoin' }, + { key: 'lightning', label: 'Lightning' }, + { key: 'arkade', label: 'Arkade' }, + { key: 'citrea', label: 'Citrea' }, + ] as const + ).map((c) => ( + onChangeCategory(c.key)} style={[styles.chip, c.key === category ? styles.chipSelected : styles.chipUnselected]} hitSlop={8} activeOpacity={0.85}> + {c.label} + + ))} + + + {filteredPartners.length === 0 ? ( + + {query.trim() ? `No results for "${query.trim()}".` : `No partners found for ${getCategoryLabel(category)}.`} + + ) : ( + + {filteredPartners.map((p) => ( + onOpenWebApp(p.url)} style={styles.appCard} activeOpacity={0.85}> + {p.imgUrl ? : } + + + + {p.name} + + {p.description ? ( + + {p.description} + + ) : ( + + )} + + + ))} + + )} + + + ); +} + +const styles = StyleSheet.create({ + scroll: { + flex: 1, + backgroundColor: '#000', + }, + contentContainer: { + paddingHorizontal: 14, + paddingBottom: 40, + paddingTop: 12, + }, + sectionGap: { + marginBottom: 18, + }, + highlightCard: { + backgroundColor: 'rgba(255,255,255,0.05)', + borderRadius: 12, + overflow: 'hidden', + }, + highlightMap: { + height: 130, + backgroundColor: 'rgba(0,0,0,0.35)', + position: 'relative', + }, + highlightGrid: { + ...StyleSheet.absoluteFillObject, + backgroundColor: 'rgba(255,255,255,0.05)', + opacity: 0.18, + }, + highlightOverlay: { + position: 'absolute', + left: 12, + right: 12, + bottom: 12, + padding: 10, + borderRadius: 12, + backgroundColor: 'rgba(0,0,0,0.55)', + }, + highlightTitle: { + color: 'rgba(255,255,255,0.95)', + fontSize: 16, + fontWeight: '600', + }, + highlightDescription: { + marginTop: 4, + color: 'rgba(255,255,255,0.7)', + fontSize: 13, + fontWeight: '400', + }, + chipsRow: { + flexDirection: 'row', + gap: 12, + paddingBottom: 10, + paddingRight: 0, + paddingLeft: 0, + minWidth: '100%', + }, + chipsScroll: { + flexGrow: 1, + width: '100%', + }, + chip: { + paddingHorizontal: 12, + paddingVertical: 8, + borderRadius: 999, + borderWidth: 0, + }, + chipSelected: { + backgroundColor: 'rgba(255,255,255,0.14)', + }, + chipUnselected: { + backgroundColor: 'rgba(255,255,255,0.08)', + }, + chipText: { + fontSize: 13, + fontWeight: '600', + }, + chipTextSelected: { + color: 'rgba(255,255,255,0.95)', + }, + chipTextUnselected: { + color: 'rgba(255,255,255,0.7)', + }, + emptyState: { + paddingVertical: 18, + alignItems: 'center', + justifyContent: 'center', + }, + emptyStateText: { + color: 'rgba(255,255,255,0.65)', + fontSize: 14, + textAlign: 'center', + }, + appsList: { + gap: 12, + }, + appCard: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + paddingHorizontal: 10, + paddingVertical: 12, + borderRadius: 16, + backgroundColor: 'transparent', + borderWidth: 0, + }, + appIconWrap: { + width: 44, + height: 44, + borderRadius: 14, + overflow: 'hidden', + backgroundColor: 'transparent', + alignItems: 'center', + justifyContent: 'center', + }, + appIcon: { + width: '100%', + height: '100%', + }, + appIconPlaceholder: { + width: '70%', + height: '70%', + borderRadius: 10, + backgroundColor: 'rgba(255,255,255,0.15)', + }, + appInfo: { + flex: 1, + }, + appName: { + color: 'rgba(255,255,255,0.95)', + fontSize: 15, + fontWeight: '600', + }, + appDescription: { + marginTop: 4, + color: 'rgba(255,255,255,0.7)', + fontSize: 13, + fontWeight: '400', + }, + bitcoinAppsTitle: { + color: 'rgba(255,255,255,0.95)', + fontSize: 18, + fontWeight: '500', + marginBottom: 10, + }, + earnList: { + gap: 8, + }, + earnRow: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 4, + paddingVertical: 4, + gap: 10, + }, + earnTokenIconWrap: { + width: 34, + height: 34, + borderRadius: 12, + overflow: 'hidden', + backgroundColor: 'rgba(255,255,255,0.08)', + alignItems: 'center', + justifyContent: 'center', + }, + earnTokenIcon: { + width: '100%', + height: '100%', + }, + earnTokenIconPlaceholder: { + width: '60%', + height: '60%', + borderRadius: 8, + backgroundColor: 'rgba(255,255,255,0.18)', + }, + earnRowInfo: { + flex: 1, + }, + earnTokenSymbol: { + color: 'rgba(255,255,255,0.95)', + fontSize: 14, + fontWeight: '600', + }, + earnUpToText: { + color: '#00ff6e', + fontSize: 13, + fontWeight: '600', + maxWidth: 120, + }, +}); diff --git a/mobile/components/Explorer/ExplorerView.tsx b/mobile/components/Explorer/ExplorerView.tsx new file mode 100644 index 000000000..5e8a4bb08 --- /dev/null +++ b/mobile/components/Explorer/ExplorerView.tsx @@ -0,0 +1,426 @@ +import React, { useMemo, useState, useContext, useCallback } from 'react'; +import { StyleSheet, TextInput, View } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { Image as ExpoImage } from 'expo-image'; + +import Pressable from '@/components/Pressable'; +import { ThemedText } from '@/components/ThemedText'; +import RadialGradientScreen from '@/components/RadialGradientScreen'; +import YieldView from '@/components/YieldView'; +import SectionContainer from '@/components/SectionContainer'; + +import { useRouter } from 'expo-router'; +import { NetworkContext } from '@shared/hooks/NetworkContext'; +import { NETWORK_BITCOIN, NETWORK_CITREA } from '@shared/types/networks'; +import { getPartnersList } from '@shared/models/partners-list'; +import type { PartnerInfo } from '@shared/types/partner-info'; + +type ExplorerCategory = 'all' | 'bitcoin' | 'lightning' | 'arkade' | 'citrea'; + +const getCategoryLabel = (category: ExplorerCategory): string => { + switch (category) { + case 'all': + return 'All'; + case 'bitcoin': + return 'Bitcoin'; + case 'lightning': + return 'Lightning'; + case 'arkade': + return 'Arkade'; + case 'citrea': + return 'Citrea'; + default: + return 'Bitcoin'; + } +}; + +const getPartnersForCategory = (category: ExplorerCategory): PartnerInfo[] => { + switch (category) { + case 'bitcoin': + return getPartnersList(NETWORK_BITCOIN); + // MVP: show the same partner set as bitcoin (matches explore website behavior for `network=lightning`) + case 'lightning': + return getPartnersList(NETWORK_BITCOIN); + case 'arkade': + return []; + case 'citrea': + return getPartnersList(NETWORK_CITREA); + case 'all': + return [...getPartnersForCategory('bitcoin'), ...getPartnersForCategory('citrea')]; + default: + return []; + } +}; + +export type ExplorerViewProps = { + onBackToBrowser: () => void; + onOpenWebApp: (url: string) => void; +}; + +export default function ExplorerView({ onBackToBrowser, onOpenWebApp }: ExplorerViewProps) { + const router = useRouter(); + const { network } = useContext(NetworkContext); + + const [category, setCategory] = useState('bitcoin'); + const [query, setQuery] = useState(''); + const [isFocused, setIsFocused] = useState(false); + + const basePartners = useMemo(() => getPartnersForCategory(category), [category]); + + const filteredPartners = useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) return basePartners; + return basePartners.filter((p) => { + const haystack = `${p.name} ${p.description ?? ''}`.toLowerCase(); + return haystack.includes(q); + }); + }, [basePartners, query]); + + const suggestions = useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) return []; + return filteredPartners.slice(0, 6); + }, [filteredPartners, query]); + + const highlightPartner = filteredPartners[0] ?? basePartners[0] ?? null; + + const openFirstSuggestion = useCallback(() => { + const first = suggestions[0] ?? filteredPartners[0]; + if (!first) return; + onOpenWebApp(first.url); + }, [filteredPartners, onOpenWebApp, suggestions]); + + const handleSubmitEditing = useCallback(() => { + openFirstSuggestion(); + }, [openFirstSuggestion]); + + return ( + + + + + + + + router.replace('/Home')} hitSlop={10}> + + + + + setQuery(t)} + onFocus={() => setIsFocused(true)} + onBlur={() => setIsFocused(false)} + returnKeyType="go" + keyboardType="default" + autoCorrect={false} + autoCapitalize="none" + onSubmitEditing={handleSubmitEditing} + testID="ExplorerSearchInput" + /> + + + + + {}} hitSlop={10} testID="ExplorerOptionsButton"> + + + + + {isFocused && suggestions.length > 0 && ( + + {suggestions.map((p) => ( + { + setIsFocused(false); + setQuery(p.name); + onOpenWebApp(p.url); + }} + activeOpacity={0.7} + > + + {p.name} + + + ))} + + )} + + + + { + router.push('/YieldList'); + }} + /> + + + + + + + + + {highlightPartner?.name ?? 'Explore partners'} + {highlightPartner?.description ? ( + + {highlightPartner.description} + + ) : ( + + Discover services and apps across networks. + + )} + + + + + + + + + + {( + [ + { key: 'all', label: 'See all' }, + { key: 'bitcoin', label: 'Bitcoin' }, + { key: 'lightning', label: 'Lightning' }, + { key: 'arkade', label: 'Arkade' }, + { key: 'citrea', label: 'Citrea' }, + ] as const + ).map((c) => ( + setCategory(c.key)} style={[styles.chip, c.key === category ? styles.chipSelected : styles.chipUnselected]} hitSlop={8} activeOpacity={0.85}> + {c.label} + + ))} + + + {filteredPartners.length === 0 ? ( + + No partners found for {getCategoryLabel(category)}. + + ) : ( + + {filteredPartners.map((p) => ( + onOpenWebApp(p.url)} style={styles.appCard} activeOpacity={0.85}> + + {p.imgUrl ? : } + + + + + {p.name} + + {p.description ? ( + + {p.description} + + ) : ( + + )} + + + ))} + + )} + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + paddingHorizontal: 0, + }, + topBar: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + paddingHorizontal: 14, + paddingTop: 8, + }, + iconButton: { + width: 36, + height: 36, + borderRadius: 18, + backgroundColor: 'rgba(255,255,255,0.06)', + alignItems: 'center', + justifyContent: 'center', + }, + searchContainer: { + flex: 1, + height: 40, + borderRadius: 20, + backgroundColor: 'rgba(255,255,255,0.12)', + overflow: 'hidden', + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 12, + }, + searchInput: { + flex: 1, + fontSize: 14, + color: 'rgba(255,255,255,0.92)', + paddingVertical: 0, + }, + searchIconSpacer: { + width: 1, + }, + suggestionsContainer: { + marginHorizontal: 14, + marginTop: 10, + borderRadius: 16, + backgroundColor: 'rgba(0,0,0,0.75)', + overflow: 'hidden', + }, + suggestionItem: { + paddingHorizontal: 12, + paddingVertical: 10, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: 'rgba(255,255,255,0.12)', + }, + suggestionText: { + color: 'rgba(255,255,255,0.92)', + fontSize: 13, + fontWeight: '500', + }, + sections: { + marginTop: 12, + paddingHorizontal: 14, + }, + sectionGap: { + marginBottom: 18, + }, + highlightCard: { + backgroundColor: 'rgba(255,255,255,0.05)', + borderRadius: 12, + overflow: 'hidden', + }, + highlightMap: { + height: 130, + backgroundColor: 'rgba(0,0,0,0.35)', + position: 'relative', + }, + highlightGrid: { + ...StyleSheet.absoluteFillObject, + backgroundColor: 'rgba(255,255,255,0.05)', + opacity: 0.18, + }, + highlightOverlay: { + position: 'absolute', + left: 12, + right: 12, + bottom: 12, + padding: 10, + borderRadius: 12, + backgroundColor: 'rgba(0,0,0,0.55)', + }, + highlightTitle: { + color: 'rgba(255,255,255,0.95)', + fontSize: 16, + fontWeight: '600', + }, + highlightDescription: { + marginTop: 4, + color: 'rgba(255,255,255,0.7)', + fontSize: 13, + fontWeight: '400', + }, + chipsRow: { + flexDirection: 'row', + gap: 8, + flexWrap: 'wrap', + paddingBottom: 10, + }, + chip: { + paddingHorizontal: 12, + paddingVertical: 8, + borderRadius: 999, + borderWidth: 1, + }, + chipSelected: { + backgroundColor: 'rgba(255,255,255,0.12)', + borderColor: 'rgba(255,255,255,0.25)', + }, + chipUnselected: { + backgroundColor: 'rgba(255,255,255,0.06)', + borderColor: 'rgba(255,255,255,0.12)', + }, + chipText: { + fontSize: 13, + fontWeight: '600', + }, + chipTextSelected: { + color: 'rgba(255,255,255,0.95)', + }, + chipTextUnselected: { + color: 'rgba(255,255,255,0.7)', + }, + emptyState: { + paddingVertical: 18, + alignItems: 'center', + justifyContent: 'center', + }, + emptyStateText: { + color: 'rgba(255,255,255,0.65)', + fontSize: 14, + textAlign: 'center', + }, + appsList: { + gap: 12, + }, + appCard: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + paddingHorizontal: 10, + paddingVertical: 12, + borderRadius: 16, + backgroundColor: 'rgba(255,255,255,0.06)', + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.08)', + }, + appIconWrap: { + width: 44, + height: 44, + borderRadius: 14, + overflow: 'hidden', + backgroundColor: 'rgba(255,255,255,0.07)', + alignItems: 'center', + justifyContent: 'center', + }, + appIcon: { + width: '100%', + height: '100%', + }, + appIconPlaceholder: { + width: '70%', + height: '70%', + borderRadius: 10, + backgroundColor: 'rgba(255,255,255,0.15)', + }, + appInfo: { + flex: 1, + }, + appName: { + color: 'rgba(255,255,255,0.95)', + fontSize: 15, + fontWeight: '600', + }, + appDescription: { + marginTop: 4, + color: 'rgba(255,255,255,0.7)', + fontSize: 13, + fontWeight: '400', + }, +}); From adc904c85cd83e22ae628d6b8a5d4418c07d4b96 Mon Sep 17 00:00:00 2001 From: li0nd3v Date: Fri, 20 Mar 2026 11:56:26 +0100 Subject: [PATCH 3/5] fix: pills logic --- mobile/app/DAppBrowser.tsx | 4 +-- .../components/Explorer/ExplorerContent.tsx | 29 +++++++++---------- 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/mobile/app/DAppBrowser.tsx b/mobile/app/DAppBrowser.tsx index 8c1b84103..6f1678d9d 100644 --- a/mobile/app/DAppBrowser.tsx +++ b/mobile/app/DAppBrowser.tsx @@ -20,7 +20,7 @@ import { BackgroundExecutor } from '@/src/modules/background-executor'; import { AccountNumberContext } from '@shared/hooks/AccountNumberContext'; import { NetworkContext } from '@shared/hooks/NetworkContext'; import { useFocusEffect } from '@react-navigation/native'; -import { NETWORK_BITCOIN, NETWORK_CITREA } from '@shared/types/networks'; +import { NETWORK_BITCOIN, NETWORK_BOTANIX, NETWORK_CITREA, NETWORK_ROOTSTOCK } from '@shared/types/networks'; import { getNetworkImageAsset } from '@/utils/networkAssets'; import { ActionPopupButton } from '@/components/ActionPopupButton'; import { DAppBrowserTabs } from './DAppBrowserTabs'; @@ -1353,7 +1353,7 @@ const DAppBrowser: React.FC = () => { // Otherwise treat it as an app search and open the first match. const q = raw.toLowerCase(); - const partners = [...getPartnersList(NETWORK_BITCOIN), ...getPartnersList(NETWORK_CITREA)]; + const partners = [...getPartnersList(NETWORK_BITCOIN), ...getPartnersList(NETWORK_BOTANIX), ...getPartnersList(NETWORK_ROOTSTOCK), ...getPartnersList(NETWORK_CITREA)]; const first = partners.find((p) => `${p.name} ${p.description ?? ''}`.toLowerCase().includes(q)); if (first?.url) { void openWebAppInNewTab(first.url); diff --git a/mobile/components/Explorer/ExplorerContent.tsx b/mobile/components/Explorer/ExplorerContent.tsx index b29158327..426ee31fe 100644 --- a/mobile/components/Explorer/ExplorerContent.tsx +++ b/mobile/components/Explorer/ExplorerContent.tsx @@ -12,10 +12,10 @@ import type { PartnerInfo } from '@shared/types/partner-info'; import { getTokenInfo } from '@shared/models/token-list'; import { YIELD_TOKEN_DEFINITIONS_BY_NETWORK } from '@shared/hooks/useYieldDiscovery'; import type { Networks } from '@shared/types/networks'; -import { NETWORK_BITCOIN, NETWORK_CITREA } from '@shared/types/networks'; +import { NETWORK_BITCOIN, NETWORK_BOTANIX, NETWORK_CITREA, NETWORK_ROOTSTOCK } from '@shared/types/networks'; import type { TokenInfo } from '@shared/types/token-info'; -export type ExplorerCategory = 'all' | 'bitcoin' | 'lightning' | 'arkade' | 'citrea'; +export type ExplorerCategory = 'all' | 'bitcoin' | 'botanix' | 'rootstock' | 'citrea'; const getCategoryLabel = (category: ExplorerCategory): string => { switch (category) { @@ -23,10 +23,10 @@ const getCategoryLabel = (category: ExplorerCategory): string => { return 'All'; case 'bitcoin': return 'Bitcoin'; - case 'lightning': - return 'Lightning'; - case 'arkade': - return 'Arkade'; + case 'botanix': + return 'Botanix'; + case 'rootstock': + return 'Rootstock'; case 'citrea': return 'Citrea'; default: @@ -38,15 +38,14 @@ const getPartnersForCategory = (category: ExplorerCategory): PartnerInfo[] => { switch (category) { case 'bitcoin': return getPartnersList(NETWORK_BITCOIN); - // MVP: match website behavior for `network=lightning` which returns the same as bitcoin partners. - case 'lightning': - return getPartnersList(NETWORK_BITCOIN); - case 'arkade': - return []; + case 'botanix': + return getPartnersList(NETWORK_BOTANIX); + case 'rootstock': + return getPartnersList(NETWORK_ROOTSTOCK); case 'citrea': return getPartnersList(NETWORK_CITREA); case 'all': - return [...getPartnersForCategory('bitcoin'), ...getPartnersForCategory('citrea')]; + return [...getPartnersForCategory('bitcoin'), ...getPartnersForCategory('botanix'), ...getPartnersForCategory('rootstock'), ...getPartnersForCategory('citrea')]; default: return []; } @@ -62,7 +61,7 @@ export type ExplorerContentProps = { export default function ExplorerContent({ category, query, onChangeCategory, onOpenWebApp }: ExplorerContentProps) { const router = useRouter(); const basePartners = useMemo(() => getPartnersForCategory(category), [category]); - const allPartners = useMemo(() => [...getPartnersList(NETWORK_BITCOIN), ...getPartnersList(NETWORK_CITREA)], []); + const allPartners = useMemo(() => [...getPartnersList(NETWORK_BITCOIN), ...getPartnersList(NETWORK_BOTANIX), ...getPartnersList(NETWORK_ROOTSTOCK), ...getPartnersList(NETWORK_CITREA)], []); const filteredPartners = useMemo(() => { const q = query.trim().toLowerCase(); @@ -148,8 +147,8 @@ export default function ExplorerContent({ category, query, onChangeCategory, onO [ { key: 'all', label: 'See all' }, { key: 'bitcoin', label: 'Bitcoin' }, - { key: 'lightning', label: 'Lightning' }, - { key: 'arkade', label: 'Arkade' }, + { key: 'botanix', label: 'Botanix' }, + { key: 'rootstock', label: 'Rootstock' }, { key: 'citrea', label: 'Citrea' }, ] as const ).map((c) => ( From 042197847586139bb512c7f00a8deb098a7ad9cc Mon Sep 17 00:00:00 2001 From: li0nd3v Date: Mon, 23 Mar 2026 18:18:17 +0100 Subject: [PATCH 4/5] refactor: remove default partners url website --- mobile/app/DAppBrowser.tsx | 80 ++++++----- mobile/app/DAppBrowserTabs.tsx | 49 ++----- .../components/Explorer/ExplorerContent.tsx | 133 +++++++++++++----- 3 files changed, 153 insertions(+), 109 deletions(-) diff --git a/mobile/app/DAppBrowser.tsx b/mobile/app/DAppBrowser.tsx index 6f1678d9d..3d1353e89 100644 --- a/mobile/app/DAppBrowser.tsx +++ b/mobile/app/DAppBrowser.tsx @@ -20,7 +20,7 @@ import { BackgroundExecutor } from '@/src/modules/background-executor'; import { AccountNumberContext } from '@shared/hooks/AccountNumberContext'; import { NetworkContext } from '@shared/hooks/NetworkContext'; import { useFocusEffect } from '@react-navigation/native'; -import { NETWORK_BITCOIN, NETWORK_BOTANIX, NETWORK_CITREA, NETWORK_ROOTSTOCK } from '@shared/types/networks'; +import { NETWORK_BITCOIN, NETWORK_BOTANIX, NETWORK_CITREA, NETWORK_ROOTSTOCK, NETWORK_LIGHTNING, NETWORK_SPARK, NETWORK_ARK } from '@shared/types/networks'; import { getNetworkImageAsset } from '@/utils/networkAssets'; import { ActionPopupButton } from '@/components/ActionPopupButton'; import { DAppBrowserTabs } from './DAppBrowserTabs'; @@ -63,8 +63,6 @@ export const BROWSER_CONSTANTS = { const homeIcon = require('@/assets/images/home.svg'); -const getHomeUrl = (network: string): string => `https://layerztec.github.io/website/explore/?network=${network}`; // to test: https://metamask.github.io/test-dapp/ & https://eip6963.org/ - const getTabTitle = (url: string): string => { try { const { hostname } = new URL(url); @@ -74,6 +72,8 @@ const getTabTitle = (url: string): string => { } }; +const BLANK_TAB_URL = 'https://google.com'; + const isValidUrl = (urlString: string): boolean => { try { const url = new URL(urlString); @@ -115,8 +115,6 @@ const createBrowserTab = (url: string, id?: string): BrowserTab => ({ timestamp: Date.now(), }); -const createHomeTab = (network: string, id?: string): BrowserTab => createBrowserTab(getHomeUrl(network), id); - export type DappBrowserProps = { url?: string; }; @@ -157,13 +155,13 @@ const DAppBrowser: React.FC = () => { const [error, setError] = useState(null); const params = useLocalSearchParams(); const [viewMode, setViewMode] = useState<'explorer' | 'browser'>(() => (params.url ? 'browser' : 'explorer')); - const [explorerCategory, setExplorerCategory] = useState('bitcoin'); + const [explorerCategory, setExplorerCategory] = useState('all'); const viewModeRef = useRef(viewMode); useEffect(() => { viewModeRef.current = viewMode; }, [viewMode]); const explorerPlaceholder = 'Search on Bitcoin'; - const initialUrl = params.url || getHomeUrl(network); + const initialUrl = params.url || ''; const [tabs, setTabs] = useState([]); const [activeTabId, setActiveTabId] = useState(''); const [isRestoringTabs, setIsRestoringTabs] = useState(true); @@ -511,10 +509,10 @@ const DAppBrowser: React.FC = () => { } } catch (e) {} - const newTab = createHomeTab(network); - setTabs([newTab]); - setActiveTabId(newTab.id); - setAddressBarValue(newTab.url, { ensureStartVisible: true }); + setTabs([]); + setActiveTabId(''); + setAddressInput(''); + setViewMode('explorer'); setShowTabsOverview(false); hasPurgedRef.current = true; @@ -522,7 +520,7 @@ const DAppBrowser: React.FC = () => { isPurgingRef.current = false; } }, - [network, setAddressBarValue] + [setAddressBarValue] ); const saveTabs = useCallback( @@ -731,26 +729,24 @@ const DAppBrowser: React.FC = () => { } } } else { - const initialTab = createHomeTab(network); - setTabs([initialTab]); - setActiveTabId(initialTab.id); - if (viewModeRef.current === 'browser') { - setAddressBarValue(initialTab.url, { ensureStartVisible: true }); - } else { - setAddressInput(''); - } + setTabs([]); + setActiveTabId(''); + setAddressInput(''); } setIsRestoringTabs(false); }; restoreTabs(); - }, [network, loadTabs, setAddressBarValue]); + }, [loadTabs, setAddressBarValue]); useEffect(() => { - if (!isRestoringTabs && tabs.length > 0 && activeTabId) { + if (isRestoringTabs) return; + if (tabs.length > 0 && activeTabId) { saveTabs(tabs, activeTabId); + return; } + void AsyncStorage.multiRemove([BROWSER_CONSTANTS.STORAGE.TABS_KEY, BROWSER_CONSTANTS.STORAGE.ACTIVE_TAB_KEY]); }, [tabs, activeTabId, isRestoringTabs, saveTabs]); useEffect(() => { @@ -787,7 +783,7 @@ const DAppBrowser: React.FC = () => { }, [activeTabId, captureTabScreenshot, viewMode]); useEffect(() => { - if (params.url && !isRestoringTabs && tabs.length > 0 && lastHandledUrl.current !== params.url) { + if (params.url && !isRestoringTabs && lastHandledUrl.current !== params.url) { lastHandledUrl.current = params.url; if (!isValidUrl(params.url)) { @@ -852,7 +848,7 @@ const DAppBrowser: React.FC = () => { await captureTabScreenshot(activeTabId, 50).catch((error) => globalThis.handleError?.(error, 'captureTabScreenshot')); } - const newTab = createHomeTab(network); + const newTab = createBrowserTab(BLANK_TAB_URL); setTabs((prev) => [...prev, newTab]); setActiveTabId(newTab.id); @@ -875,10 +871,13 @@ const DAppBrowser: React.FC = () => { screenshots.remove(tabId); if (tabs.length === 1) { - const newTab = createHomeTab(network); - setTabs([newTab]); - setActiveTabId(newTab.id); - setAddressBarValue(newTab.url, { ensureStartVisible: true }); + setTabs([]); + setActiveTabId(''); + setAddressInput(''); + setViewMode('explorer'); + if (showTabsOverview) { + hideTabsOverview(); + } return; } @@ -1001,11 +1000,10 @@ const DAppBrowser: React.FC = () => { if (viewModeRef.current !== 'browser') { setViewMode('browser'); } - const newTab = createHomeTab(network); - - setTabs([newTab]); - setActiveTabId(newTab.id); - setAddressBarValue(newTab.url, { ensureStartVisible: true }); + setTabs([]); + setActiveTabId(''); + setAddressInput(''); + setViewMode('explorer'); if (showTabsOverview) { hideTabsOverview(); } @@ -1353,7 +1351,15 @@ const DAppBrowser: React.FC = () => { // Otherwise treat it as an app search and open the first match. const q = raw.toLowerCase(); - const partners = [...getPartnersList(NETWORK_BITCOIN), ...getPartnersList(NETWORK_BOTANIX), ...getPartnersList(NETWORK_ROOTSTOCK), ...getPartnersList(NETWORK_CITREA)]; + const partners = [ + ...getPartnersList(NETWORK_BITCOIN), + ...getPartnersList(NETWORK_BOTANIX), + ...getPartnersList(NETWORK_ROOTSTOCK), + ...getPartnersList(NETWORK_CITREA), + ...getPartnersList(NETWORK_LIGHTNING), + ...getPartnersList(NETWORK_SPARK), + ...getPartnersList(NETWORK_ARK), + ]; const first = partners.find((p) => `${p.name} ${p.description ?? ''}`.toLowerCase().includes(q)); if (first?.url) { void openWebAppInNewTab(first.url); @@ -1616,7 +1622,7 @@ const DAppBrowser: React.FC = () => { getTabTitle={getTabTitle} onEnsurePreview={ensureTabPreview} onInvalidatePreview={invalidateTabPreview} - onCloseAllTabs={handleCloseAllTabs} + onCloseOverview={hideTabsOverview} /> {showTabsOverview && ( @@ -1633,8 +1639,8 @@ const DAppBrowser: React.FC = () => { - - + + diff --git a/mobile/app/DAppBrowserTabs.tsx b/mobile/app/DAppBrowserTabs.tsx index 5c6bdd2de..30462f6bd 100644 --- a/mobile/app/DAppBrowserTabs.tsx +++ b/mobile/app/DAppBrowserTabs.tsx @@ -1,5 +1,5 @@ -import React, { useCallback, useRef } from 'react'; -import { View, ScrollView, StyleSheet, Platform, ActionSheetIOS, UIManager, findNodeHandle } from 'react-native'; +import React from 'react'; +import { View, ScrollView, StyleSheet } from 'react-native'; import Animated from 'react-native-reanimated'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { Ionicons } from '@expo/vector-icons'; @@ -32,7 +32,7 @@ interface DAppBrowserTabsProps { getTabTitle: (url: string) => string; onEnsurePreview: (tabId: string, forceReload?: boolean) => void | Promise; onInvalidatePreview: (tabId: string) => void; - onCloseAllTabs: () => void; + onCloseOverview: () => void; } export const DAppBrowserTabs: React.FC = ({ @@ -45,51 +45,17 @@ export const DAppBrowserTabs: React.FC = ({ getTabTitle, onEnsurePreview, onInvalidatePreview, - onCloseAllTabs, + onCloseOverview, }) => { const insets = useSafeAreaInsets(); - const menuAnchorRef = useRef(null); - - const openTabsMenu = useCallback(() => { - const node = findNodeHandle(menuAnchorRef.current); - const popup = (UIManager as any).showPopupMenu as undefined | ((reactTag: number, items: string[], error: () => void, success: (eventName: string, index?: number) => void) => void); - - if (node && typeof popup === 'function') { - popup( - node, - ['Close all tabs'], - () => {}, - (eventName: string, index?: number) => { - if (eventName !== 'itemSelected') return; - if (index === 0) onCloseAllTabs(); - } - ); - return; - } - - // Fallback (should be rare): use system action sheet if popup menu isn't available. - if (Platform.OS === 'ios') { - ActionSheetIOS.showActionSheetWithOptions( - { - options: ['Cancel', 'Close all tabs'], - cancelButtonIndex: 0, - destructiveButtonIndex: 1, - userInterfaceStyle: 'dark', - }, - (buttonIndex) => { - if (buttonIndex === 1) onCloseAllTabs(); - } - ); - } - }, [onCloseAllTabs]); return ( Tabs - - + + @@ -156,6 +122,9 @@ const styles = StyleSheet.create({ width: 32, height: 32, borderRadius: 16, + backgroundColor: 'rgba(255, 255, 255, 0.12)', + borderWidth: StyleSheet.hairlineWidth, + borderColor: 'rgba(255, 255, 255, 0.2)', alignItems: 'center', justifyContent: 'center', }, diff --git a/mobile/components/Explorer/ExplorerContent.tsx b/mobile/components/Explorer/ExplorerContent.tsx index 426ee31fe..c83c97d39 100644 --- a/mobile/components/Explorer/ExplorerContent.tsx +++ b/mobile/components/Explorer/ExplorerContent.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useRef } from 'react'; +import React, { useMemo } from 'react'; import { ScrollView, StyleSheet, View } from 'react-native'; import { useRouter } from 'expo-router'; @@ -9,13 +9,13 @@ import { Image as ExpoImage } from 'expo-image'; import { getPartnersList } from '@shared/models/partners-list'; import type { PartnerInfo } from '@shared/types/partner-info'; -import { getTokenInfo } from '@shared/models/token-list'; +import { getTokenIconColor, getTokenInfo } from '@shared/models/token-list'; import { YIELD_TOKEN_DEFINITIONS_BY_NETWORK } from '@shared/hooks/useYieldDiscovery'; import type { Networks } from '@shared/types/networks'; -import { NETWORK_BITCOIN, NETWORK_BOTANIX, NETWORK_CITREA, NETWORK_ROOTSTOCK } from '@shared/types/networks'; +import { NETWORK_BITCOIN, NETWORK_BOTANIX, NETWORK_CITREA, NETWORK_ROOTSTOCK, NETWORK_LIGHTNING, NETWORK_SPARK, NETWORK_ARK } from '@shared/types/networks'; import type { TokenInfo } from '@shared/types/token-info'; -export type ExplorerCategory = 'all' | 'bitcoin' | 'botanix' | 'rootstock' | 'citrea'; +export type ExplorerCategory = 'all' | 'bitcoin' | 'botanix' | 'rootstock' | 'citrea' | 'lightning' | 'spark' | 'arkade'; const getCategoryLabel = (category: ExplorerCategory): string => { switch (category) { @@ -23,12 +23,18 @@ const getCategoryLabel = (category: ExplorerCategory): string => { return 'All'; case 'bitcoin': return 'Bitcoin'; + case 'lightning': + return 'Lightning'; case 'botanix': return 'Botanix'; case 'rootstock': return 'Rootstock'; case 'citrea': return 'Citrea'; + case 'spark': + return 'Spark'; + case 'arkade': + return 'Arkade'; default: return 'Bitcoin'; } @@ -44,13 +50,36 @@ const getPartnersForCategory = (category: ExplorerCategory): PartnerInfo[] => { return getPartnersList(NETWORK_ROOTSTOCK); case 'citrea': return getPartnersList(NETWORK_CITREA); + case 'lightning': + return getPartnersList(NETWORK_LIGHTNING); + case 'spark': + return getPartnersList(NETWORK_SPARK); + case 'arkade': + return getPartnersList(NETWORK_ARK); case 'all': - return [...getPartnersForCategory('bitcoin'), ...getPartnersForCategory('botanix'), ...getPartnersForCategory('rootstock'), ...getPartnersForCategory('citrea')]; + return [ + ...getPartnersForCategory('bitcoin'), + ...getPartnersForCategory('botanix'), + ...getPartnersForCategory('rootstock'), + ...getPartnersForCategory('citrea'), + ...getPartnersForCategory('lightning'), + ...getPartnersForCategory('spark'), + ...getPartnersForCategory('arkade'), + ]; default: return []; } }; +function shuffleArray(input: readonly T[]): T[] { + const arr = [...input]; + for (let i = arr.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [arr[i], arr[j]] = [arr[j], arr[i]]; + } + return arr; +} + export type ExplorerContentProps = { category: ExplorerCategory; query: string; @@ -60,8 +89,23 @@ export type ExplorerContentProps = { export default function ExplorerContent({ category, query, onChangeCategory, onOpenWebApp }: ExplorerContentProps) { const router = useRouter(); - const basePartners = useMemo(() => getPartnersForCategory(category), [category]); - const allPartners = useMemo(() => [...getPartnersList(NETWORK_BITCOIN), ...getPartnersList(NETWORK_BOTANIX), ...getPartnersList(NETWORK_ROOTSTOCK), ...getPartnersList(NETWORK_CITREA)], []); + const basePartners = useMemo(() => { + const partners = getPartnersForCategory(category); + // "See all" should show a randomized ordering (only affects non-search view). + return category === 'all' ? shuffleArray(partners) : partners; + }, [category]); + const allPartners = useMemo( + () => [ + ...getPartnersList(NETWORK_BITCOIN), + ...getPartnersList(NETWORK_BOTANIX), + ...getPartnersList(NETWORK_ROOTSTOCK), + ...getPartnersList(NETWORK_CITREA), + ...getPartnersList(NETWORK_LIGHTNING), + ...getPartnersList(NETWORK_SPARK), + ...getPartnersList(NETWORK_ARK), + ], + [] + ); const filteredPartners = useMemo(() => { const q = query.trim().toLowerCase(); @@ -72,13 +116,16 @@ export default function ExplorerContent({ category, query, onChangeCategory, onO }); }, [allPartners, basePartners, query]); - // Improvement: highlight is random and must not depend on category/pill. - const highlightPartnerRef = useRef(null); - if (highlightPartnerRef.current === null && allPartners.length > 0) { - const idx = Math.floor(Math.random() * allPartners.length); - highlightPartnerRef.current = allPartners[idx] ?? null; - } - const highlightPartner = highlightPartnerRef.current; + // Highlight is random and must not depend on category/pill. + // Only partners with a `highlight` image are eligible. + const highlightCandidates = useMemo(() => allPartners.filter((p) => !!p.highlight), [allPartners]); + const highlightPartner = useMemo(() => { + if (highlightCandidates.length === 0) return null; + if (highlightCandidates.length === 1) return highlightCandidates[0] ?? null; + const idx = Math.floor(Math.random() * highlightCandidates.length); + return highlightCandidates[idx] ?? null; + }, [highlightCandidates]); + const highlightImageUri = highlightPartner?.highlight ?? null; const availableEarnItems = useMemo(() => { const entries = Object.entries(YIELD_TOKEN_DEFINITIONS_BY_NETWORK) as [Networks, { tokenId: string; apr: string; url: string }[]][]; @@ -108,8 +155,12 @@ export default function ExplorerContent({ category, query, onChangeCategory, onO {availableEarnItems.slice(0, 3).map((item) => ( - - {item.token.logoURI ? : } + + {item.token.logoURI ? ( + + ) : ( + {item.token.symbol?.charAt(0) || '?'} + )} {item.token.symbol} @@ -124,9 +175,18 @@ export default function ExplorerContent({ category, query, onChangeCategory, onO - - + + { + if (!highlightPartner?.url) return; + onOpenWebApp(highlightPartner.url); + }} + activeOpacity={0.9} + > + {highlightImageUri && } {highlightPartner?.name ?? 'Explore partners'} @@ -135,7 +195,7 @@ export default function ExplorerContent({ category, query, onChangeCategory, onO - + @@ -147,9 +207,12 @@ export default function ExplorerContent({ category, query, onChangeCategory, onO [ { key: 'all', label: 'See all' }, { key: 'bitcoin', label: 'Bitcoin' }, + { key: 'lightning', label: 'Lightning' }, { key: 'botanix', label: 'Botanix' }, { key: 'rootstock', label: 'Rootstock' }, { key: 'citrea', label: 'Citrea' }, + { key: 'spark', label: 'Spark' }, + { key: 'arkade', label: 'Arkade' }, ] as const ).map((c) => ( onChangeCategory(c.key)} style={[styles.chip, c.key === category ? styles.chipSelected : styles.chipUnselected]} hitSlop={8} activeOpacity={0.85}> @@ -195,8 +258,8 @@ const styles = StyleSheet.create({ backgroundColor: '#000', }, contentContainer: { - paddingHorizontal: 14, - paddingBottom: 40, + paddingHorizontal: 16, + paddingBottom: 60, paddingTop: 12, }, sectionGap: { @@ -208,14 +271,21 @@ const styles = StyleSheet.create({ overflow: 'hidden', }, highlightMap: { - height: 130, - backgroundColor: 'rgba(0,0,0,0.35)', + height: 200, + backgroundColor: '#000', position: 'relative', + overflow: 'hidden', + }, + highlightBackgroundImage: { + ...StyleSheet.absoluteFillObject, + opacity: 1, + zIndex: 0, }, highlightGrid: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(255,255,255,0.05)', opacity: 0.18, + zIndex: 1, }, highlightOverlay: { position: 'absolute', @@ -225,6 +295,8 @@ const styles = StyleSheet.create({ padding: 10, borderRadius: 12, backgroundColor: 'rgba(0,0,0,0.55)', + zIndex: 2, + elevation: 2, }, highlightTitle: { color: 'rgba(255,255,255,0.95)', @@ -288,7 +360,6 @@ const styles = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', gap: 12, - paddingHorizontal: 10, paddingVertical: 12, borderRadius: 16, backgroundColor: 'transparent', @@ -339,16 +410,15 @@ const styles = StyleSheet.create({ earnRow: { flexDirection: 'row', alignItems: 'center', - paddingHorizontal: 4, + paddingHorizontal: 16, paddingVertical: 4, gap: 10, }, earnTokenIconWrap: { width: 34, height: 34, - borderRadius: 12, + borderRadius: 40, overflow: 'hidden', - backgroundColor: 'rgba(255,255,255,0.08)', alignItems: 'center', justifyContent: 'center', }, @@ -356,11 +426,10 @@ const styles = StyleSheet.create({ width: '100%', height: '100%', }, - earnTokenIconPlaceholder: { - width: '60%', - height: '60%', - borderRadius: 8, - backgroundColor: 'rgba(255,255,255,0.18)', + earnTokenIconText: { + color: 'white', + fontSize: 14, + fontWeight: '700', }, earnRowInfo: { flex: 1, From efcf02fcc3569ee5e157c1f51e5a6f48a112f64e Mon Sep 17 00:00:00 2001 From: li0nd3v Date: Mon, 23 Mar 2026 18:19:07 +0100 Subject: [PATCH 5/5] refactor: remove default partners url website --- shared/models/partners-list.ts | 133 ++++++++++++++++++++++++++++----- shared/types/partner-info.ts | 1 + 2 files changed, 114 insertions(+), 20 deletions(-) diff --git a/shared/models/partners-list.ts b/shared/models/partners-list.ts index f5fa41d29..efba60675 100644 --- a/shared/models/partners-list.ts +++ b/shared/models/partners-list.ts @@ -1,21 +1,93 @@ -import { Networks, NETWORK_BITCOIN, NETWORK_CITREA_TESTNET, NETWORK_BOTANIX, NETWORK_ROOTSTOCK, NETWORK_CITREA } from '../types/networks'; +import { Networks, NETWORK_BITCOIN, NETWORK_CITREA_TESTNET, NETWORK_BOTANIX, NETWORK_ROOTSTOCK, NETWORK_CITREA, NETWORK_LIGHTNING, NETWORK_SPARK, NETWORK_ARK } from '../types/networks'; import { PartnerInfo } from '../types/partner-info'; const partnersList: PartnerInfo[] = [ - /* { - name: 'HodlHodl', - network: NETWORK_BITCOIN, - url: 'https://hodlhodl.com/join/NPH2J', - imgUrl: 'https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcRAejGlNwvmchVooYPpUquqzQ7z0KahArwSVw&s', - description: 'Buy & sell bitcoin non-custodially, p2p', - }, */ { name: 'BTC Map', network: NETWORK_BITCOIN, url: 'https://btcmap.org/map', - imgUrl: '', + imgUrl: 'https://cdn.brandfetch.io/idUWkC2cvG/theme/dark/logo.svg?c=1bxid64Mup7aczewSAYMX&t=1759867429752', + highlight: 'https://btcmap.org/images/og/map.png', description: 'Find places to spend sats wherever you are', }, + { + name: 'AirBTC', + network: NETWORK_BITCOIN, + url: 'https://airbtc.online', + imgUrl: 'https://cdn.brandfetch.io/id5Cbx0-cD/w/400/h/400/theme/dark/icon.jpeg?c=1bxid64Mup7aczewSAYMX&t=1757511823062', + description: 'Stay anywhere with Bitcoin', + }, + { + name: 'Boltz', + network: NETWORK_BITCOIN, + url: 'https://boltz.exchange/', + imgUrl: 'https://cdn.brandfetch.io/ido147kV5m/w/390/h/390/theme/dark/icon.png?c=1bxid64Mup7aczewSAYMX&t=1760962230818', + highlight: 'https://cdn.brandfetch.io/ido147kV5m/w/390/h/390/theme/dark/icon.png?c=1bxid64Mup7aczewSAYMX&t=1760962230818', + description: 'Non-custodial Bitcoin bridge', + }, + { + name: 'Stacker News', + network: NETWORK_LIGHTNING, + url: 'https://stacker.news/', + imgUrl: 'https://cdn.brandfetch.io/id1b_CmEE6/w/256/h/256/theme/dark/icon.png?c=1bxid64Mup7aczewSAYMX&t=1764689848903', + description: 'Upvote and comment on the latest news in the Bitcoin ecosystem', + }, + { + name: 'Satlantis', + network: NETWORK_LIGHTNING, + url: 'https://www.satlantis.io/events', + imgUrl: 'https://cdn.brandfetch.io/id09y2K0BQ/w/180/h/180/theme/dark/logo.png?c=1bxid64Mup7aczewSAYMX&t=1745426752134', + description: 'Curated places and real-world experiences powered by Bitcoin.', + }, + { + name: 'BitcoinFlash', + network: NETWORK_LIGHTNING, + url: 'https://bitcoinflash.xyz/', + imgUrl: 'https://cdn.brandfetch.io/idXI0XZ_Sl/w/180/h/180/theme/dark/logo.png?c=1bxid64Mup7aczewSAYMX&t=1772219346444', + description: 'Access Bitcoin with low-friction options.', + }, + { + name: 'Bit.Spenda', + network: NETWORK_LIGHTNING, + url: 'https://send.bitspenda.app', + imgUrl: 'https://send.bitspenda.app/logo.png', + description: 'Spend Bitcoin via gift cards and spend-ready flows.', + }, + { + name: 'BitZed', + network: NETWORK_LIGHTNING, + url: 'https://app.bitzed.xyz', + imgUrl: 'https://bitzed.xyz/logo.png', + description: 'Bitcoin spending app.', + }, + { + name: 'Aureo', + network: NETWORK_LIGHTNING, + url: 'https://app.aureobitcoin.com/', + imgUrl: 'https://www.aureobitcoin.com/_next/image?url=https%3A%2F%2Fus-west-2.graphassets.com%2Fcmhi1xs950q2h06mx975ucw16%2Fcmhktrdr6kgdz07n1j9kz1fiz&w=3840&q=75', + description: 'A Bitcoin on-ramp/off-ramp experience.', + }, + { + name: 'Offramp | Cashwyre', + network: NETWORK_LIGHTNING, + url: 'https://offramp.cashwyre.com/offramp', + imgUrl: 'https://paylink-plugin.vercel.app/widget-icon.png', + description: 'Cashwyre offramp for Bitcoin.', + }, + { + name: 'Use Tando', + network: NETWORK_LIGHTNING, + url: 'https://use.tando.me/', + imgUrl: 'https://cdn.brandfetch.io/idpZe_Mbxi/w/400/h/400/theme/dark/icon.jpeg?c=1bxid64Mup7aczewSAYMX&t=1749183223711', + description: 'Spend Bitcoin anywhere in Kenya.', + }, + { + name: 'LNEsim', + network: NETWORK_LIGHTNING, + url: 'https://www.lnesim.com/', + imgUrl: 'https://www.lnesim.com/lnesim.png', + description: 'Instantly buy travel eSIM with Bitcoin Lightning Network', + }, { name: 'Bitrefill', network: NETWORK_BITCOIN, @@ -31,11 +103,11 @@ const partnersList: PartnerInfo[] = [ description: 'Secure Cold Wallet for Effortless Transactions', }, { - name: 'Check out Botanix Dapps', + name: 'Botanix Dapps', network: NETWORK_BOTANIX, url: 'https://botanixlabs.com/use', - imgUrl: 'https://bridge.botanixlabs.com/images/white-logo.png', - description: '', + imgUrl: 'https://cdn.brandfetch.io/idEa3WW1c8/w/400/h/400/theme/dark/icon.jpeg?c=1bxid64Mup7aczewSAYMX&t=1773848256724', + description: 'Check out Botanix Dapps', }, { name: 'Citrea Faucet', @@ -55,44 +127,65 @@ const partnersList: PartnerInfo[] = [ name: 'Bridge', network: NETWORK_BOTANIX, url: 'https://bridge.botanixlabs.com', - imgUrl: 'https://yield.botanixlabs.com/images/white-logo.png', + imgUrl: 'https://cdn.brandfetch.io/idEa3WW1c8/w/400/h/400/theme/dark/icon.jpeg?c=1bxid64Mup7aczewSAYMX&t=1773848256724', description: 'Bridge Bitcoin to Botanix', }, { name: 'Yield', network: NETWORK_BOTANIX, url: 'https://yield.botanixlabs.com/', - imgUrl: 'https://bridge.botanixlabs.com/images/white-logo.png', + imgUrl: 'https://cdn.brandfetch.io/idEa3WW1c8/w/400/h/400/theme/dark/icon.jpeg?c=1bxid64Mup7aczewSAYMX&t=1773848256724', description: 'Bitcoin in - More Bitcoin out. Backed by economic activity', }, { name: 'Oku Trade', network: NETWORK_ROOTSTOCK, url: 'https://oku.trade/?inputChain=rootstock', - imgUrl: '', - description: '', + imgUrl: 'https://cdn.brandfetch.io/idKAuL9ClT/w/180/h/180/theme/dark/logo.png?c=1bxid64Mup7aczewSAYMX&t=1773398820981', + description: 'The best routing options for Rootstock', }, { name: 'Bridge', network: NETWORK_CITREA, url: 'https://citrea.xyz/bridge', - imgUrl: '', - description: '', + imgUrl: 'https://cdn.brandfetch.io/idrgyTyq_W/w/180/h/180/theme/dark/logo.png?c=1bxid64Mup7aczewSAYMX&t=1769291838005', + description: 'Bridge Bitcoin to Citrea', }, { name: 'Citrea Dashboard', network: NETWORK_CITREA, url: 'https://app.citrea.xyz', - imgUrl: '', + imgUrl: 'https://cdn.brandfetch.io/idrgyTyq_W/w/180/h/180/theme/dark/logo.png?c=1bxid64Mup7aczewSAYMX&t=1769291838005', description: 'Your Citrea mainnet usage tiers, reflected in the progress bars.', }, { name: 'Citrea Ecosystem', network: NETWORK_CITREA, url: 'https://citrea.xyz/ecosystem', - imgUrl: '', + imgUrl: 'https://cdn.brandfetch.io/idrgyTyq_W/w/180/h/180/theme/dark/logo.png?c=1bxid64Mup7aczewSAYMX&t=1769291838005', description: 'Built for Bitcoin, Powered by Citrea', }, + { + name: 'Flashnet', + network: NETWORK_SPARK, + url: 'https://www.flashnet.xyz/', + imgUrl: 'https://cdn.brandfetch.io/idCPksPrT0/w/400/h/400/theme/dark/icon.jpeg?c=1bxid64Mup7aczewSAYMX&t=1770231197231', + description: 'Fastest Bitcoin liquidity layer', + }, + { + name: 'UTXO.fun', + network: NETWORK_SPARK, + url: 'https://www.utxo.fun/', + imgUrl: 'https://www.spark.money/ecosystem/app-utxo-fun.png', + description: 'Bitcoin UTXO discovery', + }, + { + name: 'LendaSat', + network: NETWORK_ARK, + url: 'https://swap.lendasat.com/usdc_eth/btc_arkade', + imgUrl: 'https://cdn.brandfetch.io/idiw3bB1RJ/w/400/h/400/theme/dark/icon.jpeg?c=1bxid64Mup7aczewSAYMX&t=1760963964272', + description: 'Bitcoin atomic swaps on Arkade.', + }, ]; export function getPartnersList(network: Networks): PartnerInfo[] { diff --git a/shared/types/partner-info.ts b/shared/types/partner-info.ts index a58889221..99260b528 100644 --- a/shared/types/partner-info.ts +++ b/shared/types/partner-info.ts @@ -11,4 +11,5 @@ export interface PartnerInfo { readonly url: string; readonly imgUrl: string; readonly description?: string; + readonly highlight?: string; }