feat(direct-call): call history, recent contacts, deregister button
Some checks failed
Mirror to GitHub / mirror (push) Failing after 39s
Build Release Binaries / build-amd64 (push) Failing after 3m41s

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:
Siavash Sameni
2026-04-10 11:03:36 +04:00
parent 76a4c53e21
commit 510eae2089
5 changed files with 525 additions and 7 deletions

View File

@@ -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;

View File

@@ -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);
}