diff --git a/android/app/src/main/assets/public/icons.js b/android/app/src/main/assets/public/icons.js
index 5f006339..497cb118 100644
--- a/android/app/src/main/assets/public/icons.js
+++ b/android/app/src/main/assets/public/icons.js
@@ -55,6 +55,7 @@ const ICONS = {
video: (props) => ICONS._feather(``, props),
file: (props) => ICONS._feather(``, props),
mic: (props) => ICONS._feather(``, props),
+ headphones: (props) => ICONS._feather(``, props),
speaker: (props) => ICONS._create(``, { viewBox: "0 0 24 24", width: "1.4rem", height: "1.4rem", fill: "var(--text-color)", stroke: "none" }, props),
home: (props) => ICONS._create(``, { viewBox: "-1 -1 18 18", width: "1.5rem", height: "1.5rem", fill: "none", stroke: "none" }, props),
diff --git a/android/app/src/main/assets/public/main.js b/android/app/src/main/assets/public/main.js
index fa231a06..dded8edb 100644
--- a/android/app/src/main/assets/public/main.js
+++ b/android/app/src/main/assets/public/main.js
@@ -2674,6 +2674,8 @@ async function renderDms(res) {
let isRead = dmData.string3 === "true";
let nameStyle = isRead ? "opacity: 0.8;" : "opacity: 1;";
+ seedCallStateFromList(dmIdRaw, dmData.call === true);
+
let safeTargetUsername = encodeURIComponent(targetUsername).replace(/'/g, "%27");
let unreadBadge = `
`;
@@ -2686,6 +2688,7 @@ async function renderDms(res) {
${escapeHtml(targetUsername)} (${escapeHtml(targetId.split(';')[0])})
${unreadBadge}
+ ${getCallIndicatorHtml()}
`;
count++;
@@ -2694,6 +2697,7 @@ async function renderDms(res) {
if (count > 0) {
container.innerHTML = html;
container.classList.remove("empty");
+ updateVoicePresenceUI();
} else {
container.innerHTML = `desc.no.dms
`;
container.classList.add("empty");
@@ -2731,6 +2735,8 @@ async function renderGroups(res) {
let isRead = groupData.string3 === "true";
let nameStyle = isRead ? "opacity: 0.8;" : "opacity: 1;";
+ seedCallStateFromList(groupId, groupData.call === true);
+
let infoRes = await fetchEncrypted("group/info", groupId);
if (infoRes && infoRes.startsWith("{")) {
let info = JSON.parse(infoRes);
@@ -2750,6 +2756,7 @@ async function renderGroups(res) {
${escapeHtmlText(groupId.replace(';', ':'))}
${unreadBadge}
+ ${getCallIndicatorHtml()}
`;
count++;
@@ -2762,6 +2769,7 @@ async function renderGroups(res) {
if (count > 0) {
container.innerHTML = html;
container.classList.remove("empty");
+ updateVoicePresenceUI();
} else {
container.innerHTML = `desc.no.groups
`;
container.classList.add("empty");
@@ -2813,6 +2821,101 @@ function escapeHtmlText(value) {
return escapeHtml(String(value ?? ""));
}
+// --- Wskaźniki aktywnej rozmowy głosowej (zielone słuchawki) ---
+
+function getCallIndicatorHtml() {
+ return `${ICONS.headphones({ width: "1rem", height: "1rem", stroke: "var(--big-green)" })}
`;
+}
+
+function roomHasActiveCall(roomId) {
+ if (!roomId || !window.activeCallsByRoom) return false;
+ //Semantyka mapy: klucz ISTNIEJE = rozmowa trwa. Pusta tablica oznacza
+ //"aktywna, ale skład jeszcze nieznany" (seed z listy DM/grup).
+ return Array.isArray(window.activeCallsByRoom[sanitizeId(roomId)]);
+}
+
+//Zapisuje flagę "trwa rozmowa" z listy DM/grup (nie nadpisuje znanej listy
+//uczestników z call_update - ta jest dokładniejsza).
+function ensureActiveCallsMap() {
+ //Obiekt bez prototypu - klucze pochodzą z danych sieciowych (roomId), a
+ //zapis np. pod "__proto__" na zwykłym obiekcie zanieczyściłby prototyp
+ if (!window.activeCallsByRoom) window.activeCallsByRoom = Object.create(null);
+ return window.activeCallsByRoom;
+}
+
+function seedCallStateFromList(roomId, hasCall) {
+ if (!hasCall) return;
+ let map = ensureActiveCallsMap();
+ let key = sanitizeId(roomId);
+ if (!Array.isArray(map[key])) {
+ map[key] = [];
+ }
+}
+
+function toggleCallIndicator(rootEl, show) {
+ if (!rootEl) return;
+ let ind = rootEl.querySelector(".call-indicator");
+ if (ind) ind.classList.toggle("show", !!show);
+}
+
+//Odświeża wskaźniki rozmów: listy DM/grup, przyciski call w top-barach
+//oraz wiersze członków w panelu details.
+//roomIds = null -> pełna synchronizacja (po renderze list),
+//roomIds = [id] -> tylko wskazane pokoje (ścieżka gorąca: call_update),
+// żeby przy dużych listach nie skanować całego DOM.
+function updateVoicePresenceUI(roomIds = null) {
+ let targets = Array.isArray(roomIds) ? roomIds.map(r => sanitizeId(r)) : null;
+
+ [["dms-list", "dm-btn-"], ["groups-list", "group-btn-"]].forEach(([listId, prefix]) => {
+ let container = document.getElementById(listId);
+ if (!container) return;
+ container.querySelectorAll(".room-entry").forEach(btn => {
+ if (!btn.id.startsWith(prefix)) return;
+ let roomId = btn.id.substring(prefix.length);
+ if (targets && !targets.includes(roomId)) return;
+ toggleCallIndicator(btn, roomHasActiveCall(roomId));
+ });
+ });
+
+ let currentKey = window.currentDmId ? sanitizeId(window.currentDmId) : null;
+ let currentAffected = targets === null || (currentKey !== null && targets.includes(currentKey));
+ if (!currentAffected) return;
+
+ let inCurrent = roomHasActiveCall(window.currentDmId);
+ document.querySelectorAll(".room-action-call svg").forEach(svg => {
+ svg.style.fill = inCurrent ? "var(--big-green)" : "";
+ });
+
+ if (currentKey) {
+ let users = (window.activeCallsByRoom && window.activeCallsByRoom[currentKey]) || [];
+ let detailsBar = document.getElementById("roomdetailsbar");
+ if (detailsBar) {
+ detailsBar.querySelectorAll(".room-entry[data-member-id]").forEach(btn => {
+ toggleCallIndicator(btn, users.includes(btn.getAttribute("data-member-id")));
+ });
+ }
+ }
+}
+
+//Pobiera aktualny skład rozmowy dla otwartego pokoju (snapshot po otwarciu -
+//call_update przychodzi dopiero przy join/leave).
+async function refreshRoomCallState() {
+ try {
+ if (!window.currentDmId || !window.currentRoomType) return;
+ let res = await fetchEncrypted("room/voice/state", JSON.stringify({
+ string1: window.currentRoomType,
+ string2: window.currentDmId
+ }));
+ if (!res || !res.startsWith("[") || !window.currentDmId) return;
+ let participants = JSON.parse(res).map(u => String(u).split(";")[0]);
+ let map = ensureActiveCallsMap();
+ let key = sanitizeId(window.currentDmId);
+ if (participants.length > 0) map[key] = participants;
+ else delete map[key];
+ updateVoicePresenceUI([key]);
+ } catch (e) {}
+}
+
async function generateDmMembersHtml(targetId, targetUsername) {
let myIdStr = id + ";" + host;
let myUsername = await fetchAsync(`${url}/idtoname?id=${myIdStr}`);
@@ -2831,19 +2934,21 @@ async function generateDmMembersHtml(targetId, targetUsername) {
-