Compare commits
No commits in common. "e3b1a6fc8bd87ae700fc53b4ca2c941fe305895f" and "d618ee3134d1a1402a7171eef2a0369b79dcdb6d" have entirely different histories.
e3b1a6fc8b
...
d618ee3134
14 changed files with 165 additions and 887 deletions
|
|
@ -7,8 +7,8 @@ android {
|
||||||
applicationId "olcxja.miarven"
|
applicationId "olcxja.miarven"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 8
|
versionCode 7
|
||||||
versionName "0.2.2"
|
versionName "0.2.1"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,6 @@ 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),
|
||||||
|
|
|
||||||
|
|
@ -1149,20 +1149,12 @@ function createAvatarSvg(name, size = 512) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getAvatarUrl(id, username) {
|
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 {
|
try {
|
||||||
let pfpUrl = `${url}/user/storage/public/getentry?id=${id}&e=larp.profile.pfp`;
|
let pfpUrl = `${url}/user/storage/public/getentry?id=${id}&e=larp.profile.pfp`;
|
||||||
if (id === window.id && window.profileUpdateTimestamp) pfpUrl += `&t=${window.profileUpdateTimestamp}`;
|
if (id === window.id && window.profileUpdateTimestamp) pfpUrl += `&t=${window.profileUpdateTimestamp}`;
|
||||||
if ((await fetchAsync(pfpUrl)) == "") {
|
if ((await fetchAsync(pfpUrl)) == "") {
|
||||||
throw Error();
|
throw Error();
|
||||||
}
|
}
|
||||||
window.pfpExistenceCache[id] = { url: pfpUrl, ts: Date.now() };
|
|
||||||
return pfpUrl;
|
return pfpUrl;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return createAvatarSvg(username);
|
return createAvatarSvg(username);
|
||||||
|
|
@ -1170,15 +1162,11 @@ async function getAvatarUrl(id, username) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getGroupAvatarUrl(groupId, groupName) {
|
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 {
|
try {
|
||||||
let pfpUrl = `${url}/group/storage/public/getentry?id=${groupId}&e=larp.profile.pfp`;
|
let pfpUrl = `${url}/group/storage/public/getentry?id=${groupId}&e=larp.profile.pfp`;
|
||||||
if ((await fetchAsync(pfpUrl)) == "") {
|
if ((await fetchAsync(pfpUrl)) == "") {
|
||||||
throw Error();
|
throw Error();
|
||||||
}
|
}
|
||||||
window.groupPfpExistenceCache[groupId] = { url: pfpUrl, ts: Date.now() };
|
|
||||||
return pfpUrl;
|
return pfpUrl;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return createAvatarSvg(groupName);
|
return createAvatarSvg(groupName);
|
||||||
|
|
@ -1569,7 +1557,7 @@ async function switchRoomsBar(title, content) {
|
||||||
|
|
||||||
await delay(200);
|
await delay(200);
|
||||||
|
|
||||||
roomsTopBarTransition.innerHTML = escapeHtmlText(processBlah(title));
|
roomsTopBarTransition.innerHTML = processBlah(title);
|
||||||
|
|
||||||
|
|
||||||
let parser = new DOMParser();
|
let parser = new DOMParser();
|
||||||
|
|
@ -1648,10 +1636,10 @@ async function switchRoomContent(title, content, showRoomBar, icon = "", skipMob
|
||||||
|
|
||||||
let clickableContent = showRoomBar ?
|
let clickableContent = showRoomBar ?
|
||||||
`<button id="room-topbar-clickable" class="inherit" onclick="mobileNavDetails();" style="cursor: pointer; display: flex; align-items: center; border-radius: 0.65rem; background: transparent; outline: none; height: 100%; text-align: left; min-width: 0; width: auto; max-width: 100%;">
|
`<button id="room-topbar-clickable" class="inherit" onclick="mobileNavDetails();" style="cursor: pointer; display: flex; align-items: center; border-radius: 0.65rem; background: transparent; outline: none; height: 100%; text-align: left; min-width: 0; width: auto; max-width: 100%;">
|
||||||
${icon ? icon + '<space></space>' : ''}<inherit style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; min-width: 0; width: auto;">${escapeHtmlText(processBlah(title))}</inherit>
|
${icon ? icon + '<space></space>' : ''}<inherit style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; min-width: 0; width: auto;">${processBlah(title)}</inherit>
|
||||||
</button>` :
|
</button>` :
|
||||||
`<inherit style="display: flex; align-items: center; height: 100%; min-width: 0; width: auto; max-width: 100%;">
|
`<inherit style="display: flex; align-items: center; height: 100%; min-width: 0; width: auto; max-width: 100%;">
|
||||||
${icon ? icon + '<space></space>' : ''}<inherit style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; min-width: 0; width: auto;">${escapeHtmlText(processBlah(title))}</inherit>
|
${icon ? icon + '<space></space>' : ''}<inherit style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; min-width: 0; width: auto;">${processBlah(title)}</inherit>
|
||||||
</inherit>`;
|
</inherit>`;
|
||||||
roomsTopBarTransition.innerHTML = `${backBtnHtml}${clickableContent}<div class="flex-spacer"></div>${pinCallBtns}`;
|
roomsTopBarTransition.innerHTML = `${backBtnHtml}${clickableContent}<div class="flex-spacer"></div>${pinCallBtns}`;
|
||||||
|
|
||||||
|
|
@ -1722,7 +1710,7 @@ async function switchDetailsContent(title, content, showBack = false, backAction
|
||||||
pinCallBtns = `${groupMenuBtnHtml}${pinCallBtns}`;
|
pinCallBtns = `${groupMenuBtnHtml}${pinCallBtns}`;
|
||||||
}
|
}
|
||||||
let backBtn = showBack ? `<button class="topbar-action-btn" onclick="${backAction}" style="margin-right: 0.5rem">${ICONS.arrowBack({ width: "1.5rem", height: "1.5rem" })}</button>` : "";
|
let backBtn = showBack ? `<button class="topbar-action-btn" onclick="${backAction}" style="margin-right: 0.5rem">${ICONS.arrowBack({ width: "1.5rem", height: "1.5rem" })}</button>` : "";
|
||||||
roomsTopBarTransition.innerHTML = `<inherit style="display: flex; align-items: center;">${backBtn}<inherit>${escapeHtmlText(processBlah(title))}</inherit></inherit><div class="flex-spacer"></div>${pinCallBtns}`;
|
roomsTopBarTransition.innerHTML = `<inherit style="display: flex; align-items: center;">${backBtn}<inherit>${processBlah(title)}</inherit></inherit><div class="flex-spacer"></div>${pinCallBtns}`;
|
||||||
|
|
||||||
let parser = new DOMParser();
|
let parser = new DOMParser();
|
||||||
let doc = parser.parseFromString(content, "text/html");
|
let doc = parser.parseFromString(content, "text/html");
|
||||||
|
|
@ -2045,9 +2033,7 @@ async function openProfile(accountId) {
|
||||||
});
|
});
|
||||||
let roleRes = await fetchEncrypted("room/role", payload);
|
let roleRes = await fetchEncrypted("room/role", payload);
|
||||||
if (roleRes && !roleRes.startsWith("error:")) {
|
if (roleRes && !roleRes.startsWith("error:")) {
|
||||||
//roleRes trafia do innerHTML - whitelist znaków zamiast surowej
|
let mappedRole = "role." + roleRes.replace(" ", "_");
|
||||||
//podstawienia (audyt W6: serwer-controlled string w szablonie)
|
|
||||||
let mappedRole = "role." + String(roleRes).replace(/[^A-Za-z0-9_.]/g, "_");
|
|
||||||
roleHtml = `<div class="profile-role" style="opacity:0.7; font-size:0.9rem; margin-top:0.3rem;"><blah>${mappedRole}</blah></div>`;
|
roleHtml = `<div class="profile-role" style="opacity:0.7; font-size:0.9rem; margin-top:0.3rem;"><blah>${mappedRole}</blah></div>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2289,7 +2275,7 @@ async function renderInvites(res, type) {
|
||||||
}
|
}
|
||||||
|
|
||||||
let entry = invitesEntry
|
let entry = invitesEntry
|
||||||
.replaceAll("{username}", escapeHtmlText(groupName))
|
.replaceAll("{username}", groupName)
|
||||||
.replaceAll("{pfp}", pfp)
|
.replaceAll("{pfp}", pfp)
|
||||||
.replaceAll("{desc}", `<blah>${desc}</blah>`)
|
.replaceAll("{desc}", `<blah>${desc}</blah>`)
|
||||||
.replaceAll("{actions}", actions)
|
.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) {
|
async function renderDms(res) {
|
||||||
const gen = ++dmListRenderGen;
|
|
||||||
let container = document.getElementById("dms-list");
|
let container = document.getElementById("dms-list");
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
|
|
@ -2685,13 +2665,8 @@ async function renderDms(res) {
|
||||||
let myId = `${id};${host}`;
|
let myId = `${id};${host}`;
|
||||||
let targetId = sanitizeId(parts[0] === myId ? parts[1] : parts[0]);
|
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 = await fetchAsync(`${url}/idtoname?id=${targetId}`);
|
||||||
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 (!targetUsername || targetUsername.trim() === "" || targetUsername.startsWith("error:")) continue;
|
||||||
if (window.dmUsernameCache) window.dmUsernameCache[targetId] = targetUsername;
|
|
||||||
}
|
|
||||||
|
|
||||||
let pfp = await getAvatarUrl(targetId, targetUsername);
|
let pfp = await getAvatarUrl(targetId, targetUsername);
|
||||||
let displayName = escapeHtml(targetUsername.split(':')[0]);
|
let displayName = escapeHtml(targetUsername.split(':')[0]);
|
||||||
|
|
@ -2699,8 +2674,6 @@ 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>`;
|
||||||
|
|
@ -2713,19 +2686,15 @@ 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++;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (gen !== dmListRenderGen) return; //nowszy render przejął listę
|
|
||||||
if (count > 0) {
|
if (count > 0) {
|
||||||
container.innerHTML = html;
|
container.innerHTML = html;
|
||||||
container.classList.remove("empty");
|
container.classList.remove("empty");
|
||||||
updateVoicePresenceUI();
|
|
||||||
} else {
|
} else {
|
||||||
if (gen !== dmListRenderGen) return;
|
|
||||||
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");
|
||||||
let blahTags = container.getElementsByTagName("blah");
|
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) {
|
async function renderGroups(res) {
|
||||||
const gen = ++groupListRenderGen;
|
|
||||||
let container = document.getElementById("groups-list");
|
let container = document.getElementById("groups-list");
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
|
|
@ -2772,26 +2731,11 @@ 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);
|
|
||||||
|
|
||||||
//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);
|
let infoRes = await fetchEncrypted("group/info", groupId);
|
||||||
if (infoRes && infoRes.startsWith("{")) {
|
if (infoRes && infoRes.startsWith("{")) {
|
||||||
groupName = JSON.parse(infoRes).string2;
|
let info = JSON.parse(infoRes);
|
||||||
if (!window.groupNameCache) window.groupNameCache = {};
|
let groupName = info.string2;
|
||||||
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 safeGroupName = encodeURIComponent(groupName).replace(/'/g, "%27");
|
||||||
let pfp = await getGroupAvatarUrl(groupId, groupName);
|
let pfp = await getGroupAvatarUrl(groupId, groupName);
|
||||||
|
|
||||||
|
|
@ -2806,20 +2750,19 @@ 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++;
|
||||||
|
} 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) {
|
if (count > 0) {
|
||||||
container.innerHTML = html;
|
container.innerHTML = html;
|
||||||
container.classList.remove("empty");
|
container.classList.remove("empty");
|
||||||
updateVoicePresenceUI();
|
|
||||||
} else {
|
} else {
|
||||||
if (gen !== groupListRenderGen) return;
|
|
||||||
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");
|
||||||
let blahTags = container.getElementsByTagName("blah");
|
let blahTags = container.getElementsByTagName("blah");
|
||||||
|
|
@ -2870,101 +2813,6 @@ 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}`);
|
||||||
|
|
@ -2983,21 +2831,19 @@ 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" data-member-id="${sanitizeId(targetId.split(';')[0])}" onclick="openProfile('${safeTargetId}')" style="margin-bottom: 0;">
|
<button class="room-entry" 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" data-member-id="${sanitizeId(String(id))}" onclick="openProfile('${safeMyId}')" style="margin-bottom: 0;">
|
<button class="room-entry" 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>
|
||||||
`;
|
`;
|
||||||
|
|
@ -3126,16 +2972,14 @@ 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" data-member-id="${memberNumericId}" onclick="openProfile('${safeMemberId}')" oncontextmenu="handleMemberContextMenu(event, '${safeGroupId}', '${safeMemberId}')" style="margin-bottom: 0;">
|
<button class="room-entry" 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>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3223,9 +3067,6 @@ 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>`;
|
||||||
|
|
|
||||||
|
|
@ -1201,27 +1201,6 @@ 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;
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
const CLIENT_VERSION = "0.2.2";
|
const CLIENT_VERSION = "0.2.1";
|
||||||
|
|
|
||||||
|
|
@ -6,16 +6,6 @@ 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,
|
||||||
|
|
@ -137,9 +127,6 @@ const VOICE_MAX_RECONNECTS = 2;
|
||||||
let peerLastSeen = {};
|
let peerLastSeen = {};
|
||||||
let deadPeers = new Set();
|
let deadPeers = new Set();
|
||||||
let lastVoiceServerPacketAt = 0;
|
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 voicePingTimer = null;
|
||||||
let voiceRosterRetryTimer = null;
|
let voiceRosterRetryTimer = null;
|
||||||
let lastVoiceKeyRequestAt = 0;
|
let lastVoiceKeyRequestAt = 0;
|
||||||
|
|
@ -547,10 +534,8 @@ function setVoiceStatus(state) {
|
||||||
updateVoicePing();
|
updateVoicePing();
|
||||||
} else {
|
} else {
|
||||||
if (voiceTimerInterval) { clearInterval(voiceTimerInterval); voiceTimerInterval = null; }
|
if (voiceTimerInterval) { clearInterval(voiceTimerInterval); voiceTimerInterval = null; }
|
||||||
//Anchor czasu NIE jest kasowany przy "connecting" - reconnect w trakcie
|
voiceConnectedAt = null;
|
||||||
//rozmowy nie może resetować licznika na 00:00; czyszczone tylko przy
|
if (state === "disconnected") { voicePingMs = null; updateVoicePing(); }
|
||||||
//realnym rozłączeniu (disconnected z endVoiceSession)
|
|
||||||
if (state === "disconnected") { voiceConnectedAt = null; voicePingMs = null; updateVoicePing(); }
|
|
||||||
if (textEl) textEl.innerText = processBlah(state === "disconnected" ? "status.voice.disconnected" : "status.voice.connecting");
|
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
|
// Ghost peers: still on the room roster but totally silent (pings count as
|
||||||
// life). Audyt S15: UKRYWANIE ich z UI tworzyło niewidzialnego
|
// life) - drop them locally until the server or the peer proves otherwise.
|
||||||
// podsłuchiwacza - voice-server dalej relayaudio do ich socketu, a u innych
|
|
||||||
// znikał z listy. Teraz peer zostaje WIDOCZNY (roster serwera jest źródłem
|
|
||||||
// prawdy); czyścimy jedynie jego kanały mediów - wrócą z pierwszym pakietem.
|
|
||||||
// deadPeers pełni tu rolę znacznika "media już wycięte", nie "ukryty".
|
|
||||||
let ghostRemoved = false;
|
let ghostRemoved = false;
|
||||||
for (const u of voiceActiveUsers) {
|
for (const u of voiceActiveUsers) {
|
||||||
if (u === String(window.id)) continue;
|
if (u === String(window.id)) continue;
|
||||||
|
|
@ -626,30 +607,19 @@ setInterval(() => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (ghostRemoved) {
|
if (ghostRemoved) {
|
||||||
|
voiceActiveUsers = voiceActiveUsers.filter(u => !deadPeers.has(u));
|
||||||
updateVoiceUIUsers();
|
updateVoiceUIUsers();
|
||||||
}
|
}
|
||||||
// Half-open socket watchdog + backgrounded-tab audio recovery.
|
// Half-open socket watchdog + backgrounded-tab audio recovery.
|
||||||
if (currentVoiceWs && currentVoiceWs.readyState === WebSocket.OPEN) {
|
if (currentVoiceWs && currentVoiceWs.readyState === WebSocket.OPEN) {
|
||||||
const others = voiceActiveUsers.filter(u => u !== String(window.id)).length;
|
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
|
// Alive peers ping every 2s regardless of mute, so total inbound
|
||||||
// silence while others are present means the socket is dead.
|
// silence while others are present means the socket is dead.
|
||||||
if (others > 0 && now - lastVoiceServerPacketAt > VOICE_SERVER_TIMEOUT_MS) {
|
if (others > 0 && now - lastVoiceServerPacketAt > VOICE_SERVER_TIMEOUT_MS) {
|
||||||
console.warn("Voice: no inbound traffic, forcing reconnect");
|
console.warn("Voice: no inbound traffic, forcing reconnect");
|
||||||
try { currentVoiceWs.close(); } catch (e) {}
|
try { currentVoiceWs.close(); } catch (e) {}
|
||||||
} else if (audioContext && audioContext.state !== 'running') {
|
} else if (audioContext && audioContext.state === 'suspended') {
|
||||||
//Nie tylko "suspended" - iOS/Safari zna też "interrupted"; każdy
|
|
||||||
//stan != running przy żywej rozmowie wymaga próby wznowienia.
|
|
||||||
//Dokładamy też kickstart-na-geście, jeśli jeszcze nie jest uzbrojony
|
|
||||||
//(armVoiceContextKickstart sam jest idempotentny)
|
|
||||||
audioContext.resume().catch(() => {});
|
audioContext.resume().catch(() => {});
|
||||||
armVoiceContextKickstart();
|
|
||||||
}
|
}
|
||||||
// Safety net for the call-key handshake: if others are in the call but
|
// 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
|
// we never received the root key (raced/lost signal), ask for it
|
||||||
|
|
@ -773,52 +743,13 @@ class TaskQueue {
|
||||||
|
|
||||||
let voiceJoinInFlight = false;
|
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) {
|
async function initiateVoiceCall(roomId = null) {
|
||||||
if (currentVoiceWs || voiceJoinInFlight) {
|
if (currentVoiceWs || voiceJoinInFlight) {
|
||||||
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 {
|
||||||
|
|
@ -828,12 +759,6 @@ async function initiateVoiceCall(roomId = null) {
|
||||||
|
|
||||||
if (joined) {
|
if (joined) {
|
||||||
await startVoiceSession(joined.serverUrl, joined.ticket);
|
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 {
|
} finally {
|
||||||
voiceJoinInFlight = false;
|
voiceJoinInFlight = false;
|
||||||
|
|
@ -943,12 +868,12 @@ async function startVoiceSession(serverUrl, ticket) {
|
||||||
voicePingMs = Math.round(performance.now() - t0);
|
voicePingMs = Math.round(performance.now() - t0);
|
||||||
updateVoicePing();
|
updateVoicePing();
|
||||||
const rawUsers = usersRaw.map(u => String(u));
|
const rawUsers = usersRaw.map(u => String(u));
|
||||||
//Serwer jest źródłem prawdy o składzie - nie filtrujemy po
|
// Peers we locally declared dead stay hidden until either
|
||||||
//deadPeers (audyt S15: ukryty peer nadal dostaje audio)
|
// they send anything again or the server drops them.
|
||||||
for (const gone of [...deadPeers]) {
|
for (const gone of [...deadPeers]) {
|
||||||
if (!rawUsers.includes(gone)) deadPeers.delete(gone);
|
if (!rawUsers.includes(gone)) deadPeers.delete(gone);
|
||||||
}
|
}
|
||||||
const users = rawUsers;
|
const users = rawUsers.filter(u => !deadPeers.has(u));
|
||||||
let changed = (users.length !== voiceActiveUsers.length);
|
let changed = (users.length !== voiceActiveUsers.length);
|
||||||
for(let u of users) if(!voiceActiveUsers.includes(u)) changed = true;
|
for(let u of users) if(!voiceActiveUsers.includes(u)) changed = true;
|
||||||
if(changed) {
|
if(changed) {
|
||||||
|
|
@ -979,11 +904,6 @@ async function startVoiceSession(serverUrl, ticket) {
|
||||||
if (voicePingTimer) clearInterval(voicePingTimer);
|
if (voicePingTimer) clearInterval(voicePingTimer);
|
||||||
voicePingTimer = setInterval(sendVoicePing, VOICE_PING_INTERVAL_MS);
|
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
|
// Reconnects reuse the live mic stream - only capture a new one
|
||||||
// when the previous is gone (full teardown or track ended).
|
// when the previous is gone (full teardown or track ended).
|
||||||
if (!micStream || micStream.getTracks().some(t => t.readyState !== 'live')) {
|
if (!micStream || micStream.getTracks().some(t => t.readyState !== 'live')) {
|
||||||
|
|
@ -1005,17 +925,7 @@ async function startVoiceSession(serverUrl, ticket) {
|
||||||
screenReceiverNode = new AudioWorkletNode(audioContext, 'audio-receiver');
|
screenReceiverNode = new AudioWorkletNode(audioContext, 'audio-receiver');
|
||||||
isAudioWorkletLoaded = true;
|
isAudioWorkletLoaded = true;
|
||||||
}
|
}
|
||||||
if (audioContext.state !== 'running') {
|
if (audioContext.state === 'suspended') await audioContext.resume();
|
||||||
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();
|
applyVoiceSpeakerSink();
|
||||||
setupVoiceOutput();
|
setupVoiceOutput();
|
||||||
|
|
||||||
|
|
@ -1025,12 +935,6 @@ async function startVoiceSession(serverUrl, ticket) {
|
||||||
}
|
}
|
||||||
micSourceNode = audioContext.createMediaStreamSource(micStream);
|
micSourceNode = audioContext.createMediaStreamSource(micStream);
|
||||||
micSourceNode.connect(senderNode);
|
micSourceNode.connect(senderNode);
|
||||||
} catch (err) {
|
|
||||||
console.error("Voice: call setup failed", err);
|
|
||||||
showBlahNotification("error:call.connect.failed");
|
|
||||||
endVoiceSession();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let vadHangover = 0;
|
let vadHangover = 0;
|
||||||
senderNode.port.onmessage = async (e) => {
|
senderNode.port.onmessage = async (e) => {
|
||||||
|
|
@ -1090,8 +994,7 @@ async function startVoiceSession(serverUrl, ticket) {
|
||||||
const baseUrl = currentVoiceWs.url.split('/ws')[0];
|
const baseUrl = currentVoiceWs.url.split('/ws')[0];
|
||||||
const httpUrl = baseUrl.replace('ws:', 'http:').replace('wss:', 'https:');
|
const httpUrl = baseUrl.replace('ws:', 'http:').replace('wss:', 'https:');
|
||||||
fetch(`${httpUrl}/room/users?room=${currentVoiceCallId}`).then(res => res.json()).then(usersRaw => {
|
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)).filter(u => !deadPeers.has(u));
|
||||||
voiceActiveUsers = usersRaw.map(u => String(u));
|
|
||||||
updateVoiceUIUsers();
|
updateVoiceUIUsers();
|
||||||
}).catch(()=>{});
|
}).catch(()=>{});
|
||||||
}
|
}
|
||||||
|
|
@ -1117,17 +1020,12 @@ async function startVoiceSession(serverUrl, ticket) {
|
||||||
audioDecryptChains[audioId].enqueue(async () => {
|
audioDecryptChains[audioId].enqueue(async () => {
|
||||||
try {
|
try {
|
||||||
const senderKey = await senderKeyPromise;
|
const senderKey = await senderKeyPromise;
|
||||||
if (!senderKey) {
|
if (!senderKey) return;
|
||||||
voiceLogError('media.nokey[' + audioId + ']', new Error('brak podklucza nadawcy - root nie zainstalowany?'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const decryptedBuffer = await crypto.subtle.decrypt({name: "AES-GCM", iv}, senderKey, ciphertext);
|
const decryptedBuffer = await crypto.subtle.decrypt({name: "AES-GCM", iv}, senderKey, ciphertext);
|
||||||
if(targetReceiver) targetReceiver.port.postMessage({ clientId: audioId, audioData: decryptedBuffer }, [decryptedBuffer]);
|
if(targetReceiver) targetReceiver.port.postMessage({ clientId: audioId, audioData: decryptedBuffer }, [decryptedBuffer]);
|
||||||
|
|
||||||
setTileSpeaking(clientId, true, isScreenAudio ? `${clientId}-2` : null);
|
setTileSpeaking(clientId, true, isScreenAudio ? `${clientId}-2` : null);
|
||||||
} catch (err) {
|
} catch (err) {}
|
||||||
voiceLogError('media.decrypt[' + audioId + ']', err);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
} else if (type === 1 || type === 2) {
|
} else if (type === 1 || type === 2) {
|
||||||
const decoderId = `${clientId}-${type}`;
|
const decoderId = `${clientId}-${type}`;
|
||||||
|
|
@ -1255,52 +1153,14 @@ 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 = [];
|
//Ignore updates for rooms we have nothing to do with - they would otherwise
|
||||||
if (parts.slice(2).join(":") !== "") {
|
//overwrite the participant list of an ongoing call and wipe its tiles.
|
||||||
try { incomingState = JSON.parse(parts.slice(2).join(":")).map(u => String(u)); } catch (e) {}
|
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;
|
voiceActiveUsers = state;
|
||||||
|
|
||||||
if (currentVoiceCallId === roomId) {
|
if (currentVoiceCallId === roomId) {
|
||||||
|
|
@ -1317,15 +1177,6 @@ async function handleCallUpdate(data) {
|
||||||
for (let u of state) {
|
for (let u of state) {
|
||||||
if (u !== myIdStr) sendVoiceSignalToUser(u, roomId);
|
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();
|
updateVoiceUIUsers();
|
||||||
|
|
||||||
|
|
@ -1350,6 +1201,13 @@ 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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1396,28 +1254,13 @@ 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, participantCount = null) {
|
function showIncomingCallUI(roomId) {
|
||||||
if (currentVoiceWs || voiceJoinInFlight) return;
|
if (document.getElementById("incoming-call-popup") || 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
|
||||||
|
|
@ -1426,10 +1269,10 @@ function showIncomingCallUI(roomId, participantCount = null) {
|
||||||
<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:${participantCount}`)}</span>
|
<span class="incoming-call-subtitle">${processBlah("title.incoming.call")} · ${processBlah(`:desc.call.in.chat:${voiceActiveUsers.length}`)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="incoming-call-actions">
|
<div class="incoming-call-actions">
|
||||||
<button class="voice-call-btn accept" onclick="joinIncomingCall('${safeRoomId}')">
|
<button class="voice-call-btn accept" onclick="joinIncomingCall('${roomId}')">
|
||||||
${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()">
|
||||||
|
|
@ -1453,14 +1296,7 @@ function showIncomingCallUI(roomId, participantCount = null) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
//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");
|
||||||
|
|
@ -1524,7 +1360,6 @@ async function handleRoomSignal(data) {
|
||||||
let aesKeyBytes = await deriveHybridKeyFromSetup(otherPublicKeysObj, setupData.ctMlKem);
|
let aesKeyBytes = await deriveHybridKeyFromSetup(otherPublicKeysObj, setupData.ctMlKem);
|
||||||
let decRoomKeyB64 = await decryptAesGcmFromBase64(setupData.enc, aesKeyBytes);
|
let decRoomKeyB64 = await decryptAesGcmFromBase64(setupData.enc, aesKeyBytes);
|
||||||
await setVoiceRootKey(base64ToUint8(decRoomKeyB64));
|
await setVoiceRootKey(base64ToUint8(decRoomKeyB64));
|
||||||
console.warn("Voice: call key installed (from peer " + signalObj.from + ")");
|
|
||||||
}
|
}
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
console.error("Signal decryption failed", e);
|
console.error("Signal decryption failed", e);
|
||||||
|
|
@ -2254,10 +2089,7 @@ function updateVoiceUIUsers() {
|
||||||
let avatarUrl = avatarRes ? avatarRes : "assets/default_avatar.png";
|
let avatarUrl = avatarRes ? avatarRes : "assets/default_avatar.png";
|
||||||
|
|
||||||
if (!avatarRes && window.getAvatarUrl) {
|
if (!avatarRes && window.getAvatarUrl) {
|
||||||
//Nie podawaj numerycznego ID jako "nazwy" - fallback SVG z cyframi
|
getAvatarUrl(u, authorName || "User").then(url => {
|
||||||
//(id) wygląda jak bug; gdy nie znamy nazwy, neutralny "User"
|
|
||||||
const nameForAvatar = (authorName && authorName !== u) ? authorName : "User";
|
|
||||||
getAvatarUrl(u, nameForAvatar).then(url => {
|
|
||||||
if(url) {
|
if(url) {
|
||||||
window.dmPfpCache[u] = url;
|
window.dmPfpCache[u] = url;
|
||||||
let el = document.getElementById(`voice-tile-${u}`);
|
let el = document.getElementById(`voice-tile-${u}`);
|
||||||
|
|
@ -2550,17 +2382,6 @@ 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) {}
|
||||||
|
|
@ -2592,7 +2413,6 @@ function endVoiceSession() {
|
||||||
deadPeers.clear();
|
deadPeers.clear();
|
||||||
lastVoiceKeyRequestAt = 0;
|
lastVoiceKeyRequestAt = 0;
|
||||||
voiceActiveUsers = [];
|
voiceActiveUsers = [];
|
||||||
voiceHadRemotePeers = false;
|
|
||||||
|
|
||||||
for (let id in audioDecoders) {
|
for (let id in audioDecoders) {
|
||||||
try { audioDecoders[id].close(); } catch(e){}
|
try { audioDecoders[id].close(); } catch(e){}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "Miarven",
|
"name": "Miarven",
|
||||||
"version": "0.2.2",
|
"version": "0.2.1",
|
||||||
"description": "An Amazing Capacitor App",
|
"description": "An Amazing Capacitor App",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "",
|
"name": "",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "olcxja.miarven",
|
"name": "olcxja.miarven",
|
||||||
"version": "0.2.2",
|
"version": "0.2.1",
|
||||||
"description": "Larpix client",
|
"description": "Larpix client",
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"author": "olcxja's softworks",
|
"author": "olcxja's softworks",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"versionCode": 8,
|
"versionCode": 7,
|
||||||
"versionName": "0.2.2",
|
"versionName": "0.2.1",
|
||||||
"npmVersion": "0.2.2"
|
"npmVersion": "0.2.1"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,6 @@ 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),
|
||||||
|
|
|
||||||
189
webroot/main.js
189
webroot/main.js
|
|
@ -1149,20 +1149,12 @@ function createAvatarSvg(name, size = 512) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getAvatarUrl(id, username) {
|
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 {
|
try {
|
||||||
let pfpUrl = `${url}/user/storage/public/getentry?id=${id}&e=larp.profile.pfp`;
|
let pfpUrl = `${url}/user/storage/public/getentry?id=${id}&e=larp.profile.pfp`;
|
||||||
if (id === window.id && window.profileUpdateTimestamp) pfpUrl += `&t=${window.profileUpdateTimestamp}`;
|
if (id === window.id && window.profileUpdateTimestamp) pfpUrl += `&t=${window.profileUpdateTimestamp}`;
|
||||||
if ((await fetchAsync(pfpUrl)) == "") {
|
if ((await fetchAsync(pfpUrl)) == "") {
|
||||||
throw Error();
|
throw Error();
|
||||||
}
|
}
|
||||||
window.pfpExistenceCache[id] = { url: pfpUrl, ts: Date.now() };
|
|
||||||
return pfpUrl;
|
return pfpUrl;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return createAvatarSvg(username);
|
return createAvatarSvg(username);
|
||||||
|
|
@ -1170,15 +1162,11 @@ async function getAvatarUrl(id, username) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getGroupAvatarUrl(groupId, groupName) {
|
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 {
|
try {
|
||||||
let pfpUrl = `${url}/group/storage/public/getentry?id=${groupId}&e=larp.profile.pfp`;
|
let pfpUrl = `${url}/group/storage/public/getentry?id=${groupId}&e=larp.profile.pfp`;
|
||||||
if ((await fetchAsync(pfpUrl)) == "") {
|
if ((await fetchAsync(pfpUrl)) == "") {
|
||||||
throw Error();
|
throw Error();
|
||||||
}
|
}
|
||||||
window.groupPfpExistenceCache[groupId] = { url: pfpUrl, ts: Date.now() };
|
|
||||||
return pfpUrl;
|
return pfpUrl;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return createAvatarSvg(groupName);
|
return createAvatarSvg(groupName);
|
||||||
|
|
@ -1569,7 +1557,7 @@ async function switchRoomsBar(title, content) {
|
||||||
|
|
||||||
await delay(200);
|
await delay(200);
|
||||||
|
|
||||||
roomsTopBarTransition.innerHTML = escapeHtmlText(processBlah(title));
|
roomsTopBarTransition.innerHTML = processBlah(title);
|
||||||
|
|
||||||
|
|
||||||
let parser = new DOMParser();
|
let parser = new DOMParser();
|
||||||
|
|
@ -1648,10 +1636,10 @@ async function switchRoomContent(title, content, showRoomBar, icon = "", skipMob
|
||||||
|
|
||||||
let clickableContent = showRoomBar ?
|
let clickableContent = showRoomBar ?
|
||||||
`<button id="room-topbar-clickable" class="inherit" onclick="mobileNavDetails();" style="cursor: pointer; display: flex; align-items: center; border-radius: 0.65rem; background: transparent; outline: none; height: 100%; text-align: left; min-width: 0; width: auto; max-width: 100%;">
|
`<button id="room-topbar-clickable" class="inherit" onclick="mobileNavDetails();" style="cursor: pointer; display: flex; align-items: center; border-radius: 0.65rem; background: transparent; outline: none; height: 100%; text-align: left; min-width: 0; width: auto; max-width: 100%;">
|
||||||
${icon ? icon + '<space></space>' : ''}<inherit style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; min-width: 0; width: auto;">${escapeHtmlText(processBlah(title))}</inherit>
|
${icon ? icon + '<space></space>' : ''}<inherit style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; min-width: 0; width: auto;">${processBlah(title)}</inherit>
|
||||||
</button>` :
|
</button>` :
|
||||||
`<inherit style="display: flex; align-items: center; height: 100%; min-width: 0; width: auto; max-width: 100%;">
|
`<inherit style="display: flex; align-items: center; height: 100%; min-width: 0; width: auto; max-width: 100%;">
|
||||||
${icon ? icon + '<space></space>' : ''}<inherit style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; min-width: 0; width: auto;">${escapeHtmlText(processBlah(title))}</inherit>
|
${icon ? icon + '<space></space>' : ''}<inherit style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; min-width: 0; width: auto;">${processBlah(title)}</inherit>
|
||||||
</inherit>`;
|
</inherit>`;
|
||||||
roomsTopBarTransition.innerHTML = `${backBtnHtml}${clickableContent}<div class="flex-spacer"></div>${pinCallBtns}`;
|
roomsTopBarTransition.innerHTML = `${backBtnHtml}${clickableContent}<div class="flex-spacer"></div>${pinCallBtns}`;
|
||||||
|
|
||||||
|
|
@ -1722,7 +1710,7 @@ async function switchDetailsContent(title, content, showBack = false, backAction
|
||||||
pinCallBtns = `${groupMenuBtnHtml}${pinCallBtns}`;
|
pinCallBtns = `${groupMenuBtnHtml}${pinCallBtns}`;
|
||||||
}
|
}
|
||||||
let backBtn = showBack ? `<button class="topbar-action-btn" onclick="${backAction}" style="margin-right: 0.5rem">${ICONS.arrowBack({ width: "1.5rem", height: "1.5rem" })}</button>` : "";
|
let backBtn = showBack ? `<button class="topbar-action-btn" onclick="${backAction}" style="margin-right: 0.5rem">${ICONS.arrowBack({ width: "1.5rem", height: "1.5rem" })}</button>` : "";
|
||||||
roomsTopBarTransition.innerHTML = `<inherit style="display: flex; align-items: center;">${backBtn}<inherit>${escapeHtmlText(processBlah(title))}</inherit></inherit><div class="flex-spacer"></div>${pinCallBtns}`;
|
roomsTopBarTransition.innerHTML = `<inherit style="display: flex; align-items: center;">${backBtn}<inherit>${processBlah(title)}</inherit></inherit><div class="flex-spacer"></div>${pinCallBtns}`;
|
||||||
|
|
||||||
let parser = new DOMParser();
|
let parser = new DOMParser();
|
||||||
let doc = parser.parseFromString(content, "text/html");
|
let doc = parser.parseFromString(content, "text/html");
|
||||||
|
|
@ -2045,9 +2033,7 @@ async function openProfile(accountId) {
|
||||||
});
|
});
|
||||||
let roleRes = await fetchEncrypted("room/role", payload);
|
let roleRes = await fetchEncrypted("room/role", payload);
|
||||||
if (roleRes && !roleRes.startsWith("error:")) {
|
if (roleRes && !roleRes.startsWith("error:")) {
|
||||||
//roleRes trafia do innerHTML - whitelist znaków zamiast surowej
|
let mappedRole = "role." + roleRes.replace(" ", "_");
|
||||||
//podstawienia (audyt W6: serwer-controlled string w szablonie)
|
|
||||||
let mappedRole = "role." + String(roleRes).replace(/[^A-Za-z0-9_.]/g, "_");
|
|
||||||
roleHtml = `<div class="profile-role" style="opacity:0.7; font-size:0.9rem; margin-top:0.3rem;"><blah>${mappedRole}</blah></div>`;
|
roleHtml = `<div class="profile-role" style="opacity:0.7; font-size:0.9rem; margin-top:0.3rem;"><blah>${mappedRole}</blah></div>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2289,7 +2275,7 @@ async function renderInvites(res, type) {
|
||||||
}
|
}
|
||||||
|
|
||||||
let entry = invitesEntry
|
let entry = invitesEntry
|
||||||
.replaceAll("{username}", escapeHtmlText(groupName))
|
.replaceAll("{username}", groupName)
|
||||||
.replaceAll("{pfp}", pfp)
|
.replaceAll("{pfp}", pfp)
|
||||||
.replaceAll("{desc}", `<blah>${desc}</blah>`)
|
.replaceAll("{desc}", `<blah>${desc}</blah>`)
|
||||||
.replaceAll("{actions}", actions)
|
.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) {
|
async function renderDms(res) {
|
||||||
const gen = ++dmListRenderGen;
|
|
||||||
let container = document.getElementById("dms-list");
|
let container = document.getElementById("dms-list");
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
|
|
@ -2685,13 +2665,8 @@ async function renderDms(res) {
|
||||||
let myId = `${id};${host}`;
|
let myId = `${id};${host}`;
|
||||||
let targetId = sanitizeId(parts[0] === myId ? parts[1] : parts[0]);
|
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 = await fetchAsync(`${url}/idtoname?id=${targetId}`);
|
||||||
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 (!targetUsername || targetUsername.trim() === "" || targetUsername.startsWith("error:")) continue;
|
||||||
if (window.dmUsernameCache) window.dmUsernameCache[targetId] = targetUsername;
|
|
||||||
}
|
|
||||||
|
|
||||||
let pfp = await getAvatarUrl(targetId, targetUsername);
|
let pfp = await getAvatarUrl(targetId, targetUsername);
|
||||||
let displayName = escapeHtml(targetUsername.split(':')[0]);
|
let displayName = escapeHtml(targetUsername.split(':')[0]);
|
||||||
|
|
@ -2699,8 +2674,6 @@ 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>`;
|
||||||
|
|
@ -2713,19 +2686,15 @@ 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++;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (gen !== dmListRenderGen) return; //nowszy render przejął listę
|
|
||||||
if (count > 0) {
|
if (count > 0) {
|
||||||
container.innerHTML = html;
|
container.innerHTML = html;
|
||||||
container.classList.remove("empty");
|
container.classList.remove("empty");
|
||||||
updateVoicePresenceUI();
|
|
||||||
} else {
|
} else {
|
||||||
if (gen !== dmListRenderGen) return;
|
|
||||||
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");
|
||||||
let blahTags = container.getElementsByTagName("blah");
|
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) {
|
async function renderGroups(res) {
|
||||||
const gen = ++groupListRenderGen;
|
|
||||||
let container = document.getElementById("groups-list");
|
let container = document.getElementById("groups-list");
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
|
|
@ -2772,26 +2731,11 @@ 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);
|
|
||||||
|
|
||||||
//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);
|
let infoRes = await fetchEncrypted("group/info", groupId);
|
||||||
if (infoRes && infoRes.startsWith("{")) {
|
if (infoRes && infoRes.startsWith("{")) {
|
||||||
groupName = JSON.parse(infoRes).string2;
|
let info = JSON.parse(infoRes);
|
||||||
if (!window.groupNameCache) window.groupNameCache = {};
|
let groupName = info.string2;
|
||||||
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 safeGroupName = encodeURIComponent(groupName).replace(/'/g, "%27");
|
||||||
let pfp = await getGroupAvatarUrl(groupId, groupName);
|
let pfp = await getGroupAvatarUrl(groupId, groupName);
|
||||||
|
|
||||||
|
|
@ -2806,20 +2750,19 @@ 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++;
|
||||||
|
} 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) {
|
if (count > 0) {
|
||||||
container.innerHTML = html;
|
container.innerHTML = html;
|
||||||
container.classList.remove("empty");
|
container.classList.remove("empty");
|
||||||
updateVoicePresenceUI();
|
|
||||||
} else {
|
} else {
|
||||||
if (gen !== groupListRenderGen) return;
|
|
||||||
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");
|
||||||
let blahTags = container.getElementsByTagName("blah");
|
let blahTags = container.getElementsByTagName("blah");
|
||||||
|
|
@ -2870,101 +2813,6 @@ 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}`);
|
||||||
|
|
@ -2983,21 +2831,19 @@ 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" data-member-id="${sanitizeId(targetId.split(';')[0])}" onclick="openProfile('${safeTargetId}')" style="margin-bottom: 0;">
|
<button class="room-entry" 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" data-member-id="${sanitizeId(String(id))}" onclick="openProfile('${safeMyId}')" style="margin-bottom: 0;">
|
<button class="room-entry" 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>
|
||||||
`;
|
`;
|
||||||
|
|
@ -3126,16 +2972,14 @@ 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" data-member-id="${memberNumericId}" onclick="openProfile('${safeMemberId}')" oncontextmenu="handleMemberContextMenu(event, '${safeGroupId}', '${safeMemberId}')" style="margin-bottom: 0;">
|
<button class="room-entry" 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>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3223,9 +3067,6 @@ 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>`;
|
||||||
|
|
|
||||||
|
|
@ -1201,27 +1201,6 @@ 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;
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
const CLIENT_VERSION = "0.2.2";
|
const CLIENT_VERSION = "0.2.1";
|
||||||
|
|
|
||||||
244
webroot/voice.js
244
webroot/voice.js
|
|
@ -6,16 +6,6 @@ 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,
|
||||||
|
|
@ -137,9 +127,6 @@ const VOICE_MAX_RECONNECTS = 2;
|
||||||
let peerLastSeen = {};
|
let peerLastSeen = {};
|
||||||
let deadPeers = new Set();
|
let deadPeers = new Set();
|
||||||
let lastVoiceServerPacketAt = 0;
|
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 voicePingTimer = null;
|
||||||
let voiceRosterRetryTimer = null;
|
let voiceRosterRetryTimer = null;
|
||||||
let lastVoiceKeyRequestAt = 0;
|
let lastVoiceKeyRequestAt = 0;
|
||||||
|
|
@ -547,10 +534,8 @@ function setVoiceStatus(state) {
|
||||||
updateVoicePing();
|
updateVoicePing();
|
||||||
} else {
|
} else {
|
||||||
if (voiceTimerInterval) { clearInterval(voiceTimerInterval); voiceTimerInterval = null; }
|
if (voiceTimerInterval) { clearInterval(voiceTimerInterval); voiceTimerInterval = null; }
|
||||||
//Anchor czasu NIE jest kasowany przy "connecting" - reconnect w trakcie
|
voiceConnectedAt = null;
|
||||||
//rozmowy nie może resetować licznika na 00:00; czyszczone tylko przy
|
if (state === "disconnected") { voicePingMs = null; updateVoicePing(); }
|
||||||
//realnym rozłączeniu (disconnected z endVoiceSession)
|
|
||||||
if (state === "disconnected") { voiceConnectedAt = null; voicePingMs = null; updateVoicePing(); }
|
|
||||||
if (textEl) textEl.innerText = processBlah(state === "disconnected" ? "status.voice.disconnected" : "status.voice.connecting");
|
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
|
// Ghost peers: still on the room roster but totally silent (pings count as
|
||||||
// life). Audyt S15: UKRYWANIE ich z UI tworzyło niewidzialnego
|
// life) - drop them locally until the server or the peer proves otherwise.
|
||||||
// podsłuchiwacza - voice-server dalej relayaudio do ich socketu, a u innych
|
|
||||||
// znikał z listy. Teraz peer zostaje WIDOCZNY (roster serwera jest źródłem
|
|
||||||
// prawdy); czyścimy jedynie jego kanały mediów - wrócą z pierwszym pakietem.
|
|
||||||
// deadPeers pełni tu rolę znacznika "media już wycięte", nie "ukryty".
|
|
||||||
let ghostRemoved = false;
|
let ghostRemoved = false;
|
||||||
for (const u of voiceActiveUsers) {
|
for (const u of voiceActiveUsers) {
|
||||||
if (u === String(window.id)) continue;
|
if (u === String(window.id)) continue;
|
||||||
|
|
@ -626,30 +607,19 @@ setInterval(() => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (ghostRemoved) {
|
if (ghostRemoved) {
|
||||||
|
voiceActiveUsers = voiceActiveUsers.filter(u => !deadPeers.has(u));
|
||||||
updateVoiceUIUsers();
|
updateVoiceUIUsers();
|
||||||
}
|
}
|
||||||
// Half-open socket watchdog + backgrounded-tab audio recovery.
|
// Half-open socket watchdog + backgrounded-tab audio recovery.
|
||||||
if (currentVoiceWs && currentVoiceWs.readyState === WebSocket.OPEN) {
|
if (currentVoiceWs && currentVoiceWs.readyState === WebSocket.OPEN) {
|
||||||
const others = voiceActiveUsers.filter(u => u !== String(window.id)).length;
|
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
|
// Alive peers ping every 2s regardless of mute, so total inbound
|
||||||
// silence while others are present means the socket is dead.
|
// silence while others are present means the socket is dead.
|
||||||
if (others > 0 && now - lastVoiceServerPacketAt > VOICE_SERVER_TIMEOUT_MS) {
|
if (others > 0 && now - lastVoiceServerPacketAt > VOICE_SERVER_TIMEOUT_MS) {
|
||||||
console.warn("Voice: no inbound traffic, forcing reconnect");
|
console.warn("Voice: no inbound traffic, forcing reconnect");
|
||||||
try { currentVoiceWs.close(); } catch (e) {}
|
try { currentVoiceWs.close(); } catch (e) {}
|
||||||
} else if (audioContext && audioContext.state !== 'running') {
|
} else if (audioContext && audioContext.state === 'suspended') {
|
||||||
//Nie tylko "suspended" - iOS/Safari zna też "interrupted"; każdy
|
|
||||||
//stan != running przy żywej rozmowie wymaga próby wznowienia.
|
|
||||||
//Dokładamy też kickstart-na-geście, jeśli jeszcze nie jest uzbrojony
|
|
||||||
//(armVoiceContextKickstart sam jest idempotentny)
|
|
||||||
audioContext.resume().catch(() => {});
|
audioContext.resume().catch(() => {});
|
||||||
armVoiceContextKickstart();
|
|
||||||
}
|
}
|
||||||
// Safety net for the call-key handshake: if others are in the call but
|
// 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
|
// we never received the root key (raced/lost signal), ask for it
|
||||||
|
|
@ -773,52 +743,13 @@ class TaskQueue {
|
||||||
|
|
||||||
let voiceJoinInFlight = false;
|
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) {
|
async function initiateVoiceCall(roomId = null) {
|
||||||
if (currentVoiceWs || voiceJoinInFlight) {
|
if (currentVoiceWs || voiceJoinInFlight) {
|
||||||
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 {
|
||||||
|
|
@ -828,12 +759,6 @@ async function initiateVoiceCall(roomId = null) {
|
||||||
|
|
||||||
if (joined) {
|
if (joined) {
|
||||||
await startVoiceSession(joined.serverUrl, joined.ticket);
|
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 {
|
} finally {
|
||||||
voiceJoinInFlight = false;
|
voiceJoinInFlight = false;
|
||||||
|
|
@ -943,12 +868,12 @@ async function startVoiceSession(serverUrl, ticket) {
|
||||||
voicePingMs = Math.round(performance.now() - t0);
|
voicePingMs = Math.round(performance.now() - t0);
|
||||||
updateVoicePing();
|
updateVoicePing();
|
||||||
const rawUsers = usersRaw.map(u => String(u));
|
const rawUsers = usersRaw.map(u => String(u));
|
||||||
//Serwer jest źródłem prawdy o składzie - nie filtrujemy po
|
// Peers we locally declared dead stay hidden until either
|
||||||
//deadPeers (audyt S15: ukryty peer nadal dostaje audio)
|
// they send anything again or the server drops them.
|
||||||
for (const gone of [...deadPeers]) {
|
for (const gone of [...deadPeers]) {
|
||||||
if (!rawUsers.includes(gone)) deadPeers.delete(gone);
|
if (!rawUsers.includes(gone)) deadPeers.delete(gone);
|
||||||
}
|
}
|
||||||
const users = rawUsers;
|
const users = rawUsers.filter(u => !deadPeers.has(u));
|
||||||
let changed = (users.length !== voiceActiveUsers.length);
|
let changed = (users.length !== voiceActiveUsers.length);
|
||||||
for(let u of users) if(!voiceActiveUsers.includes(u)) changed = true;
|
for(let u of users) if(!voiceActiveUsers.includes(u)) changed = true;
|
||||||
if(changed) {
|
if(changed) {
|
||||||
|
|
@ -979,11 +904,6 @@ async function startVoiceSession(serverUrl, ticket) {
|
||||||
if (voicePingTimer) clearInterval(voicePingTimer);
|
if (voicePingTimer) clearInterval(voicePingTimer);
|
||||||
voicePingTimer = setInterval(sendVoicePing, VOICE_PING_INTERVAL_MS);
|
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
|
// Reconnects reuse the live mic stream - only capture a new one
|
||||||
// when the previous is gone (full teardown or track ended).
|
// when the previous is gone (full teardown or track ended).
|
||||||
if (!micStream || micStream.getTracks().some(t => t.readyState !== 'live')) {
|
if (!micStream || micStream.getTracks().some(t => t.readyState !== 'live')) {
|
||||||
|
|
@ -1005,17 +925,7 @@ async function startVoiceSession(serverUrl, ticket) {
|
||||||
screenReceiverNode = new AudioWorkletNode(audioContext, 'audio-receiver');
|
screenReceiverNode = new AudioWorkletNode(audioContext, 'audio-receiver');
|
||||||
isAudioWorkletLoaded = true;
|
isAudioWorkletLoaded = true;
|
||||||
}
|
}
|
||||||
if (audioContext.state !== 'running') {
|
if (audioContext.state === 'suspended') await audioContext.resume();
|
||||||
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();
|
applyVoiceSpeakerSink();
|
||||||
setupVoiceOutput();
|
setupVoiceOutput();
|
||||||
|
|
||||||
|
|
@ -1025,12 +935,6 @@ async function startVoiceSession(serverUrl, ticket) {
|
||||||
}
|
}
|
||||||
micSourceNode = audioContext.createMediaStreamSource(micStream);
|
micSourceNode = audioContext.createMediaStreamSource(micStream);
|
||||||
micSourceNode.connect(senderNode);
|
micSourceNode.connect(senderNode);
|
||||||
} catch (err) {
|
|
||||||
console.error("Voice: call setup failed", err);
|
|
||||||
showBlahNotification("error:call.connect.failed");
|
|
||||||
endVoiceSession();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let vadHangover = 0;
|
let vadHangover = 0;
|
||||||
senderNode.port.onmessage = async (e) => {
|
senderNode.port.onmessage = async (e) => {
|
||||||
|
|
@ -1090,8 +994,7 @@ async function startVoiceSession(serverUrl, ticket) {
|
||||||
const baseUrl = currentVoiceWs.url.split('/ws')[0];
|
const baseUrl = currentVoiceWs.url.split('/ws')[0];
|
||||||
const httpUrl = baseUrl.replace('ws:', 'http:').replace('wss:', 'https:');
|
const httpUrl = baseUrl.replace('ws:', 'http:').replace('wss:', 'https:');
|
||||||
fetch(`${httpUrl}/room/users?room=${currentVoiceCallId}`).then(res => res.json()).then(usersRaw => {
|
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)).filter(u => !deadPeers.has(u));
|
||||||
voiceActiveUsers = usersRaw.map(u => String(u));
|
|
||||||
updateVoiceUIUsers();
|
updateVoiceUIUsers();
|
||||||
}).catch(()=>{});
|
}).catch(()=>{});
|
||||||
}
|
}
|
||||||
|
|
@ -1117,17 +1020,12 @@ async function startVoiceSession(serverUrl, ticket) {
|
||||||
audioDecryptChains[audioId].enqueue(async () => {
|
audioDecryptChains[audioId].enqueue(async () => {
|
||||||
try {
|
try {
|
||||||
const senderKey = await senderKeyPromise;
|
const senderKey = await senderKeyPromise;
|
||||||
if (!senderKey) {
|
if (!senderKey) return;
|
||||||
voiceLogError('media.nokey[' + audioId + ']', new Error('brak podklucza nadawcy - root nie zainstalowany?'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const decryptedBuffer = await crypto.subtle.decrypt({name: "AES-GCM", iv}, senderKey, ciphertext);
|
const decryptedBuffer = await crypto.subtle.decrypt({name: "AES-GCM", iv}, senderKey, ciphertext);
|
||||||
if(targetReceiver) targetReceiver.port.postMessage({ clientId: audioId, audioData: decryptedBuffer }, [decryptedBuffer]);
|
if(targetReceiver) targetReceiver.port.postMessage({ clientId: audioId, audioData: decryptedBuffer }, [decryptedBuffer]);
|
||||||
|
|
||||||
setTileSpeaking(clientId, true, isScreenAudio ? `${clientId}-2` : null);
|
setTileSpeaking(clientId, true, isScreenAudio ? `${clientId}-2` : null);
|
||||||
} catch (err) {
|
} catch (err) {}
|
||||||
voiceLogError('media.decrypt[' + audioId + ']', err);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
} else if (type === 1 || type === 2) {
|
} else if (type === 1 || type === 2) {
|
||||||
const decoderId = `${clientId}-${type}`;
|
const decoderId = `${clientId}-${type}`;
|
||||||
|
|
@ -1255,52 +1153,14 @@ 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 = [];
|
//Ignore updates for rooms we have nothing to do with - they would otherwise
|
||||||
if (parts.slice(2).join(":") !== "") {
|
//overwrite the participant list of an ongoing call and wipe its tiles.
|
||||||
try { incomingState = JSON.parse(parts.slice(2).join(":")).map(u => String(u)); } catch (e) {}
|
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;
|
voiceActiveUsers = state;
|
||||||
|
|
||||||
if (currentVoiceCallId === roomId) {
|
if (currentVoiceCallId === roomId) {
|
||||||
|
|
@ -1317,15 +1177,6 @@ async function handleCallUpdate(data) {
|
||||||
for (let u of state) {
|
for (let u of state) {
|
||||||
if (u !== myIdStr) sendVoiceSignalToUser(u, roomId);
|
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();
|
updateVoiceUIUsers();
|
||||||
|
|
||||||
|
|
@ -1350,6 +1201,13 @@ 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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1396,28 +1254,13 @@ 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, participantCount = null) {
|
function showIncomingCallUI(roomId) {
|
||||||
if (currentVoiceWs || voiceJoinInFlight) return;
|
if (document.getElementById("incoming-call-popup") || 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
|
||||||
|
|
@ -1426,10 +1269,10 @@ function showIncomingCallUI(roomId, participantCount = null) {
|
||||||
<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:${participantCount}`)}</span>
|
<span class="incoming-call-subtitle">${processBlah("title.incoming.call")} · ${processBlah(`:desc.call.in.chat:${voiceActiveUsers.length}`)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="incoming-call-actions">
|
<div class="incoming-call-actions">
|
||||||
<button class="voice-call-btn accept" onclick="joinIncomingCall('${safeRoomId}')">
|
<button class="voice-call-btn accept" onclick="joinIncomingCall('${roomId}')">
|
||||||
${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()">
|
||||||
|
|
@ -1453,14 +1296,7 @@ function showIncomingCallUI(roomId, participantCount = null) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
//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");
|
||||||
|
|
@ -1524,7 +1360,6 @@ async function handleRoomSignal(data) {
|
||||||
let aesKeyBytes = await deriveHybridKeyFromSetup(otherPublicKeysObj, setupData.ctMlKem);
|
let aesKeyBytes = await deriveHybridKeyFromSetup(otherPublicKeysObj, setupData.ctMlKem);
|
||||||
let decRoomKeyB64 = await decryptAesGcmFromBase64(setupData.enc, aesKeyBytes);
|
let decRoomKeyB64 = await decryptAesGcmFromBase64(setupData.enc, aesKeyBytes);
|
||||||
await setVoiceRootKey(base64ToUint8(decRoomKeyB64));
|
await setVoiceRootKey(base64ToUint8(decRoomKeyB64));
|
||||||
console.warn("Voice: call key installed (from peer " + signalObj.from + ")");
|
|
||||||
}
|
}
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
console.error("Signal decryption failed", e);
|
console.error("Signal decryption failed", e);
|
||||||
|
|
@ -2254,10 +2089,7 @@ function updateVoiceUIUsers() {
|
||||||
let avatarUrl = avatarRes ? avatarRes : "assets/default_avatar.png";
|
let avatarUrl = avatarRes ? avatarRes : "assets/default_avatar.png";
|
||||||
|
|
||||||
if (!avatarRes && window.getAvatarUrl) {
|
if (!avatarRes && window.getAvatarUrl) {
|
||||||
//Nie podawaj numerycznego ID jako "nazwy" - fallback SVG z cyframi
|
getAvatarUrl(u, authorName || "User").then(url => {
|
||||||
//(id) wygląda jak bug; gdy nie znamy nazwy, neutralny "User"
|
|
||||||
const nameForAvatar = (authorName && authorName !== u) ? authorName : "User";
|
|
||||||
getAvatarUrl(u, nameForAvatar).then(url => {
|
|
||||||
if(url) {
|
if(url) {
|
||||||
window.dmPfpCache[u] = url;
|
window.dmPfpCache[u] = url;
|
||||||
let el = document.getElementById(`voice-tile-${u}`);
|
let el = document.getElementById(`voice-tile-${u}`);
|
||||||
|
|
@ -2550,17 +2382,6 @@ 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) {}
|
||||||
|
|
@ -2592,7 +2413,6 @@ function endVoiceSession() {
|
||||||
deadPeers.clear();
|
deadPeers.clear();
|
||||||
lastVoiceKeyRequestAt = 0;
|
lastVoiceKeyRequestAt = 0;
|
||||||
voiceActiveUsers = [];
|
voiceActiveUsers = [];
|
||||||
voiceHadRemotePeers = false;
|
|
||||||
|
|
||||||
for (let id in audioDecoders) {
|
for (let id in audioDecoders) {
|
||||||
try { audioDecoders[id].close(); } catch(e){}
|
try { audioDecoders[id].close(); } catch(e){}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue