2026-06-22 23:22:32 +02:00
|
|
|
import { useState, useEffect } from 'react'
|
2026-06-22 21:57:32 +02:00
|
|
|
import { useWaste } from '../store'
|
|
|
|
|
|
|
|
|
|
interface Props {
|
|
|
|
|
status: 'disconnected' | 'connecting' | 'connected'
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-22 23:22:32 +02:00
|
|
|
// Parse URL search params for pre-filling the join form.
|
|
|
|
|
// Supports:
|
2026-06-22 23:29:45 +02:00
|
|
|
// ?invite=waste:<b64> waste: invite string (anchor + network name + net hash)
|
2026-06-22 23:22:32 +02:00
|
|
|
// ?n=<name> network name shorthand
|
|
|
|
|
// ?network=<name> network name
|
2026-06-23 00:06:41 +02:00
|
|
|
// ?net=<64hex> yaw2-style full network hash
|
|
|
|
|
// ?a=<url> anchor URL hint
|
2026-06-22 23:29:45 +02:00
|
|
|
function parseInviteParams(): { network: string; netHash: string; anchor: string; inviteString: string } {
|
2026-06-22 23:22:32 +02:00
|
|
|
const p = new URLSearchParams(window.location.search)
|
|
|
|
|
const inviteString = p.get('invite') ?? ''
|
|
|
|
|
let network = p.get('n') ?? p.get('network') ?? ''
|
2026-06-22 23:29:45 +02:00
|
|
|
let netHash = p.get('net') ?? ''
|
2026-06-22 23:22:32 +02:00
|
|
|
let anchor = p.get('a') ?? p.get('anchor') ?? ''
|
|
|
|
|
|
|
|
|
|
if (inviteString.startsWith('waste:')) {
|
|
|
|
|
try {
|
|
|
|
|
const json = JSON.parse(atob(inviteString.slice(6).replace(/-/g, '+').replace(/_/g, '/')))
|
|
|
|
|
network = network || json.network || ''
|
2026-06-22 23:29:45 +02:00
|
|
|
netHash = netHash || json.net || ''
|
2026-06-22 23:22:32 +02:00
|
|
|
anchor = anchor || json.anchor || ''
|
|
|
|
|
} catch { /* ignore bad invite */ }
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-22 23:29:45 +02:00
|
|
|
return { network, netHash, anchor, inviteString }
|
2026-06-22 23:22:32 +02:00
|
|
|
}
|
|
|
|
|
|
2026-06-23 00:06:41 +02:00
|
|
|
const DEFAULT_ANCHOR = (() => {
|
2026-06-23 08:52:21 +02:00
|
|
|
const cfg = (window as unknown as { WASTE_CONFIG?: { signalURL?: string } })['WASTE_CONFIG']
|
|
|
|
|
return cfg?.signalURL ?? localStorage.getItem('waste_anchor_url') ?? ''
|
2026-06-23 00:06:41 +02:00
|
|
|
})()
|
|
|
|
|
|
|
|
|
|
const isLocal = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'
|
|
|
|
|
|
2026-06-22 21:57:32 +02:00
|
|
|
export function Onboarding({ status }: Props) {
|
2026-06-23 00:06:41 +02:00
|
|
|
const { send, connect, connectBrowser, adapterMode, masterAlias, masterId, exportedBackup } = useWaste()
|
|
|
|
|
|
2026-06-22 23:22:32 +02:00
|
|
|
const [network, setNetwork] = useState('')
|
2026-06-22 23:29:45 +02:00
|
|
|
const [netHash, setNetHash] = useState('')
|
2026-06-22 23:22:32 +02:00
|
|
|
const [inviteString, setInviteString] = useState('')
|
2026-06-23 00:06:41 +02:00
|
|
|
const [anchorUrl, setAnchorUrl] = useState(DEFAULT_ANCHOR)
|
|
|
|
|
const [nick, setNick] = useState(localStorage.getItem('waste_nick') || '')
|
2026-06-22 23:22:32 +02:00
|
|
|
const [exportPass, setExportPass] = useState('')
|
|
|
|
|
const [importJson, setImportJson] = useState('')
|
|
|
|
|
const [importPass, setImportPass] = useState('')
|
|
|
|
|
const [importStatus, setImportStatus] = useState('')
|
|
|
|
|
const [showBackup, setShowBackup] = useState(false)
|
2026-06-23 00:06:41 +02:00
|
|
|
const [daemonUrl, setDaemonUrl] = useState(localStorage.getItem('waste_daemon_ws') || 'ws://127.0.0.1:17338')
|
2026-06-22 21:57:32 +02:00
|
|
|
|
2026-06-22 23:22:32 +02:00
|
|
|
useEffect(() => {
|
2026-06-22 23:29:45 +02:00
|
|
|
const { network: n, netHash: nh, anchor: a, inviteString: inv } = parseInviteParams()
|
2026-06-22 23:22:32 +02:00
|
|
|
if (n) setNetwork(n)
|
2026-06-22 23:29:45 +02:00
|
|
|
if (nh) setNetHash(nh)
|
2026-06-23 00:06:41 +02:00
|
|
|
if (a) setAnchorUrl(a)
|
2026-06-22 23:22:32 +02:00
|
|
|
if (inv) setInviteString(inv)
|
|
|
|
|
}, [])
|
2026-06-22 21:57:32 +02:00
|
|
|
|
2026-06-22 23:22:32 +02:00
|
|
|
function joinNetwork(e: React.FormEvent) {
|
2026-06-22 21:57:32 +02:00
|
|
|
e.preventDefault()
|
2026-06-22 23:22:32 +02:00
|
|
|
const name = network.trim()
|
2026-06-22 23:29:45 +02:00
|
|
|
const hash = netHash.trim()
|
|
|
|
|
if (!name && !hash) return
|
2026-06-23 00:06:41 +02:00
|
|
|
|
|
|
|
|
// In browser mode: persist anchor URL choice and pass it via localStorage
|
|
|
|
|
if (adapterMode === 'browser') {
|
|
|
|
|
localStorage.setItem('waste_anchor_url', anchorUrl)
|
|
|
|
|
if (nick.trim()) localStorage.setItem('waste_nick', nick.trim())
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-22 23:29:45 +02:00
|
|
|
if (hash.length === 64 && !name) {
|
|
|
|
|
send({ type: 'join_network', network_hash: hash })
|
|
|
|
|
} else {
|
|
|
|
|
send({ type: 'join_network', network_name: name })
|
|
|
|
|
}
|
2026-06-22 21:57:32 +02:00
|
|
|
}
|
|
|
|
|
|
2026-06-22 23:22:32 +02:00
|
|
|
function exportIdentity(e: React.FormEvent) {
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
if (!exportPass.trim()) return
|
|
|
|
|
send({ type: 'export_identity', passphrase: exportPass })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function importIdentity(e: React.FormEvent) {
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
if (!importJson.trim() || !importPass.trim()) return
|
|
|
|
|
setImportStatus('Verifying…')
|
|
|
|
|
send({ type: 'import_identity', backup: importJson, passphrase: importPass })
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-23 00:06:41 +02:00
|
|
|
function switchToDaemon() {
|
|
|
|
|
localStorage.setItem('waste_daemon_ws', daemonUrl)
|
|
|
|
|
connect(daemonUrl)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function switchToBrowser() {
|
|
|
|
|
connectBrowser()
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-22 23:22:32 +02:00
|
|
|
const shortId = masterId ? masterId.slice(0, 16).replace(/(.{4})/g, '$1 ').trim() : null
|
|
|
|
|
|
|
|
|
|
// ── disconnected / connecting ────────────────────────────────────────────────
|
|
|
|
|
if (status !== 'connected') {
|
|
|
|
|
return (
|
|
|
|
|
<div className="onboarding">
|
|
|
|
|
<h1>waste</h1>
|
|
|
|
|
{status === 'connecting' ? (
|
2026-06-23 00:06:41 +02:00
|
|
|
<p className="status connecting">
|
|
|
|
|
{adapterMode === 'browser' ? 'Loading crypto…' : 'Connecting to daemon…'}
|
|
|
|
|
</p>
|
2026-06-22 23:22:32 +02:00
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
<p className="status disconnected">Daemon not running</p>
|
2026-06-23 00:06:41 +02:00
|
|
|
<p className="onboarding-hint">Start the daemon to use the web UI:</p>
|
2026-06-22 23:22:32 +02:00
|
|
|
<pre className="onboarding-code">./launch-web.sh</pre>
|
2026-06-23 00:06:41 +02:00
|
|
|
<p className="onboarding-hint muted">Or use browser mode (no install required):</p>
|
|
|
|
|
<button className="primary" onClick={switchToBrowser}>Use browser mode</button>
|
2026-06-22 23:22:32 +02:00
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── connected — join form ────────────────────────────────────────────────────
|
2026-06-22 21:57:32 +02:00
|
|
|
return (
|
|
|
|
|
<div className="onboarding">
|
|
|
|
|
<h1>waste</h1>
|
|
|
|
|
|
2026-06-22 23:22:32 +02:00
|
|
|
{masterAlias && (
|
|
|
|
|
<div className="onboarding-identity">
|
|
|
|
|
<span className="alias">{masterAlias}</span>
|
|
|
|
|
{shortId && <span className="peer-id mono">{shortId}</span>}
|
2026-06-23 00:06:41 +02:00
|
|
|
<span className="peer-id" style={{ opacity: 0.4, fontSize: '9px' }}>
|
|
|
|
|
{adapterMode === 'browser' ? 'browser mode' : 'daemon mode'}
|
|
|
|
|
</span>
|
2026-06-22 23:22:32 +02:00
|
|
|
</div>
|
2026-06-22 21:57:32 +02:00
|
|
|
)}
|
2026-06-22 23:22:32 +02:00
|
|
|
|
|
|
|
|
<form onSubmit={joinNetwork} className="join-form">
|
|
|
|
|
<label className="join-label">Join a network</label>
|
2026-06-23 00:06:41 +02:00
|
|
|
|
|
|
|
|
{adapterMode === 'browser' && (
|
|
|
|
|
<input
|
|
|
|
|
value={nick}
|
|
|
|
|
onChange={e => setNick(e.target.value)}
|
|
|
|
|
placeholder="your name (optional)"
|
|
|
|
|
autoComplete="nickname"
|
|
|
|
|
style={{ marginBottom: '0.4rem' }}
|
|
|
|
|
/>
|
2026-06-22 23:22:32 +02:00
|
|
|
)}
|
2026-06-23 00:06:41 +02:00
|
|
|
|
2026-06-22 23:29:45 +02:00
|
|
|
{netHash && !network ? (
|
2026-06-22 23:22:32 +02:00
|
|
|
<input
|
2026-06-22 23:29:45 +02:00
|
|
|
value={netHash}
|
|
|
|
|
onChange={e => setNetHash(e.target.value)}
|
|
|
|
|
placeholder="network hash (64 hex)"
|
|
|
|
|
autoComplete="off"
|
|
|
|
|
autoFocus
|
|
|
|
|
className="mono"
|
2026-06-22 23:22:32 +02:00
|
|
|
style={{ fontSize: '0.72rem' }}
|
|
|
|
|
/>
|
2026-06-22 23:29:45 +02:00
|
|
|
) : (
|
|
|
|
|
<input
|
|
|
|
|
value={network}
|
|
|
|
|
onChange={e => setNetwork(e.target.value)}
|
|
|
|
|
placeholder="network name"
|
|
|
|
|
autoComplete="off"
|
|
|
|
|
autoFocus
|
|
|
|
|
/>
|
|
|
|
|
)}
|
2026-06-23 00:06:41 +02:00
|
|
|
|
|
|
|
|
{adapterMode === 'browser' && (
|
|
|
|
|
<input
|
|
|
|
|
value={anchorUrl}
|
|
|
|
|
onChange={e => setAnchorUrl(e.target.value)}
|
|
|
|
|
placeholder="signal server (wss://…)"
|
|
|
|
|
autoComplete="url"
|
|
|
|
|
className="mono"
|
|
|
|
|
style={{ fontSize: '0.78rem' }}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-06-22 23:29:45 +02:00
|
|
|
{inviteString && (
|
|
|
|
|
<p className="onboarding-hint muted mono" style={{ fontSize: '0.68rem', wordBreak: 'break-all' }}>
|
|
|
|
|
{inviteString.slice(0, 48)}…
|
|
|
|
|
</p>
|
2026-06-22 23:22:32 +02:00
|
|
|
)}
|
2026-06-22 23:29:45 +02:00
|
|
|
<button type="submit" className="primary" disabled={!network.trim() && netHash.length !== 64}>
|
2026-06-22 23:22:32 +02:00
|
|
|
Join
|
|
|
|
|
</button>
|
|
|
|
|
</form>
|
|
|
|
|
|
2026-06-23 00:06:41 +02:00
|
|
|
{/* Mode switcher — only shown when on localhost */}
|
|
|
|
|
{isLocal && adapterMode !== null && (
|
|
|
|
|
<div className="onboarding-section">
|
|
|
|
|
{adapterMode === 'daemon' ? (
|
|
|
|
|
<button className="toggle-link" onClick={switchToBrowser}>
|
|
|
|
|
Switch to browser mode
|
|
|
|
|
</button>
|
|
|
|
|
) : (
|
|
|
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
|
|
|
|
|
<button className="toggle-link" onClick={() => {}}>
|
|
|
|
|
Switch to daemon mode
|
|
|
|
|
</button>
|
|
|
|
|
<input
|
|
|
|
|
value={daemonUrl}
|
|
|
|
|
onChange={e => setDaemonUrl(e.target.value)}
|
|
|
|
|
placeholder="ws://127.0.0.1:17338"
|
|
|
|
|
className="mono"
|
|
|
|
|
style={{ fontSize: '0.78rem' }}
|
|
|
|
|
/>
|
|
|
|
|
<button className="primary" onClick={switchToDaemon}>Connect</button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-06-22 23:22:32 +02:00
|
|
|
<div className="onboarding-section">
|
|
|
|
|
<button
|
|
|
|
|
className="toggle-link"
|
|
|
|
|
onClick={() => setShowBackup(b => !b)}
|
|
|
|
|
>
|
|
|
|
|
{showBackup ? '▾' : '▸'} Identity backup
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
{showBackup && (
|
|
|
|
|
<div className="backup-panel">
|
|
|
|
|
<p className="onboarding-hint">
|
|
|
|
|
Export your identity as an encrypted file. You can import it on
|
2026-06-23 00:06:41 +02:00
|
|
|
any device — browser or TUI.
|
2026-06-22 23:22:32 +02:00
|
|
|
</p>
|
|
|
|
|
|
|
|
|
|
<form onSubmit={exportIdentity} className="backup-form">
|
|
|
|
|
<input
|
|
|
|
|
type="password"
|
|
|
|
|
value={exportPass}
|
|
|
|
|
onChange={e => setExportPass(e.target.value)}
|
|
|
|
|
placeholder="passphrase for backup"
|
|
|
|
|
autoComplete="new-password"
|
|
|
|
|
/>
|
|
|
|
|
<button type="submit" disabled={!exportPass.trim()}>Export</button>
|
|
|
|
|
</form>
|
|
|
|
|
|
|
|
|
|
{exportedBackup && (
|
|
|
|
|
<div className="export-result">
|
|
|
|
|
<textarea
|
|
|
|
|
readOnly
|
|
|
|
|
value={exportedBackup}
|
|
|
|
|
rows={4}
|
|
|
|
|
className="mono"
|
|
|
|
|
onClick={e => (e.target as HTMLTextAreaElement).select()}
|
|
|
|
|
/>
|
|
|
|
|
<button onClick={() => {
|
|
|
|
|
const a = document.createElement('a')
|
|
|
|
|
a.href = URL.createObjectURL(new Blob([exportedBackup], { type: 'application/json' }))
|
|
|
|
|
a.download = `waste-identity-${masterId?.slice(0, 8) ?? 'backup'}.json`
|
|
|
|
|
a.click()
|
|
|
|
|
}}>Download file</button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<details style={{ marginTop: '1rem' }}>
|
|
|
|
|
<summary className="onboarding-hint" style={{ cursor: 'pointer' }}>Restore from backup</summary>
|
|
|
|
|
<form onSubmit={importIdentity} className="backup-form" style={{ marginTop: '0.5rem' }}>
|
|
|
|
|
<textarea
|
|
|
|
|
value={importJson}
|
|
|
|
|
onChange={e => setImportJson(e.target.value)}
|
|
|
|
|
placeholder='{"yaw":"yaw-key-backup-1", …}'
|
|
|
|
|
rows={3}
|
|
|
|
|
className="mono"
|
|
|
|
|
/>
|
|
|
|
|
<input
|
|
|
|
|
type="password"
|
|
|
|
|
value={importPass}
|
|
|
|
|
onChange={e => setImportPass(e.target.value)}
|
|
|
|
|
placeholder="passphrase"
|
|
|
|
|
autoComplete="current-password"
|
|
|
|
|
/>
|
|
|
|
|
<button type="submit" disabled={!importJson.trim() || !importPass.trim()}>
|
|
|
|
|
Verify & restore
|
|
|
|
|
</button>
|
|
|
|
|
{importStatus && <p className="onboarding-hint">{importStatus}</p>}
|
|
|
|
|
</form>
|
|
|
|
|
</details>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2026-06-22 21:57:32 +02:00
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|