import React, { useState, useEffect, useMemo, useCallback } from 'react'; import { Alert, Keyboard, LayoutAnimation, Platform, ScrollView, StyleSheet, Switch, TextInput, View } from 'react-native'; import * as BlueElectrum from '../../blue_modules/BlueElectrum'; import triggerHapticFeedback, { HapticFeedbackTypes, triggerSelectionHapticFeedback } from '../../blue_modules/hapticFeedback'; import { BlueCard, BlueSpacing10, BlueSpacing20, BlueText } from '../../BlueComponents'; import DeeplinkSchemaMatch from '../../class/deeplink-schema-match'; import presentAlert from '../../components/Alert'; import Button from '../../components/Button'; import loc from '../../loc'; import { DoneAndDismissKeyboardInputAccessory, DoneAndDismissKeyboardInputAccessoryViewID, } from '../../components/DoneAndDismissKeyboardInputAccessory'; import DefaultPreference from 'react-native-default-preference'; import { DismissKeyboardInputAccessory, DismissKeyboardInputAccessoryViewID } from '../../components/DismissKeyboardInputAccessory'; import { useTheme } from '../../components/themes'; import { RouteProp, useRoute } from '@react-navigation/native'; import { DetailViewStackParamList } from '../../navigation/DetailViewStackParamList'; import { useExtendedNavigation } from '../../hooks/useExtendedNavigation'; import { CommonToolTipActions } from '../../typings/CommonToolTipActions'; import { Divider } from '@rneui/themed'; import { Header } from '../../components/Header'; import AddressInput from '../../components/AddressInput'; import { GROUP_IO_BLUEWALLET } from '../../blue_modules/currency'; import { Action } from '../../components/types'; import ListItem, { PressableWrapper } from '../../components/ListItem'; import HeaderMenuButton from '../../components/HeaderMenuButton'; import { useSettings } from '../../hooks/context/useSettings'; import { suggestedServers, hardcodedPeers, presentResetToDefaultsAlert } from '../../blue_modules/BlueElectrum'; type RouteProps = RouteProp; export interface ElectrumServerItem { host: string; tcp?: number; ssl?: number; } const SET_PREFERRED_PREFIX = 'set_preferred_'; const ElectrumSettings: React.FC = () => { const { colors } = useTheme(); const params = useRoute().params; const { server } = params; const navigation = useExtendedNavigation(); const [isLoading, setIsLoading] = useState(true); const [serverHistory, setServerHistory] = useState>(new Set()); const [config, setConfig] = useState<{ connected?: number; host?: string; port?: string }>({}); const [host, setHost] = useState(''); const [port, setPort] = useState(); const [sslPort, setSslPort] = useState(undefined); const [isAndroidNumericKeyboardFocused, setIsAndroidNumericKeyboardFocused] = useState(false); const [isAndroidAddressKeyboardVisible, setIsAndroidAddressKeyboardVisible] = useState(false); const { setIsElectrumDisabled, isElectrumDisabled } = useSettings(); const [savedServer, setSavedServer] = useState<{ host: string; tcp: string; ssl: string }>({ host: '', tcp: '', ssl: '', }); const stylesHook = StyleSheet.create({ inputWrap: { borderColor: colors.formBorder, backgroundColor: colors.inputBackgroundColor, }, containerConnected: { backgroundColor: colors.feeLabel, }, containerDisconnected: { backgroundColor: colors.redBG, }, textConnected: { color: colors.feeValue, }, textDisconnected: { color: colors.redText, }, hostname: { color: colors.foregroundColor, }, inputText: { color: colors.foregroundColor, }, usePort: { color: colors.foregroundColor, }, }); const configIntervalRef = React.useRef(null); const fetchData = useCallback(async () => { console.log('Fetching data...'); const preferredServer = await BlueElectrum.getPreferredServer(); const savedHost = preferredServer?.host; const savedPort = preferredServer?.tcp ? Number(preferredServer.tcp) : undefined; const savedSslPort = preferredServer?.ssl ? Number(preferredServer.ssl) : undefined; const serverHistoryStr = (await DefaultPreference.get(BlueElectrum.ELECTRUM_SERVER_HISTORY)) as string; console.log('Preferred server:', preferredServer); console.log('Server history string:', serverHistoryStr); const parsedServerHistory: ElectrumServerItem[] = serverHistoryStr ? JSON.parse(serverHistoryStr) : []; // Allow duplicates for same host if ssl/tcp differs. Only skip if host, ssl, and tcp are all the same: const newServerHistoryArray: ElectrumServerItem[] = []; for (const item of parsedServerHistory) { const existing = newServerHistoryArray.find(s => s.host === item.host && s.tcp === item.tcp && s.ssl === item.ssl); if (!existing) { newServerHistoryArray.push(item); } } const filteredServerHistory = new Set( newServerHistoryArray.filter( v => v.host && (v.tcp || v.ssl) && !suggestedServers.some(s => s.host === v.host && s.tcp === v.tcp && s.ssl === v.ssl) && !hardcodedPeers.some(peer => peer.host === v.host && peer.tcp === v.tcp && peer.ssl === v.ssl), ), ); console.log('Filtered server history:', filteredServerHistory); setHost(savedHost || ''); setPort(savedPort); setSslPort(savedSslPort); setServerHistory(filteredServerHistory); setConfig(await BlueElectrum.getConfig()); configIntervalRef.current = setInterval(async () => { setConfig(await BlueElectrum.getConfig()); }, 500); setSavedServer({ host: savedHost || '', tcp: savedPort ? savedPort.toString() : '', ssl: savedSslPort ? savedSslPort.toString() : '', }); setIsLoading(false); return () => { if (configIntervalRef.current) clearInterval(configIntervalRef.current); }; }, []); useEffect(() => { fetchData(); return () => { if (configIntervalRef.current) clearInterval(configIntervalRef.current); }; }, [fetchData]); useEffect(() => { if (server) { triggerHapticFeedback(HapticFeedbackTypes.ImpactHeavy); Alert.alert( loc.formatString(loc.settings.set_electrum_server_as_default, { server: (server as ElectrumServerItem).host }), '', [ { text: loc._.ok, onPress: () => { onBarScanned(JSON.stringify(server)); }, style: 'default', }, { text: loc._.cancel, onPress: () => {}, style: 'cancel' }, ], { cancelable: false }, ); } }, [server]); const save = useCallback( async (v?: ElectrumServerItem) => { Keyboard.dismiss(); setIsLoading(true); try { const serverHost = v?.host || host; const serverPort = v?.tcp ? v.tcp.toString() : port?.toString() || ''; const serverSslPort = v?.ssl ? v.ssl.toString() : sslPort?.toString() || ''; if (serverHost && (serverPort || serverSslPort)) { const testConnect = await BlueElectrum.testConnection(serverHost, Number(serverPort), Number(serverSslPort)); if (!testConnect) { return presentAlert({ message: serverHost.endsWith('.onion') ? loc.settings.electrum_error_connect_tor : loc.settings.electrum_error_connect, }); } await DefaultPreference.setName(GROUP_IO_BLUEWALLET); // Clear current data for the preferred host console.log('Clearing current data for the preferred host'); await DefaultPreference.clear(BlueElectrum.ELECTRUM_HOST); await DefaultPreference.clear(BlueElectrum.ELECTRUM_TCP_PORT); await DefaultPreference.clear(BlueElectrum.ELECTRUM_SSL_PORT); // Save the new preferred host console.log('Saving new preferred host'); await DefaultPreference.set(BlueElectrum.ELECTRUM_HOST, serverHost); await DefaultPreference.set(BlueElectrum.ELECTRUM_TCP_PORT, serverPort); await DefaultPreference.set(BlueElectrum.ELECTRUM_SSL_PORT, serverSslPort); const serverExistsInHistory = Array.from(serverHistory).some( s => s.host === serverHost && s.tcp === Number(serverPort) && s.ssl === Number(serverSslPort), ); if (!serverExistsInHistory && (serverPort || serverSslPort) && !hardcodedPeers.some(peer => peer.host === serverHost)) { const newServerHistory = new Set(serverHistory); newServerHistory.add({ host: serverHost, tcp: Number(serverPort), ssl: Number(serverSslPort) }); await DefaultPreference.set(BlueElectrum.ELECTRUM_SERVER_HISTORY, JSON.stringify(Array.from(newServerHistory))); setServerHistory(newServerHistory); } } else { throw new Error(loc.settings.electrum_error_connect); } triggerHapticFeedback(HapticFeedbackTypes.NotificationSuccess); presentAlert({ message: loc.settings.electrum_saved }); await fetchData(); } catch (error) { triggerHapticFeedback(HapticFeedbackTypes.NotificationError); presentAlert({ message: (error as Error).message }); } finally { setIsLoading(false); } }, [host, port, sslPort, fetchData, serverHistory], ); const selectServer = useCallback( (value: string) => { const parsedServer = JSON.parse(value) as ElectrumServerItem; setHost(parsedServer.host); setPort(parsedServer.tcp); setSslPort(parsedServer.ssl); save(parsedServer); }, [save], ); const presentSelectServerAlert = useCallback( (value: ElectrumServerItem) => { triggerHapticFeedback(HapticFeedbackTypes.ImpactHeavy); Alert.alert( loc.settings.electrum_preferred_server, loc.formatString(loc.settings.set_as_preferred_electrum, { host: value.host, port: String(value.ssl ?? value.tcp) }), [ { text: loc._.ok, onPress: () => { selectServer(JSON.stringify(value)); }, style: 'default', }, { text: loc._.cancel, onPress: () => {}, style: 'cancel' }, ], { cancelable: false }, ); }, [selectServer], ); const onPressMenuItem = useCallback( (id: string) => { if (id.startsWith(SET_PREFERRED_PREFIX)) { const rawServer = JSON.parse(id.replace(SET_PREFERRED_PREFIX, '')); presentSelectServerAlert(rawServer); } else { switch (id) { case CommonToolTipActions.ResetToDefault.id: presentResetToDefaultsAlert().then(reset => { if (reset) { triggerHapticFeedback(HapticFeedbackTypes.NotificationSuccess); presentAlert({ message: loc.settings.electrum_saved }); fetchData(); } }); break; default: try { selectServer(id); } catch (error) { console.warn('Unknown menu item selected:', id); } break; } } }, [presentSelectServerAlert, fetchData, selectServer], ); const isPreferred = useCallback( (value: ElectrumServerItem) => { return value.host === host && ((sslPort !== undefined && value.ssl === sslPort) || (sslPort === undefined && value.tcp === port)); }, [host, port, sslPort], ); type TCreateServerActionParameters = { value: ElectrumServerItem; seenHosts: Set; isPreferred?: boolean; isConnectedTo?: boolean; isSuggested?: boolean; }; const createServerAction = useCallback( ({ value, seenHosts, isPreferred: _unused, isConnectedTo = false, isSuggested = false }: TCreateServerActionParameters) => { const hostKey = `${value.host}:${value.tcp ?? ''}:${value.ssl ?? ''}`; seenHosts.add(hostKey); return { id: `${SET_PREFERRED_PREFIX}${JSON.stringify(value)}`, text: Platform.OS === 'android' ? `${value.host}:${value.ssl ?? value.tcp}` : value.host, icon: isPreferred(value) ? { iconValue: Platform.OS === 'ios' ? 'star.fill' : 'star_off' } : undefined, menuState: isConnectedTo, disabled: isPreferred(value), subtitle: value.ssl ? `${loc._.ssl_port}: ${value.ssl}` : `${loc._.port}: ${value.tcp}`, } as Action; }, [isPreferred], ); const generateToolTipActions = useCallback(() => { const determineConnectedServer = (): string | null => { const allServers = [...suggestedServers, ...Array.from(serverHistory)]; for (const value of allServers) { const isThisConnected = config?.host === value.host && (config.port === value.tcp || config.port === value.ssl); if (isThisConnected && isPreferred(value)) return JSON.stringify(value); } for (const value of allServers) { const isThisConnected = config?.host === value.host && (config.port === value.tcp || config.port === value.ssl); if (isThisConnected) return JSON.stringify(value); } return null; }; const connectedServer = config?.connected ? determineConnectedServer() : null; const seenHosts = new Set(); let preferredServerFound = false; let connectedServerFound = false; const mapServers = (servers: ElectrumServerItem[], isSuggested: boolean) => { return servers .map(value => { const isConnectedTo = !connectedServerFound && connectedServer === JSON.stringify(value); if (isConnectedTo) connectedServerFound = true; const isPreferredServer = !preferredServerFound && isPreferred(value); if (isPreferredServer) preferredServerFound = true; return createServerAction({ value, seenHosts, isPreferred: isPreferredServer, isConnectedTo, isSuggested, }); }) .filter((action): action is Action => action !== null); }; const suggestedServersAction: Action = { id: 'suggested_servers', text: loc._.suggested, displayInline: true, subtitle: loc.settings.electrum_suggested_description, subactions: mapServers(suggestedServers, true), }; const actions: Action[] = []; actions.push(suggestedServersAction); if (serverHistory.size > 0) { const serverSubactions: Action[] = mapServers(Array.from(serverHistory), false); actions.push({ id: 'server_history', text: loc.settings.electrum_history, displayInline: serverHistory.size <= 5 && serverHistory.size > 0, subactions: serverSubactions, hidden: serverHistory.size === 0, }); } const resetToDefaults = { ...CommonToolTipActions.ResetToDefault }; resetToDefaults.hidden = !host && serverHistory.size === 0; actions.push(resetToDefaults); return actions; }, [config?.connected, config?.host, config.port, createServerAction, host, isPreferred, serverHistory]); const HeaderRight = useMemo( () => , [onPressMenuItem, generateToolTipActions], ); useEffect(() => { navigation.setOptions({ headerRight: isElectrumDisabled ? null : () => HeaderRight, }); }, [HeaderRight, isElectrumDisabled, navigation]); const checkServer = async () => { setIsLoading(true); try { const features = await BlueElectrum.serverFeatures(); triggerHapticFeedback(HapticFeedbackTypes.NotificationWarning); presentAlert({ message: JSON.stringify(features, null, 2) }); } catch (error) { triggerHapticFeedback(HapticFeedbackTypes.NotificationError); presentAlert({ message: (error as Error).message }); } setIsLoading(false); }; const onBarScanned = (value: string) => { let v = value; if (value && DeeplinkSchemaMatch.getServerFromSetElectrumServerAction(value)) { v = DeeplinkSchemaMatch.getServerFromSetElectrumServerAction(value) as string; } const [scannedHost, scannedPort, type] = v?.split(':') ?? []; setHost(scannedHost); if (type === 's') { setSslPort(Number(scannedPort)); setPort(undefined); } else { setPort(Number(scannedPort)); setSslPort(undefined); } }; useEffect(() => { const data = params.onBarScanned; if (data) { onBarScanned(data); navigation.setParams({ onBarScanned: undefined }); } }, [navigation, params.onBarScanned]); const onSSLPortChange = (value: boolean) => { Keyboard.dismiss(); if (value) { // Move the current port to sslPort setSslPort(port); setPort(undefined); } else { // Move the current sslPort to port setPort(sslPort); setSslPort(undefined); } }; const onElectrumConnectionEnabledSwitchChange = async (value: boolean) => { LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); try { triggerSelectionHapticFeedback(); await BlueElectrum.setDisabled(value); setIsElectrumDisabled(value); } catch (error) { triggerHapticFeedback(HapticFeedbackTypes.NotificationError); presentAlert({ message: (error as Error).message }); } }; const preferredServerIsEmpty = !host || (!port && !sslPort); const saveDisabled: boolean = preferredServerIsEmpty || (host === savedServer.host && ((savedServer.tcp !== '' && port?.toString() === savedServer.tcp) || (savedServer.ssl !== '' && sslPort?.toString() === savedServer.ssl))); const renderElectrumSettings = () => { return ( <>
{config.connected === 1 ? loc.settings.electrum_connected : loc.settings.electrum_connected_not} {config.host}:{config.port}
{loc.settings.electrum_preferred_server_description} setHost(text.trim())} editable={!isLoading} keyboardType="default" skipValidation onBlur={() => setIsAndroidAddressKeyboardVisible(false)} onFocus={() => setIsAndroidAddressKeyboardVisible(true)} inputAccessoryViewID={DoneAndDismissKeyboardInputAccessoryViewID} isLoading={isLoading} /> { const parsed = Number(text.trim()); if (Number.isNaN(parsed)) { // Handle invalid input sslPort === undefined ? setPort(undefined) : setSslPort(undefined); return; } sslPort === undefined ? setPort(parsed) : setSslPort(parsed); }} numberOfLines={1} style={[styles.inputText, stylesHook.inputText]} editable={!isLoading} placeholderTextColor="#81868e" underlineColorAndroid="transparent" autoCorrect={false} autoCapitalize="none" keyboardType="number-pad" inputAccessoryViewID={DismissKeyboardInputAccessoryViewID} testID="PortInput" onFocus={() => setIsAndroidNumericKeyboardFocused(true)} onBlur={() => setIsAndroidNumericKeyboardFocused(false)} /> {loc.settings.use_ssl}