diff --git a/android/app/build.gradle b/android/app/build.gradle index 70f0a50e..11a28535 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -7,8 +7,8 @@ android { applicationId "olcxja.miarven" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 7 - versionName "0.2.1" + versionCode 8 + versionName "0.2.2" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. 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..a5d0ca32 100644 --- a/android/app/src/main/assets/public/main.js +++ b/android/app/src/main/assets/public/main.js @@ -1149,12 +1149,20 @@ function createAvatarSvg(name, size = 512) { } async function getAvatarUrl(id, username) { + //S22: helper robi fetch istnienia pliku - bez cache każde odświeżenie listy + //generowało 1-2 żądania NA wiersz (główny składnik N+1). + //Cache'owane TYLKO pozytywne potwierdzenia - nieudane lookupy (chwilowy + //network itp.) nie mogą przyklejać fallback-SVG na minutę + if (!window.pfpExistenceCache) window.pfpExistenceCache = {}; + const cached = window.pfpExistenceCache[id]; + if (cached && Date.now() - cached.ts < 60000) return cached.url; try { let pfpUrl = `${url}/user/storage/public/getentry?id=${id}&e=larp.profile.pfp`; if (id === window.id && window.profileUpdateTimestamp) pfpUrl += `&t=${window.profileUpdateTimestamp}`; if ((await fetchAsync(pfpUrl)) == "") { throw Error(); } + window.pfpExistenceCache[id] = { url: pfpUrl, ts: Date.now() }; return pfpUrl; } catch (e) { return createAvatarSvg(username); @@ -1162,11 +1170,15 @@ async function getAvatarUrl(id, username) { } async function getGroupAvatarUrl(groupId, groupName) { + if (!window.groupPfpExistenceCache) window.groupPfpExistenceCache = {}; + const cached = window.groupPfpExistenceCache[groupId]; + if (cached && Date.now() - cached.ts < 60000) return cached.url; try { let pfpUrl = `${url}/group/storage/public/getentry?id=${groupId}&e=larp.profile.pfp`; if ((await fetchAsync(pfpUrl)) == "") { throw Error(); } + window.groupPfpExistenceCache[groupId] = { url: pfpUrl, ts: Date.now() }; return pfpUrl; } catch (e) { return createAvatarSvg(groupName); @@ -1557,7 +1569,7 @@ async function switchRoomsBar(title, content) { await delay(200); - roomsTopBarTransition.innerHTML = processBlah(title); + roomsTopBarTransition.innerHTML = escapeHtmlText(processBlah(title)); let parser = new DOMParser(); @@ -1636,10 +1648,10 @@ async function switchRoomContent(title, content, showRoomBar, icon = "", skipMob let clickableContent = showRoomBar ? `` : ` - ${icon ? icon + '' : ''}${processBlah(title)} + ${icon ? icon + '' : ''}${escapeHtmlText(processBlah(title))} `; roomsTopBarTransition.innerHTML = `${backBtnHtml}${clickableContent}
${pinCallBtns}`; @@ -1710,7 +1722,7 @@ async function switchDetailsContent(title, content, showBack = false, backAction pinCallBtns = `${groupMenuBtnHtml}${pinCallBtns}`; } let backBtn = showBack ? `` : ""; - roomsTopBarTransition.innerHTML = `${backBtn}${processBlah(title)}
${pinCallBtns}`; + roomsTopBarTransition.innerHTML = `${backBtn}${escapeHtmlText(processBlah(title))}
${pinCallBtns}`; let parser = new DOMParser(); let doc = parser.parseFromString(content, "text/html"); @@ -2033,7 +2045,9 @@ async function openProfile(accountId) { }); let roleRes = await fetchEncrypted("room/role", payload); if (roleRes && !roleRes.startsWith("error:")) { - let mappedRole = "role." + roleRes.replace(" ", "_"); + //roleRes trafia do innerHTML - whitelist znaków zamiast surowej + //podstawienia (audyt W6: serwer-controlled string w szablonie) + let mappedRole = "role." + String(roleRes).replace(/[^A-Za-z0-9_.]/g, "_"); roleHtml = `
${mappedRole}
`; } } @@ -2275,7 +2289,7 @@ async function renderInvites(res, type) { } let entry = invitesEntry - .replaceAll("{username}", groupName) + .replaceAll("{username}", escapeHtmlText(groupName)) .replaceAll("{pfp}", pfp) .replaceAll("{desc}", `${desc}`) .replaceAll("{actions}", actions) @@ -2644,7 +2658,13 @@ function handleMobileSwipe() { } } +//S22: generacje renderów - przy nakładających się refreshDms/refreshGroups +//(ws events, fallbacki) starszy wynik nie może nadpisać nowszego innerHTML-em +let dmListRenderGen = 0; +let groupListRenderGen = 0; + async function renderDms(res) { + const gen = ++dmListRenderGen; let container = document.getElementById("dms-list"); if (!container) return; @@ -2665,8 +2685,13 @@ async function renderDms(res) { let myId = `${id};${host}`; let targetId = sanitizeId(parts[0] === myId ? parts[1] : parts[0]); - let targetUsername = await fetchAsync(`${url}/idtoname?id=${targetId}`); - if (!targetUsername || targetUsername.trim() === "" || targetUsername.startsWith("error:")) continue; + //S22: cache nazw - idtoname było wołane od nowa przy każdym renderze + let targetUsername = window.dmUsernameCache ? window.dmUsernameCache[targetId] : null; + if (!targetUsername) { + targetUsername = await fetchAsync(`${url}/idtoname?id=${targetId}`); + if (!targetUsername || targetUsername.trim() === "" || targetUsername.startsWith("error:")) continue; + if (window.dmUsernameCache) window.dmUsernameCache[targetId] = targetUsername; + } let pfp = await getAvatarUrl(targetId, targetUsername); let displayName = escapeHtml(targetUsername.split(':')[0]); @@ -2674,6 +2699,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,15 +2713,19 @@ async function renderDms(res) { ${escapeHtml(targetUsername)} (${escapeHtml(targetId.split(';')[0])}) ${unreadBadge} + ${getCallIndicatorHtml()} `; count++; } + if (gen !== dmListRenderGen) return; //nowszy render przejął listę if (count > 0) { container.innerHTML = html; container.classList.remove("empty"); + updateVoicePresenceUI(); } else { + if (gen !== dmListRenderGen) return; container.innerHTML = `

desc.no.dms

`; container.classList.add("empty"); let blahTags = container.getElementsByTagName("blah"); @@ -2712,7 +2743,17 @@ async function renderDms(res) { } } +//S22: TTL-cache nazw grup (60 s) - nazwy rzadko się zmieniają, +//a group/info było najdroższym elementem renderu listy grup +function getGroupNameFromCache(groupId) { + if (!window.groupNameCache) return null; + let entry = window.groupNameCache[sanitizeId(groupId)]; + if (!entry || Date.now() - entry.ts > 60000) return null; + return entry.name; +} + async function renderGroups(res) { + const gen = ++groupListRenderGen; let container = document.getElementById("groups-list"); if (!container) return; @@ -2731,11 +2772,26 @@ async function renderGroups(res) { let isRead = groupData.string3 === "true"; let nameStyle = isRead ? "opacity: 0.8;" : "opacity: 1;"; - let infoRes = await fetchEncrypted("group/info", groupId); - if (infoRes && infoRes.startsWith("{")) { - let info = JSON.parse(infoRes); - let groupName = info.string2; + seedCallStateFromList(groupId, groupData.call === true); + //S22: TTL-cache nazw grup - group/info było wołane per grupa per render + let groupName = getGroupNameFromCache(groupId); + if (groupName === null) { + let infoRes = await fetchEncrypted("group/info", groupId); + if (infoRes && infoRes.startsWith("{")) { + groupName = JSON.parse(infoRes).string2; + if (!window.groupNameCache) window.groupNameCache = {}; + window.groupNameCache[sanitizeId(groupId)] = { name: groupName, ts: Date.now() }; + } else { + if (infoRes === "error:group.not.found") { + //grupa została usunięta - wyczyść nieaktualny wpis z listy + removeRoomFromCachedList(groupId, 'groups'); + } + continue; + } + } + + { let safeGroupName = encodeURIComponent(groupName).replace(/'/g, "%27"); let pfp = await getGroupAvatarUrl(groupId, groupName); @@ -2750,19 +2806,20 @@ async function renderGroups(res) { ${escapeHtmlText(groupId.replace(';', ':'))} ${unreadBadge} + ${getCallIndicatorHtml()} `; count++; - } else if (infoRes === "error:group.not.found") { - //grupa została usunięta - wyczyść nieaktualny wpis z listy - removeRoomFromCachedList(groupId, 'groups'); } } + if (gen !== groupListRenderGen) return; //nowszy render przejął listę if (count > 0) { container.innerHTML = html; container.classList.remove("empty"); + updateVoicePresenceUI(); } else { + if (gen !== groupListRenderGen) return; container.innerHTML = `

desc.no.groups

`; container.classList.add("empty"); let blahTags = container.getElementsByTagName("blah"); @@ -2813,6 +2870,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 +2983,21 @@ async function generateDmMembersHtml(targetId, targetUsername) { - - `; @@ -2972,14 +3126,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 +3223,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/version.js b/android/app/src/main/assets/public/version.js index cfb04077..c9fc05d6 100644 --- a/android/app/src/main/assets/public/version.js +++ b/android/app/src/main/assets/public/version.js @@ -1 +1 @@ -const CLIENT_VERSION = "0.2.1"; +const CLIENT_VERSION = "0.2.2"; diff --git a/android/app/src/main/assets/public/voice.js b/android/app/src/main/assets/public/voice.js index 3235b5ed..ea07e4c2 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, @@ -127,6 +137,9 @@ const VOICE_MAX_RECONNECTS = 2; let peerLastSeen = {}; let deadPeers = new Set(); let lastVoiceServerPacketAt = 0; +//Czy w bieżącym okresie socketu byli jacyś inni uczestnicy - baza dla +//resetu baseline'u ciszy w watchdogu (patrz komentarz przy użyciu) +let voiceHadRemotePeers = false; let voicePingTimer = null; let voiceRosterRetryTimer = null; let lastVoiceKeyRequestAt = 0; @@ -534,8 +547,10 @@ function setVoiceStatus(state) { updateVoicePing(); } else { if (voiceTimerInterval) { clearInterval(voiceTimerInterval); voiceTimerInterval = null; } - voiceConnectedAt = null; - if (state === "disconnected") { voicePingMs = null; updateVoicePing(); } + //Anchor czasu NIE jest kasowany przy "connecting" - reconnect w trakcie + //rozmowy nie może resetować licznika na 00:00; czyszczone tylko przy + //realnym rozłączeniu (disconnected z endVoiceSession) + if (state === "disconnected") { voiceConnectedAt = null; voicePingMs = null; updateVoicePing(); } if (textEl) textEl.innerText = processBlah(state === "disconnected" ? "status.voice.disconnected" : "status.voice.connecting"); } } @@ -595,7 +610,11 @@ setInterval(() => { } } // Ghost peers: still on the room roster but totally silent (pings count as - // life) - drop them locally until the server or the peer proves otherwise. + // life). Audyt S15: UKRYWANIE ich z UI tworzyło niewidzialnego + // podsłuchiwacza - voice-server dalej relayaudio do ich socketu, a u innych + // znikał z listy. Teraz peer zostaje WIDOCZNY (roster serwera jest źródłem + // prawdy); czyścimy jedynie jego kanały mediów - wrócą z pierwszym pakietem. + // deadPeers pełni tu rolę znacznika "media już wycięte", nie "ukryty". let ghostRemoved = false; for (const u of voiceActiveUsers) { if (u === String(window.id)) continue; @@ -607,19 +626,30 @@ setInterval(() => { } } if (ghostRemoved) { - voiceActiveUsers = voiceActiveUsers.filter(u => !deadPeers.has(u)); updateVoiceUIUsers(); } // Half-open socket watchdog + backgrounded-tab audio recovery. if (currentVoiceWs && currentVoiceWs.readyState === WebSocket.OPEN) { const others = voiceActiveUsers.filter(u => u !== String(window.id)).length; + // Baseline ciszy odświeżany przy przejściu 0->N uczestników: solo w + // rozmowie NIE MA żadnego ruchu przychodzącego, więc po pojawieniu się + // nowej osoby lastVoiceServerPacketAt bywa nieświeży z okresu solo. + // Bez tego resetu join drugiej osoby natychmiast spełniał warunek + // ">10s bez inbound" i rozłączał zdrowy socket (fałszywy alarm). + if (others > 0 && !voiceHadRemotePeers) lastVoiceServerPacketAt = now; + voiceHadRemotePeers = others > 0; // Alive peers ping every 2s regardless of mute, so total inbound // silence while others are present means the socket is dead. if (others > 0 && now - lastVoiceServerPacketAt > VOICE_SERVER_TIMEOUT_MS) { console.warn("Voice: no inbound traffic, forcing reconnect"); try { currentVoiceWs.close(); } catch (e) {} - } else if (audioContext && audioContext.state === 'suspended') { + } else if (audioContext && audioContext.state !== 'running') { + //Nie tylko "suspended" - iOS/Safari zna też "interrupted"; każdy + //stan != running przy żywej rozmowie wymaga próby wznowienia. + //Dokładamy też kickstart-na-geście, jeśli jeszcze nie jest uzbrojony + //(armVoiceContextKickstart sam jest idempotentny) audioContext.resume().catch(() => {}); + armVoiceContextKickstart(); } // Safety net for the call-key handshake: if others are in the call but // we never received the root key (raced/lost signal), ask for it @@ -743,13 +773,52 @@ class TaskQueue { let voiceJoinInFlight = false; +//Ograniczony log diagnostyczny toru głosu - pierwsze kilka wystąpień danego +//problemu trafia do konsoli, dalej cicho (brak spamu przy długich rozmowach) +let voiceErrorLogsLeft = 6; +function voiceLogError(where, err) { + if (voiceErrorLogsLeft > 0) { + voiceErrorLogsLeft--; + console.warn("Voice: error [" + where + "]", err); + } +} + +//Wznawia AudioContext przy pierwszym geście użytkownika (polityka autoplay +//może zablokować kontekst stworzony po async-owych krokach dołączania do +//rozmowy). Listenery są one-shot i zdejmują się nawzajem. +let voiceCtxKickstartHandler = null; +function armVoiceContextKickstart() { + if (!audioContext) return; + if (voiceCtxKickstartHandler) return; //już uzbrojone + voiceCtxKickstartHandler = () => { + audioContext.resume({}).then(() => { + if (audioContext.state === 'running') { + console.warn("Voice: AudioContext resumed after user gesture"); + document.removeEventListener('click', voiceCtxKickstartHandler); + document.removeEventListener('touchend', voiceCtxKickstartHandler); + document.removeEventListener('keydown', voiceCtxKickstartHandler); + voiceCtxKickstartHandler = null; + } + }).catch(() => {}); + }; + document.addEventListener('click', voiceCtxKickstartHandler); + document.addEventListener('touchend', voiceCtxKickstartHandler); + document.addEventListener('keydown', voiceCtxKickstartHandler); +} + async function initiateVoiceCall(roomId = null) { if (currentVoiceWs || voiceJoinInFlight) { 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 { @@ -759,6 +828,12 @@ async function initiateVoiceCall(roomId = null) { if (joined) { await startVoiceSession(joined.serverUrl, joined.ticket); + } else { + //S14: nieudany join nie może zostawiać ustawionego currentVoiceCallId/ + //Type - uzbrojony gate sygnalizacji pozwalał obcemu na wstrzyknięcie + //"klucza rozmowy", a zatruty klucz przechodziłby do następnej rozmowy + currentVoiceCallId = null; + currentVoiceCallType = null; } } finally { voiceJoinInFlight = false; @@ -868,12 +943,12 @@ async function startVoiceSession(serverUrl, ticket) { voicePingMs = Math.round(performance.now() - t0); updateVoicePing(); const rawUsers = usersRaw.map(u => String(u)); - // Peers we locally declared dead stay hidden until either - // they send anything again or the server drops them. + //Serwer jest źródłem prawdy o składzie - nie filtrujemy po + //deadPeers (audyt S15: ukryty peer nadal dostaje audio) for (const gone of [...deadPeers]) { if (!rawUsers.includes(gone)) deadPeers.delete(gone); } - const users = rawUsers.filter(u => !deadPeers.has(u)); + const users = rawUsers; let changed = (users.length !== voiceActiveUsers.length); for(let u of users) if(!voiceActiveUsers.includes(u)) changed = true; if(changed) { @@ -904,37 +979,58 @@ async function startVoiceSession(serverUrl, ticket) { if (voicePingTimer) clearInterval(voicePingTimer); voicePingTimer = setInterval(sendVoicePing, VOICE_PING_INTERVAL_MS); - // Reconnects reuse the live mic stream - only capture a new one - // when the previous is gone (full teardown or track ended). - if (!micStream || micStream.getTracks().some(t => t.readyState !== 'live')) { - micStream = await navigator.mediaDevices.getUserMedia(buildVoiceMicConstraints(voiceSettings.micId)); - if (micSourceNode) { try { micSourceNode.disconnect(); } catch (e) {} micSourceNode = null; } - } + //S16: getUserMedia/worklet/rezump AudioContext potrafią rzucić + //(brak uprawnień, zniknięte urządzenie) - bez obsługi zostawał + //"connected" zombie-call ze spalonym single-use ticketem i bez + //mikrofonu, bez powiadomienia i bez recovery + try { + // Reconnects reuse the live mic stream - only capture a new one + // when the previous is gone (full teardown or track ended). + if (!micStream || micStream.getTracks().some(t => t.readyState !== 'live')) { + micStream = await navigator.mediaDevices.getUserMedia(buildVoiceMicConstraints(voiceSettings.micId)); + if (micSourceNode) { try { micSourceNode.disconnect(); } catch (e) {} micSourceNode = null; } + } - const nativeRouting = getNativeVoiceRouting(); - if (nativeRouting && nativeRouting.startCall) { - try { await nativeRouting.startCall(); } catch (e) {} - } + const nativeRouting = getNativeVoiceRouting(); + if (nativeRouting && nativeRouting.startCall) { + try { await nativeRouting.startCall(); } catch (e) {} + } - if (!audioContext) audioContext = new (window.AudioContext || window.webkitAudioContext)({sampleRate: 48000}); - if (!isAudioWorkletLoaded) { - await audioContext.audioWorklet.addModule(URL.createObjectURL(new Blob([voiceWorkletCode], {type: 'application/javascript'}))); - senderNode = new AudioWorkletNode(audioContext, 'audio-sender'); - const forwardPcm = !!(getNativeVoiceRouting() && isMobileVoiceDevice()); - receiverNode = new AudioWorkletNode(audioContext, 'audio-receiver', {processorOptions: {forward: forwardPcm}}); - screenReceiverNode = new AudioWorkletNode(audioContext, 'audio-receiver'); - isAudioWorkletLoaded = true; + if (!audioContext) audioContext = new (window.AudioContext || window.webkitAudioContext)({sampleRate: 48000}); + if (!isAudioWorkletLoaded) { + await audioContext.audioWorklet.addModule(URL.createObjectURL(new Blob([voiceWorkletCode], {type: 'application/javascript'}))); + senderNode = new AudioWorkletNode(audioContext, 'audio-sender'); + const forwardPcm = !!(getNativeVoiceRouting() && isMobileVoiceDevice()); + receiverNode = new AudioWorkletNode(audioContext, 'audio-receiver', {processorOptions: {forward: forwardPcm}}); + screenReceiverNode = new AudioWorkletNode(audioContext, 'audio-receiver'); + isAudioWorkletLoaded = true; + } + if (audioContext.state !== 'running') { + try { await audioContext.resume(); } catch (e) {} + } + if (audioContext.state !== 'running') { + //Polityka autoplay / stan "interrupted": kontekst nie wstał + //samoaktualizacyjnie. Bez tego cisza w OBIE strony (mikrofon + //i głośnik żyją w tym samym kontekście), mimo że wskaźniki + //mówienia i screen share działają (nie potrzebują kontekstu). + console.warn("Voice: AudioContext not running after open (" + audioContext.state + ") - arming user-gesture kickstart"); + armVoiceContextKickstart(); + } + applyVoiceSpeakerSink(); + setupVoiceOutput(); + + if (micSourceNode) { + try { micSourceNode.disconnect(); } catch (e) {} + micSourceNode = null; + } + micSourceNode = audioContext.createMediaStreamSource(micStream); + micSourceNode.connect(senderNode); + } catch (err) { + console.error("Voice: call setup failed", err); + showBlahNotification("error:call.connect.failed"); + endVoiceSession(); + return; } - if (audioContext.state === 'suspended') await audioContext.resume(); - applyVoiceSpeakerSink(); - setupVoiceOutput(); - - if (micSourceNode) { - try { micSourceNode.disconnect(); } catch (e) {} - micSourceNode = null; - } - micSourceNode = audioContext.createMediaStreamSource(micStream); - micSourceNode.connect(senderNode); let vadHangover = 0; senderNode.port.onmessage = async (e) => { @@ -994,7 +1090,8 @@ async function startVoiceSession(serverUrl, ticket) { const baseUrl = currentVoiceWs.url.split('/ws')[0]; const httpUrl = baseUrl.replace('ws:', 'http:').replace('wss:', 'https:'); fetch(`${httpUrl}/room/users?room=${currentVoiceCallId}`).then(res => res.json()).then(usersRaw => { - voiceActiveUsers = usersRaw.map(u => String(u)).filter(u => !deadPeers.has(u)); + //bez filtra deadPeers - serwer jest źródłem prawdy (audyt S15) + voiceActiveUsers = usersRaw.map(u => String(u)); updateVoiceUIUsers(); }).catch(()=>{}); } @@ -1020,12 +1117,17 @@ async function startVoiceSession(serverUrl, ticket) { audioDecryptChains[audioId].enqueue(async () => { try { const senderKey = await senderKeyPromise; - if (!senderKey) return; + if (!senderKey) { + voiceLogError('media.nokey[' + audioId + ']', new Error('brak podklucza nadawcy - root nie zainstalowany?')); + return; + } const decryptedBuffer = await crypto.subtle.decrypt({name: "AES-GCM", iv}, senderKey, ciphertext); if(targetReceiver) targetReceiver.port.postMessage({ clientId: audioId, audioData: decryptedBuffer }, [decryptedBuffer]); setTileSpeaking(clientId, true, isScreenAudio ? `${clientId}-2` : null); - } catch (err) {} + } catch (err) { + voiceLogError('media.decrypt[' + audioId + ']', err); + } }); } else if (type === 1 || type === 2) { const decoderId = `${clientId}-${type}`; @@ -1153,14 +1255,52 @@ async function startVoiceSession(serverUrl, ticket) { async function handleCallUpdate(data) { let parts = data.split(":"); let roomId = parts[1]; - //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 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); + } + } + + //Roster rozmowy podmieniamy TYLKO dla pokoju, w którym faktycznie trwa + //nasza rozmowa (audyt S13): call_update otwartego czatu nie może wyzerować + //kafelków ani rozdzielić kluczy trwającej rozmowy w innym pokoju + if (roomId !== currentVoiceCallId) return; + let state = incomingState; voiceActiveUsers = state; if (currentVoiceCallId === roomId) { @@ -1177,6 +1317,15 @@ async function handleCallUpdate(data) { for (let u of state) { if (u !== myIdStr) sendVoiceSignalToUser(u, roomId); } + } else if (state.length > 0 && Date.now() - lastVoiceKeyRequestAt > 1500) { + //Jesteśmy w rozmowie BEZ klucza (np. dołączyliśmy do trwającej) - + //nie czekamy na tick watchdoga (do 4 s), prosimy natychmiast, + //inaczej pierwsze sekundy rozmowy są u nas i u nich ciszą + lastVoiceKeyRequestAt = Date.now(); + console.warn("Voice: joined keyless - requesting call key now"); + for (const u of state) { + if (u !== String(window.id)) sendVoiceSignalToUser(u, roomId, true); + } } updateVoiceUIUsers(); @@ -1201,13 +1350,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 +1396,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 +1426,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}`)}
- ` : ` - ${icon ? icon + '' : ''}${processBlah(title)} + ${icon ? icon + '' : ''}${escapeHtmlText(processBlah(title))} `; roomsTopBarTransition.innerHTML = `${backBtnHtml}${clickableContent}
${pinCallBtns}`; @@ -1710,7 +1722,7 @@ async function switchDetailsContent(title, content, showBack = false, backAction pinCallBtns = `${groupMenuBtnHtml}${pinCallBtns}`; } let backBtn = showBack ? `` : ""; - roomsTopBarTransition.innerHTML = `${backBtn}${processBlah(title)}
${pinCallBtns}`; + roomsTopBarTransition.innerHTML = `${backBtn}${escapeHtmlText(processBlah(title))}
${pinCallBtns}`; let parser = new DOMParser(); let doc = parser.parseFromString(content, "text/html"); @@ -2033,7 +2045,9 @@ async function openProfile(accountId) { }); let roleRes = await fetchEncrypted("room/role", payload); if (roleRes && !roleRes.startsWith("error:")) { - let mappedRole = "role." + roleRes.replace(" ", "_"); + //roleRes trafia do innerHTML - whitelist znaków zamiast surowej + //podstawienia (audyt W6: serwer-controlled string w szablonie) + let mappedRole = "role." + String(roleRes).replace(/[^A-Za-z0-9_.]/g, "_"); roleHtml = `
${mappedRole}
`; } } @@ -2275,7 +2289,7 @@ async function renderInvites(res, type) { } let entry = invitesEntry - .replaceAll("{username}", groupName) + .replaceAll("{username}", escapeHtmlText(groupName)) .replaceAll("{pfp}", pfp) .replaceAll("{desc}", `${desc}`) .replaceAll("{actions}", actions) @@ -2644,7 +2658,13 @@ function handleMobileSwipe() { } } +//S22: generacje renderów - przy nakładających się refreshDms/refreshGroups +//(ws events, fallbacki) starszy wynik nie może nadpisać nowszego innerHTML-em +let dmListRenderGen = 0; +let groupListRenderGen = 0; + async function renderDms(res) { + const gen = ++dmListRenderGen; let container = document.getElementById("dms-list"); if (!container) return; @@ -2665,8 +2685,13 @@ async function renderDms(res) { let myId = `${id};${host}`; let targetId = sanitizeId(parts[0] === myId ? parts[1] : parts[0]); - let targetUsername = await fetchAsync(`${url}/idtoname?id=${targetId}`); - if (!targetUsername || targetUsername.trim() === "" || targetUsername.startsWith("error:")) continue; + //S22: cache nazw - idtoname było wołane od nowa przy każdym renderze + let targetUsername = window.dmUsernameCache ? window.dmUsernameCache[targetId] : null; + if (!targetUsername) { + targetUsername = await fetchAsync(`${url}/idtoname?id=${targetId}`); + if (!targetUsername || targetUsername.trim() === "" || targetUsername.startsWith("error:")) continue; + if (window.dmUsernameCache) window.dmUsernameCache[targetId] = targetUsername; + } let pfp = await getAvatarUrl(targetId, targetUsername); let displayName = escapeHtml(targetUsername.split(':')[0]); @@ -2674,6 +2699,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,15 +2713,19 @@ async function renderDms(res) { ${escapeHtml(targetUsername)} (${escapeHtml(targetId.split(';')[0])})
${unreadBadge} + ${getCallIndicatorHtml()} `; count++; } + if (gen !== dmListRenderGen) return; //nowszy render przejął listę if (count > 0) { container.innerHTML = html; container.classList.remove("empty"); + updateVoicePresenceUI(); } else { + if (gen !== dmListRenderGen) return; container.innerHTML = `

