7 Commits

Author SHA1 Message Date
Siavash Sameni
217567383d fix(ui): timestamps in logs, proper call debounce, no cross-calling
- Copy/Share log now includes HH:MM:SS timestamps
- callInProgress stays true until call resolves (setup or hangup),
  preventing multiple taps from firing multiple place_call offers
- Block place_call when there's a pending incoming call
- leaveVoice clears all call state (callInProgress, pendingCallId)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 19:16:20 +04:00
Siavash Sameni
98ed981805 fix(ui): self-call prevention, debounce, codec in stats
- Filter self from lobby list (double-check in renderLobbyUsers)
- Disable "Direct Call" button when tapping own user
- Debounce call button (callInProgress flag prevents double-tap)
- Block calling own fingerprint
- Stats line shows codec names + fps + audio level

The direct call to the other phone failing is likely because
both phones share the same reflexive addr:port on the same NAT,
making determine_role return None (equal addrs). This is an
existing edge case in reflect.rs — not a UI bug.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 19:10:31 +04:00
Siavash Sameni
01a3133544 fix(ui): drawer buttons, stats fields, nicknames
- Buttons: use text labels (Mic/Spk/End) instead of emoji HTML
  entities that rendered as raw text on Android WebView
- Stats: match Rust CallStatus fields (tx_codec, rx_codec,
  encode_fps, recv_fps, audio_level, spk_muted)
- Nicknames: register_signal sends derive_alias() as the alias
  so other users see "Brave Falcon" instead of "a525:e9b2:..."
- Lobby header shows alias from get_app_info instead of raw fp
- pollStatus uses correct field names from Rust struct

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 19:00:09 +04:00
Siavash Sameni
25471c694f feat(ui): voice drawer replaces full-screen call UI
Discord-style bottom drawer for voice instead of navigating away:

- "Join Voice" hides the FAB, slides up a persistent bottom bar
- Drawer shows: room name, timer, P2P/Relay badge, level meter
- Controls: mic, speaker, end call — all in the drawer
- Direct call info (identicon, name, P2P badge) shown inline
- Lobby stays visible above the drawer at all times
- Stats line shows codec/packet/FEC info
- Leave voice = drawer slides away, FAB returns

Removed: full-screen call-screen, back button, old participant
list, old mic/speaker/hangup buttons. All voice interaction
happens in the 15% bottom drawer while the lobby stays live.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 18:47:40 +04:00
Siavash Sameni
a058a83c91 feat(ui): relay list management in settings
Settings now shows relay list with:
- Visual list of all configured relays
- Active relay highlighted in green with "ACTIVE" badge
- Tap a relay to switch (deregisters + reconnects automatically)
- X button to remove a relay (keeps at least 1)
- Add relay with name + address inputs
- Reconnect flow: deregister → clear lobby → auto-connect to new relay

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 18:37:58 +04:00
Siavash Sameni
9b8013ba7f merge main: PresenceList direct send fix 2026-04-14 18:36:01 +04:00
Siavash Sameni
defd8eab07 fix(signal): send PresenceList directly to new client after ack
Some checks failed
Mirror to GitHub / mirror (push) Failing after 24s
Build Release Binaries / build-amd64 (push) Failing after 3m50s
The broadcast alone wasn't reaching the first client because its
recv loop hadn't started yet when the second client registered.
Now the relay sends PresenceList directly to the new client (right
after RegisterPresenceAck) AND broadcasts to all others.

This guarantees every client gets the full user list:
- New client: via direct send (queued before recv loop starts)
- Existing clients: via broadcast

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 18:20:37 +04:00
5 changed files with 329 additions and 165 deletions

View File

