feat: relay server dropdown with status indicators and manage dialog
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 3m38s
Some checks failed
Build Release Binaries / build-amd64 (push) Failing after 3m38s
- 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) <noreply@anthropic.com>
This commit is contained in:
@@ -14,9 +14,16 @@
|
|||||||
<p class="subtitle">Encrypted Voice</p>
|
<p class="subtitle">Encrypted Voice</p>
|
||||||
<div class="form">
|
<div class="form">
|
||||||
<label>Relay
|
<label>Relay
|
||||||
<div class="relay-row">
|
<div class="relay-dropdown-wrap">
|
||||||
<input id="relay" type="text" value="193.180.213.68:4433" />
|
<button id="relay-selected" class="relay-selected" type="button">
|
||||||
<span id="relay-status" class="relay-status"></span>
|
<span id="relay-dot" class="dot"></span>
|
||||||
|
<span id="relay-label">Select relay...</span>
|
||||||
|
<span class="arrow">▾</span>
|
||||||
|
</button>
|
||||||
|
<div id="relay-menu" class="relay-menu hidden">
|
||||||
|
<div id="relay-list"></div>
|
||||||
|
<button id="relay-manage-btn" class="relay-manage-btn" type="button">Manage Relays...</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
<label>Room
|
<label>Room
|
||||||
@@ -53,14 +60,10 @@
|
|||||||
<span id="call-timer" class="call-timer">0:00</span>
|
<span id="call-timer" class="call-timer">0:00</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Audio level meter -->
|
|
||||||
<div class="level-meter">
|
<div class="level-meter">
|
||||||
<div id="level-bar" class="level-bar-fill"></div>
|
<div id="level-bar" class="level-bar-fill"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="participants" class="participants"></div>
|
<div id="participants" class="participants"></div>
|
||||||
|
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
<button id="mic-btn" class="control-btn" title="Toggle Mic (m)">
|
<button id="mic-btn" class="control-btn" title="Toggle Mic (m)">
|
||||||
<span class="icon" id="mic-icon">Mic</span>
|
<span class="icon" id="mic-icon">Mic</span>
|
||||||
@@ -72,23 +75,18 @@
|
|||||||
<span class="icon" id="spk-icon">Spk</span>
|
<span class="icon" id="spk-icon">Spk</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="stats" class="stats"></div>
|
<div id="stats" class="stats"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Settings panel (overlay) -->
|
<!-- Settings panel -->
|
||||||
<div id="settings-panel" class="hidden">
|
<div id="settings-panel" class="hidden">
|
||||||
<div class="settings-card">
|
<div class="settings-card">
|
||||||
<div class="settings-header">
|
<div class="settings-header">
|
||||||
<h2>Settings</h2>
|
<h2>Settings</h2>
|
||||||
<button id="settings-close" class="icon-btn">×</button>
|
<button id="settings-close" class="icon-btn">×</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
<h3>Connection</h3>
|
<h3>Connection</h3>
|
||||||
<label>Default Relay
|
|
||||||
<input id="s-relay" type="text" />
|
|
||||||
</label>
|
|
||||||
<label>Default Room
|
<label>Default Room
|
||||||
<input id="s-room" type="text" />
|
<input id="s-room" type="text" />
|
||||||
</label>
|
</label>
|
||||||
@@ -96,7 +94,6 @@
|
|||||||
<input id="s-alias" type="text" />
|
<input id="s-alias" type="text" />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
<h3>Audio</h3>
|
<h3>Audio</h3>
|
||||||
<label class="checkbox">
|
<label class="checkbox">
|
||||||
@@ -108,7 +105,6 @@
|
|||||||
Automatic Gain Control
|
Automatic Gain Control
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
<h3>Identity</h3>
|
<h3>Identity</h3>
|
||||||
<div class="setting-row">
|
<div class="setting-row">
|
||||||
@@ -120,16 +116,30 @@
|
|||||||
<span class="fp-display">~/.wzp/identity</span>
|
<span class="fp-display">~/.wzp/identity</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
<h3>Recent Rooms</h3>
|
<h3>Recent Rooms</h3>
|
||||||
<div id="s-recent-rooms" class="recent-rooms-list"></div>
|
<div id="s-recent-rooms" class="recent-rooms-list"></div>
|
||||||
<button id="s-clear-recent" class="secondary-btn">Clear History</button>
|
<button id="s-clear-recent" class="secondary-btn">Clear History</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button id="settings-save" class="primary">Save</button>
|
<button id="settings-save" class="primary">Save</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Manage Relays dialog -->
|
||||||
|
<div id="relay-dialog" class="hidden">
|
||||||
|
<div class="settings-card relay-dialog-card">
|
||||||
|
<div class="settings-header">
|
||||||
|
<h2>Manage Relays</h2>
|
||||||
|
<button id="relay-dialog-close" class="icon-btn">×</button>
|
||||||
|
</div>
|
||||||
|
<div id="relay-dialog-list" class="relay-dialog-list"></div>
|
||||||
|
<div class="relay-add-row">
|
||||||
|
<input id="relay-add-name" type="text" placeholder="Name (e.g. EU-1)" />
|
||||||
|
<input id="relay-add-addr" type="text" placeholder="host:port" />
|
||||||
|
<button id="relay-add-btn" class="secondary-btn">Add</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script type="module" src="/src/main.ts"></script>
|
<script type="module" src="/src/main.ts"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { listen } from "@tauri-apps/api/event";
|
|||||||
// ── Elements ──
|
// ── Elements ──
|
||||||
const connectScreen = document.getElementById("connect-screen")!;
|
const connectScreen = document.getElementById("connect-screen")!;
|
||||||
const callScreen = document.getElementById("call-screen")!;
|
const callScreen = document.getElementById("call-screen")!;
|
||||||
const relayInput = document.getElementById("relay") as HTMLInputElement;
|
|
||||||
const roomInput = document.getElementById("room") as HTMLInputElement;
|
const roomInput = document.getElementById("room") as HTMLInputElement;
|
||||||
const aliasInput = document.getElementById("alias") as HTMLInputElement;
|
const aliasInput = document.getElementById("alias") as HTMLInputElement;
|
||||||
const osAecCheckbox = document.getElementById("os-aec") 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 myFingerprintEl = document.getElementById("my-fingerprint")!;
|
||||||
const recentRoomsDiv = document.getElementById("recent-rooms")!;
|
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 settingsPanel = document.getElementById("settings-panel")!;
|
||||||
const settingsClose = document.getElementById("settings-close")!;
|
const settingsClose = document.getElementById("settings-close")!;
|
||||||
const settingsSave = document.getElementById("settings-save")!;
|
const settingsSave = document.getElementById("settings-save")!;
|
||||||
const settingsBtnHome = document.getElementById("settings-btn-home")!;
|
const settingsBtnHome = document.getElementById("settings-btn-home")!;
|
||||||
const settingsBtnCall = document.getElementById("settings-btn-call")!;
|
const settingsBtnCall = document.getElementById("settings-btn-call")!;
|
||||||
const sRelay = document.getElementById("s-relay") as HTMLInputElement;
|
|
||||||
const sRoom = document.getElementById("s-room") as HTMLInputElement;
|
const sRoom = document.getElementById("s-room") as HTMLInputElement;
|
||||||
const sAlias = document.getElementById("s-alias") as HTMLInputElement;
|
const sAlias = document.getElementById("s-alias") as HTMLInputElement;
|
||||||
const sOsAec = document.getElementById("s-os-aec") 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 statusInterval: number | null = null;
|
||||||
let myFingerprint = "";
|
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 {
|
interface RecentRoom {
|
||||||
relay: string;
|
relay: string;
|
||||||
room: string;
|
room: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Settings {
|
interface Settings {
|
||||||
relay: string;
|
relays: RelayServer[];
|
||||||
|
selectedRelay: number; // index into relays
|
||||||
room: string;
|
room: string;
|
||||||
alias: string;
|
alias: string;
|
||||||
osAec: boolean;
|
osAec: boolean;
|
||||||
@@ -59,7 +81,8 @@ interface Settings {
|
|||||||
|
|
||||||
function loadSettings(): Settings {
|
function loadSettings(): Settings {
|
||||||
const defaults: Settings = {
|
const defaults: Settings = {
|
||||||
relay: "193.180.213.68:4433",
|
relays: [{ name: "Default", address: "193.180.213.68:4433" }],
|
||||||
|
selectedRelay: 0,
|
||||||
room: "android",
|
room: "android",
|
||||||
alias: "",
|
alias: "",
|
||||||
osAec: true,
|
osAec: true,
|
||||||
@@ -70,9 +93,16 @@ function loadSettings(): Settings {
|
|||||||
const raw = localStorage.getItem("wzp-settings");
|
const raw = localStorage.getItem("wzp-settings");
|
||||||
if (raw) {
|
if (raw) {
|
||||||
const parsed = JSON.parse(raw);
|
const parsed = JSON.parse(raw);
|
||||||
// Migrate old string[] recentRooms to RecentRoom[]
|
// Migrate: old format had relay as string
|
||||||
if (parsed.recentRooms && parsed.recentRooms.length > 0 && typeof parsed.recentRooms[0] === "string") {
|
if (parsed.relay && !parsed.relays) {
|
||||||
parsed.recentRooms = parsed.recentRooms.map((r: string) => ({ relay: parsed.relay || defaults.relay, room: r }));
|
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 };
|
return { ...defaults, ...parsed };
|
||||||
}
|
}
|
||||||
@@ -80,80 +110,193 @@ function loadSettings(): Settings {
|
|||||||
return defaults;
|
return defaults;
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveSettings() {
|
function saveSettingsObj(s: Settings) {
|
||||||
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);
|
|
||||||
}
|
|
||||||
localStorage.setItem("wzp-settings", JSON.stringify(s));
|
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() {
|
function applySettings() {
|
||||||
const s = loadSettings();
|
const s = loadSettings();
|
||||||
relayInput.value = s.relay;
|
|
||||||
roomInput.value = s.room;
|
roomInput.value = s.room;
|
||||||
aliasInput.value = s.alias;
|
aliasInput.value = s.alias;
|
||||||
osAecCheckbox.checked = s.osAec;
|
osAecCheckbox.checked = s.osAec;
|
||||||
renderRecentRooms(s.recentRooms);
|
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) => `
|
||||||
|
<div class="relay-menu-item ${i === s.selectedRelay ? "active" : ""}" data-idx="${i}">
|
||||||
|
<span class="dot ${dotClass(r.rtt)}"></span>
|
||||||
|
<div class="relay-info">
|
||||||
|
<div class="relay-name">${escapeHtml(r.name)}</div>
|
||||||
|
<div class="relay-addr">${escapeHtml(r.address)}</div>
|
||||||
|
</div>
|
||||||
|
<span class="relay-rtt">${rttText(r.rtt)}</span>
|
||||||
|
</div>`)
|
||||||
|
.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) => `
|
||||||
|
<div class="relay-dialog-item" data-idx="${i}">
|
||||||
|
<span class="dot ${dotClass(r.rtt)}"></span>
|
||||||
|
<div class="relay-info">
|
||||||
|
<div class="relay-name">${escapeHtml(r.name)}</div>
|
||||||
|
<div class="relay-addr">${escapeHtml(r.address)}</div>
|
||||||
|
</div>
|
||||||
|
<span class="relay-rtt">${rttText(r.rtt)}</span>
|
||||||
|
<button class="remove" data-idx="${i}">×</button>
|
||||||
|
</div>`)
|
||||||
|
.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[]) {
|
function renderRecentRooms(rooms: RecentRoom[]) {
|
||||||
recentRoomsDiv.innerHTML = rooms
|
recentRoomsDiv.innerHTML = rooms
|
||||||
.map(
|
.map((r) => `<span class="recent-room" data-relay="${escapeHtml(r.relay)}" data-room="${escapeHtml(r.room)}">${escapeHtml(r.room)}</span>`)
|
||||||
(r) =>
|
|
||||||
`<span class="recent-room" data-relay="${escapeHtml(r.relay)}" data-room="${escapeHtml(r.room)}">${escapeHtml(r.room)}</span>`
|
|
||||||
)
|
|
||||||
.join("");
|
.join("");
|
||||||
recentRoomsDiv.querySelectorAll(".recent-room").forEach((el) => {
|
recentRoomsDiv.querySelectorAll(".recent-room").forEach((el) => {
|
||||||
el.addEventListener("click", () => {
|
el.addEventListener("click", () => {
|
||||||
const ds = (el as HTMLElement).dataset;
|
const ds = (el as HTMLElement).dataset;
|
||||||
roomInput.value = ds.room || "";
|
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();
|
applySettings();
|
||||||
|
setTimeout(pingAllRelays, 300);
|
||||||
|
|
||||||
// ── Relay ping ──
|
// Load fingerprint at startup
|
||||||
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) ──
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const fp: string = await invoke("get_identity");
|
const fp: string = await invoke("get_identity");
|
||||||
@@ -163,38 +306,56 @@ setTimeout(() => pingRelay(relayInput.value), 300);
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
// Click fingerprint to copy
|
// Click fingerprint to copy
|
||||||
myFingerprintEl.addEventListener("click", copyFingerprint);
|
function copyFingerprint(el: HTMLElement) {
|
||||||
myFingerprintEl.style.cursor = "pointer";
|
|
||||||
sFingerprint.addEventListener("click", copyFingerprint);
|
|
||||||
sFingerprint.style.cursor = "pointer";
|
|
||||||
|
|
||||||
function copyFingerprint() {
|
|
||||||
if (myFingerprint) {
|
if (myFingerprint) {
|
||||||
navigator.clipboard.writeText(myFingerprint).then(() => {
|
navigator.clipboard.writeText(myFingerprint).then(() => {
|
||||||
const el = document.activeElement === sFingerprint ? sFingerprint : myFingerprintEl;
|
|
||||||
const orig = el.textContent;
|
const orig = el.textContent;
|
||||||
el.textContent = "Copied!";
|
el.textContent = "Copied!";
|
||||||
setTimeout(() => { el.textContent = orig; }, 1000);
|
setTimeout(() => { el.textContent = orig; }, 1000);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
myFingerprintEl.addEventListener("click", () => copyFingerprint(myFingerprintEl));
|
||||||
|
myFingerprintEl.style.cursor = "pointer";
|
||||||
|
sFingerprint.addEventListener("click", () => copyFingerprint(sFingerprint));
|
||||||
|
sFingerprint.style.cursor = "pointer";
|
||||||
|
|
||||||
// ── Connect ──
|
// ── Connect ──
|
||||||
connectBtn.addEventListener("click", doConnect);
|
connectBtn.addEventListener("click", doConnect);
|
||||||
[relayInput, roomInput, aliasInput].forEach((el) =>
|
[roomInput, aliasInput].forEach((el) =>
|
||||||
el.addEventListener("keydown", (e) => { if (e.key === "Enter") doConnect(); })
|
el.addEventListener("keydown", (e) => { if (e.key === "Enter") doConnect(); })
|
||||||
);
|
);
|
||||||
|
|
||||||
async function 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 = "";
|
connectError.textContent = "";
|
||||||
connectBtn.disabled = true;
|
connectBtn.disabled = true;
|
||||||
connectBtn.textContent = "Connecting...";
|
connectBtn.textContent = "Connecting...";
|
||||||
saveSettings();
|
|
||||||
userDisconnected = false;
|
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 {
|
try {
|
||||||
await invoke("connect", {
|
await invoke("connect", {
|
||||||
relay: relayInput.value,
|
relay: relay.address,
|
||||||
room: roomInput.value,
|
room: roomInput.value,
|
||||||
alias: aliasInput.value,
|
alias: aliasInput.value,
|
||||||
osAec: osAecCheckbox.checked,
|
osAec: osAecCheckbox.checked,
|
||||||
@@ -221,13 +382,10 @@ function showConnectScreen() {
|
|||||||
connectBtn.disabled = false;
|
connectBtn.disabled = false;
|
||||||
connectBtn.textContent = "Connect";
|
connectBtn.textContent = "Connect";
|
||||||
levelBar.style.width = "0%";
|
levelBar.style.width = "0%";
|
||||||
if (statusInterval) {
|
if (statusInterval) { clearInterval(statusInterval); statusInterval = null; }
|
||||||
clearInterval(statusInterval);
|
|
||||||
statusInterval = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Mute buttons ──
|
// ── Mute / hangup ──
|
||||||
micBtn.addEventListener("click", async () => {
|
micBtn.addEventListener("click", async () => {
|
||||||
try {
|
try {
|
||||||
const muted: boolean = await invoke("toggle_mic");
|
const muted: boolean = await invoke("toggle_mic");
|
||||||
@@ -250,7 +408,6 @@ hangupBtn.addEventListener("click", async () => {
|
|||||||
showConnectScreen();
|
showConnectScreen();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Keyboard shortcuts (only in call, not in inputs)
|
|
||||||
document.addEventListener("keydown", (e) => {
|
document.addEventListener("keydown", (e) => {
|
||||||
if (callScreen.classList.contains("hidden")) return;
|
if (callScreen.classList.contains("hidden")) return;
|
||||||
if ((e.target as HTMLElement).tagName === "INPUT") return;
|
if ((e.target as HTMLElement).tagName === "INPUT") return;
|
||||||
@@ -285,26 +442,24 @@ async function pollStatus() {
|
|||||||
try {
|
try {
|
||||||
const st: CallStatusI = await invoke("get_status");
|
const st: CallStatusI = await invoke("get_status");
|
||||||
if (!st.active) {
|
if (!st.active) {
|
||||||
// Connection dropped — try auto-reconnect unless user hung up
|
|
||||||
if (!userDisconnected && reconnectAttempts < MAX_RECONNECT) {
|
if (!userDisconnected && reconnectAttempts < MAX_RECONNECT) {
|
||||||
reconnectAttempts++;
|
reconnectAttempts++;
|
||||||
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts - 1), 10000);
|
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts - 1), 10000);
|
||||||
callStatus.className = "status-dot reconnecting";
|
callStatus.className = "status-dot reconnecting";
|
||||||
statsDiv.textContent = `Reconnecting (${reconnectAttempts}/${MAX_RECONNECT})...`;
|
statsDiv.textContent = `Reconnecting (${reconnectAttempts}/${MAX_RECONNECT})...`;
|
||||||
setTimeout(async () => {
|
const relay = getSelectedRelay();
|
||||||
try {
|
if (relay) {
|
||||||
await invoke("connect", {
|
setTimeout(async () => {
|
||||||
relay: relayInput.value,
|
try {
|
||||||
room: roomInput.value,
|
await invoke("connect", {
|
||||||
alias: aliasInput.value,
|
relay: relay.address, room: roomInput.value,
|
||||||
osAec: osAecCheckbox.checked,
|
alias: aliasInput.value, osAec: osAecCheckbox.checked,
|
||||||
});
|
});
|
||||||
reconnectAttempts = 0;
|
reconnectAttempts = 0;
|
||||||
callStatus.className = "status-dot";
|
callStatus.className = "status-dot";
|
||||||
} catch {
|
} catch {}
|
||||||
// Will retry on next poll
|
}, delay);
|
||||||
}
|
}
|
||||||
}, delay);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
reconnectAttempts = 0;
|
reconnectAttempts = 0;
|
||||||
@@ -313,43 +468,36 @@ async function pollStatus() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
reconnectAttempts = 0;
|
reconnectAttempts = 0;
|
||||||
|
|
||||||
if (st.fingerprint) myFingerprint = st.fingerprint;
|
if (st.fingerprint) myFingerprint = st.fingerprint;
|
||||||
|
|
||||||
// Mute state
|
|
||||||
micBtn.classList.toggle("muted", st.mic_muted);
|
micBtn.classList.toggle("muted", st.mic_muted);
|
||||||
micIcon.textContent = st.mic_muted ? "Mic Off" : "Mic";
|
micIcon.textContent = st.mic_muted ? "Mic Off" : "Mic";
|
||||||
spkBtn.classList.toggle("muted", st.spk_muted);
|
spkBtn.classList.toggle("muted", st.spk_muted);
|
||||||
spkIcon.textContent = st.spk_muted ? "Spk Off" : "Spk";
|
spkIcon.textContent = st.spk_muted ? "Spk Off" : "Spk";
|
||||||
|
|
||||||
// Timer
|
|
||||||
callTimer.textContent = formatDuration(st.call_duration_secs);
|
callTimer.textContent = formatDuration(st.call_duration_secs);
|
||||||
|
|
||||||
// Audio level
|
|
||||||
const rms = st.audio_level;
|
const rms = st.audio_level;
|
||||||
const pct = rms > 0 ? Math.min(100, (Math.log(rms) / Math.log(32767)) * 100) : 0;
|
const pct = rms > 0 ? Math.min(100, (Math.log(rms) / Math.log(32767)) * 100) : 0;
|
||||||
levelBar.style.width = `${pct}%`;
|
levelBar.style.width = `${pct}%`;
|
||||||
|
|
||||||
// Participants
|
|
||||||
if (st.participants.length === 0) {
|
if (st.participants.length === 0) {
|
||||||
participantsDiv.innerHTML = '<div class="participants-empty">Waiting for participants...</div>';
|
participantsDiv.innerHTML = '<div class="participants-empty">Waiting for participants...</div>';
|
||||||
} else {
|
} else {
|
||||||
participantsDiv.innerHTML = st.participants
|
participantsDiv.innerHTML = st.participants.map((p) => {
|
||||||
.map((p) => {
|
const name = p.alias || "Anonymous";
|
||||||
const name = p.alias || "Anonymous";
|
const initial = name.charAt(0).toUpperCase();
|
||||||
const initial = name.charAt(0).toUpperCase();
|
const fp = p.fingerprint ? p.fingerprint.substring(0, 16) : "";
|
||||||
const fp = p.fingerprint ? p.fingerprint.substring(0, 16) : "";
|
const isMe = p.fingerprint && myFingerprint.includes(p.fingerprint);
|
||||||
const isMe = p.fingerprint && myFingerprint.includes(p.fingerprint);
|
return `
|
||||||
return `
|
<div class="participant">
|
||||||
<div class="participant">
|
<div class="avatar ${isMe ? "me" : ""}">${initial}</div>
|
||||||
<div class="avatar ${isMe ? "me" : ""}">${initial}</div>
|
<div class="info">
|
||||||
<div class="info">
|
<div class="name">${escapeHtml(name)} ${isMe ? '<span class="you-badge">you</span>' : ""}</div>
|
||||||
<div class="name">${escapeHtml(name)} ${isMe ? '<span class="you-badge">you</span>' : ""}</div>
|
<div class="fp">${escapeHtml(fp)}</div>
|
||||||
<div class="fp">${escapeHtml(fp)}</div>
|
</div>
|
||||||
</div>
|
</div>`;
|
||||||
</div>`;
|
}).join("");
|
||||||
})
|
|
||||||
.join("");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
statsDiv.textContent = `TX: ${st.encode_fps} | RX: ${st.recv_fps}`;
|
statsDiv.textContent = `TX: ${st.encode_fps} | RX: ${st.recv_fps}`;
|
||||||
@@ -362,19 +510,15 @@ function escapeHtml(s: string): string {
|
|||||||
return d.innerHTML;
|
return d.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Events from backend ──
|
|
||||||
listen("call-event", (event: any) => {
|
listen("call-event", (event: any) => {
|
||||||
const { kind } = event.payload;
|
const { kind } = event.payload;
|
||||||
if (kind === "room-update") pollStatus();
|
if (kind === "room-update") pollStatus();
|
||||||
if (kind === "disconnected") {
|
if (kind === "disconnected" && !userDisconnected) pollStatus();
|
||||||
if (!userDisconnected) pollStatus(); // triggers reconnect
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Settings panel ──
|
// ── Settings panel ──
|
||||||
function openSettings() {
|
function openSettings() {
|
||||||
const s = loadSettings();
|
const s = loadSettings();
|
||||||
sRelay.value = s.relay;
|
|
||||||
sRoom.value = s.room;
|
sRoom.value = s.room;
|
||||||
sAlias.value = s.alias;
|
sAlias.value = s.alias;
|
||||||
sOsAec.checked = s.osAec;
|
sOsAec.checked = s.osAec;
|
||||||
@@ -383,30 +527,24 @@ function openSettings() {
|
|||||||
settingsPanel.classList.remove("hidden");
|
settingsPanel.classList.remove("hidden");
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeSettings() {
|
function closeSettings() { settingsPanel.classList.add("hidden"); }
|
||||||
settingsPanel.classList.add("hidden");
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderSettingsRecentRooms(rooms: RecentRoom[]) {
|
function renderSettingsRecentRooms(rooms: RecentRoom[]) {
|
||||||
if (rooms.length === 0) {
|
if (rooms.length === 0) {
|
||||||
sRecentRooms.innerHTML = '<span style="color:var(--text-dim);font-size:12px">No recent rooms</span>';
|
sRecentRooms.innerHTML = '<span style="color:var(--text-dim);font-size:12px">No recent rooms</span>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
sRecentRooms.innerHTML = rooms
|
sRecentRooms.innerHTML = rooms.map((r, i) => `
|
||||||
.map(
|
<div class="recent-room-item">
|
||||||
(r, i) => `
|
<span>${escapeHtml(r.room)} <small style="color:var(--text-dim)">${escapeHtml(r.relay)}</small></span>
|
||||||
<div class="recent-room-item">
|
<button class="remove" data-idx="${i}">×</button>
|
||||||
<span>${escapeHtml(r.room)} <small style="color:var(--text-dim)">${escapeHtml(r.relay)}</small></span>
|
</div>`).join("");
|
||||||
<button class="remove" data-idx="${i}">×</button>
|
|
||||||
</div>`
|
|
||||||
)
|
|
||||||
.join("");
|
|
||||||
sRecentRooms.querySelectorAll(".remove").forEach((btn) => {
|
sRecentRooms.querySelectorAll(".remove").forEach((btn) => {
|
||||||
btn.addEventListener("click", () => {
|
btn.addEventListener("click", () => {
|
||||||
const idx = parseInt((btn as HTMLElement).dataset.idx || "0");
|
const idx = parseInt((btn as HTMLElement).dataset.idx || "0");
|
||||||
const s = loadSettings();
|
const s = loadSettings();
|
||||||
s.recentRooms.splice(idx, 1);
|
s.recentRooms.splice(idx, 1);
|
||||||
localStorage.setItem("wzp-settings", JSON.stringify(s));
|
saveSettingsObj(s);
|
||||||
renderSettingsRecentRooms(s.recentRooms);
|
renderSettingsRecentRooms(s.recentRooms);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -419,12 +557,10 @@ settingsPanel.addEventListener("click", (e) => { if (e.target === settingsPanel)
|
|||||||
|
|
||||||
settingsSave.addEventListener("click", () => {
|
settingsSave.addEventListener("click", () => {
|
||||||
const s = loadSettings();
|
const s = loadSettings();
|
||||||
s.relay = sRelay.value;
|
|
||||||
s.room = sRoom.value;
|
s.room = sRoom.value;
|
||||||
s.alias = sAlias.value;
|
s.alias = sAlias.value;
|
||||||
s.osAec = sOsAec.checked;
|
s.osAec = sOsAec.checked;
|
||||||
localStorage.setItem("wzp-settings", JSON.stringify(s));
|
saveSettingsObj(s);
|
||||||
relayInput.value = s.relay;
|
|
||||||
roomInput.value = s.room;
|
roomInput.value = s.room;
|
||||||
aliasInput.value = s.alias;
|
aliasInput.value = s.alias;
|
||||||
osAecCheckbox.checked = s.osAec;
|
osAecCheckbox.checked = s.osAec;
|
||||||
@@ -435,18 +571,18 @@ settingsSave.addEventListener("click", () => {
|
|||||||
sClearRecent.addEventListener("click", () => {
|
sClearRecent.addEventListener("click", () => {
|
||||||
const s = loadSettings();
|
const s = loadSettings();
|
||||||
s.recentRooms = [];
|
s.recentRooms = [];
|
||||||
localStorage.setItem("wzp-settings", JSON.stringify(s));
|
saveSettingsObj(s);
|
||||||
renderSettingsRecentRooms([]);
|
renderSettingsRecentRooms([]);
|
||||||
renderRecentRooms([]);
|
renderRecentRooms([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Cmd+, / Ctrl+, opens settings, Escape closes
|
|
||||||
document.addEventListener("keydown", (e) => {
|
document.addEventListener("keydown", (e) => {
|
||||||
if ((e.metaKey || e.ctrlKey) && e.key === ",") {
|
if ((e.metaKey || e.ctrlKey) && e.key === ",") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
settingsPanel.classList.contains("hidden") ? openSettings() : closeSettings();
|
settingsPanel.classList.contains("hidden") ? openSettings() : closeSettings();
|
||||||
}
|
}
|
||||||
if (e.key === "Escape" && !settingsPanel.classList.contains("hidden")) {
|
if (e.key === "Escape") {
|
||||||
closeSettings();
|
if (!relayDialog.classList.contains("hidden")) closeRelayDialog();
|
||||||
|
else if (!settingsPanel.classList.contains("hidden")) closeSettings();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -89,24 +89,164 @@ body {
|
|||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.relay-row {
|
/* ── Relay dropdown ── */
|
||||||
|
.relay-dropdown-wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.relay-selected {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
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 {
|
.relay-selected .dot {
|
||||||
font-size: 11px;
|
width: 8px;
|
||||||
white-space: nowrap;
|
height: 8px;
|
||||||
min-width: 50px;
|
border-radius: 50%;
|
||||||
text-align: right;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.relay-status.online { color: var(--green); }
|
.relay-selected .arrow {
|
||||||
.relay-status.offline { color: var(--red); }
|
margin-left: auto;
|
||||||
.relay-status.pinging { color: var(--text-dim); }
|
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 {
|
.form-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
Reference in New Issue
Block a user