feat(direct-call): call history, recent contacts, deregister button
Persistent JSON-backed call history for the direct-call screen so users
can see what they've placed / received / missed and dial back with one
click. Also fixes two small latent UX issues reported alongside.
Backend (Rust)
- new crate/module desktop/src-tauri/src/history.rs: thread-safe in-
process store (OnceLock<RwLock<Vec<CallHistoryEntry>>>) backed by
<APP_DATA_DIR>/call_history.json. Atomic writes via temp+rename. Max
200 entries, FIFO pruning. CallDirection { Placed, Received, Missed }.
- Log hooks in the signal loop + commands:
* place_call → Placed entry (with target fingerprint)
* DirectCallOffer → Missed entry up front; upgraded to Received
inside answer_call when accept_mode != Reject
via history::mark_received_if_pending(call_id).
If user rejects or never answers, it stays Missed.
- New Tauri commands:
* get_call_history() → all entries, newest first
* get_recent_contacts() → unique peers by fp, newest interaction first
* clear_call_history() → wipes JSON + in-memory
* deregister() → tears down signal transport + endpoint
Backend emits `history-changed` events so the UI can live-refresh
without polling.
Frontend (main.ts + index.html + style.css)
- Direct-call panel now has:
* Recent contacts chip row (top 6 unique peers). Click a chip → dial.
* Call history list (up to 50 rows). Direction icon (↗ placed, ↙
received, ✗ missed), peer alias/fp, relative timestamp, callback
button. Both click handlers populate target-fp and fire place_call.
* Deregister button in the "registered" header — calls the new
deregister command, tears down the signal transport, returns the
UI to the pre-register state.
* Clear-history link in the history header.
- Subscribes to `history-changed` events so the list updates the moment
the backend logs a new entry. Also refreshed on register + after a
clear.
- Nothing is rendered until there is data — empty sections stay hidden.
Tasks #20 + #21 (small UX items bundled in)
- Default room "general" for new installations: the html input value
attribute is now "general" and loadSettings() defaults match. Existing
users' localStorage still wins.
- Random alias on desktop: already latent but confirmed working — the
startup IIFE at main.ts:374 calls get_app_info() and prefills the
alias input from derive_alias(seed) when the input is empty. No code
change needed, just verified it flows through the same path as the
Android client.
Known follow-ups (deferred to step 6 polish)
- Call duration tracking (currently all entries have no duration field)
- Hangup signal from an unanswered incoming should emit history-changed
so the missed state is visible even when the user never tapped accept
- Android UI layout fit-check on the smaller Nothing screen
This commit is contained in:
@@ -21,7 +21,7 @@
|
||||
</button>
|
||||
</label>
|
||||
<label>Room
|
||||
<input id="room" type="text" value="android" />
|
||||
<input id="room" type="text" value="general" />
|
||||
</label>
|
||||
<label>Alias
|
||||
<input id="alias" type="text" placeholder="your name" />
|
||||
@@ -48,7 +48,10 @@
|
||||
<div id="direct-mode" class="hidden">
|
||||
<button id="register-btn" class="primary" style="background:#2196F3">Register on Relay</button>
|
||||
<div id="direct-registered" class="hidden" style="margin-top:12px">
|
||||
<p style="color:var(--green);font-size:13px">✅ Registered — waiting for calls</p>
|
||||
<div class="direct-registered-header">
|
||||
<p style="color:var(--green);font-size:13px;margin:0">✅ Registered — waiting for calls</p>
|
||||
<button id="deregister-btn" class="secondary-btn small">Deregister</button>
|
||||
</div>
|
||||
<div id="incoming-call-panel" class="hidden" style="background:#1B5E20;padding:12px;border-radius:8px;margin:8px 0">
|
||||
<p style="font-weight:bold;margin:0 0 4px 0">Incoming Call</p>
|
||||
<p id="incoming-caller" style="font-size:12px;opacity:0.8;margin:0 0 8px 0">From: unknown</p>
|
||||
@@ -57,6 +60,22 @@
|
||||
<button id="reject-call-btn" style="flex:1;background:var(--red);color:white;border:none;padding:8px;border-radius:6px;cursor:pointer">Reject</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent contacts -->
|
||||
<div id="recent-contacts-section" class="hidden">
|
||||
<div class="history-header">Recent contacts</div>
|
||||
<div id="recent-contacts-list" class="history-list"></div>
|
||||
</div>
|
||||
|
||||
<!-- Call history -->
|
||||
<div id="call-history-section" class="hidden">
|
||||
<div class="history-header">
|
||||
History
|
||||
<button id="clear-history-btn" class="link-btn">clear</button>
|
||||
</div>
|
||||
<div id="call-history-list" class="history-list"></div>
|
||||
</div>
|
||||
|
||||
<label style="margin-top:8px">Call by fingerprint
|
||||
<input id="target-fp" type="text" placeholder="xxxx:xxxx:xxxx:..." />
|
||||
</label>
|
||||
|
||||
161
desktop/src-tauri/src/history.rs
Normal file
161
desktop/src-tauri/src/history.rs
Normal file
@@ -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 `<APP_DATA_DIR>/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<String>,
|
||||
pub direction: CallDirection,
|
||||
/// Seconds since UNIX epoch, UTC.
|
||||
pub timestamp_unix: u64,
|
||||
}
|
||||
|
||||
// ─── In-process store (loaded from disk once) ─────────────────────────────
|
||||
|
||||
static STORE: OnceLock<RwLock<Vec<CallHistoryEntry>>> = OnceLock::new();
|
||||
|
||||
fn store() -> &'static RwLock<Vec<CallHistoryEntry>> {
|
||||
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<CallHistoryEntry> {
|
||||
let path = history_path();
|
||||
let Ok(bytes) = std::fs::read(&path) else {
|
||||
return Vec::new();
|
||||
};
|
||||
serde_json::from_slice::<Vec<CallHistoryEntry>>(&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<String>,
|
||||
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<CallHistoryEntry> {
|
||||
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<CallHistoryEntry> {
|
||||
let guard = store().read().unwrap();
|
||||
let mut seen: std::collections::HashSet<String> = 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
|
||||
}
|
||||
@@ -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<bool, String> {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Call history commands ───────────────────────────────────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
fn get_call_history() -> Vec<history::CallHistoryEntry> {
|
||||
history::all()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_recent_contacts() -> Vec<history::CallHistoryEntry> {
|
||||
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<AppState>>, target_fp: String) -> Result<(), String> {
|
||||
async fn place_call(
|
||||
state: tauri::State<'_, Arc<AppState>>,
|
||||
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<AppState>>, call_id: String, mode: i32) -> Result<(), String> {
|
||||
async fn answer_call(
|
||||
state: tauri::State<'_, Arc<AppState>>,
|
||||
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<AppState>>, 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<AppState>>) -> Result<ser
|
||||
Ok(serde_json::json!({"status":sig.signal_status,"fingerprint":sig.fingerprint,"incoming_call_id":sig.incoming_call_id,"incoming_caller_fp":sig.incoming_caller_fp}))
|
||||
}
|
||||
|
||||
/// Tear down the signal connection so the user goes back to idle. Called
|
||||
/// when the user clicks "Deregister" on the direct-call screen. The
|
||||
/// spawned recv loop will break out naturally when the transport closes.
|
||||
#[tauri::command]
|
||||
async fn deregister(state: tauri::State<'_, Arc<AppState>>) -> 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");
|
||||
|
||||
@@ -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<CallHistoryEntry[]>("get_call_history"),
|
||||
invoke<CallHistoryEntry[]>("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 = `<span class="contact-dot"></span><span class="contact-label">${label}</span>`;
|
||||
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 = `
|
||||
<span class="history-dir">${directionIcon(e.direction)}</span>
|
||||
<div class="history-meta">
|
||||
<span class="history-peer">${label}</span>
|
||||
<span class="history-time">${fmtTimestamp(e.timestamp_unix)}</span>
|
||||
</div>
|
||||
<button class="history-call-btn" title="Call back">Call</button>
|
||||
`;
|
||||
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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user