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