From 61b6e67610b8dae455b9fffb2d4fe0dbeecfdcfb Mon Sep 17 00:00:00 2001 From: Siavash Sameni Date: Mon, 6 Apr 2026 12:44:19 +0400 Subject: [PATCH] feat: relay server dropdown with status indicators and manage dialog - Relay selector as dropdown with green/yellow/red status dots (green < 200ms, yellow > 200ms, red = offline, gray = unknown) - All relays pinged on startup, RTT shown next to each - "Manage Relays..." dialog: add/remove servers, see live status - Clicking a relay in dropdown selects it, fills connect form - Recent room chips auto-select matching relay - Migrates old single-relay settings format automatically - Prevents connecting to offline relays Co-Authored-By: Claude Opus 4.6 (1M context) --- desktop/index.html | 44 +++-- desktop/src/main.ts | 408 ++++++++++++++++++++++++++++-------------- desktop/src/style.css | 160 +++++++++++++++-- 3 files changed, 449 insertions(+), 163 deletions(-) diff --git a/desktop/index.html b/desktop/index.html index 7e28ddd..10f52c5 100644 --- a/desktop/index.html +++ b/desktop/index.html @@ -14,9 +14,16 @@

Encrypted Voice

- -
-
-
-
- + + + + diff --git a/desktop/src/main.ts b/desktop/src/main.ts index 18a0c02..799624f 100644 --- a/desktop/src/main.ts +++ b/desktop/src/main.ts @@ -4,7 +4,6 @@ import { listen } from "@tauri-apps/api/event"; // ── Elements ── const connectScreen = document.getElementById("connect-screen")!; const callScreen = document.getElementById("call-screen")!; -const relayInput = document.getElementById("relay") as HTMLInputElement; const roomInput = document.getElementById("room") as HTMLInputElement; const aliasInput = document.getElementById("alias") as HTMLInputElement; const osAecCheckbox = document.getElementById("os-aec") as HTMLInputElement; @@ -24,12 +23,28 @@ const statsDiv = document.getElementById("stats")!; const myFingerprintEl = document.getElementById("my-fingerprint")!; const recentRoomsDiv = document.getElementById("recent-rooms")!; +// Relay dropdown +const relaySelected = document.getElementById("relay-selected")!; +const relayDot = document.getElementById("relay-dot")!; +const relayLabel = document.getElementById("relay-label")!; +const relayMenu = document.getElementById("relay-menu")!; +const relayList = document.getElementById("relay-list")!; +const relayManageBtn = document.getElementById("relay-manage-btn")!; + +// Relay dialog +const relayDialog = document.getElementById("relay-dialog")!; +const relayDialogClose = document.getElementById("relay-dialog-close")!; +const relayDialogList = document.getElementById("relay-dialog-list")!; +const relayAddName = document.getElementById("relay-add-name") as HTMLInputElement; +const relayAddAddr = document.getElementById("relay-add-addr") as HTMLInputElement; +const relayAddBtn = document.getElementById("relay-add-btn")!; + +// Settings const settingsPanel = document.getElementById("settings-panel")!; const settingsClose = document.getElementById("settings-close")!; const settingsSave = document.getElementById("settings-save")!; const settingsBtnHome = document.getElementById("settings-btn-home")!; const settingsBtnCall = document.getElementById("settings-btn-call")!; -const sRelay = document.getElementById("s-relay") as HTMLInputElement; const sRoom = document.getElementById("s-room") as HTMLInputElement; const sAlias = document.getElementById("s-alias") as HTMLInputElement; const sOsAec = document.getElementById("s-os-aec") as HTMLInputElement; @@ -40,16 +55,23 @@ const sClearRecent = document.getElementById("s-clear-recent")!; let statusInterval: number | null = null; let myFingerprint = ""; -let userDisconnected = false; // true when user clicks hangup (no auto-reconnect) +let userDisconnected = false; + +// ── Data types ── +interface RelayServer { + name: string; + address: string; + rtt?: number | null; // null = unknown, -1 = offline +} -// ── Settings persistence ── interface RecentRoom { relay: string; room: string; } interface Settings { - relay: string; + relays: RelayServer[]; + selectedRelay: number; // index into relays room: string; alias: string; osAec: boolean; @@ -59,7 +81,8 @@ interface Settings { function loadSettings(): Settings { const defaults: Settings = { - relay: "193.180.213.68:4433", + relays: [{ name: "Default", address: "193.180.213.68:4433" }], + selectedRelay: 0, room: "android", alias: "", osAec: true, @@ -70,9 +93,16 @@ function loadSettings(): Settings { const raw = localStorage.getItem("wzp-settings"); if (raw) { const parsed = JSON.parse(raw); - // Migrate old string[] recentRooms to RecentRoom[] - if (parsed.recentRooms && parsed.recentRooms.length > 0 && typeof parsed.recentRooms[0] === "string") { - parsed.recentRooms = parsed.recentRooms.map((r: string) => ({ relay: parsed.relay || defaults.relay, room: r })); + // Migrate: old format had relay as string + if (parsed.relay && !parsed.relays) { + parsed.relays = [{ name: "Default", address: parsed.relay }]; + parsed.selectedRelay = 0; + delete parsed.relay; + } + // Migrate: old recentRooms as string[] + if (parsed.recentRooms?.length > 0 && typeof parsed.recentRooms[0] === "string") { + const addr = parsed.relays?.[0]?.address || defaults.relays[0].address; + parsed.recentRooms = parsed.recentRooms.map((r: string) => ({ relay: addr, room: r })); } return { ...defaults, ...parsed }; } @@ -80,80 +110,193 @@ function loadSettings(): Settings { return defaults; } -function saveSettings() { - const s = loadSettings(); - s.relay = relayInput.value; - s.room = roomInput.value; - s.alias = aliasInput.value; - s.osAec = osAecCheckbox.checked; - // Add (relay, room) pair to recent list (dedup, max 5) - const relay = relayInput.value.trim(); - const room = roomInput.value.trim(); - if (room) { - const entry: RecentRoom = { relay, room }; - s.recentRooms = [ - entry, - ...s.recentRooms.filter((r) => !(r.relay === relay && r.room === room)), - ].slice(0, 5); - } +function saveSettingsObj(s: Settings) { localStorage.setItem("wzp-settings", JSON.stringify(s)); } +function getSelectedRelay(): RelayServer | undefined { + const s = loadSettings(); + return s.relays[s.selectedRelay]; +} + +// ── Apply settings to form ── function applySettings() { const s = loadSettings(); - relayInput.value = s.relay; roomInput.value = s.room; aliasInput.value = s.alias; osAecCheckbox.checked = s.osAec; renderRecentRooms(s.recentRooms); + renderRelayDropdown(); } +// ── Relay dropdown ── +function dotClass(rtt: number | null | undefined): string { + if (rtt === undefined || rtt === null) return "gray"; + if (rtt < 0) return "red"; + if (rtt > 200) return "yellow"; + return "green"; +} + +function rttText(rtt: number | null | undefined): string { + if (rtt === undefined || rtt === null) return ""; + if (rtt < 0) return "offline"; + return `${rtt}ms`; +} + +function renderRelayDropdown() { + const s = loadSettings(); + const sel = s.relays[s.selectedRelay]; + if (sel) { + relayDot.className = `dot ${dotClass(sel.rtt)}`; + relayLabel.textContent = `${sel.name} (${sel.address})`; + } else { + relayDot.className = "dot gray"; + relayLabel.textContent = "No relay configured"; + } + // Menu items + relayList.innerHTML = s.relays + .map((r, i) => ` +
+ +
+
${escapeHtml(r.name)}
+
${escapeHtml(r.address)}
+
+ ${rttText(r.rtt)} +
`) + .join(""); + + relayList.querySelectorAll(".relay-menu-item").forEach((el) => { + el.addEventListener("click", () => { + const idx = parseInt((el as HTMLElement).dataset.idx || "0"); + const s = loadSettings(); + s.selectedRelay = idx; + saveSettingsObj(s); + relayMenu.classList.add("hidden"); + renderRelayDropdown(); + }); + }); +} + +relaySelected.addEventListener("click", (e) => { + e.stopPropagation(); + relayMenu.classList.toggle("hidden"); +}); + +document.addEventListener("click", () => { + relayMenu.classList.add("hidden"); +}); + +relayMenu.addEventListener("click", (e) => e.stopPropagation()); + +relayManageBtn.addEventListener("click", () => { + relayMenu.classList.add("hidden"); + openRelayDialog(); +}); + +// ── Relay manage dialog ── +function openRelayDialog() { + renderRelayDialogList(); + relayAddName.value = ""; + relayAddAddr.value = ""; + relayDialog.classList.remove("hidden"); +} + +function closeRelayDialog() { + relayDialog.classList.add("hidden"); + renderRelayDropdown(); +} + +function renderRelayDialogList() { + const s = loadSettings(); + relayDialogList.innerHTML = s.relays + .map((r, i) => ` +
+ +
+
${escapeHtml(r.name)}
+
${escapeHtml(r.address)}
+
+ ${rttText(r.rtt)} + +
`) + .join(""); + + relayDialogList.querySelectorAll(".remove").forEach((btn) => { + btn.addEventListener("click", (e) => { + e.stopPropagation(); + const idx = parseInt((btn as HTMLElement).dataset.idx || "0"); + const s = loadSettings(); + s.relays.splice(idx, 1); + if (s.selectedRelay >= s.relays.length) s.selectedRelay = Math.max(0, s.relays.length - 1); + saveSettingsObj(s); + renderRelayDialogList(); + }); + }); +} + +relayAddBtn.addEventListener("click", () => { + const name = relayAddName.value.trim(); + const addr = relayAddAddr.value.trim(); + if (!addr) return; + const s = loadSettings(); + s.relays.push({ name: name || addr, address: addr }); + saveSettingsObj(s); + relayAddName.value = ""; + relayAddAddr.value = ""; + renderRelayDialogList(); + pingAllRelays(); +}); + +relayDialogClose.addEventListener("click", closeRelayDialog); +relayDialog.addEventListener("click", (e) => { if (e.target === relayDialog) closeRelayDialog(); }); + +// ── Ping all relays ── +async function pingAllRelays() { + const s = loadSettings(); + for (let i = 0; i < s.relays.length; i++) { + const r = s.relays[i]; + try { + const rtt: number = await invoke("ping_relay", { relay: r.address }); + r.rtt = rtt; + } catch { + r.rtt = -1; + } + } + saveSettingsObj(s); + renderRelayDropdown(); + // Also update dialog if open + if (!relayDialog.classList.contains("hidden")) { + renderRelayDialogList(); + } +} + +// ── Recent rooms ── function renderRecentRooms(rooms: RecentRoom[]) { recentRoomsDiv.innerHTML = rooms - .map( - (r) => - `${escapeHtml(r.room)}` - ) + .map((r) => `${escapeHtml(r.room)}`) .join(""); recentRoomsDiv.querySelectorAll(".recent-room").forEach((el) => { el.addEventListener("click", () => { const ds = (el as HTMLElement).dataset; roomInput.value = ds.room || ""; - relayInput.value = ds.relay || relayInput.value; + // Select matching relay + const s = loadSettings(); + const idx = s.relays.findIndex((r) => r.address === ds.relay); + if (idx >= 0) { + s.selectedRelay = idx; + saveSettingsObj(s); + renderRelayDropdown(); + } }); }); } +// ── Init ── applySettings(); +setTimeout(pingAllRelays, 300); -// ── Relay ping ── -const relayStatusEl = document.getElementById("relay-status")!; -let pingDebounce: number | null = null; - -async function pingRelay(address: string) { - relayStatusEl.textContent = "..."; - relayStatusEl.className = "relay-status pinging"; - try { - const rtt: number = await invoke("ping_relay", { relay: address }); - relayStatusEl.textContent = `${rtt}ms`; - relayStatusEl.className = "relay-status online"; - connectBtn.disabled = false; - } catch { - relayStatusEl.textContent = "offline"; - relayStatusEl.className = "relay-status offline"; - } -} - -// Ping on load and when relay input changes -relayInput.addEventListener("input", () => { - if (pingDebounce) clearTimeout(pingDebounce); - pingDebounce = window.setTimeout(() => pingRelay(relayInput.value), 500); -}); - -// Initial ping -setTimeout(() => pingRelay(relayInput.value), 300); - -// ── Load fingerprint at startup (no connection needed) ── +// Load fingerprint at startup (async () => { try { const fp: string = await invoke("get_identity"); @@ -163,38 +306,56 @@ setTimeout(() => pingRelay(relayInput.value), 300); })(); // Click fingerprint to copy -myFingerprintEl.addEventListener("click", copyFingerprint); -myFingerprintEl.style.cursor = "pointer"; -sFingerprint.addEventListener("click", copyFingerprint); -sFingerprint.style.cursor = "pointer"; - -function copyFingerprint() { +function copyFingerprint(el: HTMLElement) { if (myFingerprint) { navigator.clipboard.writeText(myFingerprint).then(() => { - const el = document.activeElement === sFingerprint ? sFingerprint : myFingerprintEl; const orig = el.textContent; el.textContent = "Copied!"; setTimeout(() => { el.textContent = orig; }, 1000); }); } } +myFingerprintEl.addEventListener("click", () => copyFingerprint(myFingerprintEl)); +myFingerprintEl.style.cursor = "pointer"; +sFingerprint.addEventListener("click", () => copyFingerprint(sFingerprint)); +sFingerprint.style.cursor = "pointer"; // ── Connect ── connectBtn.addEventListener("click", doConnect); -[relayInput, roomInput, aliasInput].forEach((el) => +[roomInput, aliasInput].forEach((el) => el.addEventListener("keydown", (e) => { if (e.key === "Enter") doConnect(); }) ); async function doConnect() { + const relay = getSelectedRelay(); + if (!relay) { + connectError.textContent = "No relay selected"; + return; + } + if (relay.rtt !== undefined && relay.rtt !== null && relay.rtt < 0) { + connectError.textContent = "Relay is offline"; + return; + } connectError.textContent = ""; connectBtn.disabled = true; connectBtn.textContent = "Connecting..."; - saveSettings(); userDisconnected = false; + // Save recent room + const s = loadSettings(); + s.room = roomInput.value; + s.alias = aliasInput.value; + s.osAec = osAecCheckbox.checked; + const room = roomInput.value.trim(); + if (room) { + const entry: RecentRoom = { relay: relay.address, room }; + s.recentRooms = [entry, ...s.recentRooms.filter((r) => !(r.relay === relay.address && r.room === room))].slice(0, 5); + } + saveSettingsObj(s); + try { await invoke("connect", { - relay: relayInput.value, + relay: relay.address, room: roomInput.value, alias: aliasInput.value, osAec: osAecCheckbox.checked, @@ -221,13 +382,10 @@ function showConnectScreen() { connectBtn.disabled = false; connectBtn.textContent = "Connect"; levelBar.style.width = "0%"; - if (statusInterval) { - clearInterval(statusInterval); - statusInterval = null; - } + if (statusInterval) { clearInterval(statusInterval); statusInterval = null; } } -// ── Mute buttons ── +// ── Mute / hangup ── micBtn.addEventListener("click", async () => { try { const muted: boolean = await invoke("toggle_mic"); @@ -250,7 +408,6 @@ hangupBtn.addEventListener("click", async () => { showConnectScreen(); }); -// Keyboard shortcuts (only in call, not in inputs) document.addEventListener("keydown", (e) => { if (callScreen.classList.contains("hidden")) return; if ((e.target as HTMLElement).tagName === "INPUT") return; @@ -285,26 +442,24 @@ async function pollStatus() { try { const st: CallStatusI = await invoke("get_status"); if (!st.active) { - // Connection dropped — try auto-reconnect unless user hung up if (!userDisconnected && reconnectAttempts < MAX_RECONNECT) { reconnectAttempts++; const delay = Math.min(1000 * Math.pow(2, reconnectAttempts - 1), 10000); callStatus.className = "status-dot reconnecting"; statsDiv.textContent = `Reconnecting (${reconnectAttempts}/${MAX_RECONNECT})...`; - setTimeout(async () => { - try { - await invoke("connect", { - relay: relayInput.value, - room: roomInput.value, - alias: aliasInput.value, - osAec: osAecCheckbox.checked, - }); - reconnectAttempts = 0; - callStatus.className = "status-dot"; - } catch { - // Will retry on next poll - } - }, delay); + const relay = getSelectedRelay(); + if (relay) { + setTimeout(async () => { + try { + await invoke("connect", { + relay: relay.address, room: roomInput.value, + alias: aliasInput.value, osAec: osAecCheckbox.checked, + }); + reconnectAttempts = 0; + callStatus.className = "status-dot"; + } catch {} + }, delay); + } return; } reconnectAttempts = 0; @@ -313,43 +468,36 @@ async function pollStatus() { } reconnectAttempts = 0; - if (st.fingerprint) myFingerprint = st.fingerprint; - // Mute state micBtn.classList.toggle("muted", st.mic_muted); micIcon.textContent = st.mic_muted ? "Mic Off" : "Mic"; spkBtn.classList.toggle("muted", st.spk_muted); spkIcon.textContent = st.spk_muted ? "Spk Off" : "Spk"; - // Timer callTimer.textContent = formatDuration(st.call_duration_secs); - // Audio level const rms = st.audio_level; const pct = rms > 0 ? Math.min(100, (Math.log(rms) / Math.log(32767)) * 100) : 0; levelBar.style.width = `${pct}%`; - // Participants if (st.participants.length === 0) { participantsDiv.innerHTML = '
Waiting for participants...
'; } else { - participantsDiv.innerHTML = st.participants - .map((p) => { - const name = p.alias || "Anonymous"; - const initial = name.charAt(0).toUpperCase(); - const fp = p.fingerprint ? p.fingerprint.substring(0, 16) : ""; - const isMe = p.fingerprint && myFingerprint.includes(p.fingerprint); - return ` -
-
${initial}
-
-
${escapeHtml(name)} ${isMe ? 'you' : ""}
-
${escapeHtml(fp)}
-
-
`; - }) - .join(""); + participantsDiv.innerHTML = st.participants.map((p) => { + const name = p.alias || "Anonymous"; + const initial = name.charAt(0).toUpperCase(); + const fp = p.fingerprint ? p.fingerprint.substring(0, 16) : ""; + const isMe = p.fingerprint && myFingerprint.includes(p.fingerprint); + return ` +
+
${initial}
+
+
${escapeHtml(name)} ${isMe ? 'you' : ""}
+
${escapeHtml(fp)}
+
+
`; + }).join(""); } statsDiv.textContent = `TX: ${st.encode_fps} | RX: ${st.recv_fps}`; @@ -362,19 +510,15 @@ function escapeHtml(s: string): string { return d.innerHTML; } -// ── Events from backend ── listen("call-event", (event: any) => { const { kind } = event.payload; if (kind === "room-update") pollStatus(); - if (kind === "disconnected") { - if (!userDisconnected) pollStatus(); // triggers reconnect - } + if (kind === "disconnected" && !userDisconnected) pollStatus(); }); // ── Settings panel ── function openSettings() { const s = loadSettings(); - sRelay.value = s.relay; sRoom.value = s.room; sAlias.value = s.alias; sOsAec.checked = s.osAec; @@ -383,30 +527,24 @@ function openSettings() { settingsPanel.classList.remove("hidden"); } -function closeSettings() { - settingsPanel.classList.add("hidden"); -} +function closeSettings() { settingsPanel.classList.add("hidden"); } function renderSettingsRecentRooms(rooms: RecentRoom[]) { if (rooms.length === 0) { sRecentRooms.innerHTML = 'No recent rooms'; return; } - sRecentRooms.innerHTML = rooms - .map( - (r, i) => ` -
- ${escapeHtml(r.room)} ${escapeHtml(r.relay)} - -
` - ) - .join(""); + sRecentRooms.innerHTML = rooms.map((r, i) => ` +
+ ${escapeHtml(r.room)} ${escapeHtml(r.relay)} + +
`).join(""); sRecentRooms.querySelectorAll(".remove").forEach((btn) => { btn.addEventListener("click", () => { const idx = parseInt((btn as HTMLElement).dataset.idx || "0"); const s = loadSettings(); s.recentRooms.splice(idx, 1); - localStorage.setItem("wzp-settings", JSON.stringify(s)); + saveSettingsObj(s); renderSettingsRecentRooms(s.recentRooms); }); }); @@ -419,12 +557,10 @@ settingsPanel.addEventListener("click", (e) => { if (e.target === settingsPanel) settingsSave.addEventListener("click", () => { const s = loadSettings(); - s.relay = sRelay.value; s.room = sRoom.value; s.alias = sAlias.value; s.osAec = sOsAec.checked; - localStorage.setItem("wzp-settings", JSON.stringify(s)); - relayInput.value = s.relay; + saveSettingsObj(s); roomInput.value = s.room; aliasInput.value = s.alias; osAecCheckbox.checked = s.osAec; @@ -435,18 +571,18 @@ settingsSave.addEventListener("click", () => { sClearRecent.addEventListener("click", () => { const s = loadSettings(); s.recentRooms = []; - localStorage.setItem("wzp-settings", JSON.stringify(s)); + saveSettingsObj(s); renderSettingsRecentRooms([]); renderRecentRooms([]); }); -// Cmd+, / Ctrl+, opens settings, Escape closes document.addEventListener("keydown", (e) => { if ((e.metaKey || e.ctrlKey) && e.key === ",") { e.preventDefault(); settingsPanel.classList.contains("hidden") ? openSettings() : closeSettings(); } - if (e.key === "Escape" && !settingsPanel.classList.contains("hidden")) { - closeSettings(); + if (e.key === "Escape") { + if (!relayDialog.classList.contains("hidden")) closeRelayDialog(); + else if (!settingsPanel.classList.contains("hidden")) closeSettings(); } }); diff --git a/desktop/src/style.css b/desktop/src/style.css index b468d2c..f60c428 100644 --- a/desktop/src/style.css +++ b/desktop/src/style.css @@ -89,24 +89,164 @@ body { border-color: var(--accent); } -.relay-row { +/* ── Relay dropdown ── */ +.relay-dropdown-wrap { + position: relative; +} + +.relay-selected { display: flex; align-items: center; gap: 8px; + width: 100%; + background: var(--surface); + border: 1px solid #333; + border-radius: 8px; + padding: 10px 12px; + color: var(--text); + font-size: 14px; + cursor: pointer; + text-align: left; + transition: border-color 0.2s; } -.relay-row input { flex: 1; } +.relay-selected:hover { border-color: var(--accent); } -.relay-status { - font-size: 11px; - white-space: nowrap; - min-width: 50px; - text-align: right; +.relay-selected .dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; } -.relay-status.online { color: var(--green); } -.relay-status.offline { color: var(--red); } -.relay-status.pinging { color: var(--text-dim); } +.relay-selected .arrow { + margin-left: auto; + font-size: 10px; + color: var(--text-dim); +} + +.dot.green { background: var(--green); } +.dot.yellow { background: var(--yellow); } +.dot.red { background: var(--red); } +.dot.gray { background: #555; } + +.relay-menu { + position: absolute; + top: calc(100% + 4px); + left: 0; + right: 0; + background: var(--surface); + border: 1px solid #444; + border-radius: 8px; + overflow: hidden; + z-index: 50; + box-shadow: 0 8px 24px rgba(0,0,0,0.4); +} + +.relay-menu-item { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + cursor: pointer; + font-size: 13px; + transition: background 0.1s; +} + +.relay-menu-item:hover { background: var(--surface2); } +.relay-menu-item.active { background: var(--primary); } + +.relay-menu-item .dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; } + +.relay-menu-item .relay-info { flex: 1; min-width: 0; } +.relay-menu-item .relay-name { font-weight: 500; } +.relay-menu-item .relay-addr { font-size: 11px; color: var(--text-dim); font-family: monospace; } +.relay-menu-item .relay-rtt { font-size: 11px; color: var(--text-dim); white-space: nowrap; } + +.relay-manage-btn { + display: block; + width: 100%; + padding: 10px; + background: none; + border: none; + border-top: 1px solid #333; + color: var(--accent); + font-size: 13px; + cursor: pointer; + text-align: center; +} + +.relay-manage-btn:hover { background: var(--surface2); } + +/* ── Relay dialog ── */ +#relay-dialog { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.6); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 200; + padding: 20px; +} + +.relay-dialog-card { + max-width: 420px; +} + +.relay-dialog-list { + display: flex; + flex-direction: column; + gap: 6px; + max-height: 300px; + overflow-y: auto; +} + +.relay-dialog-item { + display: flex; + align-items: center; + gap: 8px; + background: var(--surface); + border-radius: 8px; + padding: 8px 12px; +} + +.relay-dialog-item .dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; } +.relay-dialog-item .relay-info { flex: 1; } +.relay-dialog-item .relay-name { font-size: 13px; font-weight: 500; } +.relay-dialog-item .relay-addr { font-size: 11px; color: var(--text-dim); font-family: monospace; } +.relay-dialog-item .relay-rtt { font-size: 11px; color: var(--text-dim); margin-right: 4px; } + +.relay-dialog-item .remove { + background: none; + border: none; + color: var(--text-dim); + cursor: pointer; + font-size: 16px; + padding: 0 4px; +} + +.relay-dialog-item .remove:hover { color: var(--red); } + +.relay-add-row { + display: flex; + gap: 6px; + margin-top: 12px; +} + +.relay-add-row input { + background: var(--surface); + border: 1px solid #333; + border-radius: 8px; + padding: 8px 10px; + color: var(--text); + font-size: 13px; + outline: none; + flex: 1; +} + +.relay-add-row input:focus { border-color: var(--accent); } .form-row { display: flex;