diff --git a/apps/frontend/src/components/connect/ConnectList/ConnectList.tsx b/apps/frontend/src/components/connect/ConnectList/ConnectList.tsx index 321670c6..d83f98d2 100644 --- a/apps/frontend/src/components/connect/ConnectList/ConnectList.tsx +++ b/apps/frontend/src/components/connect/ConnectList/ConnectList.tsx @@ -46,6 +46,7 @@ import { fetchVouches } from '../../../services/nostr.service'; import { RendezvousService } from '../../../services/http.service'; import JoinFactoryModal from '../JoinFactoryModal/JoinFactoryModal'; import AcceptInviteModal from '../../factories/AcceptInviteModal/AcceptInviteModal'; +import ManualConnectModal from '../ManualConnectModal/ManualConnectModal'; type RowSource = 'sample' | VouchTier; type SortKey = 'factory' | 'capacity' | 'minChannel' | 'opens'; @@ -134,6 +135,7 @@ const ConnectList = () => { const activeCoordinators = useSelector(selectActiveCoords); const [showAcceptInvite, setShowAcceptInvite] = useState(false); + const [showManualConnect, setShowManualConnect] = useState(false); const [showSample, setShowSample] = useState(false); const [joinRequests, setJoinRequests] = useState>({}); const [joinModalFor, setJoinModalFor] = useState(null); @@ -562,7 +564,8 @@ const ConnectList = () => { lnAddresses={joinModalFor.lnAddresses} /> )} -
+ +
+
setShowAcceptInvite(false)} /> + setShowManualConnect(false)} + onConnect={(pk, addr, al) => { + setShowManualConnect(false); + // Construct a synthetic FactoryRow just to feed JoinFactoryModal — + // the modal only reads pubkey/alias/lnAddresses, so the other + // fields are placeholders. + setJoinModalFor({ + id: `manual-${pk}`, + pubkey: pk, + alias: al, + capacitySats: null, + minChannelSats: null, + opensInBlocks: null, + status: 'pending', + isSelf: false, + nBreachEpochs: 0, + source: 'sample', + lnAddresses: addr ? [addr] : undefined, + }); + }} + /> ); diff --git a/apps/frontend/src/components/connect/ManualConnectModal/ManualConnectModal.tsx b/apps/frontend/src/components/connect/ManualConnectModal/ManualConnectModal.tsx new file mode 100644 index 00000000..f27ddf0b --- /dev/null +++ b/apps/frontend/src/components/connect/ManualConnectModal/ManualConnectModal.tsx @@ -0,0 +1,159 @@ +import { useState } from 'react'; +import { Modal, Button, Form, Alert } from 'react-bootstrap'; + +/* Task #119: manual-connect modal. + * + * The "Open Factories" Card on the Connect page only lists LSPs that a + * rendezvous coordinator has advertised. "Join via invite link" requires + * a full superscalar:// URL with iid baked in. This modal fills the + * remaining gap: someone DMs you "my LSP is @, come + * browse my factories" and you want to talk to that LSP directly. + * + * On submit we validate format and pass the entered pubkey + address back + * to the parent, which pops the existing JoinFactoryModal with those + * values. The plugin's auto-connect helper (#118) handles the BOLT-8 hop. + * + * No plugin RPC of our own: factory-browse-host (called by + * JoinFactoryModal) takes a peer + optional address hint, and the plugin + * connects on demand. */ + +type Props = { + show: boolean; + onHide: () => void; + onConnect: (lspPubkey: string, address: string | undefined, lspAlias: string) => void; +}; + +const PUBKEY_RE = /^[0-9a-fA-F]{66}$/; +/* host:port — host is anything non-whitespace, port is 1..5 digits. We do + * NOT try to validate the host shape strictly (IPv4 / IPv6 / .onion / + * DNS all valid), CLN's connectd does the real check. */ +const ADDRESS_RE = /^[^\s/@]+:[0-9]{1,5}$/; + +function ManualConnectModal({ show, onHide, onConnect }: Props) { + const [pubkey, setPubkey] = useState(''); + const [address, setAddress] = useState(''); + const [alias, setAlias] = useState(''); + const [error, setError] = useState(null); + + const handleSubmit = (e?: React.FormEvent) => { + e?.preventDefault(); + const pk = pubkey.trim(); + const addr = address.trim(); + const al = alias.trim(); + + if (!PUBKEY_RE.test(pk)) { + setError('Pubkey must be 66 hex chars (33 bytes, secp256k1 compressed).'); + return; + } + if (addr && !ADDRESS_RE.test(addr)) { + setError('Address must be host:port (e.g. 1.2.3.4:9735 or example.onion:9735).'); + return; + } + setError(null); + onConnect(pk, addr || undefined, al || pk.slice(0, 8) + '…' + pk.slice(-4)); + // Reset for next time + setPubkey(''); + setAddress(''); + setAlias(''); + }; + + const handleClose = () => { + setError(null); + onHide(); + }; + + /* Convenience: accept "@" pasted into the pubkey field + * and split it for the user — this is the format CLN's `connect` cmd + * uses, so it's the most common thing someone will paste. */ + const handlePubkeyChange = (v: string) => { + const trimmed = v.trim(); + const atIdx = trimmed.indexOf('@'); + if (atIdx > 0 && atIdx < trimmed.length - 1) { + const left = trimmed.slice(0, atIdx); + const right = trimmed.slice(atIdx + 1); + if (PUBKEY_RE.test(left)) { + setPubkey(left); + if (!address) setAddress(right); + return; + } + } + setPubkey(trimmed); + }; + + return ( + + + Connect to an LSP manually + +
+ +

+ Enter an LSP's node pubkey and address. We connect, fetch its public factory + inventory, and let you pick one to join. Use this when someone shares their LSP + details out-of-band instead of an invite link or a rendezvous-advertised entry. +

+

+ Tip: you can paste a full <pubkey>@<host:port> into the + pubkey field and we'll split it for you. +

+ + + LSP pubkey (66 hex chars) + handlePubkeyChange(e.target.value)} + placeholder='0212ab… or 0212ab…@1.2.3.4:9735' + data-testid='manual-connect-pubkey' + style={{ fontFamily: 'monospace', fontSize: '0.8rem' }} + autoFocus + /> + + + + Address (host:port, optional if already a peer) + setAddress(e.target.value)} + placeholder='1.2.3.4:9735 or example.onion:9735' + data-testid='manual-connect-address' + style={{ fontFamily: 'monospace', fontSize: '0.8rem' }} + /> + + If you've already connected to this LSP in another session you can leave this + blank — the wallet will use the existing peer connection. Required the first time. + + + + + Display label (optional) + setAlias(e.target.value)} + placeholder='e.g. Friend Alice — defaults to truncated pubkey' + data-testid='manual-connect-alias' + /> + + + {error && ( + + {error} + + )} +
+ + + + +
+
+ ); +} + +export default ManualConnectModal;