diff --git a/android/app/build.gradle b/android/app/build.gradle
index 70f0a50e..11a28535 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -7,8 +7,8 @@ android {
applicationId "olcxja.miarven"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
- versionCode 7
- versionName "0.2.1"
+ versionCode 8
+ versionName "0.2.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
diff --git a/android/app/src/main/assets/public/icons.js b/android/app/src/main/assets/public/icons.js
index 5f006339..497cb118 100644
--- a/android/app/src/main/assets/public/icons.js
+++ b/android/app/src/main/assets/public/icons.js
@@ -55,6 +55,7 @@ const ICONS = {
video: (props) => ICONS._feather(``, props),
file: (props) => ICONS._feather(``, props),
mic: (props) => ICONS._feather(``, props),
+ headphones: (props) => ICONS._feather(``, props),
speaker: (props) => ICONS._create(``, { viewBox: "0 0 24 24", width: "1.4rem", height: "1.4rem", fill: "var(--text-color)", stroke: "none" }, props),
home: (props) => ICONS._create(``, { viewBox: "-1 -1 18 18", width: "1.5rem", height: "1.5rem", fill: "none", stroke: "none" }, props),
diff --git a/android/app/src/main/assets/public/main.js b/android/app/src/main/assets/public/main.js
index fa231a06..a5d0ca32 100644
--- a/android/app/src/main/assets/public/main.js
+++ b/android/app/src/main/assets/public/main.js
@@ -1149,12 +1149,20 @@ function createAvatarSvg(name, size = 512) {
}
async function getAvatarUrl(id, username) {
+ //S22: helper robi fetch istnienia pliku - bez cache każde odświeżenie listy
+ //generowało 1-2 żądania NA wiersz (główny składnik N+1).
+ //Cache'owane TYLKO pozytywne potwierdzenia - nieudane lookupy (chwilowy
+ //network itp.) nie mogą przyklejać fallback-SVG na minutę
+ if (!window.pfpExistenceCache) window.pfpExistenceCache = {};
+ const cached = window.pfpExistenceCache[id];
+ if (cached && Date.now() - cached.ts < 60000) return cached.url;
try {
let pfpUrl = `${url}/user/storage/public/getentry?id=${id}&e=larp.profile.pfp`;
if (id === window.id && window.profileUpdateTimestamp) pfpUrl += `&t=${window.profileUpdateTimestamp}`;
if ((await fetchAsync(pfpUrl)) == "") {
throw Error();
}
+ window.pfpExistenceCache[id] = { url: pfpUrl, ts: Date.now() };
return pfpUrl;
} catch (e) {
return createAvatarSvg(username);
@@ -1162,11 +1170,15 @@ async function getAvatarUrl(id, username) {
}
async function getGroupAvatarUrl(groupId, groupName) {
+ if (!window.groupPfpExistenceCache) window.groupPfpExistenceCache = {};
+ const cached = window.groupPfpExistenceCache[groupId];
+ if (cached && Date.now() - cached.ts < 60000) return cached.url;
try {
let pfpUrl = `${url}/group/storage/public/getentry?id=${groupId}&e=larp.profile.pfp`;
if ((await fetchAsync(pfpUrl)) == "") {
throw Error();
}
+ window.groupPfpExistenceCache[groupId] = { url: pfpUrl, ts: Date.now() };
return pfpUrl;
} catch (e) {
return createAvatarSvg(groupName);
@@ -1557,7 +1569,7 @@ async function switchRoomsBar(title, content) {
await delay(200);
- roomsTopBarTransition.innerHTML = processBlah(title);
+ roomsTopBarTransition.innerHTML = escapeHtmlText(processBlah(title));
let parser = new DOMParser();
@@ -1636,10 +1648,10 @@ async function switchRoomContent(title, content, showRoomBar, icon = "", skipMob
let clickableContent = showRoomBar ?
`` :
`
- ${icon ? icon + '' : ''}${processBlah(title)}
+ ${icon ? icon + '' : ''}${escapeHtmlText(processBlah(title))}
`;
roomsTopBarTransition.innerHTML = `${backBtnHtml}${clickableContent}
${pinCallBtns}`;
@@ -1710,7 +1722,7 @@ async function switchDetailsContent(title, content, showBack = false, backAction
pinCallBtns = `${groupMenuBtnHtml}${pinCallBtns}`;
}
let backBtn = showBack ? `` : "";
- roomsTopBarTransition.innerHTML = `${backBtn}${processBlah(title)}${pinCallBtns}`;
+ roomsTopBarTransition.innerHTML = `${backBtn}${escapeHtmlText(processBlah(title))}${pinCallBtns}`;
let parser = new DOMParser();
let doc = parser.parseFromString(content, "text/html");
@@ -2033,7 +2045,9 @@ async function openProfile(accountId) {
});
let roleRes = await fetchEncrypted("room/role", payload);
if (roleRes && !roleRes.startsWith("error:")) {
- let mappedRole = "role." + roleRes.replace(" ", "_");
+ //roleRes trafia do innerHTML - whitelist znaków zamiast surowej
+ //podstawienia (audyt W6: serwer-controlled string w szablonie)
+ let mappedRole = "role." + String(roleRes).replace(/[^A-Za-z0-9_.]/g, "_");
roleHtml = `${mappedRole}
`;
}
}
@@ -2275,7 +2289,7 @@ async function renderInvites(res, type) {
}
let entry = invitesEntry
- .replaceAll("{username}", groupName)
+ .replaceAll("{username}", escapeHtmlText(groupName))
.replaceAll("{pfp}", pfp)
.replaceAll("{desc}", `${desc}`)
.replaceAll("{actions}", actions)
@@ -2644,7 +2658,13 @@ function handleMobileSwipe() {
}
}
+//S22: generacje renderów - przy nakładających się refreshDms/refreshGroups
+//(ws events, fallbacki) starszy wynik nie może nadpisać nowszego innerHTML-em
+let dmListRenderGen = 0;
+let groupListRenderGen = 0;
+
async function renderDms(res) {
+ const gen = ++dmListRenderGen;
let container = document.getElementById("dms-list");
if (!container) return;
@@ -2665,8 +2685,13 @@ async function renderDms(res) {
let myId = `${id};${host}`;
let targetId = sanitizeId(parts[0] === myId ? parts[1] : parts[0]);
- let targetUsername = await fetchAsync(`${url}/idtoname?id=${targetId}`);
- if (!targetUsername || targetUsername.trim() === "" || targetUsername.startsWith("error:")) continue;
+ //S22: cache nazw - idtoname było wołane od nowa przy każdym renderze
+ let targetUsername = window.dmUsernameCache ? window.dmUsernameCache[targetId] : null;
+ if (!targetUsername) {
+ targetUsername = await fetchAsync(`${url}/idtoname?id=${targetId}`);
+ if (!targetUsername || targetUsername.trim() === "" || targetUsername.startsWith("error:")) continue;
+ if (window.dmUsernameCache) window.dmUsernameCache[targetId] = targetUsername;
+ }
let pfp = await getAvatarUrl(targetId, targetUsername);
let displayName = escapeHtml(targetUsername.split(':')[0]);
@@ -2674,6 +2699,8 @@ async function renderDms(res) {
let isRead = dmData.string3 === "true";
let nameStyle = isRead ? "opacity: 0.8;" : "opacity: 1;";
+ seedCallStateFromList(dmIdRaw, dmData.call === true);
+
let safeTargetUsername = encodeURIComponent(targetUsername).replace(/'/g, "%27");
let unreadBadge = ``;
@@ -2686,15 +2713,19 @@ async function renderDms(res) {
${escapeHtml(targetUsername)} (${escapeHtml(targetId.split(';')[0])})
${unreadBadge}
+ ${getCallIndicatorHtml()}
`;
count++;
}
+ if (gen !== dmListRenderGen) return; //nowszy render przejął listę
if (count > 0) {
container.innerHTML = html;
container.classList.remove("empty");
+ updateVoicePresenceUI();
} else {
+ if (gen !== dmListRenderGen) return;
container.innerHTML = `desc.no.dms
`;
container.classList.add("empty");
let blahTags = container.getElementsByTagName("blah");
@@ -2712,7 +2743,17 @@ async function renderDms(res) {
}
}
+//S22: TTL-cache nazw grup (60 s) - nazwy rzadko się zmieniają,
+//a group/info było najdroższym elementem renderu listy grup
+function getGroupNameFromCache(groupId) {
+ if (!window.groupNameCache) return null;
+ let entry = window.groupNameCache[sanitizeId(groupId)];
+ if (!entry || Date.now() - entry.ts > 60000) return null;
+ return entry.name;
+}
+
async function renderGroups(res) {
+ const gen = ++groupListRenderGen;
let container = document.getElementById("groups-list");
if (!container) return;
@@ -2731,11 +2772,26 @@ async function renderGroups(res) {
let isRead = groupData.string3 === "true";
let nameStyle = isRead ? "opacity: 0.8;" : "opacity: 1;";
- let infoRes = await fetchEncrypted("group/info", groupId);
- if (infoRes && infoRes.startsWith("{")) {
- let info = JSON.parse(infoRes);
- let groupName = info.string2;
+ seedCallStateFromList(groupId, groupData.call === true);
+ //S22: TTL-cache nazw grup - group/info było wołane per grupa per render
+ let groupName = getGroupNameFromCache(groupId);
+ if (groupName === null) {
+ let infoRes = await fetchEncrypted("group/info", groupId);
+ if (infoRes && infoRes.startsWith("{")) {
+ groupName = JSON.parse(infoRes).string2;
+ if (!window.groupNameCache) window.groupNameCache = {};
+ window.groupNameCache[sanitizeId(groupId)] = { name: groupName, ts: Date.now() };
+ } else {
+ if (infoRes === "error:group.not.found") {
+ //grupa została usunięta - wyczyść nieaktualny wpis z listy
+ removeRoomFromCachedList(groupId, 'groups');
+ }
+ continue;
+ }
+ }
+
+ {
let safeGroupName = encodeURIComponent(groupName).replace(/'/g, "%27");
let pfp = await getGroupAvatarUrl(groupId, groupName);
@@ -2750,19 +2806,20 @@ async function renderGroups(res) {
${escapeHtmlText(groupId.replace(';', ':'))}
${unreadBadge}
+ ${getCallIndicatorHtml()}
`;
count++;
- } else if (infoRes === "error:group.not.found") {
- //grupa została usunięta - wyczyść nieaktualny wpis z listy
- removeRoomFromCachedList(groupId, 'groups');
}
}
+ if (gen !== groupListRenderGen) return; //nowszy render przejął listę
if (count > 0) {
container.innerHTML = html;
container.classList.remove("empty");
+ updateVoicePresenceUI();
} else {
+ if (gen !== groupListRenderGen) return;
container.innerHTML = `desc.no.groups
`;
container.classList.add("empty");
let blahTags = container.getElementsByTagName("blah");
@@ -2813,6 +2870,101 @@ function escapeHtmlText(value) {
return escapeHtml(String(value ?? ""));
}
+// --- Wskaźniki aktywnej rozmowy głosowej (zielone słuchawki) ---
+
+function getCallIndicatorHtml() {
+ return `${ICONS.headphones({ width: "1rem", height: "1rem", stroke: "var(--big-green)" })}
`;
+}
+
+function roomHasActiveCall(roomId) {
+ if (!roomId || !window.activeCallsByRoom) return false;
+ //Semantyka mapy: klucz ISTNIEJE = rozmowa trwa. Pusta tablica oznacza
+ //"aktywna, ale skład jeszcze nieznany" (seed z listy DM/grup).
+ return Array.isArray(window.activeCallsByRoom[sanitizeId(roomId)]);
+}
+
+//Zapisuje flagę "trwa rozmowa" z listy DM/grup (nie nadpisuje znanej listy
+//uczestników z call_update - ta jest dokładniejsza).
+function ensureActiveCallsMap() {
+ //Obiekt bez prototypu - klucze pochodzą z danych sieciowych (roomId), a
+ //zapis np. pod "__proto__" na zwykłym obiekcie zanieczyściłby prototyp
+ if (!window.activeCallsByRoom) window.activeCallsByRoom = Object.create(null);
+ return window.activeCallsByRoom;
+}
+
+function seedCallStateFromList(roomId, hasCall) {
+ if (!hasCall) return;
+ let map = ensureActiveCallsMap();
+ let key = sanitizeId(roomId);
+ if (!Array.isArray(map[key])) {
+ map[key] = [];
+ }
+}
+
+function toggleCallIndicator(rootEl, show) {
+ if (!rootEl) return;
+ let ind = rootEl.querySelector(".call-indicator");
+ if (ind) ind.classList.toggle("show", !!show);
+}
+
+//Odświeża wskaźniki rozmów: listy DM/grup, przyciski call w top-barach
+//oraz wiersze członków w panelu details.
+//roomIds = null -> pełna synchronizacja (po renderze list),
+//roomIds = [id] -> tylko wskazane pokoje (ścieżka gorąca: call_update),
+// żeby przy dużych listach nie skanować całego DOM.
+function updateVoicePresenceUI(roomIds = null) {
+ let targets = Array.isArray(roomIds) ? roomIds.map(r => sanitizeId(r)) : null;
+
+ [["dms-list", "dm-btn-"], ["groups-list", "group-btn-"]].forEach(([listId, prefix]) => {
+ let container = document.getElementById(listId);
+ if (!container) return;
+ container.querySelectorAll(".room-entry").forEach(btn => {
+ if (!btn.id.startsWith(prefix)) return;
+ let roomId = btn.id.substring(prefix.length);
+ if (targets && !targets.includes(roomId)) return;
+ toggleCallIndicator(btn, roomHasActiveCall(roomId));
+ });
+ });
+
+ let currentKey = window.currentDmId ? sanitizeId(window.currentDmId) : null;
+ let currentAffected = targets === null || (currentKey !== null && targets.includes(currentKey));
+ if (!currentAffected) return;
+
+ let inCurrent = roomHasActiveCall(window.currentDmId);
+ document.querySelectorAll(".room-action-call svg").forEach(svg => {
+ svg.style.fill = inCurrent ? "var(--big-green)" : "";
+ });
+
+ if (currentKey) {
+ let users = (window.activeCallsByRoom && window.activeCallsByRoom[currentKey]) || [];
+ let detailsBar = document.getElementById("roomdetailsbar");
+ if (detailsBar) {
+ detailsBar.querySelectorAll(".room-entry[data-member-id]").forEach(btn => {
+ toggleCallIndicator(btn, users.includes(btn.getAttribute("data-member-id")));
+ });
+ }
+ }
+}
+
+//Pobiera aktualny skład rozmowy dla otwartego pokoju (snapshot po otwarciu -
+//call_update przychodzi dopiero przy join/leave).
+async function refreshRoomCallState() {
+ try {
+ if (!window.currentDmId || !window.currentRoomType) return;
+ let res = await fetchEncrypted("room/voice/state", JSON.stringify({
+ string1: window.currentRoomType,
+ string2: window.currentDmId
+ }));
+ if (!res || !res.startsWith("[") || !window.currentDmId) return;
+ let participants = JSON.parse(res).map(u => String(u).split(";")[0]);
+ let map = ensureActiveCallsMap();
+ let key = sanitizeId(window.currentDmId);
+ if (participants.length > 0) map[key] = participants;
+ else delete map[key];
+ updateVoicePresenceUI([key]);
+ } catch (e) {}
+}
+
async function generateDmMembersHtml(targetId, targetUsername) {
let myIdStr = id + ";" + host;
let myUsername = await fetchAsync(`${url}/idtoname?id=${myIdStr}`);
@@ -2831,19 +2983,21 @@ async function generateDmMembersHtml(targetId, targetUsername) {
-