-
✅ Registered — waiting for calls
+
Incoming Call
From: unknown
@@ -57,6 +60,22 @@
+
+
+
+
+
+
+
diff --git a/desktop/src-tauri/src/history.rs b/desktop/src-tauri/src/history.rs
new file mode 100644
index 0000000..cf2bf82
--- /dev/null
+++ b/desktop/src-tauri/src/history.rs
@@ -0,0 +1,161 @@
+//! Call history store.
+//!
+//! Keeps a rolling JSON file of the last N direct-call events so the UI can
+//! show "recent contacts" + "call history with callback buttons" on the
+//! direct-call screen. Storage lives in `
/call_history.json`
+//! alongside the identity file. The file is read lazily on first access and
+//! cached in an RwLock behind a OnceLock.
+//!
+//! This is a v1 — no duration tracking yet, entries are logged at the
+//! moment the direction is decided (placed / received / missed).
+
+use std::path::PathBuf;
+use std::sync::{OnceLock, RwLock};
+use std::time::{SystemTime, UNIX_EPOCH};
+
+use serde::{Deserialize, Serialize};
+
+/// Maximum number of history entries we keep. Older ones are pruned FIFO.
+const MAX_ENTRIES: usize = 200;
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum CallDirection {
+ /// Local user placed the call.
+ Placed,
+ /// Remote user called and local user answered.
+ Received,
+ /// Remote user called but local user did not answer (rejected or
+ /// missed entirely — the UI treats these identically).
+ Missed,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct CallHistoryEntry {
+ pub call_id: String,
+ pub peer_fp: String,
+ pub peer_alias: Option,
+ pub direction: CallDirection,
+ /// Seconds since UNIX epoch, UTC.
+ pub timestamp_unix: u64,
+}
+
+// ─── In-process store (loaded from disk once) ─────────────────────────────
+
+static STORE: OnceLock>> = OnceLock::new();
+
+fn store() -> &'static RwLock> {
+ STORE.get_or_init(|| RwLock::new(load_from_disk()))
+}
+
+fn history_path() -> PathBuf {
+ crate::APP_DATA_DIR
+ .get()
+ .cloned()
+ .unwrap_or_else(|| {
+ let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
+ PathBuf::from(home).join(".wzp")
+ })
+ .join("call_history.json")
+}
+
+fn load_from_disk() -> Vec {
+ let path = history_path();
+ let Ok(bytes) = std::fs::read(&path) else {
+ return Vec::new();
+ };
+ serde_json::from_slice::>(&bytes)
+ .inspect_err(|e| tracing::warn!(path = %path.display(), error = %e, "call_history.json parse failed"))
+ .unwrap_or_default()
+}
+
+fn save_to_disk(entries: &[CallHistoryEntry]) {
+ let path = history_path();
+ if let Some(parent) = path.parent() {
+ let _ = std::fs::create_dir_all(parent);
+ }
+ let Ok(json) = serde_json::to_vec_pretty(entries) else { return };
+ // Atomic write via temp file + rename so a crash mid-write doesn't
+ // leave us with a half-file on disk.
+ let tmp = path.with_extension("json.tmp");
+ if std::fs::write(&tmp, &json).is_ok() {
+ let _ = std::fs::rename(&tmp, &path);
+ }
+}
+
+fn now_unix() -> u64 {
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|d| d.as_secs())
+ .unwrap_or(0)
+}
+
+// ─── Public API ───────────────────────────────────────────────────────────
+
+/// Append a new entry to the store and persist to disk. Trims the store to
+/// `MAX_ENTRIES` after insertion.
+pub fn log(
+ call_id: String,
+ peer_fp: String,
+ peer_alias: Option,
+ direction: CallDirection,
+) {
+ let entry = CallHistoryEntry {
+ call_id,
+ peer_fp,
+ peer_alias,
+ direction,
+ timestamp_unix: now_unix(),
+ };
+ let mut guard = store().write().unwrap();
+ guard.push(entry);
+ if guard.len() > MAX_ENTRIES {
+ let drop_n = guard.len() - MAX_ENTRIES;
+ guard.drain(0..drop_n);
+ }
+ save_to_disk(&guard);
+}
+
+/// Return a copy of all entries in reverse-chronological order
+/// (most recent first).
+pub fn all() -> Vec {
+ let guard = store().read().unwrap();
+ guard.iter().rev().cloned().collect()
+}
+
+/// Unique peer contacts sorted by most recent interaction. Each contact
+/// is represented by the newest history entry for that fingerprint.
+pub fn contacts() -> Vec {
+ let guard = store().read().unwrap();
+ let mut seen: std::collections::HashSet = std::collections::HashSet::new();
+ let mut out = Vec::new();
+ // iterate newest → oldest
+ for entry in guard.iter().rev() {
+ if seen.insert(entry.peer_fp.clone()) {
+ out.push(entry.clone());
+ }
+ }
+ out
+}
+
+/// Clear the entire history and persist the empty file.
+pub fn clear() {
+ let mut guard = store().write().unwrap();
+ guard.clear();
+ save_to_disk(&guard);
+}
+
+/// Find a Missed-candidate entry that matches `call_id` and hasn't been
+/// answered yet. Used by the signal loop to turn "pending incoming" into
+/// "Received" when the user accepts.
+pub fn mark_received_if_pending(call_id: &str) -> bool {
+ let mut guard = store().write().unwrap();
+ for entry in guard.iter_mut().rev() {
+ if entry.call_id == call_id && entry.direction == CallDirection::Missed {
+ entry.direction = CallDirection::Received;
+ save_to_disk(&guard);
+ return true;
+ }
+ }
+ false
+}
diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs
index b18aa26..44205ca 100644
--- a/desktop/src-tauri/src/lib.rs
+++ b/desktop/src-tauri/src/lib.rs
@@ -21,6 +21,9 @@ mod wzp_native;
#[cfg(target_os = "android")]
mod android_audio;
+// Direct-call history store (persisted JSON in app data dir).
+mod history;
+
// CallEngine has a unified impl on both targets now — the Android branch of
// CallEngine::start() routes audio through the standalone wzp-native cdylib
// (loaded via the wzp_native module below), the desktop branch uses CPAL.
@@ -414,6 +417,24 @@ async fn is_speakerphone_on() -> Result {
}
}
+// ─── Call history commands ───────────────────────────────────────────────────
+
+#[tauri::command]
+fn get_call_history() -> Vec {
+ history::all()
+}
+
+#[tauri::command]
+fn get_recent_contacts() -> Vec {
+ history::contacts()
+}
+
+#[tauri::command]
+fn clear_call_history() -> Result<(), String> {
+ history::clear();
+ Ok(())
+}
+
// ─── Signaling commands — platform independent ───────────────────────────────
struct SignalState {
@@ -479,7 +500,18 @@ async fn register_signal(
tracing::info!(%call_id, caller = %caller_fingerprint, "signal: DirectCallOffer");
let mut sig = signal_state.lock().await; sig.signal_status = "incoming".into();
sig.incoming_call_id = Some(call_id.clone()); sig.incoming_caller_fp = Some(caller_fingerprint.clone()); sig.incoming_caller_alias = caller_alias.clone();
+ // Log as a Missed entry up-front. If the user accepts
+ // the call, answer_call upgrades it to Received via
+ // history::mark_received_if_pending(call_id). If they
+ // reject or ignore, it stays Missed.
+ history::log(
+ call_id.clone(),
+ caller_fingerprint.clone(),
+ caller_alias.clone(),
+ history::CallDirection::Missed,
+ );
let _ = app_clone.emit("signal-event", serde_json::json!({"type":"incoming","call_id":call_id,"caller_fp":caller_fingerprint,"caller_alias":caller_alias}));
+ let _ = app_clone.emit("history-changed", ());
}
Ok(Some(SignalMessage::DirectCallAnswer { call_id, accept_mode, .. })) => {
tracing::info!(%call_id, ?accept_mode, "signal: DirectCallAnswer (forwarded by relay)");
@@ -514,22 +546,33 @@ async fn register_signal(
}
#[tauri::command]
-async fn place_call(state: tauri::State<'_, Arc>, target_fp: String) -> Result<(), String> {
+async fn place_call(
+ state: tauri::State<'_, Arc>,
+ app: tauri::AppHandle,
+ target_fp: String,
+) -> Result<(), String> {
use wzp_proto::SignalMessage;
let sig = state.signal.lock().await;
let transport = sig.transport.as_ref().ok_or("not registered")?;
let call_id = format!("{:016x}", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos());
tracing::info!(%call_id, %target_fp, "place_call: sending DirectCallOffer");
transport.send_signal(&SignalMessage::DirectCallOffer {
- caller_fingerprint: sig.fingerprint.clone(), caller_alias: None, target_fingerprint: target_fp,
- call_id, identity_pub: [0u8; 32], ephemeral_pub: [0u8; 32], signature: vec![],
+ caller_fingerprint: sig.fingerprint.clone(), caller_alias: None, target_fingerprint: target_fp.clone(),
+ call_id: call_id.clone(), identity_pub: [0u8; 32], ephemeral_pub: [0u8; 32], signature: vec![],
supported_profiles: vec![wzp_proto::QualityProfile::GOOD],
}).await.map_err(|e| format!("{e}"))?;
+ history::log(call_id, target_fp, None, history::CallDirection::Placed);
+ let _ = app.emit("history-changed", ());
Ok(())
}
#[tauri::command]
-async fn answer_call(state: tauri::State<'_, Arc>, call_id: String, mode: i32) -> Result<(), String> {
+async fn answer_call(
+ state: tauri::State<'_, Arc>,
+ app: tauri::AppHandle,
+ call_id: String,
+ mode: i32,
+) -> Result<(), String> {
use wzp_proto::SignalMessage;
let sig = state.signal.lock().await;
let transport = sig.transport.as_ref().ok_or_else(|| {
@@ -546,6 +589,13 @@ async fn answer_call(state: tauri::State<'_, Arc>, call_id: String, mo
format!("{e}")
})?;
tracing::info!(%call_id, "answer_call: DirectCallAnswer sent successfully");
+ // Upgrade the pending "Missed" entry to "Received" if the user
+ // accepted (mode != Reject). Mode 0 = Reject → leave as Missed.
+ if mode != 0 {
+ if history::mark_received_if_pending(&call_id) {
+ let _ = app.emit("history-changed", ());
+ }
+ }
Ok(())
}
@@ -555,6 +605,24 @@ async fn get_signal_status(state: tauri::State<'_, Arc>) -> Result>) -> Result<(), String> {
+ let mut sig = state.signal.lock().await;
+ if let Some(transport) = sig.transport.take() {
+ tracing::info!("deregister: closing signal transport");
+ transport.close().await.ok();
+ }
+ sig.endpoint = None;
+ sig.signal_status = "idle".into();
+ sig.incoming_call_id = None;
+ sig.incoming_caller_fp = None;
+ sig.incoming_caller_alias = None;
+ Ok(())
+}
+
// ─── App entry point ─────────────────────────────────────────────────────────
/// Shared Tauri app builder. Used by the desktop `main.rs` and the mobile
@@ -616,7 +684,9 @@ pub fn run() {
ping_relay, get_identity, get_app_info,
connect, disconnect, toggle_mic, toggle_speaker, get_status,
register_signal, place_call, answer_call, get_signal_status,
+ deregister,
set_speakerphone, is_speakerphone_on,
+ get_call_history, get_recent_contacts, clear_call_history,
])
.run(tauri::generate_context!())
.expect("error while running WarzonePhone");
diff --git a/desktop/src/main.ts b/desktop/src/main.ts
index 57f3caf..676830b 100644
--- a/desktop/src/main.ts
+++ b/desktop/src/main.ts
@@ -115,7 +115,7 @@ function loadSettings(): Settings {
{ name: "Laptop", address: "172.16.81.125:4433" },
{ name: "Default", address: "193.180.213.68:4433" },
],
- selectedRelay: 0, room: "android", alias: "",
+ selectedRelay: 0, room: "general", alias: "",
osAec: true, agc: true, quality: "auto", recentRooms: [],
};
try {
@@ -749,6 +749,7 @@ 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")!;
@@ -757,6 +758,11 @@ 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";
@@ -781,6 +787,114 @@ modeDirect.addEventListener("click", () => {
(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 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}
+ ${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; }
@@ -791,6 +905,7 @@ registerBtn.addEventListener("click", async () => {
registerBtn.classList.add("hidden");
directRegistered.classList.remove("hidden");
callStatusText.textContent = `Your fingerprint: ${fp}`;
+ refreshHistory();
} catch (e: any) {
connectError.textContent = String(e);
registerBtn.disabled = false;
@@ -798,6 +913,20 @@ registerBtn.addEventListener("click", async () => {
}
});
+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;
diff --git a/desktop/src/style.css b/desktop/src/style.css
index ddde6cf..e7f03ad 100644
--- a/desktop/src/style.css
+++ b/desktop/src/style.css
@@ -890,3 +890,142 @@ button.primary:disabled { opacity: 0.5; cursor: not-allowed; }
.mode-btn:hover:not(.active) {
background: var(--surface2);
}
+
+/* ── Direct call history + contacts ── */
+
+.direct-registered-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ margin-bottom: 4px;
+}
+
+.secondary-btn.small {
+ padding: 4px 10px;
+ font-size: 11px;
+}
+
+.history-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin: 12px 0 4px 0;
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--dim);
+}
+
+.link-btn {
+ background: none;
+ border: none;
+ color: var(--dim);
+ font-size: 11px;
+ cursor: pointer;
+ padding: 2px 6px;
+ text-decoration: underline;
+}
+.link-btn:hover { color: var(--text); }
+
+.history-list {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ max-height: 180px;
+ overflow-y: auto;
+}
+
+/* Recent contacts — horizontally wrapping chips */
+#recent-contacts-list {
+ flex-direction: row;
+ flex-wrap: wrap;
+ max-height: none;
+}
+
+.contact-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ background: var(--surface2);
+ border: 1px solid var(--surface2);
+ color: var(--text);
+ border-radius: 16px;
+ padding: 4px 10px;
+ font-size: 12px;
+ cursor: pointer;
+ transition: all 0.15s;
+}
+.contact-chip:hover {
+ background: var(--accent);
+ border-color: var(--accent);
+ color: white;
+}
+.contact-dot {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: var(--green);
+}
+.contact-chip .contact-label {
+ max-width: 120px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+/* Full history rows */
+.history-row {
+ display: grid;
+ grid-template-columns: 20px 1fr auto;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 8px;
+ background: var(--surface);
+ border-radius: 6px;
+ font-size: 12px;
+}
+.history-row.dir-placed .history-dir { color: var(--accent); }
+.history-row.dir-received .history-dir { color: var(--green); }
+.history-row.dir-missed .history-dir { color: var(--red); }
+.history-dir {
+ text-align: center;
+ font-weight: 700;
+}
+.history-meta {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ overflow: hidden;
+}
+.history-peer {
+ color: var(--text);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.history-time {
+ color: var(--dim);
+ font-size: 11px;
+}
+.history-call-btn {
+ background: var(--surface2);
+ border: 1px solid var(--surface2);
+ color: var(--text);
+ padding: 4px 10px;
+ border-radius: 4px;
+ font-size: 11px;
+ cursor: pointer;
+ transition: all 0.15s;
+}
+.history-call-btn:hover {
+ background: var(--accent);
+ border-color: var(--accent);
+ color: white;
+}
+
+/* Speaker routing button (non-muted earpiece state should not look red) */
+#spk-btn.speaker-on .icon {
+ color: var(--accent);
+}