From dab5387cbd8766ba998b968e5d7e212b45b98c11 Mon Sep 17 00:00:00 2001 From: Fredrik Johansson Date: Mon, 29 Jun 2026 16:38:46 +0200 Subject: [PATCH] feat: multi-network join, persistence, and switching in web UI - Sidebar: "+" button next to Networks opens an inline join form; in browser mode shows anchor URL field; saves to waste_saved_networks - Onboarding: saved networks shown as quick-join chips above the join form; auto-rejoin all saved networks on reconnect (not just one) - Browser mode: networks persisted as [{name, anchor}] array in localStorage (waste_saved_networks); legacy waste_last_network kept for backward compat - Daemon mode: daemon already tracks joined networks; web UI just needs the "+" to send join_network for additional ones Co-Authored-By: Claude Sonnet 4.6 --- web/src/App.css | 4 +++ web/src/components/Sidebar.tsx | 53 +++++++++++++++++++++++++++++++--- web/src/pages/Onboarding.tsx | 39 +++++++++++++++++++++---- 3 files changed, 87 insertions(+), 9 deletions(-) diff --git a/web/src/App.css b/web/src/App.css index 8d48852..97ae738 100644 --- a/web/src/App.css +++ b/web/src/App.css @@ -33,6 +33,10 @@ input:focus { border-color: var(--accent); } .onboarding-section { width: 100%; border-top: 1px solid var(--border); padding-top: 12px; } .join-form { display: flex; flex-direction: column; gap: 8px; width: 100%; } .join-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); } +.saved-networks { display: flex; flex-direction: column; gap: 6px; width: 100%; } +.saved-network-chips { display: flex; flex-wrap: wrap; gap: 6px; } +.network-chip { background: var(--surface); border: 1px solid var(--border); color: var(--text); border-radius: 20px; padding: 4px 14px; font-size: 13px; } +.network-chip:hover { border-color: var(--accent); color: var(--accent); background: var(--surface); } button.primary { background: var(--accent); width: 100%; padding: 8px; font-size: 14px; } .toggle-link { background: none; color: var(--muted); font-size: 12px; padding: 4px 0; text-align: left; } .toggle-link:hover { color: var(--text); } diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx index 9ca6982..583d4f9 100644 --- a/web/src/components/Sidebar.tsx +++ b/web/src/components/Sidebar.tsx @@ -32,10 +32,13 @@ export function Sidebar() { networks, activeNetworkId, activeRoom, connectedPeers, peerStatus, setActiveRoom, setActiveNetwork, messages, browseFiles, sendFileTo, adapterMode, - customRooms, createRoom, logout, + customRooms, createRoom, logout, send, } = useWaste() const [addingRoom, setAddingRoom] = useState(false) const [newRoomName, setNewRoomName] = useState('') + const [addingNetwork, setAddingNetwork] = useState(false) + const [newNetName, setNewNetName] = useState('') + const [newNetAnchor, setNewNetAnchor] = useState('') const netCustomRooms = activeNetworkId ? (customRooms[activeNetworkId] ?? []) : [] const rooms = ['general', ...netCustomRooms] @@ -50,6 +53,25 @@ export function Sidebar() { setAddingRoom(false) } + function submitNewNetwork(e: React.FormEvent) { + e.preventDefault() + const name = newNetName.trim() + if (!name) return + if (adapterMode === 'browser') { + // Persist to saved networks list in localStorage. + const anchor = newNetAnchor.trim() || localStorage.getItem('waste_anchor_url') || '' + const saved: Array<{name: string; anchor: string}> = JSON.parse(localStorage.getItem('waste_saved_networks') ?? '[]') + if (!saved.some(n => n.name === name && n.anchor === anchor)) { + saved.push({ name, anchor }) + localStorage.setItem('waste_saved_networks', JSON.stringify(saved)) + } + } + send({ type: 'join_network', network_name: name }) + setNewNetName('') + setNewNetAnchor('') + setAddingNetwork(false) + } + const displayAlias = localPeer?.alias ?? masterAlias ?? '' const displayId = localPeer?.id ?? masterId ?? '' const card = displayId ? makeYawCard(displayId, displayAlias) : null @@ -95,9 +117,12 @@ export function Sidebar() {
Networks - {activeNetworkId && ( - - )} + + {activeNetworkId && ( + + )} + +
{networks.map(n => (
diff --git a/web/src/pages/Onboarding.tsx b/web/src/pages/Onboarding.tsx index 5c3c3a6..ccd8c6b 100644 --- a/web/src/pages/Onboarding.tsx +++ b/web/src/pages/Onboarding.tsx @@ -72,8 +72,15 @@ export function Onboarding({ status }: Props) { if (adapterMode !== 'browser' || status !== 'connected') return const { network: n, netHash: nh } = parseInviteParams() if (n || nh) return // explicit invite — don't auto-join, show form - const savedNetwork = localStorage.getItem('waste_last_network') - if (savedNetwork) doJoin(savedNetwork, '') + // Rejoin all saved networks. + const saved: Array<{name: string; anchor: string}> = JSON.parse(localStorage.getItem('waste_saved_networks') ?? '[]') + if (saved.length > 0) { + saved.forEach(s => doJoin(s.name, '')) + } else { + // Legacy single-network fallback. + const savedNetwork = localStorage.getItem('waste_last_network') + if (savedNetwork) doJoin(savedNetwork, '') + } }, [adapterMode, status]) // eslint-disable-line react-hooks/exhaustive-deps function joinNetwork(e: React.FormEvent) { @@ -87,8 +94,16 @@ export function Onboarding({ status }: Props) { if (adapterMode === 'browser') { localStorage.setItem('waste_anchor_url', anchorUrl) if (nick.trim()) localStorage.setItem('waste_nick', nick.trim()) - if (name) localStorage.setItem('waste_last_network', name) - else localStorage.removeItem('waste_last_network') + if (name) { + // Persist to saved networks list. + const saved: Array<{name: string; anchor: string}> = JSON.parse(localStorage.getItem('waste_saved_networks') ?? '[]') + if (!saved.some(n => n.name === name)) { + saved.push({ name, anchor: anchorUrl }) + localStorage.setItem('waste_saved_networks', JSON.stringify(saved)) + } + // Keep legacy key for backward compat. + localStorage.setItem('waste_last_network', name) + } } if (hash.length === 64 && !name) { @@ -121,6 +136,7 @@ export function Onboarding({ status }: Props) { } const shortId = masterId ? masterId.slice(0, 16).replace(/(.{4})/g, '$1 ').trim() : null + const savedNetworks: Array<{name: string; anchor: string}> = JSON.parse(localStorage.getItem('waste_saved_networks') ?? '[]') // ── disconnected / connecting ──────────────────────────────────────────────── if (status !== 'connected') { @@ -159,8 +175,21 @@ export function Onboarding({ status }: Props) {
)} + {adapterMode === 'browser' && savedNetworks.length > 0 && ( +
+ Saved networks +
+ {savedNetworks.map(n => ( + + ))} +
+
+ )} +
- + {adapterMode === 'browser' && (