desc.no.dms

`; container.classList.add("empty"); let blahTags = container.getElementsByTagName("blah"); @@ -2712,7 +2743,17 @@ async function renderDms(res) { } } +//S22: TTL-cache nazw grup (60 s) - nazwy rzadko się zmieniają, +//a group/info było najdroższym elementem renderu listy grup +function getGroupNameFromCache(groupId) { + if (!window.groupNameCache) return null; + let entry = window.groupNameCache[sanitizeId(groupId)]; + if (!entry || Date.now() - entry.ts > 60000) return null; + return entry.name; +} + async function renderGroups(res) { + const gen = ++groupListRenderGen; let container = document.getElementById("groups-list"); if (!container) return; @@ -2731,11 +2772,26 @@ async function renderGroups(res) { let isRead = groupData.string3 === "true"; let nameStyle = isRead ? "opacity: 0.8;" : "opacity: 1;"; - let infoRes = await fetchEncrypted("group/info", groupId); - if (infoRes && infoRes.startsWith("{")) { - let info = JSON.parse(infoRes); - let groupName = info.string2; + seedCallStateFromList(groupId, groupData.call === true); + //S22: TTL-cache nazw grup - group/info było wołane per grupa per render + let groupName = getGroupNameFromCache(groupId); + if (groupName === null) { + let infoRes = await fetchEncrypted("group/info", groupId); + if (infoRes && infoRes.startsWith("{")) { + groupName = JSON.parse(infoRes).string2; + if (!window.groupNameCache) window.groupNameCache = {}; + window.groupNameCache[sanitizeId(groupId)] = { name: groupName, ts: Date.now() }; + } else { + if (infoRes === "error:group.not.found") { + //grupa została usunięta - wyczyść nieaktualny wpis z listy + removeRoomFromCachedList(groupId, 'groups'); + } + continue; + } + } + + { let safeGroupName = encodeURIComponent(groupName).replace(/'/g, "%27"); let pfp = await getGroupAvatarUrl(groupId, groupName); @@ -2750,19 +2806,20 @@ async function renderGroups(res) { ${escapeHtmlText(groupId.replace(';', ':'))} ${unreadBadge} + ${getCallIndicatorHtml()} `; count++; - } else if (infoRes === "error:group.not.found") { - //grupa została usunięta - wyczyść nieaktualny wpis z listy - removeRoomFromCachedList(groupId, 'groups'); } } + if (gen !== groupListRenderGen) return; //nowszy render przejął listę if (count > 0) { container.innerHTML = html; container.classList.remove("empty"); + updateVoicePresenceUI(); } else { + if (gen !== groupListRenderGen) return; container.innerHTML = `

desc.no.groups

`; container.classList.add("empty"); let blahTags = container.getElementsByTagName("blah"); @@ -2813,6 +2870,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 +2983,21 @@ async function generateDmMembersHtml(targetId, targetUsername) { - - `; @@ -2972,14 +3126,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 +3223,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/version.js b/webroot/version.js index cfb04077..c9fc05d6 100644 --- a/webroot/version.js +++ b/webroot/version.js @@ -1 +1 @@ -const CLIENT_VERSION = "0.2.1"; +const CLIENT_VERSION = "0.2.2"; diff --git a/webroot/voice.js b/webroot/voice.js index 3235b5ed..ea07e4c2 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, @@ -127,6 +137,9 @@ const VOICE_MAX_RECONNECTS = 2; let peerLastSeen = {}; let deadPeers = new Set(); let lastVoiceServerPacketAt = 0; +//Czy w bieżącym okresie socketu byli jacyś inni uczestnicy - baza dla +//resetu baseline'u ciszy w watchdogu (patrz komentarz przy użyciu) +let voiceHadRemotePeers = false; let voicePingTimer = null; let voiceRosterRetryTimer = null; let lastVoiceKeyRequestAt = 0; @@ -534,8 +547,10 @@ function setVoiceStatus(state) { updateVoicePing(); } else { if (voiceTimerInterval) { clearInterval(voiceTimerInterval); voiceTimerInterval = null; } - voiceConnectedAt = null; - if (state === "disconnected") { voicePingMs = null; updateVoicePing(); } + //Anchor czasu NIE jest kasowany przy "connecting" - reconnect w trakcie + //rozmowy nie może resetować licznika na 00:00; czyszczone tylko przy + //realnym rozłączeniu (disconnected z endVoiceSession) + if (state === "disconnected") { voiceConnectedAt = null; voicePingMs = null; updateVoicePing(); } if (textEl) textEl.innerText = processBlah(state === "disconnected" ? "status.voice.disconnected" : "status.voice.connecting"); } } @@ -595,7 +610,11 @@ setInterval(() => { } } // Ghost peers: still on the room roster but totally silent (pings count as - // life) - drop them locally until the server or the peer proves otherwise. + // life). Audyt S15: UKRYWANIE ich z UI tworzyło niewidzialnego + // podsłuchiwacza - voice-server dalej relayaudio do ich socketu, a u innych + // znikał z listy. Teraz peer zostaje WIDOCZNY (roster serwera jest źródłem + // prawdy); czyścimy jedynie jego kanały mediów - wrócą z pierwszym pakietem. + // deadPeers pełni tu rolę znacznika "media już wycięte", nie "ukryty". let ghostRemoved = false; for (const u of voiceActiveUsers) { if (u === String(window.id)) continue; @@ -607,19 +626,30 @@ setInterval(() => { } } if (ghostRemoved) { - voiceActiveUsers = voiceActiveUsers.filter(u => !deadPeers.has(u)); updateVoiceUIUsers(); } // Half-open socket watchdog + backgrounded-tab audio recovery. if (currentVoiceWs && currentVoiceWs.readyState === WebSocket.OPEN) { const others = voiceActiveUsers.filter(u => u !== String(window.id)).length; + // Baseline ciszy odświeżany przy przejściu 0->N uczestników: solo w + // rozmowie NIE MA żadnego ruchu przychodzącego, więc po pojawieniu się + // nowej osoby lastVoiceServerPacketAt bywa nieświeży z okresu solo. + // Bez tego resetu join drugiej osoby natychmiast spełniał warunek + // ">10s bez inbound" i rozłączał zdrowy socket (fałszywy alarm). + if (others > 0 && !voiceHadRemotePeers) lastVoiceServerPacketAt = now; + voiceHadRemotePeers = others > 0; // Alive peers ping every 2s regardless of mute, so total inbound // silence while others are present means the socket is dead. if (others > 0 && now - lastVoiceServerPacketAt > VOICE_SERVER_TIMEOUT_MS) { console.warn("Voice: no inbound traffic, forcing reconnect"); try { currentVoiceWs.close(); } catch (e) {} - } else if (audioContext && audioContext.state === 'suspended') { + } else if (audioContext && audioContext.state !== 'running') { + //Nie tylko "suspended" - iOS/Safari zna też "interrupted"; każdy + //stan != running przy żywej rozmowie wymaga próby wznowienia. + //Dokładamy też kickstart-na-geście, jeśli jeszcze nie jest uzbrojony + //(armVoiceContextKickstart sam jest idempotentny) audioContext.resume().catch(() => {}); + armVoiceContextKickstart(); } // Safety net for the call-key handshake: if others are in the call but // we never received the root key (raced/lost signal), ask for it @@ -743,13 +773,52 @@ class TaskQueue { let voiceJoinInFlight = false; +//Ograniczony log diagnostyczny toru głosu - pierwsze kilka wystąpień danego +//problemu trafia do konsoli, dalej cicho (brak spamu przy długich rozmowach) +let voiceErrorLogsLeft = 6; +function voiceLogError(where, err) { + if (voiceErrorLogsLeft > 0) { + voiceErrorLogsLeft--; + console.warn("Voice: error [" + where + "]", err); + } +} + +//Wznawia AudioContext przy pierwszym geście użytkownika (polityka autoplay +//może zablokować kontekst stworzony po async-owych krokach dołączania do +//rozmowy). Listenery są one-shot i zdejmują się nawzajem. +let voiceCtxKickstartHandler = null; +function armVoiceContextKickstart() { + if (!audioContext) return; + if (voiceCtxKickstartHandler) return; //już uzbrojone + voiceCtxKickstartHandler = () => { + audioContext.resume({}).then(() => { + if (audioContext.state === 'running') { + console.warn("Voice: AudioContext resumed after user gesture"); + document.removeEventListener('click', voiceCtxKickstartHandler); + document.removeEventListener('touchend', voiceCtxKickstartHandler); + document.removeEventListener('keydown', voiceCtxKickstartHandler); + voiceCtxKickstartHandler = null; + } + }).catch(() => {}); + }; + document.addEventListener('click', voiceCtxKickstartHandler); + document.addEventListener('touchend', voiceCtxKickstartHandler); + document.addEventListener('keydown', voiceCtxKickstartHandler); +} + async function initiateVoiceCall(roomId = null) { if (currentVoiceWs || voiceJoinInFlight) { 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 { @@ -759,6 +828,12 @@ async function initiateVoiceCall(roomId = null) { if (joined) { await startVoiceSession(joined.serverUrl, joined.ticket); + } else { + //S14: nieudany join nie może zostawiać ustawionego currentVoiceCallId/ + //Type - uzbrojony gate sygnalizacji pozwalał obcemu na wstrzyknięcie + //"klucza rozmowy", a zatruty klucz przechodziłby do następnej rozmowy + currentVoiceCallId = null; + currentVoiceCallType = null; } } finally { voiceJoinInFlight = false; @@ -868,12 +943,12 @@ async function startVoiceSession(serverUrl, ticket) { voicePingMs = Math.round(performance.now() - t0); updateVoicePing(); const rawUsers = usersRaw.map(u => String(u)); - // Peers we locally declared dead stay hidden until either - // they send anything again or the server drops them. + //Serwer jest źródłem prawdy o składzie - nie filtrujemy po + //deadPeers (audyt S15: ukryty peer nadal dostaje audio) for (const gone of [...deadPeers]) { if (!rawUsers.includes(gone)) deadPeers.delete(gone); } - const users = rawUsers.filter(u => !deadPeers.has(u)); + const users = rawUsers; let changed = (users.length !== voiceActiveUsers.length); for(let u of users) if(!voiceActiveUsers.includes(u)) changed = true; if(changed) { @@ -904,37 +979,58 @@ async function startVoiceSession(serverUrl, ticket) { if (voicePingTimer) clearInterval(voicePingTimer); voicePingTimer = setInterval(sendVoicePing, VOICE_PING_INTERVAL_MS); - // Reconnects reuse the live mic stream - only capture a new one - // when the previous is gone (full teardown or track ended). - if (!micStream || micStream.getTracks().some(t => t.readyState !== 'live')) { - micStream = await navigator.mediaDevices.getUserMedia(buildVoiceMicConstraints(voiceSettings.micId)); - if (micSourceNode) { try { micSourceNode.disconnect(); } catch (e) {} micSourceNode = null; } - } + //S16: getUserMedia/worklet/rezump AudioContext potrafią rzucić + //(brak uprawnień, zniknięte urządzenie) - bez obsługi zostawał + //"connected" zombie-call ze spalonym single-use ticketem i bez + //mikrofonu, bez powiadomienia i bez recovery + try { + // Reconnects reuse the live mic stream - only capture a new one + // when the previous is gone (full teardown or track ended). + if (!micStream || micStream.getTracks().some(t => t.readyState !== 'live')) { + micStream = await navigator.mediaDevices.getUserMedia(buildVoiceMicConstraints(voiceSettings.micId)); + if (micSourceNode) { try { micSourceNode.disconnect(); } catch (e) {} micSourceNode = null; } + } - const nativeRouting = getNativeVoiceRouting(); - if (nativeRouting && nativeRouting.startCall) { - try { await nativeRouting.startCall(); } catch (e) {} - } + const nativeRouting = getNativeVoiceRouting(); + if (nativeRouting && nativeRouting.startCall) { + try { await nativeRouting.startCall(); } catch (e) {} + } - if (!audioContext) audioContext = new (window.AudioContext || window.webkitAudioContext)({sampleRate: 48000}); - if (!isAudioWorkletLoaded) { - await audioContext.audioWorklet.addModule(URL.createObjectURL(new Blob([voiceWorkletCode], {type: 'application/javascript'}))); - senderNode = new AudioWorkletNode(audioContext, 'audio-sender'); - const forwardPcm = !!(getNativeVoiceRouting() && isMobileVoiceDevice()); - receiverNode = new AudioWorkletNode(audioContext, 'audio-receiver', {processorOptions: {forward: forwardPcm}}); - screenReceiverNode = new AudioWorkletNode(audioContext, 'audio-receiver'); - isAudioWorkletLoaded = true; + if (!audioContext) audioContext = new (window.AudioContext || window.webkitAudioContext)({sampleRate: 48000}); + if (!isAudioWorkletLoaded) { + await audioContext.audioWorklet.addModule(URL.createObjectURL(new Blob([voiceWorkletCode], {type: 'application/javascript'}))); + senderNode = new AudioWorkletNode(audioContext, 'audio-sender'); + const forwardPcm = !!(getNativeVoiceRouting() && isMobileVoiceDevice()); + receiverNode = new AudioWorkletNode(audioContext, 'audio-receiver', {processorOptions: {forward: forwardPcm}}); + screenReceiverNode = new AudioWorkletNode(audioContext, 'audio-receiver'); + isAudioWorkletLoaded = true; + } + if (audioContext.state !== 'running') { + try { await audioContext.resume(); } catch (e) {} + } + if (audioContext.state !== 'running') { + //Polityka autoplay / stan "interrupted": kontekst nie wstał + //samoaktualizacyjnie. Bez tego cisza w OBIE strony (mikrofon + //i głośnik żyją w tym samym kontekście), mimo że wskaźniki + //mówienia i screen share działają (nie potrzebują kontekstu). + console.warn("Voice: AudioContext not running after open (" + audioContext.state + ") - arming user-gesture kickstart"); + armVoiceContextKickstart(); + } + applyVoiceSpeakerSink(); + setupVoiceOutput(); + + if (micSourceNode) { + try { micSourceNode.disconnect(); } catch (e) {} + micSourceNode = null; + } + micSourceNode = audioContext.createMediaStreamSource(micStream); + micSourceNode.connect(senderNode); + } catch (err) { + console.error("Voice: call setup failed", err); + showBlahNotification("error:call.connect.failed"); + endVoiceSession(); + return; } - if (audioContext.state === 'suspended') await audioContext.resume(); - applyVoiceSpeakerSink(); - setupVoiceOutput(); - - if (micSourceNode) { - try { micSourceNode.disconnect(); } catch (e) {} - micSourceNode = null; - } - micSourceNode = audioContext.createMediaStreamSource(micStream); - micSourceNode.connect(senderNode); let vadHangover = 0; senderNode.port.onmessage = async (e) => { @@ -994,7 +1090,8 @@ async function startVoiceSession(serverUrl, ticket) { const baseUrl = currentVoiceWs.url.split('/ws')[0]; const httpUrl = baseUrl.replace('ws:', 'http:').replace('wss:', 'https:'); fetch(`${httpUrl}/room/users?room=${currentVoiceCallId}`).then(res => res.json()).then(usersRaw => { - voiceActiveUsers = usersRaw.map(u => String(u)).filter(u => !deadPeers.has(u)); + //bez filtra deadPeers - serwer jest źródłem prawdy (audyt S15) + voiceActiveUsers = usersRaw.map(u => String(u)); updateVoiceUIUsers(); }).catch(()=>{}); } @@ -1020,12 +1117,17 @@ async function startVoiceSession(serverUrl, ticket) { audioDecryptChains[audioId].enqueue(async () => { try { const senderKey = await senderKeyPromise; - if (!senderKey) return; + if (!senderKey) { + voiceLogError('media.nokey[' + audioId + ']', new Error('brak podklucza nadawcy - root nie zainstalowany?')); + return; + } const decryptedBuffer = await crypto.subtle.decrypt({name: "AES-GCM", iv}, senderKey, ciphertext); if(targetReceiver) targetReceiver.port.postMessage({ clientId: audioId, audioData: decryptedBuffer }, [decryptedBuffer]); setTileSpeaking(clientId, true, isScreenAudio ? `${clientId}-2` : null); - } catch (err) {} + } catch (err) { + voiceLogError('media.decrypt[' + audioId + ']', err); + } }); } else if (type === 1 || type === 2) { const decoderId = `${clientId}-${type}`; @@ -1153,14 +1255,52 @@ async function startVoiceSession(serverUrl, ticket) { async function handleCallUpdate(data) { let parts = data.split(":"); let roomId = parts[1]; - //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 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); + } + } + + //Roster rozmowy podmieniamy TYLKO dla pokoju, w którym faktycznie trwa + //nasza rozmowa (audyt S13): call_update otwartego czatu nie może wyzerować + //kafelków ani rozdzielić kluczy trwającej rozmowy w innym pokoju + if (roomId !== currentVoiceCallId) return; + let state = incomingState; voiceActiveUsers = state; if (currentVoiceCallId === roomId) { @@ -1177,6 +1317,15 @@ async function handleCallUpdate(data) { for (let u of state) { if (u !== myIdStr) sendVoiceSignalToUser(u, roomId); } + } else if (state.length > 0 && Date.now() - lastVoiceKeyRequestAt > 1500) { + //Jesteśmy w rozmowie BEZ klucza (np. dołączyliśmy do trwającej) - + //nie czekamy na tick watchdoga (do 4 s), prosimy natychmiast, + //inaczej pierwsze sekundy rozmowy są u nas i u nich ciszą + lastVoiceKeyRequestAt = Date.now(); + console.warn("Voice: joined keyless - requesting call key now"); + for (const u of state) { + if (u !== String(window.id)) sendVoiceSignalToUser(u, roomId, true); + } } updateVoiceUIUsers(); @@ -1201,13 +1350,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 +1396,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 +1426,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}`)}
-