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:
10
warzone/Cargo.lock
generated
10
warzone/Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -9,7 +9,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.0.35"
|
||||
version = "0.0.36"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
rust-version = "1.75"
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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">📞 Call</button>
|
||||
<button class="call-btn call-btn-green" id="btn-accept" style="display:none">✓ Accept</button>
|
||||
<button class="call-btn call-btn-red" id="btn-reject" style="display:none">✗ 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">📎<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'));
|
||||
|
||||
Reference in New Issue
Block a user