diff --git a/android/app/build.gradle b/android/app/build.gradle index 11a28535..70f0a50e 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 8 - versionName "0.2.2" + versionCode 7 + versionName "0.2.1" 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 497cb118..5f006339 100644 --- a/android/app/src/main/assets/public/icons.js +++ b/android/app/src/main/assets/public/icons.js @@ -55,7 +55,6 @@ 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 a5d0ca32..fa231a06 100644 --- a/android/app/src/main/assets/public/main.js +++ b/android/app/src/main/assets/public/main.js @@ -1149,20 +1149,12 @@ 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); @@ -1170,15 +1162,11 @@ 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); @@ -1569,7 +1557,7 @@ async function switchRoomsBar(title, content) { await delay(200); - roomsTopBarTransition.innerHTML = escapeHtmlText(processBlah(title)); + roomsTopBarTransition.innerHTML = processBlah(title); let parser = new DOMParser(); @@ -1648,10 +1636,10 @@ async function switchRoomContent(title, content, showRoomBar, icon = "", skipMob let clickableContent = showRoomBar ? `` : ` - ${icon ? icon + '' : ''}${escapeHtmlText(processBlah(title))} + ${icon ? icon + '' : ''}${processBlah(title)} `; roomsTopBarTransition.innerHTML = `${backBtnHtml}${clickableContent}
${pinCallBtns}`; @@ -1722,7 +1710,7 @@ async function switchDetailsContent(title, content, showBack = false, backAction pinCallBtns = `${groupMenuBtnHtml}${pinCallBtns}`; } let backBtn = showBack ? `` : ""; - roomsTopBarTransition.innerHTML = `${backBtn}${escapeHtmlText(processBlah(title))}
${pinCallBtns}`; + roomsTopBarTransition.innerHTML = `${backBtn}${processBlah(title)}
${pinCallBtns}`; let parser = new DOMParser(); let doc = parser.parseFromString(content, "text/html"); @@ -2045,9 +2033,7 @@ async function openProfile(accountId) { }); let roleRes = await fetchEncrypted("room/role", payload); if (roleRes && !roleRes.startsWith("error:")) { - //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, "_"); + let mappedRole = "role." + roleRes.replace(" ", "_"); roleHtml = `
${mappedRole}
`; } } @@ -2289,7 +2275,7 @@ async function renderInvites(res, type) { } let entry = invitesEntry - .replaceAll("{username}", escapeHtmlText(groupName)) + .replaceAll("{username}", groupName) .replaceAll("{pfp}", pfp) .replaceAll("{desc}", `${desc}`) .replaceAll("{actions}", actions) @@ -2658,13 +2644,7 @@ 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; @@ -2685,13 +2665,8 @@ async function renderDms(res) { let myId = `${id};${host}`; let targetId = sanitizeId(parts[0] === myId ? parts[1] : parts[0]); - //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 targetUsername = await fetchAsync(`${url}/idtoname?id=${targetId}`); + if (!targetUsername || targetUsername.trim() === "" || targetUsername.startsWith("error:")) continue; let pfp = await getAvatarUrl(targetId, targetUsername); let displayName = escapeHtml(targetUsername.split(':')[0]); @@ -2699,8 +2674,6 @@ 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 = `
`; @@ -2713,19 +2686,15 @@ 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"); @@ -2743,17 +2712,7 @@ 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; @@ -2772,26 +2731,11 @@ 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); + let groupName = info.string2; - //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); @@ -2806,20 +2750,19 @@ 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"); @@ -2870,101 +2813,6 @@ 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}`); @@ -2983,21 +2831,19 @@ async function generateDmMembersHtml(targetId, targetUsername) { - - `; @@ -3126,16 +2972,14 @@ 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 += ` - `; } @@ -3223,9 +3067,6 @@ 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 95916d8c..02cae905 100644 --- a/android/app/src/main/assets/public/style.css +++ b/android/app/src/main/assets/public/style.css @@ -1201,27 +1201,6 @@ 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 c9fc05d6..cfb04077 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.2"; +const CLIENT_VERSION = "0.2.1"; diff --git a/android/app/src/main/assets/public/voice.js b/android/app/src/main/assets/public/voice.js index ea07e4c2..3235b5ed 100644 --- a/android/app/src/main/assets/public/voice.js +++ b/android/app/src/main/assets/public/voice.js @@ -6,16 +6,6 @@ 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, @@ -137,9 +127,6 @@ 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; @@ -547,10 +534,8 @@ function setVoiceStatus(state) { updateVoicePing(); } else { if (voiceTimerInterval) { clearInterval(voiceTimerInterval); voiceTimerInterval = null; } - //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(); } + voiceConnectedAt = null; + if (state === "disconnected") { voicePingMs = null; updateVoicePing(); } if (textEl) textEl.innerText = processBlah(state === "disconnected" ? "status.voice.disconnected" : "status.voice.connecting"); } } @@ -610,11 +595,7 @@ setInterval(() => { } } // Ghost peers: still on the room roster but totally silent (pings count as - // 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". + // life) - drop them locally until the server or the peer proves otherwise. let ghostRemoved = false; for (const u of voiceActiveUsers) { if (u === String(window.id)) continue; @@ -626,30 +607,19 @@ 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 !== '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) + } else if (audioContext && audioContext.state === 'suspended') { 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 @@ -773,52 +743,13 @@ 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 { @@ -828,12 +759,6 @@ 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; @@ -943,12 +868,12 @@ async function startVoiceSession(serverUrl, ticket) { voicePingMs = Math.round(performance.now() - t0); updateVoicePing(); const rawUsers = usersRaw.map(u => String(u)); - //Serwer jest źródłem prawdy o składzie - nie filtrujemy po - //deadPeers (audyt S15: ukryty peer nadal dostaje audio) + // Peers we locally declared dead stay hidden until either + // they send anything again or the server drops them. for (const gone of [...deadPeers]) { if (!rawUsers.includes(gone)) deadPeers.delete(gone); } - const users = rawUsers; + const users = rawUsers.filter(u => !deadPeers.has(u)); let changed = (users.length !== voiceActiveUsers.length); for(let u of users) if(!voiceActiveUsers.includes(u)) changed = true; if(changed) { @@ -979,58 +904,37 @@ async function startVoiceSession(serverUrl, ticket) { if (voicePingTimer) clearInterval(voicePingTimer); voicePingTimer = setInterval(sendVoicePing, VOICE_PING_INTERVAL_MS); - //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) {} - } - - 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; + // 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) {} + } + + 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 === '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) => { @@ -1090,8 +994,7 @@ 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 => { - //bez filtra deadPeers - serwer jest źródłem prawdy (audyt S15) - voiceActiveUsers = usersRaw.map(u => String(u)); + voiceActiveUsers = usersRaw.map(u => String(u)).filter(u => !deadPeers.has(u)); updateVoiceUIUsers(); }).catch(()=>{}); } @@ -1117,17 +1020,12 @@ async function startVoiceSession(serverUrl, ticket) { audioDecryptChains[audioId].enqueue(async () => { try { const senderKey = await senderKeyPromise; - if (!senderKey) { - voiceLogError('media.nokey[' + audioId + ']', new Error('brak podklucza nadawcy - root nie zainstalowany?')); - return; - } + if (!senderKey) 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) { - voiceLogError('media.decrypt[' + audioId + ']', err); - } + } catch (err) {} }); } else if (type === 1 || type === 2) { const decoderId = `${clientId}-${type}`; @@ -1255,52 +1153,14 @@ 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) {} + //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)); } - - 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) { @@ -1317,15 +1177,6 @@ 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(); @@ -1350,6 +1201,13 @@ 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(); } } @@ -1396,28 +1254,13 @@ async function resolveCallerInfo(roomId) { return { name: (name ? name.split(':')[0] : null) || processBlah("title.unknown"), pfpUrl }; } -function showIncomingCallUI(roomId, participantCount = null) { - if (currentVoiceWs || voiceJoinInFlight) return; +function showIncomingCallUI(roomId) { + if (document.getElementById("incoming-call-popup") || 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 @@ -1426,10 +1269,10 @@ function showIncomingCallUI(roomId, participantCount = null) {
- ${processBlah("title.incoming.call")} · ${processBlah(`:desc.call.in.chat:${participantCount}`)} + ${processBlah("title.incoming.call")} · ${processBlah(`:desc.call.in.chat:${voiceActiveUsers.length}`)}
- ` : ` - ${icon ? icon + '' : ''}${escapeHtmlText(processBlah(title))} + ${icon ? icon + '' : ''}${processBlah(title)} `; roomsTopBarTransition.innerHTML = `${backBtnHtml}${clickableContent}
${pinCallBtns}`; @@ -1722,7 +1710,7 @@ async function switchDetailsContent(title, content, showBack = false, backAction pinCallBtns = `${groupMenuBtnHtml}${pinCallBtns}`; } let backBtn = showBack ? `` : ""; - roomsTopBarTransition.innerHTML = `${backBtn}${escapeHtmlText(processBlah(title))}
${pinCallBtns}`; + roomsTopBarTransition.innerHTML = `${backBtn}${processBlah(title)}
${pinCallBtns}`; let parser = new DOMParser(); let doc = parser.parseFromString(content, "text/html"); @@ -2045,9 +2033,7 @@ async function openProfile(accountId) { }); let roleRes = await fetchEncrypted("room/role", payload); if (roleRes && !roleRes.startsWith("error:")) { - //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, "_"); + let mappedRole = "role." + roleRes.replace(" ", "_"); roleHtml = `
${mappedRole}
`; } } @@ -2289,7 +2275,7 @@ async function renderInvites(res, type) { } let entry = invitesEntry - .replaceAll("{username}", escapeHtmlText(groupName)) + .replaceAll("{username}", groupName) .replaceAll("{pfp}", pfp) .replaceAll("{desc}", `${desc}`) .replaceAll("{actions}", actions) @@ -2658,13 +2644,7 @@ 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; @@ -2685,13 +2665,8 @@ async function renderDms(res) { let myId = `${id};${host}`; let targetId = sanitizeId(parts[0] === myId ? parts[1] : parts[0]); - //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 targetUsername = await fetchAsync(`${url}/idtoname?id=${targetId}`); + if (!targetUsername || targetUsername.trim() === "" || targetUsername.startsWith("error:")) continue; let pfp = await getAvatarUrl(targetId, targetUsername); let displayName = escapeHtml(targetUsername.split(':')[0]); @@ -2699,8 +2674,6 @@ 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 = `
`; @@ -2713,19 +2686,15 @@ 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"); @@ -2743,17 +2712,7 @@ 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; @@ -2772,26 +2731,11 @@ 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); + let groupName = info.string2; - //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); @@ -2806,20 +2750,19 @@ 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"); @@ -2870,101 +2813,6 @@ 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}`); @@ -2983,21 +2831,19 @@ async function generateDmMembersHtml(targetId, targetUsername) { - - `; @@ -3126,16 +2972,14 @@ 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 += ` - `; } @@ -3223,9 +3067,6 @@ 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 95916d8c..02cae905 100644 --- a/webroot/style.css +++ b/webroot/style.css @@ -1201,27 +1201,6 @@ 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 c9fc05d6..cfb04077 100644 --- a/webroot/version.js +++ b/webroot/version.js @@ -1 +1 @@ -const CLIENT_VERSION = "0.2.2"; +const CLIENT_VERSION = "0.2.1"; diff --git a/webroot/voice.js b/webroot/voice.js index ea07e4c2..3235b5ed 100644 --- a/webroot/voice.js +++ b/webroot/voice.js @@ -6,16 +6,6 @@ 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, @@ -137,9 +127,6 @@ 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; @@ -547,10 +534,8 @@ function setVoiceStatus(state) { updateVoicePing(); } else { if (voiceTimerInterval) { clearInterval(voiceTimerInterval); voiceTimerInterval = null; } - //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(); } + voiceConnectedAt = null; + if (state === "disconnected") { voicePingMs = null; updateVoicePing(); } if (textEl) textEl.innerText = processBlah(state === "disconnected" ? "status.voice.disconnected" : "status.voice.connecting"); } } @@ -610,11 +595,7 @@ setInterval(() => { } } // Ghost peers: still on the room roster but totally silent (pings count as - // 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". + // life) - drop them locally until the server or the peer proves otherwise. let ghostRemoved = false; for (const u of voiceActiveUsers) { if (u === String(window.id)) continue; @@ -626,30 +607,19 @@ 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 !== '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) + } else if (audioContext && audioContext.state === 'suspended') { 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 @@ -773,52 +743,13 @@ 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 { @@ -828,12 +759,6 @@ 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; @@ -943,12 +868,12 @@ async function startVoiceSession(serverUrl, ticket) { voicePingMs = Math.round(performance.now() - t0); updateVoicePing(); const rawUsers = usersRaw.map(u => String(u)); - //Serwer jest źródłem prawdy o składzie - nie filtrujemy po - //deadPeers (audyt S15: ukryty peer nadal dostaje audio) + // Peers we locally declared dead stay hidden until either + // they send anything again or the server drops them. for (const gone of [...deadPeers]) { if (!rawUsers.includes(gone)) deadPeers.delete(gone); } - const users = rawUsers; + const users = rawUsers.filter(u => !deadPeers.has(u)); let changed = (users.length !== voiceActiveUsers.length); for(let u of users) if(!voiceActiveUsers.includes(u)) changed = true; if(changed) { @@ -979,58 +904,37 @@ async function startVoiceSession(serverUrl, ticket) { if (voicePingTimer) clearInterval(voicePingTimer); voicePingTimer = setInterval(sendVoicePing, VOICE_PING_INTERVAL_MS); - //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) {} - } - - 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; + // 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) {} + } + + 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 === '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) => { @@ -1090,8 +994,7 @@ 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 => { - //bez filtra deadPeers - serwer jest źródłem prawdy (audyt S15) - voiceActiveUsers = usersRaw.map(u => String(u)); + voiceActiveUsers = usersRaw.map(u => String(u)).filter(u => !deadPeers.has(u)); updateVoiceUIUsers(); }).catch(()=>{}); } @@ -1117,17 +1020,12 @@ async function startVoiceSession(serverUrl, ticket) { audioDecryptChains[audioId].enqueue(async () => { try { const senderKey = await senderKeyPromise; - if (!senderKey) { - voiceLogError('media.nokey[' + audioId + ']', new Error('brak podklucza nadawcy - root nie zainstalowany?')); - return; - } + if (!senderKey) 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) { - voiceLogError('media.decrypt[' + audioId + ']', err); - } + } catch (err) {} }); } else if (type === 1 || type === 2) { const decoderId = `${clientId}-${type}`; @@ -1255,52 +1153,14 @@ 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) {} + //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)); } - - 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) { @@ -1317,15 +1177,6 @@ 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(); @@ -1350,6 +1201,13 @@ 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(); } } @@ -1396,28 +1254,13 @@ async function resolveCallerInfo(roomId) { return { name: (name ? name.split(':')[0] : null) || processBlah("title.unknown"), pfpUrl }; } -function showIncomingCallUI(roomId, participantCount = null) { - if (currentVoiceWs || voiceJoinInFlight) return; +function showIncomingCallUI(roomId) { + if (document.getElementById("incoming-call-popup") || 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 @@ -1426,10 +1269,10 @@ function showIncomingCallUI(roomId, participantCount = null) {
- ${processBlah("title.incoming.call")} · ${processBlah(`:desc.call.in.chat:${participantCount}`)} + ${processBlah("title.incoming.call")} · ${processBlah(`:desc.call.in.chat:${voiceActiveUsers.length}`)}
-