diff --git a/android/app/src/main/assets/public/icons.js b/android/app/src/main/assets/public/icons.js index 5f006339..497cb118 100644 --- a/android/app/src/main/assets/public/icons.js +++ b/android/app/src/main/assets/public/icons.js @@ -55,6 +55,7 @@ const ICONS = { video: (props) => ICONS._feather(``, props), file: (props) => ICONS._feather(``, props), mic: (props) => ICONS._feather(``, props), + headphones: (props) => ICONS._feather(``, props), speaker: (props) => ICONS._create(``, { viewBox: "0 0 24 24", width: "1.4rem", height: "1.4rem", fill: "var(--text-color)", stroke: "none" }, props), home: (props) => ICONS._create(``, { viewBox: "-1 -1 18 18", width: "1.5rem", height: "1.5rem", fill: "none", stroke: "none" }, props), diff --git a/android/app/src/main/assets/public/main.js b/android/app/src/main/assets/public/main.js index fa231a06..dded8edb 100644 --- a/android/app/src/main/assets/public/main.js +++ b/android/app/src/main/assets/public/main.js @@ -2674,6 +2674,8 @@ async function renderDms(res) { let isRead = dmData.string3 === "true"; let nameStyle = isRead ? "opacity: 0.8;" : "opacity: 1;"; + seedCallStateFromList(dmIdRaw, dmData.call === true); + let safeTargetUsername = encodeURIComponent(targetUsername).replace(/'/g, "%27"); let unreadBadge = `
`; @@ -2686,6 +2688,7 @@ async function renderDms(res) { ${escapeHtml(targetUsername)} (${escapeHtml(targetId.split(';')[0])}) ${unreadBadge} + ${getCallIndicatorHtml()} `; count++; @@ -2694,6 +2697,7 @@ async function renderDms(res) { if (count > 0) { container.innerHTML = html; container.classList.remove("empty"); + updateVoicePresenceUI(); } else { container.innerHTML = `

desc.no.dms

`; container.classList.add("empty"); @@ -2731,6 +2735,8 @@ async function renderGroups(res) { let isRead = groupData.string3 === "true"; let nameStyle = isRead ? "opacity: 0.8;" : "opacity: 1;"; + seedCallStateFromList(groupId, groupData.call === true); + let infoRes = await fetchEncrypted("group/info", groupId); if (infoRes && infoRes.startsWith("{")) { let info = JSON.parse(infoRes); @@ -2750,6 +2756,7 @@ async function renderGroups(res) { ${escapeHtmlText(groupId.replace(';', ':'))} ${unreadBadge} + ${getCallIndicatorHtml()} `; count++; @@ -2762,6 +2769,7 @@ async function renderGroups(res) { if (count > 0) { container.innerHTML = html; container.classList.remove("empty"); + updateVoicePresenceUI(); } else { container.innerHTML = `

desc.no.groups

`; container.classList.add("empty"); @@ -2813,6 +2821,101 @@ function escapeHtmlText(value) { return escapeHtml(String(value ?? "")); } +// --- Wskaźniki aktywnej rozmowy głosowej (zielone słuchawki) --- + +function getCallIndicatorHtml() { + return `
${ICONS.headphones({ width: "1rem", height: "1rem", stroke: "var(--big-green)" })}
`; +} + +function roomHasActiveCall(roomId) { + if (!roomId || !window.activeCallsByRoom) return false; + //Semantyka mapy: klucz ISTNIEJE = rozmowa trwa. Pusta tablica oznacza + //"aktywna, ale skład jeszcze nieznany" (seed z listy DM/grup). + return Array.isArray(window.activeCallsByRoom[sanitizeId(roomId)]); +} + +//Zapisuje flagę "trwa rozmowa" z listy DM/grup (nie nadpisuje znanej listy +//uczestników z call_update - ta jest dokładniejsza). +function ensureActiveCallsMap() { + //Obiekt bez prototypu - klucze pochodzą z danych sieciowych (roomId), a + //zapis np. pod "__proto__" na zwykłym obiekcie zanieczyściłby prototyp + if (!window.activeCallsByRoom) window.activeCallsByRoom = Object.create(null); + return window.activeCallsByRoom; +} + +function seedCallStateFromList(roomId, hasCall) { + if (!hasCall) return; + let map = ensureActiveCallsMap(); + let key = sanitizeId(roomId); + if (!Array.isArray(map[key])) { + map[key] = []; + } +} + +function toggleCallIndicator(rootEl, show) { + if (!rootEl) return; + let ind = rootEl.querySelector(".call-indicator"); + if (ind) ind.classList.toggle("show", !!show); +} + +//Odświeża wskaźniki rozmów: listy DM/grup, przyciski call w top-barach +//oraz wiersze członków w panelu details. +//roomIds = null -> pełna synchronizacja (po renderze list), +//roomIds = [id] -> tylko wskazane pokoje (ścieżka gorąca: call_update), +// żeby przy dużych listach nie skanować całego DOM. +function updateVoicePresenceUI(roomIds = null) { + let targets = Array.isArray(roomIds) ? roomIds.map(r => sanitizeId(r)) : null; + + [["dms-list", "dm-btn-"], ["groups-list", "group-btn-"]].forEach(([listId, prefix]) => { + let container = document.getElementById(listId); + if (!container) return; + container.querySelectorAll(".room-entry").forEach(btn => { + if (!btn.id.startsWith(prefix)) return; + let roomId = btn.id.substring(prefix.length); + if (targets && !targets.includes(roomId)) return; + toggleCallIndicator(btn, roomHasActiveCall(roomId)); + }); + }); + + let currentKey = window.currentDmId ? sanitizeId(window.currentDmId) : null; + let currentAffected = targets === null || (currentKey !== null && targets.includes(currentKey)); + if (!currentAffected) return; + + let inCurrent = roomHasActiveCall(window.currentDmId); + document.querySelectorAll(".room-action-call svg").forEach(svg => { + svg.style.fill = inCurrent ? "var(--big-green)" : ""; + }); + + if (currentKey) { + let users = (window.activeCallsByRoom && window.activeCallsByRoom[currentKey]) || []; + let detailsBar = document.getElementById("roomdetailsbar"); + if (detailsBar) { + detailsBar.querySelectorAll(".room-entry[data-member-id]").forEach(btn => { + toggleCallIndicator(btn, users.includes(btn.getAttribute("data-member-id"))); + }); + } + } +} + +//Pobiera aktualny skład rozmowy dla otwartego pokoju (snapshot po otwarciu - +//call_update przychodzi dopiero przy join/leave). +async function refreshRoomCallState() { + try { + if (!window.currentDmId || !window.currentRoomType) return; + let res = await fetchEncrypted("room/voice/state", JSON.stringify({ + string1: window.currentRoomType, + string2: window.currentDmId + })); + if (!res || !res.startsWith("[") || !window.currentDmId) return; + let participants = JSON.parse(res).map(u => String(u).split(";")[0]); + let map = ensureActiveCallsMap(); + let key = sanitizeId(window.currentDmId); + if (participants.length > 0) map[key] = participants; + else delete map[key]; + updateVoicePresenceUI([key]); + } catch (e) {} +} + async function generateDmMembersHtml(targetId, targetUsername) { let myIdStr = id + ";" + host; let myUsername = await fetchAsync(`${url}/idtoname?id=${myIdStr}`); @@ -2831,19 +2934,21 @@ async function generateDmMembersHtml(targetId, targetUsername) { - - `; @@ -2972,14 +3077,16 @@ async function generateGroupMembersHtml(groupId, groupName) { let memberDisplayName = escapeHtml(memberUsername.split(':')[0]); let safeMemberId = sanitizeId(memberId); let safeGroupId = sanitizeId(groupId); - + let memberNumericId = sanitizeId(String(memberId).split(';')[0]); + html += ` - `; } @@ -3067,6 +3174,9 @@ async function openDm(dmId, username, targetId, type = 'dms') { } await switchDetailsContent("title.details", membersHtml); + updateVoicePresenceUI(); + refreshRoomCallState(); + let msgContainer = document.getElementById("chat-messages"); if (msgContainer) { msgContainer.innerHTML = `
desc.messages.loading
`; diff --git a/android/app/src/main/assets/public/style.css b/android/app/src/main/assets/public/style.css index 02cae905..95916d8c 100644 --- a/android/app/src/main/assets/public/style.css +++ b/android/app/src/main/assets/public/style.css @@ -1201,6 +1201,27 @@ input:checked + .toggle-slider:before { 100% { transform: scale(1); } } +/* Zielone słuchawki - wskaźnik trwającej rozmowy głosowej. + Bez prawego marginesu: prawa krawędź ikony jest wtedy dokładnie na paddingu + wiersza (0.6rem) - symetrycznie do pfp po lewej stronie. */ +.call-indicator { + display: flex; + align-items: center; + justify-content: center; + width: 1rem; + height: 1rem; + flex-shrink: 0; + transition: transform 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275); + transform: scale(0); +} +.call-indicator.show { + transform: scale(1); +} +.call-indicator svg { + width: 100%; + height: 100%; +} + /* Custom Select */ .custom-select { position: relative; diff --git a/android/app/src/main/assets/public/voice.js b/android/app/src/main/assets/public/voice.js index 3235b5ed..9ca9bbd3 100644 --- a/android/app/src/main/assets/public/voice.js +++ b/android/app/src/main/assets/public/voice.js @@ -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) {
- ${processBlah("title.incoming.call")} · ${processBlah(`:desc.call.in.chat:${voiceActiveUsers.length}`)} + ${processBlah("title.incoming.call")} · ${processBlah(`:desc.call.in.chat:${participantCount}`)}
-
${unreadBadge} + ${getCallIndicatorHtml()} `; count++; @@ -2694,6 +2697,7 @@ async function renderDms(res) { if (count > 0) { container.innerHTML = html; container.classList.remove("empty"); + updateVoicePresenceUI(); } else { container.innerHTML = `

desc.no.dms

`; container.classList.add("empty"); @@ -2731,6 +2735,8 @@ async function renderGroups(res) { let isRead = groupData.string3 === "true"; let nameStyle = isRead ? "opacity: 0.8;" : "opacity: 1;"; + seedCallStateFromList(groupId, groupData.call === true); + let infoRes = await fetchEncrypted("group/info", groupId); if (infoRes && infoRes.startsWith("{")) { let info = JSON.parse(infoRes); @@ -2750,6 +2756,7 @@ async function renderGroups(res) { ${escapeHtmlText(groupId.replace(';', ':'))} ${unreadBadge} + ${getCallIndicatorHtml()} `; count++; @@ -2762,6 +2769,7 @@ async function renderGroups(res) { if (count > 0) { container.innerHTML = html; container.classList.remove("empty"); + updateVoicePresenceUI(); } else { container.innerHTML = `

desc.no.groups

`; container.classList.add("empty"); @@ -2813,6 +2821,101 @@ function escapeHtmlText(value) { return escapeHtml(String(value ?? "")); } +// --- Wskaźniki aktywnej rozmowy głosowej (zielone słuchawki) --- + +function getCallIndicatorHtml() { + return `
${ICONS.headphones({ width: "1rem", height: "1rem", stroke: "var(--big-green)" })}
`; +} + +function roomHasActiveCall(roomId) { + if (!roomId || !window.activeCallsByRoom) return false; + //Semantyka mapy: klucz ISTNIEJE = rozmowa trwa. Pusta tablica oznacza + //"aktywna, ale skład jeszcze nieznany" (seed z listy DM/grup). + return Array.isArray(window.activeCallsByRoom[sanitizeId(roomId)]); +} + +//Zapisuje flagę "trwa rozmowa" z listy DM/grup (nie nadpisuje znanej listy +//uczestników z call_update - ta jest dokładniejsza). +function ensureActiveCallsMap() { + //Obiekt bez prototypu - klucze pochodzą z danych sieciowych (roomId), a + //zapis np. pod "__proto__" na zwykłym obiekcie zanieczyściłby prototyp + if (!window.activeCallsByRoom) window.activeCallsByRoom = Object.create(null); + return window.activeCallsByRoom; +} + +function seedCallStateFromList(roomId, hasCall) { + if (!hasCall) return; + let map = ensureActiveCallsMap(); + let key = sanitizeId(roomId); + if (!Array.isArray(map[key])) { + map[key] = []; + } +} + +function toggleCallIndicator(rootEl, show) { + if (!rootEl) return; + let ind = rootEl.querySelector(".call-indicator"); + if (ind) ind.classList.toggle("show", !!show); +} + +//Odświeża wskaźniki rozmów: listy DM/grup, przyciski call w top-barach +//oraz wiersze członków w panelu details. +//roomIds = null -> pełna synchronizacja (po renderze list), +//roomIds = [id] -> tylko wskazane pokoje (ścieżka gorąca: call_update), +// żeby przy dużych listach nie skanować całego DOM. +function updateVoicePresenceUI(roomIds = null) { + let targets = Array.isArray(roomIds) ? roomIds.map(r => sanitizeId(r)) : null; + + [["dms-list", "dm-btn-"], ["groups-list", "group-btn-"]].forEach(([listId, prefix]) => { + let container = document.getElementById(listId); + if (!container) return; + container.querySelectorAll(".room-entry").forEach(btn => { + if (!btn.id.startsWith(prefix)) return; + let roomId = btn.id.substring(prefix.length); + if (targets && !targets.includes(roomId)) return; + toggleCallIndicator(btn, roomHasActiveCall(roomId)); + }); + }); + + let currentKey = window.currentDmId ? sanitizeId(window.currentDmId) : null; + let currentAffected = targets === null || (currentKey !== null && targets.includes(currentKey)); + if (!currentAffected) return; + + let inCurrent = roomHasActiveCall(window.currentDmId); + document.querySelectorAll(".room-action-call svg").forEach(svg => { + svg.style.fill = inCurrent ? "var(--big-green)" : ""; + }); + + if (currentKey) { + let users = (window.activeCallsByRoom && window.activeCallsByRoom[currentKey]) || []; + let detailsBar = document.getElementById("roomdetailsbar"); + if (detailsBar) { + detailsBar.querySelectorAll(".room-entry[data-member-id]").forEach(btn => { + toggleCallIndicator(btn, users.includes(btn.getAttribute("data-member-id"))); + }); + } + } +} + +//Pobiera aktualny skład rozmowy dla otwartego pokoju (snapshot po otwarciu - +//call_update przychodzi dopiero przy join/leave). +async function refreshRoomCallState() { + try { + if (!window.currentDmId || !window.currentRoomType) return; + let res = await fetchEncrypted("room/voice/state", JSON.stringify({ + string1: window.currentRoomType, + string2: window.currentDmId + })); + if (!res || !res.startsWith("[") || !window.currentDmId) return; + let participants = JSON.parse(res).map(u => String(u).split(";")[0]); + let map = ensureActiveCallsMap(); + let key = sanitizeId(window.currentDmId); + if (participants.length > 0) map[key] = participants; + else delete map[key]; + updateVoicePresenceUI([key]); + } catch (e) {} +} + async function generateDmMembersHtml(targetId, targetUsername) { let myIdStr = id + ";" + host; let myUsername = await fetchAsync(`${url}/idtoname?id=${myIdStr}`); @@ -2831,19 +2934,21 @@ async function generateDmMembersHtml(targetId, targetUsername) { - - `; @@ -2972,14 +3077,16 @@ async function generateGroupMembersHtml(groupId, groupName) { let memberDisplayName = escapeHtml(memberUsername.split(':')[0]); let safeMemberId = sanitizeId(memberId); let safeGroupId = sanitizeId(groupId); - + let memberNumericId = sanitizeId(String(memberId).split(';')[0]); + html += ` - `; } @@ -3067,6 +3174,9 @@ async function openDm(dmId, username, targetId, type = 'dms') { } await switchDetailsContent("title.details", membersHtml); + updateVoicePresenceUI(); + refreshRoomCallState(); + let msgContainer = document.getElementById("chat-messages"); if (msgContainer) { msgContainer.innerHTML = `
desc.messages.loading
`; diff --git a/webroot/style.css b/webroot/style.css index 02cae905..95916d8c 100644 --- a/webroot/style.css +++ b/webroot/style.css @@ -1201,6 +1201,27 @@ input:checked + .toggle-slider:before { 100% { transform: scale(1); } } +/* Zielone słuchawki - wskaźnik trwającej rozmowy głosowej. + Bez prawego marginesu: prawa krawędź ikony jest wtedy dokładnie na paddingu + wiersza (0.6rem) - symetrycznie do pfp po lewej stronie. */ +.call-indicator { + display: flex; + align-items: center; + justify-content: center; + width: 1rem; + height: 1rem; + flex-shrink: 0; + transition: transform 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275); + transform: scale(0); +} +.call-indicator.show { + transform: scale(1); +} +.call-indicator svg { + width: 100%; + height: 100%; +} + /* Custom Select */ .custom-select { position: relative; diff --git a/webroot/voice.js b/webroot/voice.js index 3235b5ed..9ca9bbd3 100644 --- a/webroot/voice.js +++ b/webroot/voice.js @@ -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) {
- ${processBlah("title.incoming.call")} · ${processBlah(`:desc.call.in.chat:${voiceActiveUsers.length}`)} + ${processBlah("title.incoming.call")} · ${processBlah(`:desc.call.in.chat:${participantCount}`)}
-