diff --git a/mobile/app/DAppBrowser.tsx b/mobile/app/DAppBrowser.tsx index 57aa187a4..d1cc06bc1 100644 --- a/mobile/app/DAppBrowser.tsx +++ b/mobile/app/DAppBrowser.tsx @@ -13,18 +13,22 @@ 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'; import { NetworkContext } from '@shared/hooks/NetworkContext'; import { useFocusEffect } from '@react-navigation/native'; -import { NETWORK_BITCOIN } 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'; 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: { @@ -36,7 +40,7 @@ export const BROWSER_CONSTANTS = { }, TIMEOUTS: { SCREENSHOT_DELAY: 500, - POST_LOAD_CAPTURE: 1000, + POST_LOAD_CAPTURE: 1500, LOADING_TIMEOUT: 10000, }, MODAL: { @@ -57,7 +61,7 @@ export const BROWSER_CONSTANTS = { }, } as const; -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 homeIcon = require('@/assets/images/home.svg'); const getTabTitle = (url: string): string => { try { @@ -68,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); @@ -109,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; }; @@ -150,11 +154,18 @@ const DAppBrowser: React.FC = () => { const [js, setJs] = useState(null); const [error, setError] = useState(null); const params = useLocalSearchParams(); - const initialUrl = params.url || getHomeUrl(network); + const [viewMode, setViewMode] = useState<'explorer' | 'browser'>(() => (params.url ? 'browser' : 'explorer')); + const [explorerCategory, setExplorerCategory] = useState('all'); + const viewModeRef = useRef(viewMode); + useEffect(() => { + viewModeRef.current = viewMode; + }, [viewMode]); + const explorerPlaceholder = 'Search on Bitcoin'; + const initialUrl = params.url || ''; 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); @@ -271,6 +282,7 @@ const DAppBrowser: React.FC = () => { })); const panGesture = Gesture.Pan() + .enabled(false) .onStart(() => { gestureStartPosition.value = modalTranslateY.value; }) @@ -350,6 +362,7 @@ const DAppBrowser: React.FC = () => { useEffect(() => { navigation.setOptions({ + // Native header only while tab overview is open; hidden during browsing / explorer to avoid double headers. headerShown: showTabsOverview, title: 'Explorer', headerBackVisible: false, @@ -405,6 +418,7 @@ const DAppBrowser: React.FC = () => { }, []); useEffect(() => { + if (viewMode !== 'browser') return; (async () => { try { // eslint-disable-next-line @typescript-eslint/no-require-imports @@ -479,7 +493,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); @@ -503,10 +517,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; @@ -514,7 +528,7 @@ const DAppBrowser: React.FC = () => { isPurgingRef.current = false; } }, - [network, setAddressBarValue] + [setAddressBarValue] ); const saveTabs = useCallback( @@ -607,16 +621,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 +684,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 +714,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(); @@ -701,25 +730,31 @@ 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 }); + 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(() => { @@ -743,6 +778,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); @@ -752,10 +788,10 @@ 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) { + if (params.url && !isRestoringTabs && lastHandledUrl.current !== params.url) { lastHandledUrl.current = params.url; if (!isValidUrl(params.url)) { @@ -772,27 +808,55 @@ 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')); } - const newTab = createHomeTab(network); + const newTab = createBrowserTab(BLANK_TAB_URL); setTabs((prev) => [...prev, newTab]); setActiveTabId(newTab.id); @@ -804,13 +868,24 @@ 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) { - const newTab = createHomeTab(network); - setTabs([newTab]); - setActiveTabId(newTab.id); - setAddressBarValue(newTab.url, { ensureStartVisible: true }); + setTabs([]); + setActiveTabId(''); + setAddressInput(''); + setViewMode('explorer'); + if (showTabsOverview) { + hideTabsOverview(); + } return; } @@ -830,6 +905,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; @@ -861,11 +944,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) { @@ -907,11 +1005,13 @@ const DAppBrowser: React.FC = () => { text: 'Close All', style: 'destructive', onPress: () => { - const newTab = createHomeTab(network); - - setTabs([newTab]); - setActiveTabId(newTab.id); - setAddressBarValue(newTab.url, { ensureStartVisible: true }); + if (viewModeRef.current !== 'browser') { + setViewMode('browser'); + } + setTabs([]); + setActiveTabId(''); + setAddressInput(''); + setViewMode('explorer'); if (showTabsOverview) { hideTabsOverview(); } @@ -954,6 +1054,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; @@ -981,34 +1082,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 +1115,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() { @@ -1213,23 +1280,23 @@ const DAppBrowser: React.FC = () => { [activeTabId, isAddressInputFocused, setAddressBarValue] ); - if (error) { + if (error && viewMode === 'browser') { return ( - + {error} - + ); } - if (!js) { + if (!js && viewMode === 'browser') { return ( - + Loading DApp browser... - + ); } @@ -1238,15 +1305,23 @@ 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_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); + } + return; + } + let url = addressInput.trim(); if (!url) return; @@ -1284,16 +1391,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 ? ( @@ -1301,75 +1416,100 @@ 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); + ), + }, + { + onClick: () => {}, + variant: 'section', + children: Autofill, }, - children: ( - - - - Copy Bitcoin Address + { + 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 && ( + { + toggleTabsOverview(); + }} + onLongPress={() => { + handleCloseAllTabs(); + }} + testID="BrowserTabsOverviewButton" + > + + {tabs.length} + + + )} - {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); + }} + /> + )} - - - - - {!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 +1667,11 @@ const styles = StyleSheet.create({ gestureRootView: { flex: 1, }, + blackScreen: { + flex: 1, + backgroundColor: '#000', + position: 'relative', + }, flex1: { flex: 1, }, @@ -1609,7 +1729,12 @@ const styles = StyleSheet.create({ }, addressBarContainer: { position: 'relative', - zIndex: 10, + zIndex: 2, + }, + dismissKeyboardOverlay: { + ...StyleSheet.absoluteFillObject, + backgroundColor: 'transparent', + zIndex: 1, }, addressBarWrapper: { flex: 1, @@ -1652,6 +1777,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 +1804,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 +1820,9 @@ const styles = StyleSheet.create({ justifyContent: 'center', alignItems: 'center', }, - networkIcon: { - width: 24, - height: 24, + homeIcon: { + width: 16, + height: 16, }, contentContainer: { flex: 1, @@ -1701,7 +1830,7 @@ const styles = StyleSheet.create({ }, webviewContainer: { flex: 1, - backgroundColor: 'white', + backgroundColor: 'black', }, tabContainer: { flex: 1, @@ -1752,6 +1881,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 +1918,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..30462f6bd 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 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,46 @@ 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; + onCloseOverview: () => 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, + onCloseOverview, +}) => { + const insets = useSafeAreaInsets(); + return ( - - + + + Tabs + + + + + {tabs.map((tab, index) => ( { onSwitchTab(tab.id); }} @@ -49,11 +76,14 @@ export const DAppBrowserTabs: React.FC = ({ tabs, animated onEnsurePreview={(forceReload) => { void onEnsurePreview(tab.id, forceReload); }} + onInvalidatePreview={() => { + onInvalidatePreview(tab.id); + }} /> ))} - + ); }; @@ -73,10 +103,35 @@ 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, + backgroundColor: 'rgba(255, 255, 255, 0.12)', + borderWidth: StyleSheet.hairlineWidth, + borderColor: 'rgba(255, 255, 255, 0.2)', + 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 @@ + + + diff --git a/mobile/components/Explorer/ExplorerContent.tsx b/mobile/components/Explorer/ExplorerContent.tsx new file mode 100644 index 000000000..c83c97d39 --- /dev/null +++ b/mobile/components/Explorer/ExplorerContent.tsx @@ -0,0 +1,448 @@ +import React, { useMemo } 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 { 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, 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' | 'lightning' | 'spark' | 'arkade'; + +const getCategoryLabel = (category: ExplorerCategory): string => { + switch (category) { + case 'all': + 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'; + } +}; + +const getPartnersForCategory = (category: ExplorerCategory): PartnerInfo[] => { + switch (category) { + case 'bitcoin': + return getPartnersList(NETWORK_BITCOIN); + case 'botanix': + return getPartnersList(NETWORK_BOTANIX); + case 'rootstock': + 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'), + ...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; + onChangeCategory: (category: ExplorerCategory) => void; + onOpenWebApp: (url: string) => void; +}; + +export default function ExplorerContent({ category, query, onChangeCategory, onOpenWebApp }: ExplorerContentProps) { + const router = useRouter(); + 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(); + if (!q) return basePartners; + return allPartners.filter((p) => { + const haystack = `${p.name} ${p.description ?? ''}`.toLowerCase(); + return haystack.includes(q); + }); + }, [allPartners, basePartners, query]); + + // 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 }[]][]; + 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?.charAt(0) || '?'} + )} + + + {item.token.symbol} + + + up to {item.apr} + + + ))} + + + + + + + { + if (!highlightPartner?.url) return; + onOpenWebApp(highlightPartner.url); + }} + activeOpacity={0.9} + > + + {highlightImageUri && } + + + {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: '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}> + {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: 16, + paddingBottom: 60, + paddingTop: 12, + }, + sectionGap: { + marginBottom: 18, + }, + highlightCard: { + backgroundColor: 'rgba(255,255,255,0.05)', + borderRadius: 12, + overflow: 'hidden', + }, + highlightMap: { + 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', + left: 12, + right: 12, + bottom: 12, + padding: 10, + borderRadius: 12, + backgroundColor: 'rgba(0,0,0,0.55)', + zIndex: 2, + elevation: 2, + }, + 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, + 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: 16, + paddingVertical: 4, + gap: 10, + }, + earnTokenIconWrap: { + width: 34, + height: 34, + borderRadius: 40, + overflow: 'hidden', + alignItems: 'center', + justifyContent: 'center', + }, + earnTokenIcon: { + width: '100%', + height: '100%', + }, + earnTokenIconText: { + color: 'white', + fontSize: 14, + fontWeight: '700', + }, + 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', + }, +}); 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; }