import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { generateIdenticon, createIdenticonEl } from "./identicon";
// ── WebView hardening ──
// Suppress the browser-style right-click context menu on desktop Tauri — it
// exposes Inspect/Reload/Back/Forward entries that don't belong in a native-
// feeling VoIP app. Dev tools remain accessible via the usual keyboard
// shortcuts (F12 / Cmd-Opt-I). On Android there is no right-click so this is
// a no-op there.
document.addEventListener("contextmenu", (e) => e.preventDefault());
// Also suppress browser-level zoom via keyboard (Ctrl/Cmd + / - / 0) so the
// fixed-layout UI can't be accidentally scaled. Pinch-zoom is already handled
// at the viewport meta level in index.html.
document.addEventListener(
"keydown",
(e) => {
if ((e.ctrlKey || e.metaKey) && (e.key === "+" || e.key === "-" || e.key === "=" || e.key === "0")) {
e.preventDefault();
}
},
{ capture: true },
);
// Block gesture-based zoom on browsers that fire these legacy events (mainly
// Safari / WebKit). Chromium sends `wheel` with ctrlKey for trackpad pinch —
// catch that too.
document.addEventListener("gesturestart", (e) => e.preventDefault());
document.addEventListener("gesturechange", (e) => e.preventDefault());
document.addEventListener("gestureend", (e) => e.preventDefault());
document.addEventListener(
"wheel",
(e) => {
if (e.ctrlKey) e.preventDefault();
},
{ passive: false },
);
// ── Elements ──
const connectScreen = document.getElementById("connect-screen")!;
const callScreen = document.getElementById("call-screen")!;
const roomInput = document.getElementById("room") as HTMLInputElement;
const aliasInput = document.getElementById("alias") as HTMLInputElement;
const osAecCheckbox = document.getElementById("os-aec") as HTMLInputElement;
const connectBtn = document.getElementById("connect-btn") as HTMLButtonElement;
const connectError = document.getElementById("connect-error")!;
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 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")!;
const myFingerprintEl = document.getElementById("my-fingerprint")!;
const myIdenticonEl = document.getElementById("my-identicon")!;
const recentRoomsDiv = document.getElementById("recent-rooms")!;
// Relay button
const relaySelected = document.getElementById("relay-selected")!;
const relayDot = document.getElementById("relay-dot")!;
const relayLabel = document.getElementById("relay-label")!;
// 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 sRoom = document.getElementById("s-room") as HTMLInputElement;
const sAlias = document.getElementById("s-alias") as HTMLInputElement;
const sOsAec = document.getElementById("s-os-aec") as HTMLInputElement;
const sDredDebug = document.getElementById("s-dred-debug") as HTMLInputElement;
const sReflectedAddr = document.getElementById("s-reflected-addr") as HTMLSpanElement;
const sReflectBtn = document.getElementById("s-reflect-btn") as HTMLButtonElement;
const sAgc = document.getElementById("s-agc") as HTMLInputElement;
const sQuality = document.getElementById("s-quality") as HTMLInputElement;
const sQualityLabel = document.getElementById("s-quality-label")!;
// Quality slider config — best (left/green) to worst (right/red)
const QUALITY_STEPS = ["studio-64k", "studio-48k", "studio-32k", "auto", "good", "degraded", "codec2-3200", "catastrophic"];
const QUALITY_LABELS = ["Studio 64k", "Studio 48k", "Studio 32k", "Auto", "Opus 24k", "Opus 6k", "Codec2 3.2k", "Codec2 1.2k"];
const QUALITY_COLORS = ["#22c55e", "#4ade80", "#86efac", "#a3e635", "#facc15", "#f59e0b", "#e97320", "#991b1b"];
function qualityToIndex(q: string): number {
const idx = QUALITY_STEPS.indexOf(q);
return idx >= 0 ? idx : 3; // default to "auto" (index 3)
}
function updateQualityUI(index: number) {
sQualityLabel.textContent = QUALITY_LABELS[index];
sQualityLabel.style.color = QUALITY_COLORS[index];
sQuality.style.background = `linear-gradient(90deg, #22c55e 0%, #86efac 25%, #facc15 50%, #e97320 75%, #991b1b 100%)`;
}
sQuality.addEventListener("input", () => {
updateQualityUI(parseInt(sQuality.value));
});
const sFingerprint = document.getElementById("s-fingerprint")!;
const sRecentRooms = document.getElementById("s-recent-rooms")!;
const sClearRecent = document.getElementById("s-clear-recent")!;
// Key warning dialog
const keyWarning = document.getElementById("key-warning")!;
const kwOldFp = document.getElementById("kw-old-fp")!;
const kwNewFp = document.getElementById("kw-new-fp")!;
const kwAccept = document.getElementById("kw-accept")!;
const kwCancel = document.getElementById("kw-cancel")!;
let statusInterval: number | null = null;
let myFingerprint = "";
let userDisconnected = false;
// ── Data types ──
interface RelayServer {
name: string;
address: string;
rtt?: number | null;
serverFingerprint?: string | null; // from ping
knownFingerprint?: string | null; // saved TOFU fingerprint
}
interface RecentRoom { relay: string; room: string; }
interface Settings {
relays: RelayServer[];
selectedRelay: number;
room: string;
alias: string;
osAec: boolean;
agc: boolean;
quality: string;
recentRooms: RecentRoom[];
/// When true, the Rust side emits the chatty per-frame DRED parse +
/// reconstruction + classical-PLC logs and adds DRED counters to the
/// recv heartbeat. Off in normal mode keeps logcat clean.
dredDebugLogs: boolean;
}
function loadSettings(): Settings {
const defaults: Settings = {
relays: [
// Local laptop relay — used during Android rewrite testing so the phone
// and the relay logs are on the same host. Laptop IP on the test LAN.
{ name: "Laptop", address: "172.16.81.125:4433" },
{ name: "Default", address: "193.180.213.68:4433" },
],
selectedRelay: 0, room: "general", alias: "",
osAec: true, agc: true, quality: "auto", recentRooms: [],
dredDebugLogs: false,
};
try {
const raw = localStorage.getItem("wzp-settings");
if (raw) {
const parsed = JSON.parse(raw);
if (parsed.relay && !parsed.relays) {
parsed.relays = [{ name: "Default", address: parsed.relay }];
parsed.selectedRelay = 0;
delete parsed.relay;
}
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 }));
}
// Ensure the Laptop test relay is present as the first entry for
// existing installs — otherwise users with cached settings keep using
// the remote default and we have to manually add it each install.
// Remove this block once the Android rewrite is stable.
const LAPTOP_ADDR = "172.16.81.125:4433";
if (Array.isArray(parsed.relays) && !parsed.relays.some((r: any) => r.address === LAPTOP_ADDR)) {
parsed.relays.unshift({ name: "Laptop", address: LAPTOP_ADDR });
parsed.selectedRelay = 0;
}
return { ...defaults, ...parsed };
}
} catch {}
return defaults;
}
function saveSettingsObj(s: Settings) {
localStorage.setItem("wzp-settings", JSON.stringify(s));
}
function getSelectedRelay(): RelayServer | undefined {
const s = loadSettings();
return s.relays[s.selectedRelay];
}
// ── Helpers ──
function escapeHtml(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
// ── Lock status ──
type LockStatus = "verified" | "new" | "changed" | "offline" | "unknown";
function lockStatus(relay: RelayServer): LockStatus {
if (relay.rtt === undefined || relay.rtt === null) return "unknown";
if (relay.rtt < 0) return "offline";
if (!relay.serverFingerprint) return "new";
if (!relay.knownFingerprint) return "new"; // first time
if (relay.serverFingerprint === relay.knownFingerprint) return "verified";
return "changed";
}
function lockIcon(status: LockStatus): string {
switch (status) {
case "verified": return "🔒";
case "new": return "🔓";
case "changed": return "⚠️";
case "offline": return "🔴";
case "unknown": return "⚪";
}
}
function lockColor(status: LockStatus): string {
switch (status) {
case "verified": return "var(--green)";
case "new": return "var(--yellow)";
case "changed": return "var(--red)";
case "offline": return "var(--red)";
case "unknown": return "var(--text-dim)";
}
}
// ── Apply settings ──
function applySettings() {
const s = loadSettings();
roomInput.value = s.room;
aliasInput.value = s.alias;
osAecCheckbox.checked = s.osAec;
renderRecentRooms(s.recentRooms);
renderRelayButton();
}
// ── Relay button ──
function renderRelayButton() {
const s = loadSettings();
const sel = s.relays[s.selectedRelay];
if (sel) {
const ls = lockStatus(sel);
relayDot.textContent = lockIcon(ls);
relayDot.className = "relay-lock";
relayLabel.textContent = `${sel.name} (${sel.address})`;
} else {
relayDot.textContent = "⚪";
relayDot.className = "relay-lock";
relayLabel.textContent = "No relay configured";
}
}
relaySelected.addEventListener("click", () => openRelayDialog());
// ── Relay dialog ──
function openRelayDialog() {
renderRelayDialogList();
relayAddName.value = "";
relayAddAddr.value = "";
relayDialog.classList.remove("hidden");
}
function closeRelayDialog() {
relayDialog.classList.add("hidden");
renderRelayButton();
}
function renderRelayDialogList() {
const s = loadSettings();
relayDialogList.innerHTML = "";
s.relays.forEach((r, i) => {
const item = document.createElement("div");
item.className = `relay-dialog-item ${i === s.selectedRelay ? "selected" : ""}`;
const ls = lockStatus(r);
const fp = r.serverFingerprint || r.address;
// Identicon
const icon = createIdenticonEl(fp, 32, true);
icon.title = r.serverFingerprint
? `Server: ${r.serverFingerprint}\nClick to copy`
: `No fingerprint yet`;
item.appendChild(icon);
// Info
const info = document.createElement("div");
info.className = "relay-info";
info.innerHTML = `
${escapeHtml(r.name)}
${escapeHtml(r.address)}
`;
item.appendChild(info);
// Lock + RTT
const meta = document.createElement("div");
meta.className = "relay-meta";
const rttStr = r.rtt !== undefined && r.rtt !== null
? (r.rtt < 0 ? "offline" : `${r.rtt}ms`)
: "";
meta.innerHTML = `
${lockIcon(ls)}
${rttStr}
`;
item.appendChild(meta);
// Delete button
const del = document.createElement("button");
del.className = "remove";
del.textContent = "×";
del.addEventListener("click", (e) => {
e.stopPropagation();
const s = loadSettings();
s.relays.splice(i, 1);
if (s.selectedRelay >= s.relays.length) s.selectedRelay = Math.max(0, s.relays.length - 1);
saveSettingsObj(s);
renderRelayDialogList();
renderRelayButton();
});
item.appendChild(del);
// Click to select
item.addEventListener("click", () => {
const s = loadSettings();
s.selectedRelay = i;
// TOFU: if first time seeing this server, trust its fingerprint
if (r.serverFingerprint && !r.knownFingerprint) {
s.relays[i].knownFingerprint = r.serverFingerprint;
}
saveSettingsObj(s);
renderRelayDialogList();
renderRelayButton();
});
relayDialogList.appendChild(item);
});
}
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 ──
interface PingResult { rtt_ms: number; server_fingerprint: string; }
async function pingAllRelays() {
const s = loadSettings();
for (let i = 0; i < s.relays.length; i++) {
const r = s.relays[i];
try {
const result: PingResult = await invoke("ping_relay", { relay: r.address });
r.rtt = result.rtt_ms;
r.serverFingerprint = result.server_fingerprint;
// TOFU: auto-save fingerprint on first contact
if (!r.knownFingerprint) {
r.knownFingerprint = result.server_fingerprint;
}
} catch {
r.rtt = -1;
}
}
saveSettingsObj(s);
renderRelayButton();
if (!relayDialog.classList.contains("hidden")) renderRelayDialogList();
}
// ── Recent rooms ──
function renderRecentRooms(rooms: RecentRoom[]) {
recentRoomsDiv.innerHTML = rooms
.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 || "";
const s = loadSettings();
const idx = s.relays.findIndex((r) => r.address === ds.relay);
if (idx >= 0) { s.selectedRelay = idx; saveSettingsObj(s); renderRelayButton(); }
});
});
}
// ── Init ──
applySettings();
setTimeout(pingAllRelays, 300);
// Hydrate the Rust DRED verbose-logs flag from saved settings on boot so
// the choice survives app restarts without needing the user to reopen
// the settings panel.
invoke("set_dred_verbose_logs", { enabled: !!loadSettings().dredDebugLogs }).catch(() => {});
// Load fingerprint + alias + git hash + render identicon
interface AppInfo { git_hash: string; alias: string; fingerprint: string; data_dir: string }
(async () => {
try {
const info: AppInfo = await invoke("get_app_info");
const fp = info.fingerprint;
myFingerprint = fp;
myFingerprintEl.textContent = fp;
myFingerprintEl.style.cursor = "pointer";
myFingerprintEl.addEventListener("click", () => {
navigator.clipboard.writeText(fp).then(() => {
const orig = myFingerprintEl.textContent;
myFingerprintEl.textContent = "Copied!";
setTimeout(() => { myFingerprintEl.textContent = orig; }, 1000);
});
});
// Identicon next to fingerprint
const icon = createIdenticonEl(fp, 28, true);
myIdenticonEl.innerHTML = "";
myIdenticonEl.appendChild(icon);
// Prefill alias if the user hasn't typed one yet
if (!aliasInput.value.trim()) {
aliasInput.value = info.alias;
const s = loadSettings();
s.alias = info.alias;
saveSettingsObj(s);
}
// Stamp the build hash on the home screen so we can prove which build
// is installed (this caused us a lot of grief on the Kotlin app).
let buildEl = document.getElementById("build-hash");
if (!buildEl) {
buildEl = document.createElement("div");
buildEl.id = "build-hash";
buildEl.style.cssText = "font-size:10px;opacity:0.6;text-align:center;margin-top:4px;font-family:monospace";
myFingerprintEl.parentElement?.appendChild(buildEl);
}
buildEl.textContent = `build ${info.git_hash} • ${info.alias}`;
buildEl.title = info.data_dir;
} catch (e) {
console.error("get_app_info failed", e);
}
})();
// ── Connect ──
connectBtn.addEventListener("click", doConnect);
[roomInput, aliasInput].forEach((el) =>
el.addEventListener("keydown", (e) => { if (e.key === "Enter") doConnect(); })
);
function showKeyWarning(oldFp: string, newFp: string): Promise {
return new Promise((resolve) => {
kwOldFp.textContent = oldFp;
kwNewFp.textContent = newFp;
keyWarning.classList.remove("hidden");
const cleanup = () => {
keyWarning.classList.add("hidden");
kwAccept.removeEventListener("click", onAccept);
kwCancel.removeEventListener("click", onCancel);
keyWarning.removeEventListener("click", onBackdrop);
};
const onAccept = () => { cleanup(); resolve(true); };
const onCancel = () => { cleanup(); resolve(false); };
const onBackdrop = (e: Event) => { if (e.target === keyWarning) { cleanup(); resolve(false); } };
kwAccept.addEventListener("click", onAccept);
kwCancel.addEventListener("click", onCancel);
keyWarning.addEventListener("click", onBackdrop);
});
}
async function doConnect() {
const relay = getSelectedRelay();
if (!relay) { connectError.textContent = "No relay selected"; return; }
// Warn on fingerprint mismatch
const ls = lockStatus(relay);
if (ls === "changed") {
const accepted = await showKeyWarning(relay.knownFingerprint || "", relay.serverFingerprint || "");
if (!accepted) return;
// User accepted — update known fingerprint
const s = loadSettings();
s.relays[s.selectedRelay].knownFingerprint = relay.serverFingerprint;
saveSettingsObj(s);
renderRelayButton();
}
// Don't block connect on offline — ping may have failed transiently
connectError.textContent = "";
connectBtn.disabled = true;
connectBtn.textContent = "Connecting...";
userDisconnected = false;
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: relay.address, room: roomInput.value,
alias: aliasInput.value, osAec: osAecCheckbox.checked,
quality: s.quality || "auto",
});
showCallScreen();
} catch (e: any) {
connectError.textContent = String(e);
connectBtn.disabled = false;
connectBtn.textContent = "Connect";
}
}
function showCallScreen() {
connectScreen.classList.add("hidden");
callScreen.classList.remove("hidden");
roomName.textContent = roomInput.value;
callStatus.className = "status-dot";
statusInterval = window.setInterval(pollStatus, 250);
// Sync the Speaker/Earpiece label with the OS state (Android only; on
// desktop the command is a no-op returning false so we land on "Earpiece"
// which is fine because desktop has no routing concept).
invoke("is_speakerphone_on")
.then((on) => { speakerphoneOn = !!on; updateSpkLabel(); })
.catch(() => { speakerphoneOn = false; updateSpkLabel(); });
}
function showConnectScreen() {
callScreen.classList.add("hidden");
connectScreen.classList.remove("hidden");
connectBtn.disabled = false;
connectBtn.textContent = "Connect";
levelBar.style.width = "0%";
if (statusInterval) { clearInterval(statusInterval); statusInterval = null; }
}
// ── Mute / hangup ──
micBtn.addEventListener("click", async () => {
try { const m: boolean = await invoke("toggle_mic"); micBtn.classList.toggle("muted", m); micIcon.textContent = m ? "Mic Off" : "Mic"; } catch {}
});
// Speaker routing (Android) — toggles AudioManager.setSpeakerphoneOn + then
// stops and restarts the Oboe streams so AAudio reconfigures with the new
// routing. The Rust-side Tauri command handles the restart, we just swap
// the button label.
//
// Earpiece is NOT a "muted" state, so DO NOT add the `.muted` CSS class
// (which would tint the button red); that was a bug in 0178cbd that made
// earpiece mode look like playback was off. A separate `.speaker-on` class
// is available for css styling if we want to visually indicate loud mode.
let speakerphoneOn = false;
let speakerphoneBusy = false;
function updateSpkLabel() {
spkBtn.classList.toggle("speaker-on", speakerphoneOn);
spkBtn.classList.remove("muted");
spkIcon.textContent = speakerphoneOn ? "🔊 Speaker" : "🔈 Earpiece";
}
spkBtn.addEventListener("click", async () => {
if (speakerphoneBusy) return; // debounce — the restart takes ~60ms
speakerphoneBusy = true;
const next = !speakerphoneOn;
spkBtn.disabled = true;
try {
await invoke("set_speakerphone", { on: next });
speakerphoneOn = next;
updateSpkLabel();
} catch (e) {
console.error("set_speakerphone failed:", e);
} finally {
spkBtn.disabled = false;
speakerphoneBusy = false;
}
});
hangupBtn.addEventListener("click", async () => {
userDisconnected = true;
try { await invoke("disconnect"); } catch {}
showConnectScreen();
});
document.addEventListener("keydown", (e) => {
if (callScreen.classList.contains("hidden")) return;
if ((e.target as HTMLElement).tagName === "INPUT") return;
if (e.key === "m") micBtn.click();
if (e.key === "s") spkBtn.click();
if (e.key === "q") hangupBtn.click();
});
// ── Status polling ──
interface CallStatusI {
active: boolean; mic_muted: boolean; spk_muted: boolean;
participants: { fingerprint: string; alias: string | null }[];
encode_fps: number; recv_fps: number; audio_level: number;
call_duration_secs: number; fingerprint: string;
}
function formatDuration(secs: number): string {
const m = Math.floor(secs / 60);
const s = Math.floor(secs % 60);
return `${m}:${s.toString().padStart(2, "0")}`;
}
let reconnectAttempts = 0;
async function pollStatus() {
try {
const st: CallStatusI = await invoke("get_status");
if (!st.active) {
if (!userDisconnected && reconnectAttempts < 5) {
reconnectAttempts++;
callStatus.className = "status-dot reconnecting";
statsDiv.textContent = `Reconnecting (${reconnectAttempts}/5)...`;
const relay = getSelectedRelay();
if (relay) {
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts - 1), 10000);
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; showConnectScreen(); return;
}
reconnectAttempts = 0;
if (st.fingerprint) myFingerprint = st.fingerprint;
micBtn.classList.toggle("muted", st.mic_muted);
micIcon.textContent = st.mic_muted ? "Mic Off" : "Mic";
// NB: spkBtn label is driven by the Android audio routing state
// (speakerphoneOn / updateSpkLabel), not by the engine's spk_muted.
// Skip that here so pollStatus doesn't clobber the routing UI.
callTimer.textContent = formatDuration(st.call_duration_secs);
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 grouped by relay
if (st.participants.length === 0) {
participantsDiv.innerHTML = 'Waiting for participants...
';
} else {
participantsDiv.innerHTML = "";
// Group by relay_label (null = this relay)
const groups: Record = {};
st.participants.forEach((p: any) => {
const relay = p.relay_label || "This Relay";
if (!groups[relay]) groups[relay] = [];
groups[relay].push(p);
});
Object.entries(groups).forEach(([relay, members]) => {
// Relay header
const header = document.createElement("div");
header.className = "relay-group-header";
const isLocal = relay === "This Relay";
header.innerHTML = ` ${escapeHtml(relay)}`;
participantsDiv.appendChild(header);
// Participants under this relay
(members as any[]).forEach((p) => {
const name = p.alias || "Anonymous";
const fp = p.fingerprint || "";
const isMe = fp && myFingerprint.includes(fp);
const row = document.createElement("div");
row.className = "participant";
const icon = createIdenticonEl(fp || name, 36, true);
if (isMe) icon.style.outline = "2px solid var(--accent)";
row.appendChild(icon);
const info = document.createElement("div");
info.className = "info";
info.innerHTML = `
${escapeHtml(name)} ${isMe ? 'you' : ""}
${escapeHtml(fp ? fp.substring(0, 16) : "")}
`;
row.appendChild(info);
participantsDiv.appendChild(row);
});
});
}
// Stats line with codec badges
const txBadge = (st as any).tx_codec ? `${escapeHtml((st as any).tx_codec)}` : "";
const rxBadge = (st as any).rx_codec ? `${escapeHtml((st as any).rx_codec)}` : "";
statsDiv.innerHTML = `${txBadge} ${rxBadge} TX: ${st.encode_fps} | RX: ${st.recv_fps}`;
} catch {}
}
listen("call-event", (event: any) => {
const { kind } = event.payload;
if (kind === "room-update") pollStatus();
if (kind === "disconnected" && !userDisconnected) pollStatus();
});
// ── Settings ──
function openSettings() {
const s = loadSettings();
sRoom.value = s.room; sAlias.value = s.alias; sOsAec.checked = s.osAec;
sDredDebug.checked = !!s.dredDebugLogs;
const qi = qualityToIndex(s.quality || "auto");
sQuality.value = String(qi);
updateQualityUI(qi);
sFingerprint.textContent = myFingerprint || "(loading...)";
renderSettingsRecentRooms(s.recentRooms);
settingsPanel.classList.remove("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.querySelectorAll(".remove").forEach((btn) => {
btn.addEventListener("click", () => {
const idx = parseInt((btn as HTMLElement).dataset.idx || "0");
const s = loadSettings();
s.recentRooms.splice(idx, 1);
saveSettingsObj(s);
renderSettingsRecentRooms(s.recentRooms);
});
});
}
settingsBtnHome.addEventListener("click", openSettings);
settingsBtnCall.addEventListener("click", openSettings);
// "STUN for QUIC" — ask the registered relay for our own public
// address. Requires register_signal to have been run first
// (otherwise the Rust side returns "not registered"). The button
// shows its working state inline so the user knows it's waiting on
// the relay rather than the network.
sReflectBtn.addEventListener("click", async () => {
sReflectedAddr.textContent = "querying...";
sReflectBtn.disabled = true;
try {
const addr = await invoke("get_reflected_address");
sReflectedAddr.textContent = addr;
sReflectedAddr.style.color = "var(--green)";
} catch (e: any) {
// Two main failure modes surfaced via the error string:
// - "not registered" — user hasn't registered
// against a relay yet
// - "reflect timeout (relay may not support reflection)"
// — old relay, pre-Phase-1
const msg = String(e);
sReflectedAddr.textContent = msg.includes("not registered")
? "⚠ register first"
: msg.includes("timeout")
? "⚠ relay does not support reflection"
: `⚠ ${msg}`;
sReflectedAddr.style.color = "var(--yellow)";
} finally {
sReflectBtn.disabled = false;
}
});
settingsClose.addEventListener("click", closeSettings);
settingsPanel.addEventListener("click", (e) => { if (e.target === settingsPanel) closeSettings(); });
settingsSave.addEventListener("click", () => {
const s = loadSettings();
s.room = sRoom.value; s.alias = sAlias.value; s.osAec = sOsAec.checked;
s.quality = QUALITY_STEPS[parseInt(sQuality.value)] || "auto";
s.dredDebugLogs = sDredDebug.checked;
saveSettingsObj(s);
// Push the new flag to the Rust side immediately so the next encoded
// frame already honors it without waiting for an app restart.
invoke("set_dred_verbose_logs", { enabled: s.dredDebugLogs }).catch(() => {});
roomInput.value = s.room; aliasInput.value = s.alias; osAecCheckbox.checked = s.osAec;
renderRecentRooms(s.recentRooms);
closeSettings();
});
sClearRecent.addEventListener("click", () => {
const s = loadSettings();
s.recentRooms = [];
saveSettingsObj(s);
renderSettingsRecentRooms([]);
renderRecentRooms([]);
});
document.addEventListener("keydown", (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === ",") {
e.preventDefault();
settingsPanel.classList.contains("hidden") ? openSettings() : closeSettings();
}
if (e.key === "Escape") {
if (!relayDialog.classList.contains("hidden")) closeRelayDialog();
else if (!settingsPanel.classList.contains("hidden")) closeSettings();
}
});
// ── Direct Calling UI ──
const modeRoom = document.getElementById("mode-room")!;
const modeDirect = document.getElementById("mode-direct")!;
const roomModeDiv = document.getElementById("room-mode")!;
const directModeDiv = document.getElementById("direct-mode")!;
const registerBtn = document.getElementById("register-btn") as HTMLButtonElement;
const deregisterBtn = document.getElementById("deregister-btn") as HTMLButtonElement;
const directRegistered = document.getElementById("direct-registered")!;
const incomingCallPanel = document.getElementById("incoming-call-panel")!;
const incomingCaller = document.getElementById("incoming-caller")!;
const acceptCallBtn = document.getElementById("accept-call-btn")!;
const rejectCallBtn = document.getElementById("reject-call-btn")!;
const targetFpInput = document.getElementById("target-fp") as HTMLInputElement;
const callBtn = document.getElementById("call-btn") as HTMLButtonElement;
const callStatusText = document.getElementById("call-status-text")!;
const recentContactsSection = document.getElementById("recent-contacts-section")!;
const recentContactsList = document.getElementById("recent-contacts-list")!;
const callHistorySection = document.getElementById("call-history-section")!;
const callHistoryList = document.getElementById("call-history-list")!;
const clearHistoryBtn = document.getElementById("clear-history-btn") as HTMLButtonElement;
let currentCallMode = "room";
modeRoom.addEventListener("click", () => {
currentCallMode = "room";
modeRoom.classList.add("active");
modeDirect.classList.remove("active");
roomModeDiv.classList.remove("hidden");
directModeDiv.classList.add("hidden");
// Show room/alias inputs
(document.querySelector('label:has(#room)') as HTMLElement)?.classList.remove("hidden");
(document.querySelector('label:has(#alias)') as HTMLElement)?.classList.remove("hidden");
});
modeDirect.addEventListener("click", () => {
currentCallMode = "direct";
modeDirect.classList.add("active");
modeRoom.classList.remove("active");
directModeDiv.classList.remove("hidden");
roomModeDiv.classList.add("hidden");
// Hide room input, keep alias
(document.querySelector('label:has(#room)') as HTMLElement)?.classList.add("hidden");
});
// ── Call history + recent contacts rendering ──
interface CallHistoryEntry {
call_id: string;
peer_fp: string;
peer_alias: string | null;
direction: "placed" | "received" | "missed";
timestamp_unix: number;
}
function fmtTimestamp(unix: number): string {
const d = new Date(unix * 1000);
const now = new Date();
const sameDay =
d.getFullYear() === now.getFullYear() &&
d.getMonth() === now.getMonth() &&
d.getDate() === now.getDate();
if (sameDay) {
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
return d.toLocaleDateString([], { month: "short", day: "numeric" }) +
" " + d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
function directionIcon(dir: string): string {
switch (dir) {
case "placed": return "↗";
case "received": return "↙";
case "missed": return "✗";
default: return "•";
}
}
function directionLabel(dir: string): string {
switch (dir) {
case "placed": return "Outgoing";
case "received": return "Incoming";
case "missed": return "Missed";
default: return dir;
}
}
function directionClass(dir: string): string {
return `dir-${dir}`;
}
function callByFingerprint(fp: string) {
targetFpInput.value = fp;
callBtn.click();
}
async function refreshHistory() {
try {
const [history, contacts] = await Promise.all([
invoke("get_call_history"),
invoke("get_recent_contacts"),
]);
// Recent contacts (top 6)
if (contacts.length === 0) {
recentContactsSection.classList.add("hidden");
} else {
recentContactsSection.classList.remove("hidden");
recentContactsList.innerHTML = "";
contacts.slice(0, 6).forEach((c) => {
const btn = document.createElement("button");
btn.className = "contact-chip";
const label = c.peer_alias || c.peer_fp.substring(0, 16);
btn.innerHTML = `${label}`;
btn.title = c.peer_fp;
btn.addEventListener("click", () => callByFingerprint(c.peer_fp));
recentContactsList.appendChild(btn);
});
}
// Full history
if (history.length === 0) {
callHistorySection.classList.add("hidden");
} else {
callHistorySection.classList.remove("hidden");
callHistoryList.innerHTML = "";
history.slice(0, 50).forEach((e) => {
const row = document.createElement("div");
row.className = `history-row ${directionClass(e.direction)}`;
const label = e.peer_alias || e.peer_fp.substring(0, 16);
row.innerHTML = `
${directionIcon(e.direction)}
${label}
${directionLabel(e.direction)} · ${fmtTimestamp(e.timestamp_unix)}
`;
row.title = e.peer_fp;
const cb = row.querySelector(".history-call-btn") as HTMLButtonElement;
cb.addEventListener("click", (ev) => {
ev.stopPropagation();
callByFingerprint(e.peer_fp);
});
callHistoryList.appendChild(row);
});
}
} catch (e) {
console.error("refreshHistory failed:", e);
}
}
// Live-refresh whenever the backend logs a new entry
listen("history-changed", () => { refreshHistory(); });
clearHistoryBtn.addEventListener("click", async () => {
if (!confirm("Clear call history?")) return;
try {
await invoke("clear_call_history");
refreshHistory();
} catch (e) { console.error(e); }
});
registerBtn.addEventListener("click", async () => {
const relay = getSelectedRelay();
if (!relay) { connectError.textContent = "No relay selected"; return; }
registerBtn.disabled = true;
registerBtn.textContent = "Registering...";
try {
const fp = await invoke("register_signal", { relay: relay.address });
registerBtn.classList.add("hidden");
directRegistered.classList.remove("hidden");
callStatusText.textContent = `Your fingerprint: ${fp}`;
refreshHistory();
} catch (e: any) {
connectError.textContent = String(e);
registerBtn.disabled = false;
registerBtn.textContent = "Register on Relay";
}
});
deregisterBtn.addEventListener("click", async () => {
try {
await invoke("deregister");
directRegistered.classList.add("hidden");
registerBtn.classList.remove("hidden");
registerBtn.disabled = false;
registerBtn.textContent = "Register on Relay";
callStatusText.textContent = "";
incomingCallPanel.classList.add("hidden");
} catch (e) {
console.error("deregister failed:", e);
}
});
callBtn.addEventListener("click", async () => {
const target = targetFpInput.value.trim();
if (!target) return;
callStatusText.textContent = "Calling...";
try {
await invoke("place_call", { targetFp: target });
} catch (e: any) {
callStatusText.textContent = `Error: ${e}`;
}
});
acceptCallBtn.addEventListener("click", async () => {
const status = await invoke("get_signal_status");
if (status.incoming_call_id) {
await invoke("answer_call", { callId: status.incoming_call_id, mode: 2 });
incomingCallPanel.classList.add("hidden");
}
});
rejectCallBtn.addEventListener("click", async () => {
const status = await invoke("get_signal_status");
if (status.incoming_call_id) {
await invoke("answer_call", { callId: status.incoming_call_id, mode: 0 });
incomingCallPanel.classList.add("hidden");
}
});
// Listen for signal events from Rust backend
listen("signal-event", (event: any) => {
const data = event.payload;
switch (data.type) {
case "ringing":
callStatusText.textContent = "🔔 Ringing...";
break;
case "incoming":
incomingCallPanel.classList.remove("hidden");
incomingCaller.textContent = `From: ${data.caller_alias || data.caller_fp?.substring(0, 16) || "unknown"}`;
break;
case "answered":
callStatusText.textContent = `Call answered (${data.mode})`;
break;
case "setup":
callStatusText.textContent = "Connecting to media...";
// Auto-connect to the call room
(async () => {
try {
await invoke("connect", {
relay: data.relay_addr,
room: data.room,
alias: aliasInput.value,
osAec: osAecCheckbox.checked,
quality: loadSettings().quality || "auto",
});
showCallScreen();
} catch (e: any) {
callStatusText.textContent = `Media connect failed: ${e}`;
}
})();
break;
case "hangup":
callStatusText.textContent = "";
incomingCallPanel.classList.add("hidden");
break;
}
});