voice chat indicators

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

View file

@ -55,6 +55,7 @@ const ICONS = {
video: (props) => ICONS._feather(`<polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/>`, props), video: (props) => ICONS._feather(`<polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/>`, props),
file: (props) => ICONS._feather(`<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>`, props), file: (props) => ICONS._feather(`<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>`, props),
mic: (props) => ICONS._feather(`<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"></path><path d="M19 10v2a7 7 0 0 1-14 0v-2"></path><line x1="12" y1="19" x2="12" y2="23"></line><line x1="8" y1="23" x2="16" y2="23"></line>`, props), mic: (props) => ICONS._feather(`<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"></path><path d="M19 10v2a7 7 0 0 1-14 0v-2"></path><line x1="12" y1="19" x2="12" y2="23"></line><line x1="8" y1="23" x2="16" y2="23"></line>`, props),
headphones: (props) => ICONS._feather(`<path d="M3 18v-6a9 9 0 0 1 18 0v6"></path><path d="M21 19a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3zM3 19a2 2 0 0 0 2 2h1a2 2 0 0 0 2-2v-3a2 2 0 0 0-2-2H3z"></path>`, props),
speaker: (props) => ICONS._create(`<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"></path>`, { viewBox: "0 0 24 24", width: "1.4rem", height: "1.4rem", fill: "var(--text-color)", stroke: "none" }, props), speaker: (props) => ICONS._create(`<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"></path>`, { viewBox: "0 0 24 24", width: "1.4rem", height: "1.4rem", fill: "var(--text-color)", stroke: "none" }, props),
home: (props) => ICONS._create(`<path d="M1 6V15H6V11C6 9.89543 6.89543 9 8 9C9.10457 9 10 9.89543 10 11V15H15V6L8 0L1 6Z" stroke="currentColor" stroke-width="1.28" stroke-linejoin="round" stroke-linecap="round" transform="translate(0 0.5)"/>`, { viewBox: "-1 -1 18 18", width: "1.5rem", height: "1.5rem", fill: "none", stroke: "none" }, props), home: (props) => ICONS._create(`<path d="M1 6V15H6V11C6 9.89543 6.89543 9 8 9C9.10457 9 10 9.89543 10 11V15H15V6L8 0L1 6Z" stroke="currentColor" stroke-width="1.28" stroke-linejoin="round" stroke-linecap="round" transform="translate(0 0.5)"/>`, { viewBox: "-1 -1 18 18", width: "1.5rem", height: "1.5rem", fill: "none", stroke: "none" }, props),

View file

@ -2674,6 +2674,8 @@ async function renderDms(res) {
let isRead = dmData.string3 === "true"; let isRead = dmData.string3 === "true";
let nameStyle = isRead ? "opacity: 0.8;" : "opacity: 1;"; let nameStyle = isRead ? "opacity: 0.8;" : "opacity: 1;";
seedCallStateFromList(dmIdRaw, dmData.call === true);
let safeTargetUsername = encodeURIComponent(targetUsername).replace(/'/g, "%27"); let safeTargetUsername = encodeURIComponent(targetUsername).replace(/'/g, "%27");
let unreadBadge = `<div class="unread-badge ${isRead ? "" : "show"}"></div>`; let unreadBadge = `<div class="unread-badge ${isRead ? "" : "show"}"></div>`;
@ -2686,6 +2688,7 @@ async function renderDms(res) {
<span class="room-id" style="font-size: 0.8rem; opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${escapeHtml(targetUsername)} (${escapeHtml(targetId.split(';')[0])})</span> <span class="room-id" style="font-size: 0.8rem; opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${escapeHtml(targetUsername)} (${escapeHtml(targetId.split(';')[0])})</span>
</div> </div>
${unreadBadge} ${unreadBadge}
${getCallIndicatorHtml()}
</button> </button>
`; `;
count++; count++;
@ -2694,6 +2697,7 @@ async function renderDms(res) {
if (count > 0) { if (count > 0) {
container.innerHTML = html; container.innerHTML = html;
container.classList.remove("empty"); container.classList.remove("empty");
updateVoicePresenceUI();
} else { } else {
container.innerHTML = `<p style="opacity: 0.5;"><blah>desc.no.dms</blah></p>`; container.innerHTML = `<p style="opacity: 0.5;"><blah>desc.no.dms</blah></p>`;
container.classList.add("empty"); container.classList.add("empty");
@ -2731,6 +2735,8 @@ async function renderGroups(res) {
let isRead = groupData.string3 === "true"; let isRead = groupData.string3 === "true";
let nameStyle = isRead ? "opacity: 0.8;" : "opacity: 1;"; let nameStyle = isRead ? "opacity: 0.8;" : "opacity: 1;";
seedCallStateFromList(groupId, groupData.call === true);
let infoRes = await fetchEncrypted("group/info", groupId); let infoRes = await fetchEncrypted("group/info", groupId);
if (infoRes && infoRes.startsWith("{")) { if (infoRes && infoRes.startsWith("{")) {
let info = JSON.parse(infoRes); let info = JSON.parse(infoRes);
@ -2750,6 +2756,7 @@ async function renderGroups(res) {
<span class="room-id" style="font-size: 0.8rem; opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${escapeHtmlText(groupId.replace(';', ':'))}</span> <span class="room-id" style="font-size: 0.8rem; opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${escapeHtmlText(groupId.replace(';', ':'))}</span>
</div> </div>
${unreadBadge} ${unreadBadge}
${getCallIndicatorHtml()}
</button> </button>
`; `;
count++; count++;
@ -2762,6 +2769,7 @@ async function renderGroups(res) {
if (count > 0) { if (count > 0) {
container.innerHTML = html; container.innerHTML = html;
container.classList.remove("empty"); container.classList.remove("empty");
updateVoicePresenceUI();
} else { } else {
container.innerHTML = `<p style="opacity: 0.5;"><blah>desc.no.groups</blah></p>`; container.innerHTML = `<p style="opacity: 0.5;"><blah>desc.no.groups</blah></p>`;
container.classList.add("empty"); container.classList.add("empty");
@ -2813,6 +2821,101 @@ function escapeHtmlText(value) {
return escapeHtml(String(value ?? "")); return escapeHtml(String(value ?? ""));
} }
// --- Wskaźniki aktywnej rozmowy głosowej (zielone słuchawki) ---
function getCallIndicatorHtml() {
return `<div class="call-indicator">${ICONS.headphones({ width: "1rem", height: "1rem", stroke: "var(--big-green)" })}</div>`;
}
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) { async function generateDmMembersHtml(targetId, targetUsername) {
let myIdStr = id + ";" + host; let myIdStr = id + ";" + host;
let myUsername = await fetchAsync(`${url}/idtoname?id=${myIdStr}`); let myUsername = await fetchAsync(`${url}/idtoname?id=${myIdStr}`);
@ -2831,19 +2934,21 @@ async function generateDmMembersHtml(targetId, targetUsername) {
<div class="sidebar-section-header" style="margin-top: 0.5rem; margin-bottom: 0.5rem; width: 100%;"> <div class="sidebar-section-header" style="margin-top: 0.5rem; margin-bottom: 0.5rem; width: 100%;">
<span style="padding-left: 0.6rem; font-size: 0.8rem; font-weight: bold; opacity: 0.5; text-transform: uppercase;"><blah>title.members</blah></span> <span style="padding-left: 0.6rem; font-size: 0.8rem; font-weight: bold; opacity: 0.5; text-transform: uppercase;"><blah>title.members</blah></span>
</div> </div>
<button class="room-entry" onclick="openProfile('${safeTargetId}')" style="margin-bottom: 0;"> <button class="room-entry" data-member-id="${sanitizeId(targetId.split(';')[0])}" onclick="openProfile('${safeTargetId}')" style="margin-bottom: 0;">
<img src="${targetPfp}" class="room-pfp"> <img src="${targetPfp}" class="room-pfp">
<div style="display: flex; flex-direction: column; text-align: left; overflow: hidden; width: 100%; margin-left: 0.37rem;"> <div style="display: flex; flex-direction: column; text-align: left; overflow: hidden; width: 100%; margin-left: 0.37rem;">
<span class="room-name">${targetDisplayName}</span> <span class="room-name">${targetDisplayName}</span>
<span class="room-id" style="font-size: 0.8rem; opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${escapeHtmlText(targetUsername)}</span> <span class="room-id" style="font-size: 0.8rem; opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${escapeHtmlText(targetUsername)}</span>
</div> </div>
${getCallIndicatorHtml()}
</button> </button>
<button class="room-entry" onclick="openProfile('${safeMyId}')" style="margin-bottom: 0;"> <button class="room-entry" data-member-id="${sanitizeId(String(id))}" onclick="openProfile('${safeMyId}')" style="margin-bottom: 0;">
<img src="${myPfp}" class="room-pfp"> <img src="${myPfp}" class="room-pfp">
<div style="display: flex; flex-direction: column; text-align: left; overflow: hidden; width: 100%; margin-left: 0.37rem;"> <div style="display: flex; flex-direction: column; text-align: left; overflow: hidden; width: 100%; margin-left: 0.37rem;">
<span class="room-name">${myDisplaySafe}</span> <span class="room-name">${myDisplaySafe}</span>
<span class="room-id" style="font-size: 0.8rem; opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${escapeHtmlText(myUsername)}</span> <span class="room-id" style="font-size: 0.8rem; opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${escapeHtmlText(myUsername)}</span>
</div> </div>
${getCallIndicatorHtml()}
</button> </button>
</div> </div>
`; `;
@ -2972,14 +3077,16 @@ async function generateGroupMembersHtml(groupId, groupName) {
let memberDisplayName = escapeHtml(memberUsername.split(':')[0]); let memberDisplayName = escapeHtml(memberUsername.split(':')[0]);
let safeMemberId = sanitizeId(memberId); let safeMemberId = sanitizeId(memberId);
let safeGroupId = sanitizeId(groupId); let safeGroupId = sanitizeId(groupId);
let memberNumericId = sanitizeId(String(memberId).split(';')[0]);
html += ` html += `
<button class="room-entry" onclick="openProfile('${safeMemberId}')" oncontextmenu="handleMemberContextMenu(event, '${safeGroupId}', '${safeMemberId}')" style="margin-bottom: 0;"> <button class="room-entry" data-member-id="${memberNumericId}" onclick="openProfile('${safeMemberId}')" oncontextmenu="handleMemberContextMenu(event, '${safeGroupId}', '${safeMemberId}')" style="margin-bottom: 0;">
<img src="${memberPfp}" class="room-pfp"> <img src="${memberPfp}" class="room-pfp">
<div style="display: flex; flex-direction: column; text-align: left; overflow: hidden; width: 100%; margin-left: 0.37rem;"> <div style="display: flex; flex-direction: column; text-align: left; overflow: hidden; width: 100%; margin-left: 0.37rem;">
<span class="room-name">${memberDisplayName}</span> <span class="room-name">${memberDisplayName}</span>
<span class="room-id" style="font-size: 0.8rem; opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${escapeHtml(memberUsername)}</span> <span class="room-id" style="font-size: 0.8rem; opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${escapeHtml(memberUsername)}</span>
</div> </div>
${getCallIndicatorHtml()}
</button>`; </button>`;
} }
@ -3067,6 +3174,9 @@ async function openDm(dmId, username, targetId, type = 'dms') {
} }
await switchDetailsContent("title.details", membersHtml); await switchDetailsContent("title.details", membersHtml);
updateVoicePresenceUI();
refreshRoomCallState();
let msgContainer = document.getElementById("chat-messages"); let msgContainer = document.getElementById("chat-messages");
if (msgContainer) { if (msgContainer) {
msgContainer.innerHTML = `<div style="text-align: center; opacity: 0.5; padding: 1rem;"><blah>desc.messages.loading</blah></div>`; msgContainer.innerHTML = `<div style="text-align: center; opacity: 0.5; padding: 1rem;"><blah>desc.messages.loading</blah></div>`;

View file

@ -1201,6 +1201,27 @@ input:checked + .toggle-slider:before {
100% { transform: scale(1); } 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 */
.custom-select { .custom-select {
position: relative; position: relative;

View file

@ -6,6 +6,16 @@ let voiceActiveUsers = [];
let voiceAesKeyBytes = null; // grupowy klucz rozmowy (root) - dystrybuowany hybrydą PQ let voiceAesKeyBytes = null; // grupowy klucz rozmowy (root) - dystrybuowany hybrydą PQ
let voiceUiContainer = null; 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): === // === Gwarantowana unikalność IV (AES-GCM): ===
// 1) każdy nadawca szyfruje WŁASNYM podkluczem HKDF(root, "larpix:voice:v1:"+senderId) // 1) każdy nadawca szyfruje WŁASNYM podkluczem HKDF(root, "larpix:voice:v1:"+senderId)
// -> kolizja między nadawcami niemożliwa z konstrukcji, // -> kolizja między nadawcami niemożliwa z konstrukcji,
@ -748,8 +758,14 @@ async function initiateVoiceCall(roomId = null) {
showBlahNotification("error:already.in.call"); showBlahNotification("error:already.in.call");
return; return;
} }
currentVoiceCallType = window.currentRoomType === "groups" ? "groups" : "dms";
currentVoiceCallId = roomId || window.currentDmId; 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; voiceJoinInFlight = true;
try { try {
@ -1153,14 +1169,51 @@ async function startVoiceSession(serverUrl, ticket) {
async function handleCallUpdate(data) { async function handleCallUpdate(data) {
let parts = data.split(":"); let parts = data.split(":");
let roomId = parts[1]; let roomId = parts[1];
let incomingState = [];
if (parts.slice(2).join(":") !== "") {
try { incomingState = JSON.parse(parts.slice(2).join(":")).map(u => String(u)); } catch (e) {}
}
if (typeof ensureActiveCallsMap !== "function") return;
let callMap = ensureActiveCallsMap();
let roomKey = sanitizeId(roomId);
let prev = callMap[roomKey];
//Brak klucza = brak rozmowy; pusta tablica = aktywna, skład nieznany (seed)
if (incomingState.length > 0) callMap[roomKey] = incomingState;
else delete callMap[roomKey];
//Optymalizacja: identyczny stan nie wymaga żadnej pracy po stronie UI
//(serwer/duplikaty mogą wysłać to samo wielokrotnie przy dużym ruchu).
//Porównujemy też samą AKTYWNOŚĆ - przejście "seed[] -> koniec rozmowy"
//(usunięcie klucza) musi odświeżyć UI mimo identycznej pustej listy.
let activeBefore = Array.isArray(prev);
let activeAfter = incomingState.length > 0;
let changed = activeBefore !== activeAfter ||
(activeAfter && !(prev.length === incomingState.length &&
prev.every((u, i) => u === incomingState[i])));
//Targetowana aktualizacja - przerysowujemy wskaźniki TYLKO tego pokoju
if (changed && typeof updateVoicePresenceUI === "function") updateVoicePresenceUI([roomKey]);
//--- GLOBALNA obsługa rozmowy przychodzącej. Działa dla KAŻDEGO pokoju, w
//którym jesteśmy członkiem, niezależnie od otwartego czatu.
let myIdStr = String(window.id);
let amInThisCall = incomingState.includes(myIdStr);
let leftAgo = Date.now() - (voiceRecentlyLeftRooms[roomId] || 0);
if (amInThisCall || incomingState.length === 0) {
if (incomingCallRoomId === roomId) dismissIncomingCall();
} else if (leftAgo > 3000 && !voiceJoinInFlight) {
if (incomingCallRoomId === roomId) {
//Banner już wisi dla tego pokoju - odśwież tylko licznik uczestników
updateIncomingCallSubtitle(incomingState.length);
} else if (!currentVoiceWs) {
showIncomingCallUI(roomId, incomingState.length);
}
}
//Ignore updates for rooms we have nothing to do with - they would otherwise //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. //overwrite the participant list of an ongoing call and wipe its tiles.
if (roomId !== currentVoiceCallId && roomId !== window.currentDmId) return; if (roomId !== currentVoiceCallId && roomId !== window.currentDmId) return;
let state = []; let state = incomingState.filter(u => !deadPeers.has(u));
if(parts.slice(2).join(":") !== "") {
let stateRaw = JSON.parse(parts.slice(2).join(":"));
state = stateRaw.map(u => String(u)).filter(u => !deadPeers.has(u));
}
voiceActiveUsers = state; voiceActiveUsers = state;
if (currentVoiceCallId === roomId) { if (currentVoiceCallId === roomId) {
@ -1201,13 +1254,6 @@ async function handleCallUpdate(data) {
updateVoiceUIUsers(); updateVoiceUIUsers();
} }
} }
} else if (state.length > 0 && !state.includes(String(window.id))) {
// Someone is in a call, and we are not. Show incoming call banner if it's the current room.
if (window.currentDmId === roomId) {
showIncomingCallUI(roomId);
}
} else if (state.length === 0) {
dismissIncomingCall();
} }
} }
@ -1254,13 +1300,28 @@ async function resolveCallerInfo(roomId) {
return { name: (name ? name.split(':')[0] : null) || processBlah("title.unknown"), pfpUrl }; return { name: (name ? name.split(':')[0] : null) || processBlah("title.unknown"), pfpUrl };
} }
function showIncomingCallUI(roomId) { function showIncomingCallUI(roomId, participantCount = null) {
if (document.getElementById("incoming-call-popup") || currentVoiceWs || voiceJoinInFlight) return; if (currentVoiceWs || voiceJoinInFlight) return;
let cachedName = window.dmUsernameCache ? window.dmUsernameCache[roomId] : null; let cachedName = window.dmUsernameCache ? window.dmUsernameCache[roomId] : null;
if (cachedName && cachedName.includes(':')) cachedName = cachedName.split(':')[0]; if (cachedName && cachedName.includes(':')) cachedName = cachedName.split(':')[0];
let cachedPfp = window.dmPfpCache ? window.dmPfpCache[roomId] : null; 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"); let incomingUi = document.createElement("div");
incomingUi.id = "incoming-call-popup"; incomingUi.id = "incoming-call-popup";
// Remote-controlled values (username/avatar) are NEVER interpolated into // Remote-controlled values (username/avatar) are NEVER interpolated into
@ -1269,10 +1330,10 @@ function showIncomingCallUI(roomId) {
<img class="incoming-call-avatar" alt=""> <img class="incoming-call-avatar" alt="">
<div class="incoming-call-info"> <div class="incoming-call-info">
<span class="incoming-call-name"></span> <span class="incoming-call-name"></span>
<span class="incoming-call-subtitle">${processBlah("title.incoming.call")} · ${processBlah(`:desc.call.in.chat:${voiceActiveUsers.length}`)}</span> <span class="incoming-call-subtitle">${processBlah("title.incoming.call")} · ${processBlah(`:desc.call.in.chat:${participantCount}`)}</span>
</div> </div>
<div class="incoming-call-actions"> <div class="incoming-call-actions">
<button class="voice-call-btn accept" onclick="joinIncomingCall('${roomId}')"> <button class="voice-call-btn accept" onclick="joinIncomingCall('${safeRoomId}')">
${ICONS.call({width: "1rem", height: "1rem"})} ${processBlah("action.call.join")} ${ICONS.call({width: "1rem", height: "1rem"})} ${processBlah("action.call.join")}
</button> </button>
<button class="voice-call-btn decline" onclick="dismissIncomingCall()"> <button class="voice-call-btn decline" onclick="dismissIncomingCall()">
@ -1296,7 +1357,14 @@ function showIncomingCallUI(roomId) {
}); });
} }
//Aktualizuje tylko licznik uczestników w wiszącym bannerze (bez re-renderu)
function updateIncomingCallSubtitle(participantCount) {
let el = document.querySelector("#incoming-call-popup .incoming-call-subtitle");
if (el) el.textContent = `${processBlah("title.incoming.call")} · ${processBlah(`:desc.call.in.chat:${participantCount}`)}`;
}
function dismissIncomingCall() { function dismissIncomingCall() {
incomingCallRoomId = null;
let el = document.getElementById("incoming-call-popup"); let el = document.getElementById("incoming-call-popup");
if (!el || el.classList.contains("hide")) return; if (!el || el.classList.contains("hide")) return;
el.classList.remove("show"); el.classList.remove("show");
@ -2382,6 +2450,17 @@ function endVoiceSession() {
setVoiceStatus("disconnected"); setVoiceStatus("disconnected");
hideVoiceDeviceSettings(); hideVoiceDeviceSettings();
//Oznacz pokój jako "właśnie opuszczony" - broadcast call_update po naszym
//leave wróci do nas z opóźnieniem i bez tego globalna obsługa bannera
//uznałaby, że ktoś dzwoni z pokoju, który sami właśnie opuściliśmy.
if (currentVoiceCallId) {
let now = Date.now();
voiceRecentlyLeftRooms[currentVoiceCallId] = now;
for (let k in voiceRecentlyLeftRooms) {
if (now - voiceRecentlyLeftRooms[k] > 30000) delete voiceRecentlyLeftRooms[k];
}
}
const nativeRouting = getNativeVoiceRouting(); const nativeRouting = getNativeVoiceRouting();
if (nativeRouting && nativeRouting.stopCall) { if (nativeRouting && nativeRouting.stopCall) {
try { nativeRouting.stopCall(); } catch (e) {} try { nativeRouting.stopCall(); } catch (e) {}

View file

@ -55,6 +55,7 @@ const ICONS = {
video: (props) => ICONS._feather(`<polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/>`, props), video: (props) => ICONS._feather(`<polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/>`, props),
file: (props) => ICONS._feather(`<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>`, props), file: (props) => ICONS._feather(`<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>`, props),
mic: (props) => ICONS._feather(`<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"></path><path d="M19 10v2a7 7 0 0 1-14 0v-2"></path><line x1="12" y1="19" x2="12" y2="23"></line><line x1="8" y1="23" x2="16" y2="23"></line>`, props), mic: (props) => ICONS._feather(`<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"></path><path d="M19 10v2a7 7 0 0 1-14 0v-2"></path><line x1="12" y1="19" x2="12" y2="23"></line><line x1="8" y1="23" x2="16" y2="23"></line>`, props),
headphones: (props) => ICONS._feather(`<path d="M3 18v-6a9 9 0 0 1 18 0v6"></path><path d="M21 19a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3zM3 19a2 2 0 0 0 2 2h1a2 2 0 0 0 2-2v-3a2 2 0 0 0-2-2H3z"></path>`, props),
speaker: (props) => ICONS._create(`<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"></path>`, { viewBox: "0 0 24 24", width: "1.4rem", height: "1.4rem", fill: "var(--text-color)", stroke: "none" }, props), speaker: (props) => ICONS._create(`<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"></path>`, { viewBox: "0 0 24 24", width: "1.4rem", height: "1.4rem", fill: "var(--text-color)", stroke: "none" }, props),
home: (props) => ICONS._create(`<path d="M1 6V15H6V11C6 9.89543 6.89543 9 8 9C9.10457 9 10 9.89543 10 11V15H15V6L8 0L1 6Z" stroke="currentColor" stroke-width="1.28" stroke-linejoin="round" stroke-linecap="round" transform="translate(0 0.5)"/>`, { viewBox: "-1 -1 18 18", width: "1.5rem", height: "1.5rem", fill: "none", stroke: "none" }, props), home: (props) => ICONS._create(`<path d="M1 6V15H6V11C6 9.89543 6.89543 9 8 9C9.10457 9 10 9.89543 10 11V15H15V6L8 0L1 6Z" stroke="currentColor" stroke-width="1.28" stroke-linejoin="round" stroke-linecap="round" transform="translate(0 0.5)"/>`, { viewBox: "-1 -1 18 18", width: "1.5rem", height: "1.5rem", fill: "none", stroke: "none" }, props),

View file

@ -2674,6 +2674,8 @@ async function renderDms(res) {
let isRead = dmData.string3 === "true"; let isRead = dmData.string3 === "true";
let nameStyle = isRead ? "opacity: 0.8;" : "opacity: 1;"; let nameStyle = isRead ? "opacity: 0.8;" : "opacity: 1;";
seedCallStateFromList(dmIdRaw, dmData.call === true);
let safeTargetUsername = encodeURIComponent(targetUsername).replace(/'/g, "%27"); let safeTargetUsername = encodeURIComponent(targetUsername).replace(/'/g, "%27");
let unreadBadge = `<div class="unread-badge ${isRead ? "" : "show"}"></div>`; let unreadBadge = `<div class="unread-badge ${isRead ? "" : "show"}"></div>`;
@ -2686,6 +2688,7 @@ async function renderDms(res) {
<span class="room-id" style="font-size: 0.8rem; opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${escapeHtml(targetUsername)} (${escapeHtml(targetId.split(';')[0])})</span> <span class="room-id" style="font-size: 0.8rem; opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${escapeHtml(targetUsername)} (${escapeHtml(targetId.split(';')[0])})</span>
</div> </div>
${unreadBadge} ${unreadBadge}
${getCallIndicatorHtml()}
</button> </button>
`; `;
count++; count++;
@ -2694,6 +2697,7 @@ async function renderDms(res) {
if (count > 0) { if (count > 0) {
container.innerHTML = html; container.innerHTML = html;
container.classList.remove("empty"); container.classList.remove("empty");
updateVoicePresenceUI();
} else { } else {
container.innerHTML = `<p style="opacity: 0.5;"><blah>desc.no.dms</blah></p>`; container.innerHTML = `<p style="opacity: 0.5;"><blah>desc.no.dms</blah></p>`;
container.classList.add("empty"); container.classList.add("empty");
@ -2731,6 +2735,8 @@ async function renderGroups(res) {
let isRead = groupData.string3 === "true"; let isRead = groupData.string3 === "true";
let nameStyle = isRead ? "opacity: 0.8;" : "opacity: 1;"; let nameStyle = isRead ? "opacity: 0.8;" : "opacity: 1;";
seedCallStateFromList(groupId, groupData.call === true);
let infoRes = await fetchEncrypted("group/info", groupId); let infoRes = await fetchEncrypted("group/info", groupId);
if (infoRes && infoRes.startsWith("{")) { if (infoRes && infoRes.startsWith("{")) {
let info = JSON.parse(infoRes); let info = JSON.parse(infoRes);
@ -2750,6 +2756,7 @@ async function renderGroups(res) {
<span class="room-id" style="font-size: 0.8rem; opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${escapeHtmlText(groupId.replace(';', ':'))}</span> <span class="room-id" style="font-size: 0.8rem; opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${escapeHtmlText(groupId.replace(';', ':'))}</span>
</div> </div>
${unreadBadge} ${unreadBadge}
${getCallIndicatorHtml()}
</button> </button>
`; `;
count++; count++;
@ -2762,6 +2769,7 @@ async function renderGroups(res) {
if (count > 0) { if (count > 0) {
container.innerHTML = html; container.innerHTML = html;
container.classList.remove("empty"); container.classList.remove("empty");
updateVoicePresenceUI();
} else { } else {
container.innerHTML = `<p style="opacity: 0.5;"><blah>desc.no.groups</blah></p>`; container.innerHTML = `<p style="opacity: 0.5;"><blah>desc.no.groups</blah></p>`;
container.classList.add("empty"); container.classList.add("empty");
@ -2813,6 +2821,101 @@ function escapeHtmlText(value) {
return escapeHtml(String(value ?? "")); return escapeHtml(String(value ?? ""));
} }
// --- Wskaźniki aktywnej rozmowy głosowej (zielone słuchawki) ---
function getCallIndicatorHtml() {
return `<div class="call-indicator">${ICONS.headphones({ width: "1rem", height: "1rem", stroke: "var(--big-green)" })}</div>`;
}
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) { async function generateDmMembersHtml(targetId, targetUsername) {
let myIdStr = id + ";" + host; let myIdStr = id + ";" + host;
let myUsername = await fetchAsync(`${url}/idtoname?id=${myIdStr}`); let myUsername = await fetchAsync(`${url}/idtoname?id=${myIdStr}`);
@ -2831,19 +2934,21 @@ async function generateDmMembersHtml(targetId, targetUsername) {
<div class="sidebar-section-header" style="margin-top: 0.5rem; margin-bottom: 0.5rem; width: 100%;"> <div class="sidebar-section-header" style="margin-top: 0.5rem; margin-bottom: 0.5rem; width: 100%;">
<span style="padding-left: 0.6rem; font-size: 0.8rem; font-weight: bold; opacity: 0.5; text-transform: uppercase;"><blah>title.members</blah></span> <span style="padding-left: 0.6rem; font-size: 0.8rem; font-weight: bold; opacity: 0.5; text-transform: uppercase;"><blah>title.members</blah></span>
</div> </div>
<button class="room-entry" onclick="openProfile('${safeTargetId}')" style="margin-bottom: 0;"> <button class="room-entry" data-member-id="${sanitizeId(targetId.split(';')[0])}" onclick="openProfile('${safeTargetId}')" style="margin-bottom: 0;">
<img src="${targetPfp}" class="room-pfp"> <img src="${targetPfp}" class="room-pfp">
<div style="display: flex; flex-direction: column; text-align: left; overflow: hidden; width: 100%; margin-left: 0.37rem;"> <div style="display: flex; flex-direction: column; text-align: left; overflow: hidden; width: 100%; margin-left: 0.37rem;">
<span class="room-name">${targetDisplayName}</span> <span class="room-name">${targetDisplayName}</span>
<span class="room-id" style="font-size: 0.8rem; opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${escapeHtmlText(targetUsername)}</span> <span class="room-id" style="font-size: 0.8rem; opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${escapeHtmlText(targetUsername)}</span>
</div> </div>
${getCallIndicatorHtml()}
</button> </button>
<button class="room-entry" onclick="openProfile('${safeMyId}')" style="margin-bottom: 0;"> <button class="room-entry" data-member-id="${sanitizeId(String(id))}" onclick="openProfile('${safeMyId}')" style="margin-bottom: 0;">
<img src="${myPfp}" class="room-pfp"> <img src="${myPfp}" class="room-pfp">
<div style="display: flex; flex-direction: column; text-align: left; overflow: hidden; width: 100%; margin-left: 0.37rem;"> <div style="display: flex; flex-direction: column; text-align: left; overflow: hidden; width: 100%; margin-left: 0.37rem;">
<span class="room-name">${myDisplaySafe}</span> <span class="room-name">${myDisplaySafe}</span>
<span class="room-id" style="font-size: 0.8rem; opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${escapeHtmlText(myUsername)}</span> <span class="room-id" style="font-size: 0.8rem; opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${escapeHtmlText(myUsername)}</span>
</div> </div>
${getCallIndicatorHtml()}
</button> </button>
</div> </div>
`; `;
@ -2972,14 +3077,16 @@ async function generateGroupMembersHtml(groupId, groupName) {
let memberDisplayName = escapeHtml(memberUsername.split(':')[0]); let memberDisplayName = escapeHtml(memberUsername.split(':')[0]);
let safeMemberId = sanitizeId(memberId); let safeMemberId = sanitizeId(memberId);
let safeGroupId = sanitizeId(groupId); let safeGroupId = sanitizeId(groupId);
let memberNumericId = sanitizeId(String(memberId).split(';')[0]);
html += ` html += `
<button class="room-entry" onclick="openProfile('${safeMemberId}')" oncontextmenu="handleMemberContextMenu(event, '${safeGroupId}', '${safeMemberId}')" style="margin-bottom: 0;"> <button class="room-entry" data-member-id="${memberNumericId}" onclick="openProfile('${safeMemberId}')" oncontextmenu="handleMemberContextMenu(event, '${safeGroupId}', '${safeMemberId}')" style="margin-bottom: 0;">
<img src="${memberPfp}" class="room-pfp"> <img src="${memberPfp}" class="room-pfp">
<div style="display: flex; flex-direction: column; text-align: left; overflow: hidden; width: 100%; margin-left: 0.37rem;"> <div style="display: flex; flex-direction: column; text-align: left; overflow: hidden; width: 100%; margin-left: 0.37rem;">
<span class="room-name">${memberDisplayName}</span> <span class="room-name">${memberDisplayName}</span>
<span class="room-id" style="font-size: 0.8rem; opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${escapeHtml(memberUsername)}</span> <span class="room-id" style="font-size: 0.8rem; opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${escapeHtml(memberUsername)}</span>
</div> </div>
${getCallIndicatorHtml()}
</button>`; </button>`;
} }
@ -3067,6 +3174,9 @@ async function openDm(dmId, username, targetId, type = 'dms') {
} }
await switchDetailsContent("title.details", membersHtml); await switchDetailsContent("title.details", membersHtml);
updateVoicePresenceUI();
refreshRoomCallState();
let msgContainer = document.getElementById("chat-messages"); let msgContainer = document.getElementById("chat-messages");
if (msgContainer) { if (msgContainer) {
msgContainer.innerHTML = `<div style="text-align: center; opacity: 0.5; padding: 1rem;"><blah>desc.messages.loading</blah></div>`; msgContainer.innerHTML = `<div style="text-align: center; opacity: 0.5; padding: 1rem;"><blah>desc.messages.loading</blah></div>`;

View file

@ -1201,6 +1201,27 @@ input:checked + .toggle-slider:before {
100% { transform: scale(1); } 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 */
.custom-select { .custom-select {
position: relative; position: relative;

View file

@ -6,6 +6,16 @@ let voiceActiveUsers = [];
let voiceAesKeyBytes = null; // grupowy klucz rozmowy (root) - dystrybuowany hybrydą PQ let voiceAesKeyBytes = null; // grupowy klucz rozmowy (root) - dystrybuowany hybrydą PQ
let voiceUiContainer = null; 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): === // === Gwarantowana unikalność IV (AES-GCM): ===
// 1) każdy nadawca szyfruje WŁASNYM podkluczem HKDF(root, "larpix:voice:v1:"+senderId) // 1) każdy nadawca szyfruje WŁASNYM podkluczem HKDF(root, "larpix:voice:v1:"+senderId)
// -> kolizja między nadawcami niemożliwa z konstrukcji, // -> kolizja między nadawcami niemożliwa z konstrukcji,
@ -748,8 +758,14 @@ async function initiateVoiceCall(roomId = null) {
showBlahNotification("error:already.in.call"); showBlahNotification("error:already.in.call");
return; return;
} }
currentVoiceCallType = window.currentRoomType === "groups" ? "groups" : "dms";
currentVoiceCallId = roomId || window.currentDmId; 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; voiceJoinInFlight = true;
try { try {
@ -1153,14 +1169,51 @@ async function startVoiceSession(serverUrl, ticket) {
async function handleCallUpdate(data) { async function handleCallUpdate(data) {
let parts = data.split(":"); let parts = data.split(":");
let roomId = parts[1]; let roomId = parts[1];
let incomingState = [];
if (parts.slice(2).join(":") !== "") {
try { incomingState = JSON.parse(parts.slice(2).join(":")).map(u => String(u)); } catch (e) {}
}
if (typeof ensureActiveCallsMap !== "function") return;
let callMap = ensureActiveCallsMap();
let roomKey = sanitizeId(roomId);
let prev = callMap[roomKey];
//Brak klucza = brak rozmowy; pusta tablica = aktywna, skład nieznany (seed)
if (incomingState.length > 0) callMap[roomKey] = incomingState;
else delete callMap[roomKey];
//Optymalizacja: identyczny stan nie wymaga żadnej pracy po stronie UI
//(serwer/duplikaty mogą wysłać to samo wielokrotnie przy dużym ruchu).
//Porównujemy też samą AKTYWNOŚĆ - przejście "seed[] -> koniec rozmowy"
//(usunięcie klucza) musi odświeżyć UI mimo identycznej pustej listy.
let activeBefore = Array.isArray(prev);
let activeAfter = incomingState.length > 0;
let changed = activeBefore !== activeAfter ||
(activeAfter && !(prev.length === incomingState.length &&
prev.every((u, i) => u === incomingState[i])));
//Targetowana aktualizacja - przerysowujemy wskaźniki TYLKO tego pokoju
if (changed && typeof updateVoicePresenceUI === "function") updateVoicePresenceUI([roomKey]);
//--- GLOBALNA obsługa rozmowy przychodzącej. Działa dla KAŻDEGO pokoju, w
//którym jesteśmy członkiem, niezależnie od otwartego czatu.
let myIdStr = String(window.id);
let amInThisCall = incomingState.includes(myIdStr);
let leftAgo = Date.now() - (voiceRecentlyLeftRooms[roomId] || 0);
if (amInThisCall || incomingState.length === 0) {
if (incomingCallRoomId === roomId) dismissIncomingCall();
} else if (leftAgo > 3000 && !voiceJoinInFlight) {
if (incomingCallRoomId === roomId) {
//Banner już wisi dla tego pokoju - odśwież tylko licznik uczestników
updateIncomingCallSubtitle(incomingState.length);
} else if (!currentVoiceWs) {
showIncomingCallUI(roomId, incomingState.length);
}
}
//Ignore updates for rooms we have nothing to do with - they would otherwise //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. //overwrite the participant list of an ongoing call and wipe its tiles.
if (roomId !== currentVoiceCallId && roomId !== window.currentDmId) return; if (roomId !== currentVoiceCallId && roomId !== window.currentDmId) return;
let state = []; let state = incomingState.filter(u => !deadPeers.has(u));
if(parts.slice(2).join(":") !== "") {
let stateRaw = JSON.parse(parts.slice(2).join(":"));
state = stateRaw.map(u => String(u)).filter(u => !deadPeers.has(u));
}
voiceActiveUsers = state; voiceActiveUsers = state;
if (currentVoiceCallId === roomId) { if (currentVoiceCallId === roomId) {
@ -1201,13 +1254,6 @@ async function handleCallUpdate(data) {
updateVoiceUIUsers(); updateVoiceUIUsers();
} }
} }
} else if (state.length > 0 && !state.includes(String(window.id))) {
// Someone is in a call, and we are not. Show incoming call banner if it's the current room.
if (window.currentDmId === roomId) {
showIncomingCallUI(roomId);
}
} else if (state.length === 0) {
dismissIncomingCall();
} }
} }
@ -1254,13 +1300,28 @@ async function resolveCallerInfo(roomId) {
return { name: (name ? name.split(':')[0] : null) || processBlah("title.unknown"), pfpUrl }; return { name: (name ? name.split(':')[0] : null) || processBlah("title.unknown"), pfpUrl };
} }
function showIncomingCallUI(roomId) { function showIncomingCallUI(roomId, participantCount = null) {
if (document.getElementById("incoming-call-popup") || currentVoiceWs || voiceJoinInFlight) return; if (currentVoiceWs || voiceJoinInFlight) return;
let cachedName = window.dmUsernameCache ? window.dmUsernameCache[roomId] : null; let cachedName = window.dmUsernameCache ? window.dmUsernameCache[roomId] : null;
if (cachedName && cachedName.includes(':')) cachedName = cachedName.split(':')[0]; if (cachedName && cachedName.includes(':')) cachedName = cachedName.split(':')[0];
let cachedPfp = window.dmPfpCache ? window.dmPfpCache[roomId] : null; 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"); let incomingUi = document.createElement("div");
incomingUi.id = "incoming-call-popup"; incomingUi.id = "incoming-call-popup";
// Remote-controlled values (username/avatar) are NEVER interpolated into // Remote-controlled values (username/avatar) are NEVER interpolated into
@ -1269,10 +1330,10 @@ function showIncomingCallUI(roomId) {
<img class="incoming-call-avatar" alt=""> <img class="incoming-call-avatar" alt="">
<div class="incoming-call-info"> <div class="incoming-call-info">
<span class="incoming-call-name"></span> <span class="incoming-call-name"></span>
<span class="incoming-call-subtitle">${processBlah("title.incoming.call")} · ${processBlah(`:desc.call.in.chat:${voiceActiveUsers.length}`)}</span> <span class="incoming-call-subtitle">${processBlah("title.incoming.call")} · ${processBlah(`:desc.call.in.chat:${participantCount}`)}</span>
</div> </div>
<div class="incoming-call-actions"> <div class="incoming-call-actions">
<button class="voice-call-btn accept" onclick="joinIncomingCall('${roomId}')"> <button class="voice-call-btn accept" onclick="joinIncomingCall('${safeRoomId}')">
${ICONS.call({width: "1rem", height: "1rem"})} ${processBlah("action.call.join")} ${ICONS.call({width: "1rem", height: "1rem"})} ${processBlah("action.call.join")}
</button> </button>
<button class="voice-call-btn decline" onclick="dismissIncomingCall()"> <button class="voice-call-btn decline" onclick="dismissIncomingCall()">
@ -1296,7 +1357,14 @@ function showIncomingCallUI(roomId) {
}); });
} }
//Aktualizuje tylko licznik uczestników w wiszącym bannerze (bez re-renderu)
function updateIncomingCallSubtitle(participantCount) {
let el = document.querySelector("#incoming-call-popup .incoming-call-subtitle");
if (el) el.textContent = `${processBlah("title.incoming.call")} · ${processBlah(`:desc.call.in.chat:${participantCount}`)}`;
}
function dismissIncomingCall() { function dismissIncomingCall() {
incomingCallRoomId = null;
let el = document.getElementById("incoming-call-popup"); let el = document.getElementById("incoming-call-popup");
if (!el || el.classList.contains("hide")) return; if (!el || el.classList.contains("hide")) return;
el.classList.remove("show"); el.classList.remove("show");
@ -2382,6 +2450,17 @@ function endVoiceSession() {
setVoiceStatus("disconnected"); setVoiceStatus("disconnected");
hideVoiceDeviceSettings(); hideVoiceDeviceSettings();
//Oznacz pokój jako "właśnie opuszczony" - broadcast call_update po naszym
//leave wróci do nas z opóźnieniem i bez tego globalna obsługa bannera
//uznałaby, że ktoś dzwoni z pokoju, który sami właśnie opuściliśmy.
if (currentVoiceCallId) {
let now = Date.now();
voiceRecentlyLeftRooms[currentVoiceCallId] = now;
for (let k in voiceRecentlyLeftRooms) {
if (now - voiceRecentlyLeftRooms[k] > 30000) delete voiceRecentlyLeftRooms[k];
}
}
const nativeRouting = getNativeVoiceRouting(); const nativeRouting = getNativeVoiceRouting();
if (nativeRouting && nativeRouting.stopCall) { if (nativeRouting && nativeRouting.stopCall) {
try { nativeRouting.stopCall(); } catch (e) {} try { nativeRouting.stopCall(); } catch (e) {}