@@ -1016,10 +1016,16 @@ async fn main() -> anyhow::Result<()> {
info!(%addr, fingerprint = %client_fp, alias = ?client_alias, "signal client registered");
// Broadcast updated presence to all signal clients
// Send the full presence list directly to the new
// client (guaranteed delivery — their recv loop is
// about to start). Then broadcast to all OTHER
// clients so they learn about the new user.
{
let hub = signal_hub.lock().await;
let presence = hub.presence_list();
// Direct send to new client (arrives right after ack)
let _ = transport.send_signal(&presence).await;
// Broadcast to everyone else
hub.broadcast(&presence).await;
}

View File

@@ -65,47 +65,40 @@
<button id="reject-call-btn" class="btn-reject">Reject</button>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════
IN-CALL — voice active (room or direct)
═══════════════════════════════════════════════════════ -->
<div id="call-screen" class="hidden">
<div class="call-header">
<div class="call-header-row">
<button id="back-to-lobby-btn" class="icon-btn small" title="Back to lobby">&#x2190;</button>
<div id="room-name" class="room-name"></div>
<button id="settings-btn-call" class="icon-btn small" title="Settings">&#9881;</button>
<!-- ═════ Voice Drawer (bottom bar, stays on lobby) ═════ -->
<div id="voice-drawer" class="voice-drawer hidden">
<div class="voice-drawer-bar" id="voice-drawer-bar">
<div class="vd-info">
<span id="vd-status" class="vd-status-dot"></span>
<span id="vd-room" class="vd-room">general</span>
<span id="vd-timer" class="vd-timer">0:00</span>
<span id="vd-badge" class="vd-badge hidden"></span>
</div>
<div class="vd-level">
<div id="vd-level-bar" class="vd-level-fill"></div>
</div>
<div class="vd-controls">
<button id="vd-mic-btn" class="vd-btn" title="Mic (m)">
<span id="vd-mic-icon">Mic</span>
</button>
<button id="vd-spk-btn" class="vd-btn" title="Speaker (s)">
<span id="vd-spk-icon">Spk</span>
</button>
<button id="vd-end-btn" class="vd-btn vd-end" title="Leave voice (q)">
<span>End</span>
</button>
</div>
</div>
<div class="call-meta">
<span id="call-status" class="status-dot"></span>
<span id="call-timer" class="call-timer">0:00</span>
<!-- Direct call info (shown during P2P calls) -->
<div id="vd-direct-info" class="vd-direct-info hidden">
<span id="vd-dc-identicon" class="vd-dc-identicon"></span>
<div class="vd-dc-details">
<div id="vd-dc-name" class="vd-dc-name">Unknown</div>
<div id="vd-dc-badge" class="vd-dc-badge">Connecting...</div>
</div>
</div>
<div id="vd-stats" class="vd-stats"></div>
</div>
<div class="level-meter">
<div id="level-bar" class="level-bar-fill"></div>
</div>
<!-- Direct-call phone layout -->
<div id="direct-call-view" class="direct-call-view hidden">
<div id="dc-identicon" class="dc-identicon"></div>
<div id="dc-name" class="dc-name">Unknown</div>
<div id="dc-fp" class="dc-fp"></div>
<div id="dc-badge" class="dc-badge">Connecting...</div>
</div>
<!-- Room participants -->
<div id="participants" class="participants"></div>
<div class="controls">
<button id="mic-btn" class="control-btn" title="Toggle Mic (m)">
<span class="icon" id="mic-icon">Mic</span>
</button>
<button id="hangup-btn" class="control-btn hangup" title="Hang Up (q)">
<span class="icon">End</span>
</button>
<button id="spk-btn" class="control-btn" title="Toggle Speaker (s)">
<span class="icon" id="spk-icon">Spk</span>
</button>
</div>
<div id="stats" class="stats"></div>
</div>
<!-- ═══════════════════════════════════════════════════════

View File

@@ -1215,8 +1215,11 @@ fn do_register_signal(
let transport = Arc::new(wzp_transport::QuinnTransport::new(conn));
emit_call_debug(&app, "register_signal:quic_connected", serde_json::json!({ "relay": relay }));
// Send alias from seed-derived adjective+noun so other
// users see a friendly name in the lobby.
let alias = derive_alias(&seed);
transport.send_signal(&SignalMessage::RegisterPresence {
identity_pub, signature: vec![], alias: None,
identity_pub, signature: vec![], alias: Some(alias),
}).await.map_err(|e| format!("{e}"))?;
emit_call_debug(&app, "register_signal:register_presence_sent", serde_json::json!({}));

View File

@@ -67,29 +67,35 @@ const incomingCallerName = document.getElementById("incoming-caller-name")!;
const incomingIdenticon = document.getElementById("incoming-identicon")!;
const acceptCallBtn = document.getElementById("accept-call-btn")!;
const rejectCallBtn = document.getElementById("reject-call-btn")!;
const backToLobbyBtn = document.getElementById("back-to-lobby-btn")!;
const roomName = document.getElementById("room-name")!;
const callTimer = document.getElementById("call-timer")!;
const callStatus = document.getElementById("call-status")!;
const levelBar = document.getElementById("level-bar")!;
const participantsDiv = document.getElementById("participants")!;
const directCallView = document.getElementById("direct-call-view")!;
const dcIdenticon = document.getElementById("dc-identicon")!;
const dcName = document.getElementById("dc-name")!;
const dcFp = document.getElementById("dc-fp")!;
const dcBadge = document.getElementById("dc-badge")!;
const micBtn = document.getElementById("mic-btn")!;
const micIcon = document.getElementById("mic-icon")!;
const spkBtn = document.getElementById("spk-btn")!;
const spkIcon = document.getElementById("spk-icon")!;
const hangupBtn = document.getElementById("hangup-btn")!;
const statsDiv = document.getElementById("stats")!;
// Voice drawer elements
const voiceDrawer = document.getElementById("voice-drawer")!;
const vdRoom = document.getElementById("vd-room")!;
const vdTimer = document.getElementById("vd-timer")!;
const vdStatus = document.getElementById("vd-status")!;
const vdBadge = document.getElementById("vd-badge")!;
const vdLevelBar = document.getElementById("vd-level-bar")!;
const vdMicBtn = document.getElementById("vd-mic-btn")!;
const vdMicIcon = document.getElementById("vd-mic-icon")!;
const vdSpkBtn = document.getElementById("vd-spk-btn")!;
const vdSpkIcon = document.getElementById("vd-spk-icon")!;
const vdEndBtn = document.getElementById("vd-end-btn")!;
const vdDirectInfo = document.getElementById("vd-direct-info")!;
const vdDcIdenticon = document.getElementById("vd-dc-identicon")!;
const vdDcName = document.getElementById("vd-dc-name")!;
const vdDcBadge = document.getElementById("vd-dc-badge")!;
const vdStats = document.getElementById("vd-stats")!;
const ctxMenu = document.getElementById("user-context-menu")!;
const ctxIdenticon = document.getElementById("ctx-identicon")!;
const ctxName = document.getElementById("ctx-name")!;
const ctxFp = document.getElementById("ctx-fp")!;
const ctxCallBtn = document.getElementById("ctx-call-btn")!;
const ctxCloseBtn = document.getElementById("ctx-close-btn")!;
// Relay management
const sRelayList = document.getElementById("s-relay-list")!;
const sRelayName = document.getElementById("s-relay-name") as HTMLInputElement;
const sRelayAddr = document.getElementById("s-relay-addr") as HTMLInputElement;
const sRelayAdd = document.getElementById("s-relay-add")!;
// Settings
const settingsPanel = document.getElementById("settings-panel")!;
const settingsBtn = document.getElementById("settings-btn")!;
@@ -210,7 +216,9 @@ sQuality?.addEventListener("input", () => updateQualityUI(parseInt(sQuality.valu
// ── Lobby rendering ───────────────────────────────────────────────
function renderLobbyUsers() {
lobbyUserList.innerHTML = "";
const users = Array.from(lobbyUsers.values()).sort((a, b) => {
const users = Array.from(lobbyUsers.values())
.filter((u) => u.fingerprint !== myFingerprint) // always exclude self
.sort((a, b) => {
// Voice users first, then alphabetical
if (a.inVoice !== b.inVoice) return a.inVoice ? -1 : 1;
return (a.alias || a.fingerprint).localeCompare(b.alias || b.fingerprint);
@@ -263,115 +271,109 @@ function openContextMenu(user: LobbyUser) {
ctxIdenticon.appendChild(createIdenticonEl(user.fingerprint, 40, true));
ctxName.textContent = user.alias || user.fingerprint.substring(0, 16);
ctxFp.textContent = user.fingerprint;
// Hide call button for self
const isSelf = user.fingerprint === myFingerprint;
(ctxCallBtn as HTMLButtonElement).disabled = isSelf;
(ctxCallBtn as HTMLElement).style.opacity = isSelf ? "0.3" : "1";
ctxMenu.classList.remove("hidden");
}
ctxCloseBtn.addEventListener("click", () => ctxMenu.classList.add("hidden"));
ctxMenu.addEventListener("click", (e) => { if (e.target === ctxMenu) ctxMenu.classList.add("hidden"); });
let callInProgress = false;
ctxCallBtn.addEventListener("click", async () => {
if (!contextUser) return;
if (!contextUser || callInProgress) return;
if (contextUser.fingerprint === myFingerprint) {
ctxMenu.classList.add("hidden");
return;
}
// Don't place a call if there's already a pending incoming call
if (pendingCallId) {
ctxMenu.classList.add("hidden");
return;
}
callInProgress = true;
ctxMenu.classList.add("hidden");
directCallPeer = { fingerprint: contextUser.fingerprint, alias: contextUser.alias };
try {
await invoke("place_call", { targetFp: contextUser.fingerprint });
// Keep callInProgress true until the call resolves (setup/hangup)
// — it's cleared in leaveVoice() or when the call connects
} catch (e: any) {
console.error("place_call failed:", e);
directCallPeer = null;
callInProgress = false;
}
});
// ── Voice join/leave ──────────────────────────────────────────────
// ── Voice join/leave (drawer-based) ───────────────────────────────
joinVoiceBtn.addEventListener("click", async () => {
if (inVoice) {
// Leave voice
try { await invoke("disconnect"); } catch {}
inVoice = false;
joinVoiceBtn.innerHTML = '<span class="fab-icon">&#x1F3A7;</span><span class="fab-label">Join Voice</span>';
joinVoiceBtn.classList.remove("active");
if (statusInterval) { clearInterval(statusInterval); statusInterval = null; }
showLobby();
} else {
// Join voice
const relay = getRelay();
const s = loadSettings();
if (!relay) return;
try {
await invoke("connect", {
relay: relay.address,
room: s.room || "general",
alias: s.alias || "",
osAec: s.osAec,
quality: s.quality || "auto",
});
inVoice = true;
joinVoiceBtn.innerHTML = '<span class="fab-icon">&#x1F3A7;</span><span class="fab-label">Leave Voice</span>';
joinVoiceBtn.classList.add("active");
showCallScreen(false);
} catch (e: any) {
console.error("connect failed:", e);
}
if (inVoice) return;
const relay = getRelay();
const s = loadSettings();
if (!relay) return;
try {
await invoke("connect", {
relay: relay.address,
room: s.room || "general",
alias: s.alias || "",
osAec: s.osAec,
quality: s.quality || "auto",
});
enterVoice(false);
} catch (e: any) {
console.error("connect failed:", e);
}
});
// ── Screen transitions ────────────────────────────────────────────
function showLobby() {
callScreen.classList.add("hidden");
lobbyScreen.classList.remove("hidden");
directCallPeer = null;
levelBar.style.width = "0%";
}
function showCallScreen(isDirect: boolean) {
lobbyScreen.classList.add("hidden");
callScreen.classList.remove("hidden");
function enterVoice(isDirect: boolean) {
inVoice = true;
const s = loadSettings();
joinVoiceBtn.classList.add("hidden");
voiceDrawer.classList.remove("hidden");
vdRoom.textContent = isDirect && directCallPeer
? (directCallPeer.alias || directCallPeer.fingerprint.substring(0, 16))
: (s.room || "general");
vdTimer.textContent = "0:00";
vdBadge.classList.add("hidden");
vdBadge.textContent = "";
if (isDirect && directCallPeer) {
roomName.textContent = directCallPeer.alias || directCallPeer.fingerprint.substring(0, 16);
dcName.textContent = directCallPeer.alias || "Unknown";
dcFp.textContent = directCallPeer.fingerprint;
dcIdenticon.innerHTML = "";
dcIdenticon.appendChild(createIdenticonEl(directCallPeer.fingerprint, 96, true));
dcBadge.textContent = "Connecting...";
dcBadge.className = "dc-badge connecting";
directCallView.classList.remove("hidden");
participantsDiv.classList.add("hidden");
vdDirectInfo.classList.remove("hidden");
vdDcIdenticon.innerHTML = "";
vdDcIdenticon.appendChild(createIdenticonEl(directCallPeer.fingerprint, 32, true));
vdDcName.textContent = directCallPeer.alias || directCallPeer.fingerprint.substring(0, 16);
vdDcBadge.textContent = "Connecting...";
vdDcBadge.className = "vd-dc-badge connecting";
} else {
const s = loadSettings();
roomName.textContent = s.room || "general";
directCallView.classList.add("hidden");
participantsDiv.classList.remove("hidden");
vdDirectInfo.classList.add("hidden");
}
callStatus.className = "status-dot";
statusInterval = window.setInterval(pollStatus, 250);
}
// Back button from call to lobby
backToLobbyBtn.addEventListener("click", async () => {
try { await invoke("disconnect"); } catch {}
function leaveVoice() {
inVoice = false;
joinVoiceBtn.innerHTML = '<span class="fab-icon">&#x1F3A7;</span><span class="fab-label">Join Voice</span>';
joinVoiceBtn.classList.remove("active");
callInProgress = false;
directCallPeer = null;
pendingCallId = null;
voiceDrawer.classList.add("hidden");
joinVoiceBtn.classList.remove("hidden");
vdLevelBar.style.width = "0%";
if (statusInterval) { clearInterval(statusInterval); statusInterval = null; }
showLobby();
});
}
// Hangup
hangupBtn.addEventListener("click", async () => {
// Drawer controls
vdEndBtn.addEventListener("click", async () => {
try { await invoke("hangup_call"); } catch {}
try { await invoke("disconnect"); } catch {}
inVoice = false;
joinVoiceBtn.innerHTML = '<span class="fab-icon">&#x1F3A7;</span><span class="fab-label">Join Voice</span>';
joinVoiceBtn.classList.remove("active");
if (statusInterval) { clearInterval(statusInterval); statusInterval = null; }
showLobby();
leaveVoice();
});
// Mic/speaker toggles
micBtn.addEventListener("click", async () => {
vdMicBtn.addEventListener("click", async () => {
try { await invoke("toggle_mic"); } catch {}
});
spkBtn.addEventListener("click", async () => {
vdSpkBtn.addEventListener("click", async () => {
try { await invoke("toggle_speaker"); } catch {}
});
@@ -379,38 +381,40 @@ spkBtn.addEventListener("click", async () => {
interface CallStatusI {
active: boolean;
mic_muted: boolean;
speaker_muted: boolean;
send_rms: number;
recv_rms: number;
codec_tx: string;
codec_rx: string;
fec_ratio: number;
send_packets: number;
recv_packets: number;
spk_muted: boolean;
participants: any[];
encode_fps: number;
recv_fps: number;
audio_level: number;
call_duration_secs: number;
fingerprint: string;
tx_codec: string;
rx_codec: string;
}
async function pollStatus() {
try {
const st: CallStatusI = await invoke("get_status");
if (!st.active) {
showLobby();
leaveVoice();
return;
}
if (st.fingerprint) myFingerprint = st.fingerprint;
micBtn.classList.toggle("muted", st.mic_muted);
micIcon.textContent = st.mic_muted ? "Mic Off" : "Mic";
spkBtn.classList.toggle("muted", st.speaker_muted);
spkIcon.textContent = st.speaker_muted ? "Spk Off" : "Spk";
const pct = Math.min(100, (st.send_rms / 10000) * 100);
levelBar.style.width = `${pct}%`;
// Update drawer controls
vdMicBtn.classList.toggle("muted", st.mic_muted);
vdMicIcon.textContent = st.mic_muted ? "Muted" : "Mic";
vdSpkBtn.classList.toggle("muted", st.spk_muted);
vdSpkIcon.textContent = st.spk_muted ? "Off" : "Spk";
// Level meter
const pct = Math.min(100, (st.audio_level / 10000) * 100);
vdLevelBar.style.width = `${pct}%`;
// Duration
const m = Math.floor(st.call_duration_secs / 60);
const s = Math.floor(st.call_duration_secs % 60);
callTimer.textContent = `${m}:${s.toString().padStart(2, "0")}`;
vdTimer.textContent = `${m}:${s.toString().padStart(2, "0")}`;
// P2P badge for direct calls
if (directCallPeer) {
@@ -418,16 +422,23 @@ async function pollStatus() {
const engineOk = [...callDebugBuffer].reverse().find((e) => e.step === "connect:call_engine_started");
if (engineOk) {
if (pathNeg?.details?.use_direct === true) {
dcBadge.textContent = "P2P Direct";
dcBadge.className = "dc-badge";
vdDcBadge.textContent = "P2P Direct";
vdDcBadge.className = "vd-dc-badge direct";
vdBadge.textContent = "P2P";
vdBadge.className = "vd-badge direct";
vdBadge.classList.remove("hidden");
} else {
dcBadge.textContent = "Via Relay";
dcBadge.className = "dc-badge relay";
vdDcBadge.textContent = "Via Relay";
vdDcBadge.className = "vd-dc-badge relay";
vdBadge.textContent = "Relay";
vdBadge.className = "vd-badge relay";
vdBadge.classList.remove("hidden");
}
}
}
statsDiv.textContent = `TX: ${st.codec_tx} ${st.send_packets}pkt | RX: ${st.codec_rx} ${st.recv_packets}pkt | FEC: ${(st.fec_ratio * 100).toFixed(0)}%`;
// Stats with codec
vdStats.textContent = `TX: ${st.tx_codec || "?"} ${st.encode_fps || 0}fps | RX: ${st.rx_codec || "?"} ${st.recv_fps || 0}fps | Level: ${st.audio_level || 0}`;
} catch {}
}
@@ -484,7 +495,7 @@ listen("signal-event", (event: any) => {
directOnly: s.directOnly || false,
birthdayAttack: s.birthdayAttack || false,
});
showCallScreen(true);
enterVoice(true);
} catch (e: any) {
console.error("connect failed:", e);
}
@@ -495,7 +506,7 @@ listen("signal-event", (event: any) => {
incomingBanner.classList.add("hidden");
(async () => {
try { await invoke("disconnect"); } catch {}
showLobby();
leaveVoice();
})();
break;
}
@@ -547,6 +558,80 @@ listen("call-event", (event: any) => {
});
// ── Settings ──────────────────────────────────────────────────────
// ── Relay list management ──────────────────────────────────────
function renderRelayList() {
const s = loadSettings();
sRelayList.innerHTML = "";
for (let i = 0; i < s.relays.length; i++) {
const r = s.relays[i];
const isActive = i === s.selectedRelay;
const row = document.createElement("div");
row.style.cssText = "display:flex;align-items:center;gap:6px;padding:8px;border-radius:6px;margin-bottom:4px;cursor:pointer;" +
(isActive ? "background:rgba(74,222,128,0.12);border:1px solid var(--green);" : "background:var(--surface);border:1px solid transparent;");
row.innerHTML = `
<span style="flex:1;font-size:13px;font-weight:${isActive ? '600' : '400'}">
<span style="color:${isActive ? 'var(--green)' : 'var(--text)'}">${r.name}</span>
<span style="color:var(--text-dim);font-size:11px;margin-left:4px">${r.address}</span>
</span>
${isActive ? '<span style="color:var(--green);font-size:11px">ACTIVE</span>' : ''}
<button class="relay-rm-btn" data-idx="${i}" style="background:none;border:none;color:var(--text-dim);cursor:pointer;font-size:16px;padding:2px 6px">&times;</button>
`;
// Click to select (not on the X button)
row.addEventListener("click", (e) => {
if ((e.target as HTMLElement).classList.contains("relay-rm-btn")) return;
const settings = loadSettings();
if (i !== settings.selectedRelay) {
settings.selectedRelay = i;
saveSettings(settings);
renderRelayList();
// Reconnect to new relay
reconnectSignal();
}
});
sRelayList.appendChild(row);
}
// Wire remove buttons
sRelayList.querySelectorAll(".relay-rm-btn").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
const idx = parseInt((btn as HTMLElement).dataset.idx || "0");
const settings = loadSettings();
if (settings.relays.length <= 1) return; // keep at least one
settings.relays.splice(idx, 1);
if (settings.selectedRelay >= settings.relays.length) {
settings.selectedRelay = 0;
}
saveSettings(settings);
renderRelayList();
reconnectSignal();
});
});
}
sRelayAdd.addEventListener("click", () => {
const name = sRelayName.value.trim();
const addr = sRelayAddr.value.trim();
if (!name || !addr) return;
if (!addr.includes(":")) return; // must be host:port
const s = loadSettings();
s.relays.push({ name, address: addr });
saveSettings(s);
sRelayName.value = "";
sRelayAddr.value = "";
renderRelayList();
});
async function reconnectSignal() {
// Deregister from current relay, then auto-connect to new one
try { await invoke("deregister"); } catch {}
lobbyUsers.clear();
renderLobbyUsers();
lobbyDot.style.background = "var(--yellow)";
lobbyRelayLabel.textContent = "Reconnecting...";
// Short delay to let deregister complete
setTimeout(() => autoConnect(), 500);
}
function openSettings() {
const s = loadSettings();
sRoom.value = s.room;
@@ -562,6 +647,7 @@ function openSettings() {
sQuality.value = String(qi);
updateQualityUI(qi);
sFingerprint.textContent = myFingerprint || "(loading...)";
renderRelayList();
settingsPanel.classList.remove("hidden");
}
@@ -584,6 +670,8 @@ settingsSave.addEventListener("click", () => {
invoke("set_dred_verbose_logs", { enabled: s.dredDebugLogs }).catch(() => {});
invoke("set_call_debug_logs", { enabled: s.callDebugLogs }).catch(() => {});
sCallDebugSection.style.display = s.callDebugLogs ? "" : "none";
// Update lobby room label
lobbyRoomLabel.textContent = s.room || "general";
settingsPanel.classList.add("hidden");
});
@@ -593,11 +681,17 @@ sCallDebugClearBtn?.addEventListener("click", () => {
sCallDebugLogEl.textContent = "";
});
sCallDebugCopyBtn?.addEventListener("click", () => {
const text = callDebugBuffer.map((e) => `${e.step} ${JSON.stringify(e.details)}`).join("\n");
const text = callDebugBuffer.map((e) => {
const t = new Date(e.ts_ms).toLocaleTimeString("en-GB", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" });
return `${t} ${e.step} ${JSON.stringify(e.details)}`;
}).join("\n");
navigator.clipboard?.writeText(text).catch(() => {});
});
sCallDebugShareBtn?.addEventListener("click", async () => {
const text = callDebugBuffer.map((e) => `${e.step} ${JSON.stringify(e.details)}`).join("\n");
const text = callDebugBuffer.map((e) => {
const t = new Date(e.ts_ms).toLocaleTimeString("en-GB", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" });
return `${t} ${e.step} ${JSON.stringify(e.details)}`;
}).join("\n");
try { await (navigator as any).share({ text }); } catch {}
});
@@ -651,13 +745,13 @@ async function autoConnect() {
lobbyDot.style.background = "var(--green)";
lobbyRelayLabel.textContent = `${relay.name} — connected`;
// Get identity
const fp: string = await invoke("get_identity");
if (fp) {
myFingerprint = fp;
lobbyFp.textContent = fp;
// Get identity + alias
const appInfo: any = await invoke("get_app_info");
if (appInfo?.fingerprint) {
myFingerprint = appInfo.fingerprint;
lobbyFp.textContent = appInfo.alias || appInfo.fingerprint;
lobbyIdenticon.innerHTML = "";
lobbyIdenticon.appendChild(createIdenticonEl(fp, 20, true));
lobbyIdenticon.appendChild(createIdenticonEl(appInfo.fingerprint, 20, true));
}
} catch (e: any) {
lobbyDot.style.background = "var(--red)";
@@ -671,9 +765,9 @@ invoke("set_call_debug_logs", { enabled: !!loadSettings().callDebugLogs }).catch
// Keyboard shortcuts
document.addEventListener("keydown", (e) => {
if ((e.target as HTMLElement).tagName === "INPUT") return;
if (e.key === "m") micBtn.click();
if (e.key === "q") hangupBtn.click();
if (e.key === "s") spkBtn.click();
if (e.key === "m") vdMicBtn.click();
if (e.key === "q") vdEndBtn.click();
if (e.key === "s") vdSpkBtn.click();
if (e.key === "," && (e.metaKey || e.ctrlKey)) { e.preventDefault(); openSettings(); }
});

View File

@@ -238,6 +238,74 @@ body {
.fab-icon { font-size: 18px; }
/* ── Voice Drawer (bottom bar) ── */
.voice-drawer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: var(--surface);
border-top: 1px solid var(--surface2);
padding: 0 16px;
padding-bottom: env(safe-area-inset-bottom, 8px);
z-index: 50;
animation: drawerUp 0.25s ease-out;
box-shadow: 0 -4px 20px rgba(0,0,0,0.4);
}
@keyframes drawerUp {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
.voice-drawer-bar {
display: flex; flex-direction: column; gap: 6px; padding: 10px 0 6px;
}
.vd-info {
display: flex; align-items: center; gap: 8px; font-size: 13px;
}
.vd-status-dot {
width: 8px; height: 8px; border-radius: 50%; background: var(--green); flex-shrink: 0;
}
.vd-room { color: var(--green); font-weight: 600; }
.vd-timer { color: var(--text-dim); font-family: ui-monospace, monospace; font-size: 12px; }
.vd-badge {
font-size: 10px; padding: 1px 6px; border-radius: 6px; font-weight: 500;
}
.vd-badge.direct { background: rgba(74,222,128,0.15); color: var(--green); }
.vd-badge.relay { background: rgba(96,165,250,0.15); color: #60a5fa; }
.vd-level { height: 3px; background: var(--surface2); border-radius: 2px; overflow: hidden; }
.vd-level-fill {
height: 100%; width: 0%; background: var(--green); border-radius: 2px; transition: width 0.1s;
}
.vd-controls {
display: flex; align-items: center; justify-content: center; gap: 12px; padding: 4px 0;
}
.vd-btn {
width: 44px; height: 44px; border-radius: 50%; border: none;
background: var(--surface2); color: var(--text); font-size: 18px;
cursor: pointer; display: flex; align-items: center; justify-content: center;
transition: background 0.15s;
}
.vd-btn:hover { background: var(--primary); }
.vd-btn.muted { background: var(--red); color: white; }
.vd-end { background: var(--red); color: white; }
.vd-end:hover { background: #dc2626; }
.vd-direct-info {
display: flex; align-items: center; gap: 10px; padding: 8px 0 4px;
border-top: 1px solid var(--surface2); margin-top: 4px;
}
.vd-dc-identicon { width: 32px; height: 32px; border-radius: 50%; flex-shrink: 0; }
.vd-dc-name { font-size: 13px; font-weight: 600; }
.vd-dc-badge {
font-size: 10px; padding: 1px 6px; border-radius: 6px;
}
.vd-dc-badge.direct { background: rgba(74,222,128,0.15); color: var(--green); }
.vd-dc-badge.relay { background: rgba(96,165,250,0.15); color: #60a5fa; }
.vd-dc-badge.connecting { background: rgba(250,204,21,0.15); color: var(--yellow); }
.vd-stats {
font-size: 10px; color: var(--text-dim); font-family: ui-monospace, monospace;
padding: 2px 0 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
/* Incoming call banner */
.incoming-banner {
position: fixed;