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]]
|
[[package]]
|
||||||
name = "warzone-client"
|
name = "warzone-client"
|
||||||
version = "0.0.35"
|
version = "0.0.36"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
@@ -2989,7 +2989,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "warzone-mule"
|
name = "warzone-mule"
|
||||||
version = "0.0.35"
|
version = "0.0.36"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -2998,7 +2998,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "warzone-protocol"
|
name = "warzone-protocol"
|
||||||
version = "0.0.35"
|
version = "0.0.36"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"bincode",
|
"bincode",
|
||||||
@@ -3023,7 +3023,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "warzone-server"
|
name = "warzone-server"
|
||||||
version = "0.0.35"
|
version = "0.0.36"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
@@ -3053,7 +3053,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "warzone-wasm"
|
name = "warzone-wasm"
|
||||||
version = "0.0.35"
|
version = "0.0.36"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"bincode",
|
"bincode",
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ members = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.0.35"
|
version = "0.0.36"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
rust-version = "1.75"
|
rust-version = "1.75"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "warzone-protocol"
|
name = "warzone-protocol"
|
||||||
version = "0.0.35"
|
version = "0.0.36"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
description = "Core crypto & wire protocol for featherChat (Warzone messenger)"
|
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 {
|
async fn service_worker() -> impl IntoResponse {
|
||||||
([(header::CONTENT_TYPE, "application/javascript")], r##"
|
([(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'];
|
const SHELL = ['/', '/wasm/warzone_wasm.js', '/wasm/warzone_wasm_bg.wasm', '/icon.svg', '/manifest.json'];
|
||||||
|
|
||||||
self.addEventListener('install', e => {
|
self.addEventListener('install', e => {
|
||||||
@@ -189,6 +189,21 @@ const WEB_HTML: &str = r##"<!DOCTYPE html>
|
|||||||
.addr { color: #4fc3f7; cursor: pointer; text-decoration: underline; }
|
.addr { color: #4fc3f7; cursor: pointer; text-decoration: underline; }
|
||||||
.addr:hover { color: #81d4fa; }
|
.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) {
|
@media (max-width: 500px) {
|
||||||
.msg { font-size: 0.8em; }
|
.msg { font-size: 0.8em; }
|
||||||
#chat-header input { width: 180px; }
|
#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">
|
<input id="peer-input" placeholder="Paste peer fingerprint..." autocomplete="off">
|
||||||
<span class="tag-server" id="hdr-server"></span>
|
<span class="tag-server" id="hdr-server"></span>
|
||||||
</div>
|
</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="messages"></div>
|
||||||
<div id="bottom">
|
<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>
|
<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>
|
</div>
|
||||||
|
|
||||||
<script type="module">
|
<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 SERVER = window.location.origin;
|
||||||
const $messages = document.getElementById('messages');
|
const $messages = document.getElementById('messages');
|
||||||
@@ -259,7 +281,7 @@ let pollTimer = null;
|
|||||||
let ws = null; // WebSocket connection
|
let ws = null; // WebSocket connection
|
||||||
let wasmReady = false;
|
let wasmReady = false;
|
||||||
|
|
||||||
const VERSION = '0.0.35';
|
const VERSION = '0.0.36';
|
||||||
let DEBUG = true; // toggle with /debug command
|
let DEBUG = true; // toggle with /debug command
|
||||||
|
|
||||||
// ── Receipt tracking ──
|
// ── Receipt tracking ──
|
||||||
@@ -620,6 +642,10 @@ async function handleIncomingMessage(bytes) {
|
|||||||
updateReceiptDisplay(result.message_id, result.receipt_type);
|
updateReceiptDisplay(result.message_id, result.receipt_type);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (result.type === 'call_signal') {
|
||||||
|
handleCallSignal(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (result.type === 'file_header') {
|
if (result.type === 'file_header') {
|
||||||
handleFileHeader(result);
|
handleFileHeader(result);
|
||||||
return;
|
return;
|
||||||
@@ -668,6 +694,7 @@ async function handleIncomingMessage(bytes) {
|
|||||||
updateReceiptDisplay(result.message_id, result.receipt_type);
|
updateReceiptDisplay(result.message_id, result.receipt_type);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (result.type === 'call_signal') { handleCallSignal(result); return; }
|
||||||
if (result.type === 'file_header') { handleFileHeader(result); return; }
|
if (result.type === 'file_header') { handleFileHeader(result); return; }
|
||||||
if (result.type === 'file_chunk') { handleFileChunk(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);
|
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
|
// SECTION: Command Handlers
|
||||||
// ═══════════════════════════════════════════════
|
// ═══════════════════════════════════════════════
|
||||||
@@ -1116,6 +1324,10 @@ async function doSend() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (text === '/debug') { DEBUG = !DEBUG; addSys('Debug logging: ' + (DEBUG ? 'ON (check browser console)' : 'OFF')); 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') {
|
if (text === '/reset') {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
addSys('localStorage cleared. Refresh the page to start fresh.');
|
addSys('localStorage cleared. Refresh the page to start fresh.');
|
||||||
@@ -1214,6 +1426,7 @@ async function doSend() {
|
|||||||
currentGroup = null;
|
currentGroup = null;
|
||||||
localStorage.setItem('wz-peer', $peerInput.value);
|
localStorage.setItem('wz-peer', $peerInput.value);
|
||||||
addSys('Peer set to ' + $peerInput.value.slice(0,16) + '...');
|
addSys('Peer set to ' + $peerInput.value.slice(0,16) + '...');
|
||||||
|
updateCallUI();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (text.startsWith('/gcreate ')) { await groupCreate(text.slice(9).trim()); 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-recover').onclick = () => doRecover();
|
||||||
document.getElementById('btn-enter').onclick = () => enterChat();
|
document.getElementById('btn-enter').onclick = () => enterChat();
|
||||||
document.getElementById('send-btn').onclick = () => doSend();
|
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('messages').onclick = () => { if (!window.getSelection().toString()) document.getElementById('msg-input').focus(); };
|
||||||
document.getElementById('hdr-eth').onclick = function() {
|
document.getElementById('hdr-eth').onclick = function() {
|
||||||
if (myEthAddress) navigator.clipboard.writeText(myEthAddress).then(() => addSys('Copied ETH address'));
|
if (myEthAddress) navigator.clipboard.writeText(myEthAddress).then(() => addSys('Copied ETH address'));
|
||||||
|
|||||||
Reference in New Issue
Block a user