v0.0.36: web call UI — call/accept/reject/hangup with signaling

Web client:
- Call bar between header and messages (hidden when idle)
- Call button appears when peer is set (not group)
- Incoming call: pulsing notification + Accept/Reject buttons
- Call states: idle → calling → ringing → active
- /call, /accept, /reject, /hangup slash commands
- CallSignal sent via WS binary frames (same as messages)
- handleCallSignal processes Offer/Answer/Hangup/Reject/Ringing/Busy
- Vibration on incoming call (mobile)
- create_call_signal WASM import wired up

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Siavash Sameni
2026-03-29 16:07:03 +04:00
parent 0b58ddcee5
commit e9182fdb41
4 changed files with 227 additions and 10 deletions

10
warzone/Cargo.lock generated
View File

@@ -2956,7 +2956,7 @@ dependencies = [
[[package]]
name = "warzone-client"
version = "0.0.35"
version = "0.0.36"
dependencies = [
"anyhow",
"argon2",
@@ -2989,7 +2989,7 @@ dependencies = [
[[package]]
name = "warzone-mule"
version = "0.0.35"
version = "0.0.36"
dependencies = [
"anyhow",
"clap",
@@ -2998,7 +2998,7 @@ dependencies = [
[[package]]
name = "warzone-protocol"
version = "0.0.35"
version = "0.0.36"
dependencies = [
"base64",
"bincode",
@@ -3023,7 +3023,7 @@ dependencies = [
[[package]]
name = "warzone-server"
version = "0.0.35"
version = "0.0.36"
dependencies = [
"anyhow",
"axum",
@@ -3053,7 +3053,7 @@ dependencies = [
[[package]]
name = "warzone-wasm"
version = "0.0.35"
version = "0.0.36"
dependencies = [
"base64",
"bincode",

View File

@@ -9,7 +9,7 @@ members = [
]
[workspace.package]
version = "0.0.35"
version = "0.0.36"
edition = "2021"
license = "MIT"
rust-version = "1.75"

View File

@@ -1,6 +1,6 @@
[package]
name = "warzone-protocol"
version = "0.0.35"
version = "0.0.36"
edition = "2021"
license = "MIT"
description = "Core crypto & wire protocol for featherChat (Warzone messenger)"

View File

@@ -50,7 +50,7 @@ async fn pwa_manifest() -> impl IntoResponse {
async fn service_worker() -> impl IntoResponse {
([(header::CONTENT_TYPE, "application/javascript")], r##"
const CACHE = 'wz-v17';
const CACHE = 'wz-v18';
const SHELL = ['/', '/wasm/warzone_wasm.js', '/wasm/warzone_wasm_bg.wasm', '/icon.svg', '/manifest.json'];
self.addEventListener('install', e => {
@@ -189,6 +189,21 @@ const WEB_HTML: &str = r##"<!DOCTYPE html>
.addr { color: #4fc3f7; cursor: pointer; text-decoration: underline; }
.addr:hover { color: #81d4fa; }
/* Call UI */
#call-bar { display: none; padding: 6px 10px; background: #1a0a2e; border-bottom: 1px solid #4a1a5c;
align-items: center; gap: 8px; font-size: 0.85em; }
#call-bar.active { display: flex; }
#call-bar .call-status { flex: 1; color: #ce93d8; }
.call-btn { padding: 4px 12px; border: none; border-radius: 4px; cursor: pointer; font-family: inherit; font-size: 0.8em; }
.call-btn-green { background: #2d5016; color: #4ade80; }
.call-btn-green:hover { background: #3d6020; }
.call-btn-red { background: #5c1a1a; color: #ff6b6b; }
.call-btn-red:hover { background: #6c2a2a; }
.call-btn-blue { background: #1a1a5c; color: #4fc3f7; }
.call-btn-blue:hover { background: #2a2a6c; }
.incoming-call { animation: pulse 1.5s infinite; }
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.5; } }
@media (max-width: 500px) {
.msg { font-size: 0.8em; }
#chat-header input { width: 180px; }
@@ -230,6 +245,13 @@ const WEB_HTML: &str = r##"<!DOCTYPE html>
<input id="peer-input" placeholder="Paste peer fingerprint..." autocomplete="off">
<span class="tag-server" id="hdr-server"></span>
</div>
<div id="call-bar">
<span class="call-status" id="call-status">No active call</span>
<button class="call-btn call-btn-blue" id="btn-call">&#128222; Call</button>
<button class="call-btn call-btn-green" id="btn-accept" style="display:none">&#10003; Accept</button>
<button class="call-btn call-btn-red" id="btn-reject" style="display:none">&#10007; Reject</button>
<button class="call-btn call-btn-red" id="btn-hangup" style="display:none">End Call</button>
</div>
<div id="messages"></div>
<div id="bottom">
<label id="file-btn" title="Send file" style="padding:10px;background:#1a1a2e;border:1px solid #333;color:#c8d6e5;border-radius:50%;cursor:pointer;min-width:40px;min-height:40px;text-align:center;line-height:20px;font-size:1.1em">&#128206;<input type="file" id="file-input" style="display:none"></label>
@@ -239,7 +261,7 @@ const WEB_HTML: &str = r##"<!DOCTYPE html>
</div>
<script type="module">
import init, { WasmIdentity, WasmSession, decrypt_wire_message, self_test, debug_bundle_info, create_receipt } from '/wasm/warzone_wasm.js';
import init, { WasmIdentity, WasmSession, decrypt_wire_message, self_test, debug_bundle_info, create_receipt, create_call_signal } from '/wasm/warzone_wasm.js';
const SERVER = window.location.origin;
const $messages = document.getElementById('messages');
@@ -259,7 +281,7 @@ let pollTimer = null;
let ws = null; // WebSocket connection
let wasmReady = false;
const VERSION = '0.0.35';
const VERSION = '0.0.36';
let DEBUG = true; // toggle with /debug command
// ── Receipt tracking ──
@@ -620,6 +642,10 @@ async function handleIncomingMessage(bytes) {
updateReceiptDisplay(result.message_id, result.receipt_type);
return;
}
if (result.type === 'call_signal') {
handleCallSignal(result);
return;
}
if (result.type === 'file_header') {
handleFileHeader(result);
return;
@@ -668,6 +694,7 @@ async function handleIncomingMessage(bytes) {
updateReceiptDisplay(result.message_id, result.receipt_type);
return;
}
if (result.type === 'call_signal') { handleCallSignal(result); return; }
if (result.type === 'file_header') { handleFileHeader(result); return; }
if (result.type === 'file_chunk') { handleFileChunk(result); return; }
@@ -1090,6 +1117,187 @@ async function sendToGroup(groupName, text) {
addMsg((myEthAddress ? myEthAddress.slice(0,12) + '...' : myFingerprint.slice(0,19)) + ' [' + groupName + ']', text, true, null);
}
// ═══════════════════════════════════════════════
// SECTION: Call Signaling
// ═══════════════════════════════════════════════
let callState = 'idle'; // idle, calling, ringing, active
let callPeer = null;
function updateCallUI() {
const bar = document.getElementById('call-bar');
const status = document.getElementById('call-status');
const btnCall = document.getElementById('btn-call');
const btnAccept = document.getElementById('btn-accept');
const btnReject = document.getElementById('btn-reject');
const btnHangup = document.getElementById('btn-hangup');
bar.classList.add('active');
btnCall.style.display = 'none';
btnAccept.style.display = 'none';
btnReject.style.display = 'none';
btnHangup.style.display = 'none';
switch(callState) {
case 'idle':
bar.classList.remove('active');
// Show call button only if peer is set
if ($peerInput.value.trim() && !$peerInput.value.startsWith('#')) {
bar.classList.add('active');
btnCall.style.display = '';
status.textContent = 'Ready to call';
}
break;
case 'calling':
status.textContent = '\u{1F4DE} Calling ' + (callPeer || '...').slice(0, 16);
status.className = 'call-status';
btnHangup.style.display = '';
break;
case 'ringing':
status.textContent = '\u{1F4DE} Incoming call from ' + (callPeer || '?').slice(0, 16);
status.className = 'call-status incoming-call';
btnAccept.style.display = '';
btnReject.style.display = '';
break;
case 'active':
status.textContent = '\u{1F50A} In call with ' + (callPeer || '?').slice(0, 16);
status.className = 'call-status';
btnHangup.style.display = '';
break;
}
}
async function startCall() {
const peer = $peerInput.value.trim();
if (!peer || peer.startsWith('#')) { addSys('Set a peer first'); return; }
callState = 'calling';
callPeer = peer;
updateCallUI();
// Send CallSignal::Offer via WS
try {
const signalBytes = create_call_signal(wasmIdentity, 'offer', '', normFP(peer));
if (ws && ws.readyState === WebSocket.OPEN) {
const fp = normFP(peer);
const header = new TextEncoder().encode(fp.padEnd(64, '0').slice(0, 64));
const payload = new Uint8Array(header.length + signalBytes.length);
payload.set(header);
payload.set(signalBytes, header.length);
ws.send(payload);
addSys('Calling ' + peer.slice(0, 16) + '...');
}
} catch(e) {
addSys('Call failed: ' + e.message);
callState = 'idle';
updateCallUI();
}
}
function acceptCall() {
if (callState !== 'ringing') return;
callState = 'active';
updateCallUI();
try {
const signalBytes = create_call_signal(wasmIdentity, 'answer', '', normFP(callPeer));
if (ws && ws.readyState === WebSocket.OPEN) {
const fp = normFP(callPeer);
const header = new TextEncoder().encode(fp.padEnd(64, '0').slice(0, 64));
const payload = new Uint8Array(header.length + signalBytes.length);
payload.set(header);
payload.set(signalBytes, header.length);
ws.send(payload);
addSys('Call accepted');
}
} catch(e) { addSys('Accept failed: ' + e.message); }
}
function rejectCall() {
if (callState !== 'ringing') return;
try {
const signalBytes = create_call_signal(wasmIdentity, 'reject', '', normFP(callPeer));
if (ws && ws.readyState === WebSocket.OPEN) {
const fp = normFP(callPeer);
const header = new TextEncoder().encode(fp.padEnd(64, '0').slice(0, 64));
const payload = new Uint8Array(header.length + signalBytes.length);
payload.set(header);
payload.set(signalBytes, header.length);
ws.send(payload);
}
} catch(e) {}
addSys('Call rejected');
callState = 'idle';
callPeer = null;
updateCallUI();
}
function hangupCall() {
if (callState === 'idle') return;
try {
const target = callPeer ? normFP(callPeer) : '';
const signalBytes = create_call_signal(wasmIdentity, 'hangup', '', target);
if (ws && ws.readyState === WebSocket.OPEN && target) {
const header = new TextEncoder().encode(target.padEnd(64, '0').slice(0, 64));
const payload = new Uint8Array(header.length + signalBytes.length);
payload.set(header);
payload.set(signalBytes, header.length);
ws.send(payload);
}
} catch(e) {}
addSys('Call ended');
callState = 'idle';
callPeer = null;
updateCallUI();
}
function handleCallSignal(signal) {
const type = signal.signal_type;
const sender = signal.sender;
switch(type) {
case 'offer':
if (callState === 'idle') {
callState = 'ringing';
callPeer = sender;
updateCallUI();
addSys('\u{1F4DE} Incoming call from ' + sender.slice(0, 16));
// Play sound or vibrate
try { navigator.vibrate && navigator.vibrate([200, 100, 200]); } catch(e) {}
}
break;
case 'answer':
if (callState === 'calling') {
callState = 'active';
updateCallUI();
addSys('Call connected!');
}
break;
case 'hangup':
case 'reject':
if (callState !== 'idle') {
addSys('Call ended' + (type === 'reject' ? ' (rejected)' : ''));
callState = 'idle';
callPeer = null;
updateCallUI();
}
break;
case 'ringing':
if (callState === 'calling') {
addSys('Ringing...');
}
break;
case 'busy':
if (callState === 'calling') {
addSys('Peer is busy');
callState = 'idle';
callPeer = null;
updateCallUI();
}
break;
}
}
// ═══════════════════════════════════════════════
// SECTION: Command Handlers
// ═══════════════════════════════════════════════
@@ -1116,6 +1324,10 @@ async function doSend() {
return;
}
if (text === '/debug') { DEBUG = !DEBUG; addSys('Debug logging: ' + (DEBUG ? 'ON (check browser console)' : 'OFF')); return; }
if (text === '/call') { startCall(); return; }
if (text === '/hangup' || text === '/end') { hangupCall(); return; }
if (text === '/accept') { acceptCall(); return; }
if (text === '/reject') { rejectCall(); return; }
if (text === '/reset') {
localStorage.clear();
addSys('localStorage cleared. Refresh the page to start fresh.');
@@ -1214,6 +1426,7 @@ async function doSend() {
currentGroup = null;
localStorage.setItem('wz-peer', $peerInput.value);
addSys('Peer set to ' + $peerInput.value.slice(0,16) + '...');
updateCallUI();
return;
}
if (text.startsWith('/gcreate ')) { await groupCreate(text.slice(9).trim()); return; }
@@ -1346,6 +1559,10 @@ document.getElementById('btn-show-recover').onclick = () => document.getElementB
document.getElementById('btn-recover').onclick = () => doRecover();
document.getElementById('btn-enter').onclick = () => enterChat();
document.getElementById('send-btn').onclick = () => doSend();
document.getElementById('btn-call').onclick = () => startCall();
document.getElementById('btn-accept').onclick = () => acceptCall();
document.getElementById('btn-reject').onclick = () => rejectCall();
document.getElementById('btn-hangup').onclick = () => hangupCall();
document.getElementById('messages').onclick = () => { if (!window.getSelection().toString()) document.getElementById('msg-input').focus(); };
document.getElementById('hdr-eth').onclick = function() {
if (myEthAddress) navigator.clipboard.writeText(myEthAddress).then(() => addSys('Copied ETH address'));