v0.0.45: call ring tones + group calls
Direct call ringing: - Incoming: oscillator ring tone (440/480Hz, on/off pattern) - Outgoing: ringback tone (2s on, 4s off) while waiting for answer - Browser Notification API for background incoming calls - All tones stop on answer/reject/hangup - Notification permission requested on first click Group calls: - /gcall — start a group call (notifies all group members) - /gjoin — join an active group call - /gleave-call — leave group call - /gmute — toggle per-group call notification mute (localStorage) - Participant tracking (joined/left messages with count) - Group call signal via group message broadcast (JSON type: group_call) - Call bar shows "Join Call" button when group call is active - Audio via same WZP bridge (room = gc-<groupname>) 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.44"
|
||||
version = "0.0.45"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
@@ -2989,7 +2989,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "warzone-mule"
|
||||
version = "0.0.44"
|
||||
version = "0.0.45"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
@@ -2998,7 +2998,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "warzone-protocol"
|
||||
version = "0.0.44"
|
||||
version = "0.0.45"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bincode",
|
||||
@@ -3023,7 +3023,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "warzone-server"
|
||||
version = "0.0.44"
|
||||
version = "0.0.45"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -3054,7 +3054,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "warzone-wasm"
|
||||
version = "0.0.44"
|
||||
version = "0.0.45"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bincode",
|
||||
|
||||
@@ -9,7 +9,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.0.44"
|
||||
version = "0.0.45"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
rust-version = "1.75"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "warzone-protocol"
|
||||
version = "0.0.44"
|
||||
version = "0.0.45"
|
||||
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-v26';
|
||||
const CACHE = 'wz-v27';
|
||||
const SHELL = ['/', '/wasm/warzone_wasm.js', '/wasm/warzone_wasm_bg.wasm', '/icon.svg', '/manifest.json'];
|
||||
|
||||
self.addEventListener('install', e => {
|
||||
@@ -288,7 +288,7 @@ let pollTimer = null;
|
||||
let ws = null; // WebSocket connection
|
||||
let wasmReady = false;
|
||||
|
||||
const VERSION = '0.0.44';
|
||||
const VERSION = '0.0.45';
|
||||
let DEBUG = true; // toggle with /debug command
|
||||
|
||||
// ── Receipt tracking ──
|
||||
@@ -601,6 +601,10 @@ function connectWebSocket() {
|
||||
// Text frame — could be a bot message or missed call notification
|
||||
try {
|
||||
const json = JSON.parse(event.data);
|
||||
if (json.type === 'group_call') {
|
||||
handleGroupCallSignal(json);
|
||||
return;
|
||||
}
|
||||
if (json.type === 'missed_call') {
|
||||
addSys('Missed call from ' + (json.data?.caller_fp || 'unknown'));
|
||||
return;
|
||||
@@ -798,6 +802,11 @@ async function handleIncomingMessage(bytes) {
|
||||
try {
|
||||
const str = new TextDecoder().decode(bytes);
|
||||
const json = JSON.parse(str);
|
||||
// Check for group call signal
|
||||
if (json.type === 'group_call') {
|
||||
handleGroupCallSignal(json);
|
||||
return;
|
||||
}
|
||||
if (json.type === 'file_header') { handleFileHeader(json); return; }
|
||||
if (json.type === 'file_chunk') { handleFileChunk(json); return; }
|
||||
// Handle bot messages (plaintext JSON from bot API)
|
||||
@@ -1083,6 +1092,7 @@ async function enterChat() {
|
||||
|
||||
addSys('v' + VERSION + ' | DM: paste peer fingerprint or @alias above');
|
||||
addSys('/alias · /g · /gleave · /gkick · /gmembers · /glist · /friend · /file · /info');
|
||||
addSys('/call · /gcall · /gjoin · /gleave-call · /gmute');
|
||||
|
||||
// Show system bots if available
|
||||
try {
|
||||
@@ -1238,6 +1248,11 @@ async function sendToGroup(groupName, text) {
|
||||
let callState = 'idle'; // idle, calling, ringing, active
|
||||
let callPeer = null;
|
||||
|
||||
// Group call state
|
||||
let groupCallRoom = null; // Current group call room name
|
||||
let groupCallGroup = null; // Group name for active group call
|
||||
let groupCallParticipants = []; // List of fingerprints in the call
|
||||
|
||||
function updateCallUI() {
|
||||
const bar = document.getElementById('call-bar');
|
||||
const status = document.getElementById('call-status');
|
||||
@@ -1311,6 +1326,7 @@ async function startCall() {
|
||||
payload.set(signalBytes, header.length);
|
||||
ws.send(payload);
|
||||
addSys('Calling ' + peer.slice(0, 16) + '...');
|
||||
startRingback();
|
||||
}
|
||||
} catch(e) {
|
||||
addSys('Call failed: ' + e.message);
|
||||
@@ -1321,6 +1337,7 @@ async function startCall() {
|
||||
|
||||
function acceptCall() {
|
||||
if (callState !== 'ringing') return;
|
||||
stopRingtone();
|
||||
callState = 'active';
|
||||
updateCallUI();
|
||||
|
||||
@@ -1341,6 +1358,7 @@ function acceptCall() {
|
||||
|
||||
function rejectCall() {
|
||||
if (callState !== 'ringing') return;
|
||||
stopRingtone();
|
||||
try {
|
||||
const signalBytes = create_call_signal(wasmIdentity, 'reject', '', normFP(callPeer));
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
@@ -1361,6 +1379,7 @@ function rejectCall() {
|
||||
|
||||
function hangupCall() {
|
||||
if (callState === 'idle') return;
|
||||
stopRingtone();
|
||||
try {
|
||||
const target = callPeer ? normFP(callPeer) : '';
|
||||
const signalBytes = create_call_signal(wasmIdentity, 'hangup', '', target);
|
||||
@@ -1389,6 +1408,8 @@ function handleCallSignal(signal) {
|
||||
callState = 'ringing';
|
||||
callPeer = sender;
|
||||
updateCallUI();
|
||||
startRingtone();
|
||||
sendCallNotification('Incoming Call', 'Call from ' + (peerEthAddr || sender.slice(0, 16)));
|
||||
addSys('\u{1F4DE} Incoming call from ' + sender.slice(0, 16));
|
||||
// Play sound or vibrate
|
||||
try { navigator.vibrate && navigator.vibrate([200, 100, 200]); } catch(e) {}
|
||||
@@ -1398,6 +1419,7 @@ function handleCallSignal(signal) {
|
||||
if (callState === 'calling') {
|
||||
callState = 'active';
|
||||
updateCallUI();
|
||||
stopRingtone();
|
||||
addSys('Call connected!');
|
||||
startAudio();
|
||||
}
|
||||
@@ -1405,6 +1427,7 @@ function handleCallSignal(signal) {
|
||||
case 'hangup':
|
||||
case 'reject':
|
||||
if (callState !== 'idle') {
|
||||
stopRingtone();
|
||||
stopAudio();
|
||||
addSys('Call ended' + (type === 'reject' ? ' (rejected)' : ''));
|
||||
callState = 'idle';
|
||||
@@ -1438,6 +1461,92 @@ let mediaStream = null;
|
||||
let captureNode = null;
|
||||
let playbackNode = null;
|
||||
|
||||
// ═══════════════════════════════════════════════
|
||||
// SECTION: Ring Tones (generated via Web Audio)
|
||||
// ═══════════════════════════════════════════════
|
||||
|
||||
let ringCtx = null;
|
||||
let ringOsc = null;
|
||||
let ringGain = null;
|
||||
let ringInterval = null;
|
||||
|
||||
function startRingtone() {
|
||||
stopRingtone();
|
||||
try {
|
||||
ringCtx = new AudioContext();
|
||||
ringGain = ringCtx.createGain();
|
||||
ringGain.connect(ringCtx.destination);
|
||||
ringGain.gain.value = 0;
|
||||
|
||||
ringOsc = ringCtx.createOscillator();
|
||||
ringOsc.type = 'sine';
|
||||
ringOsc.frequency.value = 440;
|
||||
ringOsc.connect(ringGain);
|
||||
ringOsc.start();
|
||||
|
||||
// Ring pattern: 400ms on, 200ms off, 400ms on, 1500ms off
|
||||
let step = 0;
|
||||
const pattern = [
|
||||
{ gain: 0.3, freq: 440, dur: 400 },
|
||||
{ gain: 0, freq: 440, dur: 200 },
|
||||
{ gain: 0.3, freq: 480, dur: 400 },
|
||||
{ gain: 0, freq: 480, dur: 1500 },
|
||||
];
|
||||
function tick() {
|
||||
const p = pattern[step % pattern.length];
|
||||
ringGain.gain.setValueAtTime(p.gain, ringCtx.currentTime);
|
||||
ringOsc.frequency.setValueAtTime(p.freq, ringCtx.currentTime);
|
||||
step++;
|
||||
ringInterval = setTimeout(tick, p.dur);
|
||||
}
|
||||
tick();
|
||||
} catch(e) { dbg('Ring tone error:', e); }
|
||||
}
|
||||
|
||||
function startRingback() {
|
||||
stopRingtone();
|
||||
try {
|
||||
ringCtx = new AudioContext();
|
||||
ringGain = ringCtx.createGain();
|
||||
ringGain.connect(ringCtx.destination);
|
||||
ringGain.gain.value = 0;
|
||||
|
||||
ringOsc = ringCtx.createOscillator();
|
||||
ringOsc.type = 'sine';
|
||||
ringOsc.frequency.value = 440;
|
||||
ringOsc.connect(ringGain);
|
||||
ringOsc.start();
|
||||
|
||||
// Ringback: 2s on, 4s off (US standard)
|
||||
let on = true;
|
||||
function tick() {
|
||||
ringGain.gain.setValueAtTime(on ? 0.15 : 0, ringCtx.currentTime);
|
||||
ringOsc.frequency.setValueAtTime(on ? 440 : 480, ringCtx.currentTime);
|
||||
on = !on;
|
||||
ringInterval = setTimeout(tick, on ? 4000 : 2000);
|
||||
}
|
||||
tick();
|
||||
} catch(e) { dbg('Ringback error:', e); }
|
||||
}
|
||||
|
||||
function stopRingtone() {
|
||||
if (ringInterval) { clearTimeout(ringInterval); ringInterval = null; }
|
||||
if (ringOsc) { try { ringOsc.stop(); } catch(e) {} ringOsc = null; }
|
||||
if (ringGain) { ringGain = null; }
|
||||
if (ringCtx) { ringCtx.close().catch(() => {}); ringCtx = null; }
|
||||
}
|
||||
|
||||
function sendCallNotification(title, body) {
|
||||
if (document.hasFocus()) return;
|
||||
if (Notification.permission === 'granted') {
|
||||
const n = new Notification(title, { body, icon: '/icon.svg', tag: 'wz-call', requireInteraction: true });
|
||||
n.onclick = () => { window.focus(); n.close(); };
|
||||
return n;
|
||||
} else if (Notification.permission !== 'denied') {
|
||||
Notification.requestPermission();
|
||||
}
|
||||
}
|
||||
|
||||
async function startAudio() {
|
||||
// Fetch relay config (includes auth token)
|
||||
let relayAddr, authToken;
|
||||
@@ -1574,6 +1683,223 @@ function stopAudio() {
|
||||
playbackNode = null;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════
|
||||
// SECTION: Group Calls
|
||||
// ═══════════════════════════════════════════════
|
||||
|
||||
async function startGroupCall() {
|
||||
const peer = $peerInput.value.trim();
|
||||
if (!peer || !peer.startsWith('#')) { addSys('Switch to a group first (/g <name>)'); return; }
|
||||
const gname = peer.replace('#', '');
|
||||
|
||||
groupCallGroup = gname;
|
||||
groupCallRoom = 'gc-' + gname;
|
||||
groupCallParticipants = [normFP(myFingerprint)];
|
||||
updateGroupCallUI();
|
||||
|
||||
// Notify group members
|
||||
const msg = JSON.stringify({ type: 'group_call', action: 'started', group: gname, room: groupCallRoom, from: normFP(myFingerprint) });
|
||||
await fetch(SERVER + '/v1/groups/' + gname + '/send', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ from: normFP(myFingerprint), messages: [{ to: '__broadcast__', message: Array.from(new TextEncoder().encode(msg)) }] })
|
||||
}).catch(() => {});
|
||||
|
||||
addSys('Group call started in #' + gname + ' \u2014 waiting for others to join');
|
||||
await joinGroupCallAudio();
|
||||
}
|
||||
|
||||
async function joinGroupCall(gname, room) {
|
||||
groupCallGroup = gname;
|
||||
groupCallRoom = room;
|
||||
if (!groupCallParticipants.includes(normFP(myFingerprint))) {
|
||||
groupCallParticipants.push(normFP(myFingerprint));
|
||||
}
|
||||
updateGroupCallUI();
|
||||
|
||||
// Notify others we joined
|
||||
const msg = JSON.stringify({ type: 'group_call', action: 'joined', group: gname, room: room, from: normFP(myFingerprint) });
|
||||
await fetch(SERVER + '/v1/groups/' + gname + '/send', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ from: normFP(myFingerprint), messages: [{ to: '__broadcast__', message: Array.from(new TextEncoder().encode(msg)) }] })
|
||||
}).catch(() => {});
|
||||
|
||||
addSys('Joined group call in #' + gname);
|
||||
await joinGroupCallAudio();
|
||||
}
|
||||
|
||||
async function joinGroupCallAudio() {
|
||||
// Reuse the audio bridge \u2014 connect to wzp-web with group room name
|
||||
let relayAddr, token;
|
||||
try {
|
||||
const resp = await fetch(SERVER + '/v1/wzp/relay-config');
|
||||
const data = await resp.json();
|
||||
relayAddr = data.relay_addr;
|
||||
token = data.token;
|
||||
} catch(e) { addSys('Audio: cannot get relay config'); return; }
|
||||
|
||||
try {
|
||||
mediaStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: { sampleRate: 48000, channelCount: 1, echoCancellation: true, noiseSuppression: true }
|
||||
});
|
||||
} catch(e) { addSys('Audio: mic access denied'); return; }
|
||||
|
||||
audioCtx = new AudioContext({ sampleRate: 48000 });
|
||||
const host = relayAddr.replace(/^https?:\/\//, '');
|
||||
const proto = host.startsWith('localhost') || host.startsWith('127.') ? 'ws:' : 'wss:';
|
||||
const wsUrl = proto + '//' + host + '/ws/' + groupCallRoom;
|
||||
|
||||
audioWs = new WebSocket(wsUrl);
|
||||
audioWs.binaryType = 'arraybuffer';
|
||||
|
||||
audioWs.onopen = async () => {
|
||||
audioWs.send(JSON.stringify({ type: 'auth', token }));
|
||||
addSys('Audio: connected to group call room');
|
||||
|
||||
const source = audioCtx.createMediaStreamSource(mediaStream);
|
||||
const processor = audioCtx.createScriptProcessor(1024, 1, 1);
|
||||
let captureBuffer = new Float32Array(0);
|
||||
|
||||
processor.onaudioprocess = (e) => {
|
||||
if (!groupCallRoom || !audioWs || audioWs.readyState !== WebSocket.OPEN) return;
|
||||
const input = e.inputBuffer.getChannelData(0);
|
||||
const combined = new Float32Array(captureBuffer.length + input.length);
|
||||
combined.set(captureBuffer);
|
||||
combined.set(input, captureBuffer.length);
|
||||
captureBuffer = combined;
|
||||
|
||||
while (captureBuffer.length >= 960) {
|
||||
const frame = captureBuffer.slice(0, 960);
|
||||
captureBuffer = captureBuffer.slice(960);
|
||||
const pcm = new Int16Array(frame.length);
|
||||
for (let i = 0; i < frame.length; i++) {
|
||||
pcm[i] = Math.max(-32768, Math.min(32767, Math.round(frame[i] * 32767)));
|
||||
}
|
||||
audioWs.send(pcm.buffer);
|
||||
}
|
||||
};
|
||||
|
||||
source.connect(processor);
|
||||
processor.connect(audioCtx.destination);
|
||||
captureNode = processor;
|
||||
};
|
||||
|
||||
audioWs.onmessage = (event) => {
|
||||
if (!audioCtx || typeof event.data === 'string') return;
|
||||
const pcm = new Int16Array(event.data);
|
||||
if (pcm.length === 0) return;
|
||||
const float32 = new Float32Array(pcm.length);
|
||||
for (let i = 0; i < pcm.length; i++) float32[i] = pcm[i] / 32768.0;
|
||||
const buffer = audioCtx.createBuffer(1, float32.length, 48000);
|
||||
buffer.getChannelData(0).set(float32);
|
||||
const src = audioCtx.createBufferSource();
|
||||
src.buffer = buffer;
|
||||
src.connect(audioCtx.destination);
|
||||
src.start();
|
||||
};
|
||||
|
||||
audioWs.onclose = () => { if (groupCallRoom) addSys('Audio: group call disconnected'); };
|
||||
audioWs.onerror = () => { addSys('Audio: group call connection error'); };
|
||||
}
|
||||
|
||||
function leaveGroupCall() {
|
||||
if (!groupCallGroup) return;
|
||||
const gname = groupCallGroup;
|
||||
|
||||
// Notify others
|
||||
const msg = JSON.stringify({ type: 'group_call', action: 'left', group: gname, room: groupCallRoom, from: normFP(myFingerprint) });
|
||||
fetch(SERVER + '/v1/groups/' + gname + '/send', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ from: normFP(myFingerprint), messages: [{ to: '__broadcast__', message: Array.from(new TextEncoder().encode(msg)) }] })
|
||||
}).catch(() => {});
|
||||
|
||||
stopAudio();
|
||||
addSys('Left group call in #' + gname);
|
||||
groupCallRoom = null;
|
||||
groupCallGroup = null;
|
||||
groupCallParticipants = [];
|
||||
updateGroupCallUI();
|
||||
|
||||
// Reset call button
|
||||
const btnCall = document.getElementById('btn-call');
|
||||
btnCall.textContent = '\u{1F4DE}';
|
||||
btnCall.onclick = () => startCall();
|
||||
}
|
||||
|
||||
function handleGroupCallSignal(data) {
|
||||
// Check if notifications are muted for this group
|
||||
const muted = JSON.parse(localStorage.getItem('wz-gcall-mute') || '{}');
|
||||
|
||||
switch(data.action) {
|
||||
case 'started':
|
||||
if (data.from === normFP(myFingerprint)) return; // ignore own
|
||||
if (!groupCallParticipants.includes(data.from)) groupCallParticipants = [data.from];
|
||||
if (!muted[data.group]) {
|
||||
addSys('\u{1F4DE} Group call started in #' + data.group + ' \u2014 type /gjoin to join');
|
||||
sendCallNotification('Group Call', 'Call started in #' + data.group);
|
||||
}
|
||||
groupCallGroup = data.group;
|
||||
groupCallRoom = data.room;
|
||||
updateGroupCallUI();
|
||||
break;
|
||||
case 'joined':
|
||||
if (data.from === normFP(myFingerprint)) return;
|
||||
if (!groupCallParticipants.includes(data.from)) groupCallParticipants.push(data.from);
|
||||
addSys(data.from.slice(0, 8) + '... joined #' + data.group + ' call (' + groupCallParticipants.length + ' participants)');
|
||||
updateGroupCallUI();
|
||||
break;
|
||||
case 'left':
|
||||
if (data.from === normFP(myFingerprint)) return;
|
||||
groupCallParticipants = groupCallParticipants.filter(fp => fp !== data.from);
|
||||
addSys(data.from.slice(0, 8) + '... left #' + data.group + ' call (' + groupCallParticipants.length + ' participants)');
|
||||
if (groupCallParticipants.length === 0) {
|
||||
addSys('Group call in #' + data.group + ' ended (no participants)');
|
||||
if (groupCallRoom) leaveGroupCall();
|
||||
}
|
||||
updateGroupCallUI();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function updateGroupCallUI() {
|
||||
const bar = document.getElementById('call-bar');
|
||||
const status = document.getElementById('call-status');
|
||||
const btnCall = document.getElementById('btn-call');
|
||||
const btnHangup = document.getElementById('btn-hangup');
|
||||
|
||||
if (groupCallRoom && audioWs) {
|
||||
// We're in a group call
|
||||
bar.classList.add('active');
|
||||
btnCall.style.display = 'none';
|
||||
btnHangup.style.display = '';
|
||||
document.getElementById('btn-accept').style.display = 'none';
|
||||
document.getElementById('btn-reject').style.display = 'none';
|
||||
status.textContent = '\u{1F50A} Group call #' + groupCallGroup + ' (' + groupCallParticipants.length + ' in call)';
|
||||
status.className = 'call-status';
|
||||
} else if (groupCallRoom && !audioWs) {
|
||||
// Group call exists but we haven't joined
|
||||
bar.classList.add('active');
|
||||
btnCall.style.display = '';
|
||||
btnCall.textContent = 'Join Call';
|
||||
btnCall.onclick = () => joinGroupCall(groupCallGroup, groupCallRoom);
|
||||
btnHangup.style.display = 'none';
|
||||
document.getElementById('btn-accept').style.display = 'none';
|
||||
document.getElementById('btn-reject').style.display = 'none';
|
||||
status.textContent = '\u{1F4DE} Group call in #' + groupCallGroup + ' (' + groupCallParticipants.length + ' in call)';
|
||||
status.className = 'call-status incoming-call';
|
||||
}
|
||||
// If no group call, let the regular updateCallUI handle it
|
||||
}
|
||||
|
||||
function toggleGroupCallMute(gname) {
|
||||
const muted = JSON.parse(localStorage.getItem('wz-gcall-mute') || '{}');
|
||||
muted[gname] = !muted[gname];
|
||||
localStorage.setItem('wz-gcall-mute', JSON.stringify(muted));
|
||||
addSys('Group call notifications for #' + gname + ': ' + (muted[gname] ? 'muted' : 'unmuted'));
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════
|
||||
// SECTION: Command Handlers
|
||||
// ═══════════════════════════════════════════════
|
||||
@@ -1711,6 +2037,19 @@ async function doSend() {
|
||||
updateCallUI();
|
||||
return;
|
||||
}
|
||||
if (text === '/gcall') { startGroupCall(); return; }
|
||||
if (text === '/gjoin') {
|
||||
if (groupCallRoom) joinGroupCall(groupCallGroup, groupCallRoom);
|
||||
else addSys('No active group call');
|
||||
return;
|
||||
}
|
||||
if (text === '/gleave-call' || text === '/gleave-audio') { leaveGroupCall(); return; }
|
||||
if (text.startsWith('/gmute')) {
|
||||
const peer = $peerInput.value.trim();
|
||||
if (peer && peer.startsWith('#')) toggleGroupCallMute(peer.replace('#',''));
|
||||
else addSys('Switch to a group first');
|
||||
return;
|
||||
}
|
||||
if (text.startsWith('/gcreate ')) { await groupCreate(text.slice(9).trim()); return; }
|
||||
if (text.startsWith('/gjoin ')) { await groupJoin(text.slice(7).trim()); return; }
|
||||
if (text === '/gleave') {
|
||||
@@ -1943,6 +2282,14 @@ window.addEventListener('beforeinstallprompt', e => {
|
||||
addSys('Tip: install as app for fullscreen + notifications. Type /install');
|
||||
});
|
||||
|
||||
// Request notification permission on first user interaction
|
||||
if ('Notification' in window && Notification.permission === 'default') {
|
||||
document.addEventListener('click', function reqNotif() {
|
||||
Notification.requestPermission();
|
||||
document.removeEventListener('click', reqNotif);
|
||||
}, { once: true });
|
||||
}
|
||||
|
||||
// Initialize WASM and auto-load
|
||||
(async function() {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user