voice chat indicators

This commit is contained in:
olcxja 2026-08-25 22:46:49 +02:00
commit b12a75cfc4
8 changed files with 464 additions and 42 deletions

View file

@ -6,6 +6,16 @@ let voiceActiveUsers = [];
let voiceAesKeyBytes = null; // grupowy klucz rozmowy (root) - dystrybuowany hybrydą PQ
let voiceUiContainer = null;
//Pokój, dla którego aktualnie wisi banner rozmowy przychodzącej. Banner jest
//GLOBALNY - działa niezależnie od tego, jaki czat jest otwarty (app-WebSocket
//jest aktywny cały czas i dostaje call_update dla wszystkich naszych pokoi).
let incomingCallRoomId = null;
//Pokoje opuszczone przed chwilą: broadcast call_update po naszym leave przychodzi
//asynchronicznie - bez tej blokady zaraz po wyjściu z rozmowy wyskoczyłby nam
//banner "ktoś dzwoni" z pokoju, który sami właśnie opuściliśmy.
//Bez prototypu - kluczami są roomId z sieci (ochrona przed prototype pollution).
const voiceRecentlyLeftRooms = Object.create(null);
// === Gwarantowana unikalność IV (AES-GCM): ===
// 1) każdy nadawca szyfruje WŁASNYM podkluczem HKDF(root, "larpix:voice:v1:"+senderId)
// -> kolizja między nadawcami niemożliwa z konstrukcji,
@ -748,8 +758,14 @@ async function initiateVoiceCall(roomId = null) {
showBlahNotification("error:already.in.call");
return;
}
currentVoiceCallType = window.currentRoomType === "groups" ? "groups" : "dms";
currentVoiceCallId = roomId || window.currentDmId;
if (!currentVoiceCallId) return;
//Typ pokoju MUSI wynikać z samego ID - przy joinie z bannera otwarty czat
//nie ma nic wspólnego z pokojem, do którego się dzwoni. Wcześniejszy kod
//brał typ z aktualnie otwartego pokoju, więc serwer sprawdzał członkostwo
//w złym typie pokoju i zwracał "error:forbidden".
//Formaty: DM = "idA;domA_idB;domB" (zawiera '_'), grupa = "id;domena".
currentVoiceCallType = String(currentVoiceCallId).includes("_") ? "dms" : "groups";
voiceJoinInFlight = true;
try {
@ -1153,14 +1169,51 @@ async function startVoiceSession(serverUrl, ticket) {
async function handleCallUpdate(data) {
let parts = data.split(":");
let roomId = parts[1];
let incomingState = [];
if (parts.slice(2).join(":") !== "") {
try { incomingState = JSON.parse(parts.slice(2).join(":")).map(u => String(u)); } catch (e) {}
}
if (typeof ensureActiveCallsMap !== "function") return;
let callMap = ensureActiveCallsMap();
let roomKey = sanitizeId(roomId);
let prev = callMap[roomKey];
//Brak klucza = brak rozmowy; pusta tablica = aktywna, skład nieznany (seed)
if (incomingState.length > 0) callMap[roomKey] = incomingState;
else delete callMap[roomKey];
//Optymalizacja: identyczny stan nie wymaga żadnej pracy po stronie UI
//(serwer/duplikaty mogą wysłać to samo wielokrotnie przy dużym ruchu).
//Porównujemy też samą AKTYWNOŚĆ - przejście "seed[] -> koniec rozmowy"
//(usunięcie klucza) musi odświeżyć UI mimo identycznej pustej listy.
let activeBefore = Array.isArray(prev);
let activeAfter = incomingState.length > 0;
let changed = activeBefore !== activeAfter ||
(activeAfter && !(prev.length === incomingState.length &&
prev.every((u, i) => u === incomingState[i])));
//Targetowana aktualizacja - przerysowujemy wskaźniki TYLKO tego pokoju
if (changed && typeof updateVoicePresenceUI === "function") updateVoicePresenceUI([roomKey]);
//--- GLOBALNA obsługa rozmowy przychodzącej. Działa dla KAŻDEGO pokoju, w
//którym jesteśmy członkiem, niezależnie od otwartego czatu.
let myIdStr = String(window.id);
let amInThisCall = incomingState.includes(myIdStr);
let leftAgo = Date.now() - (voiceRecentlyLeftRooms[roomId] || 0);
if (amInThisCall || incomingState.length === 0) {
if (incomingCallRoomId === roomId) dismissIncomingCall();
} else if (leftAgo > 3000 && !voiceJoinInFlight) {
if (incomingCallRoomId === roomId) {
//Banner już wisi dla tego pokoju - odśwież tylko licznik uczestników
updateIncomingCallSubtitle(incomingState.length);
} else if (!currentVoiceWs) {
showIncomingCallUI(roomId, incomingState.length);
}
}
//Ignore updates for rooms we have nothing to do with - they would otherwise
//overwrite the participant list of an ongoing call and wipe its tiles.
if (roomId !== currentVoiceCallId && roomId !== window.currentDmId) return;
let state = [];
if(parts.slice(2).join(":") !== "") {
let stateRaw = JSON.parse(parts.slice(2).join(":"));
state = stateRaw.map(u => String(u)).filter(u => !deadPeers.has(u));
}
let state = incomingState.filter(u => !deadPeers.has(u));
voiceActiveUsers = state;
if (currentVoiceCallId === roomId) {
@ -1201,13 +1254,6 @@ async function handleCallUpdate(data) {
updateVoiceUIUsers();
}
}
} else if (state.length > 0 && !state.includes(String(window.id))) {
// Someone is in a call, and we are not. Show incoming call banner if it's the current room.
if (window.currentDmId === roomId) {
showIncomingCallUI(roomId);
}
} else if (state.length === 0) {
dismissIncomingCall();
}
}
@ -1254,13 +1300,28 @@ async function resolveCallerInfo(roomId) {
return { name: (name ? name.split(':')[0] : null) || processBlah("title.unknown"), pfpUrl };
}
function showIncomingCallUI(roomId) {
if (document.getElementById("incoming-call-popup") || currentVoiceWs || voiceJoinInFlight) return;
function showIncomingCallUI(roomId, participantCount = null) {
if (currentVoiceWs || voiceJoinInFlight) return;
let cachedName = window.dmUsernameCache ? window.dmUsernameCache[roomId] : null;
if (cachedName && cachedName.includes(':')) cachedName = cachedName.split(':')[0];
let cachedPfp = window.dmPfpCache ? window.dmPfpCache[roomId] : null;
//Zamiana bannera na inny pokój - stary usuwamy natychmiast (inaczej
//guard po id zablokowałby pokazanie nowego do czasu końca animacji)
let existing = document.getElementById("incoming-call-popup");
if (existing) existing.remove();
incomingCallRoomId = roomId;
if (participantCount === null) {
let known = window.activeCallsByRoom ? window.activeCallsByRoom[sanitizeId(roomId)] : null;
participantCount = Array.isArray(known) && known.length > 0 ? known.length : voiceActiveUsers.length;
}
//roomId trafia do inline onclick - sanityzujemy, żeby nic nie mogło
//wyjść poza atrybut/string JS (defense in depth; ID z naszego serwera)
let safeRoomId = sanitizeId(roomId);
let incomingUi = document.createElement("div");
incomingUi.id = "incoming-call-popup";
// Remote-controlled values (username/avatar) are NEVER interpolated into
@ -1269,10 +1330,10 @@ function showIncomingCallUI(roomId) {
<img class="incoming-call-avatar" alt="">
<div class="incoming-call-info">
<span class="incoming-call-name"></span>
<span class="incoming-call-subtitle">${processBlah("title.incoming.call")} · ${processBlah(`:desc.call.in.chat:${voiceActiveUsers.length}`)}</span>
<span class="incoming-call-subtitle">${processBlah("title.incoming.call")} · ${processBlah(`:desc.call.in.chat:${participantCount}`)}</span>
</div>
<div class="incoming-call-actions">
<button class="voice-call-btn accept" onclick="joinIncomingCall('${roomId}')">
<button class="voice-call-btn accept" onclick="joinIncomingCall('${safeRoomId}')">
${ICONS.call({width: "1rem", height: "1rem"})} ${processBlah("action.call.join")}
</button>
<button class="voice-call-btn decline" onclick="dismissIncomingCall()">
@ -1296,7 +1357,14 @@ function showIncomingCallUI(roomId) {
});
}
//Aktualizuje tylko licznik uczestników w wiszącym bannerze (bez re-renderu)
function updateIncomingCallSubtitle(participantCount) {
let el = document.querySelector("#incoming-call-popup .incoming-call-subtitle");
if (el) el.textContent = `${processBlah("title.incoming.call")} · ${processBlah(`:desc.call.in.chat:${participantCount}`)}`;
}
function dismissIncomingCall() {
incomingCallRoomId = null;
let el = document.getElementById("incoming-call-popup");
if (!el || el.classList.contains("hide")) return;
el.classList.remove("show");
@ -2382,6 +2450,17 @@ function endVoiceSession() {
setVoiceStatus("disconnected");
hideVoiceDeviceSettings();
//Oznacz pokój jako "właśnie opuszczony" - broadcast call_update po naszym
//leave wróci do nas z opóźnieniem i bez tego globalna obsługa bannera
//uznałaby, że ktoś dzwoni z pokoju, który sami właśnie opuściliśmy.
if (currentVoiceCallId) {
let now = Date.now();
voiceRecentlyLeftRooms[currentVoiceCallId] = now;
for (let k in voiceRecentlyLeftRooms) {
if (now - voiceRecentlyLeftRooms[k] > 30000) delete voiceRecentlyLeftRooms[k];
}
}
const nativeRouting = getNativeVoiceRouting();
if (nativeRouting && nativeRouting.stopCall) {
try { nativeRouting.stopCall(); } catch (e) {}