[0.9.1] FORK_IA_UX v2: fix top-bar (visibility sheet) + Karvan module (ephemeral messages + WebRTC)
Navigation fixes: - The bottom "Explore" sheet no longer blocks the top bar: closed state is visibility:hidden (out of hit-testing), so the top identity buttons and page content always receive taps (fixes "top buttons don't work"). - Top bar is now identity only (logo, avatar). The Personal/Community filter moved into the Explore sheet, next to the hive, as a segmented control. New module "Karvan" (self-contained; inspired by karvan-protocol ephemeral rooms): - Ephemeral/temporary chat rooms held only in RAM, self-destructing on idle (30 min) / absolute (2 h) TTL — nothing is written to disk. - Server relay chat (RAM + polling) as the reliable path, plus a WebRTC data-channel layer (perfect negotiation, HTTP signaling mailbox) for instant P2P delivery; degrades to the relay if WebRTC is unavailable. Data-channel only (no camera/mic) so no wrapper permission changes are needed. - Files: models/karvan_model.js, views/karvan_view.js, client/public/js/karvan.js; routes /karvan* + karvanMod config + nav entry (network) + karvanTitle i18n (11 langs). Additive and gated by OASIS_MOBILE / karvanMod; Linux and the UX_OASIS branch untouched.
This commit is contained in:
parent
745c340917
commit
e938c8bb42
19 changed files with 592 additions and 8 deletions
201
src/client/public/js/karvan.js
Normal file
201
src/client/public/js/karvan.js
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
/* KARVAN client — mensajes temporales + WebRTC (data-channel).
|
||||
* Capa fiable: relay por servidor (RAM, autodestrucción) con polling.
|
||||
* Capa turbo: data-channel WebRTC P2P (instantáneo) con "perfect negotiation".
|
||||
* Todo degrada: si no hay WebRTC o no conecta, el chat sigue por el relay.
|
||||
* Sin cámara/mic (solo data-channel) => no necesita permisos del wrapper. */
|
||||
(function () {
|
||||
"use strict";
|
||||
var root = document.querySelector(".karvan-room");
|
||||
if (!root) return;
|
||||
var roomId = root.getAttribute("data-room");
|
||||
var selfId = root.getAttribute("data-self") || "";
|
||||
var peerId = "p" + Math.random().toString(16).slice(2, 12);
|
||||
var fromLabel = selfId || peerId;
|
||||
|
||||
var listEl = document.getElementById("karvan-msgs");
|
||||
var formEl = document.getElementById("karvan-form");
|
||||
var textEl = document.getElementById("karvan-text");
|
||||
var liveTxt = document.getElementById("karvan-live-txt");
|
||||
var liveWrap = document.getElementById("karvan-live");
|
||||
|
||||
var seen = Object.create(null);
|
||||
var lastSeq = 0;
|
||||
var lastSig = 0;
|
||||
var peers = Object.create(null);
|
||||
|
||||
function esc(s) { return String(s == null ? "" : s); }
|
||||
function shortId(id) { id = esc(id || "?"); return id.charAt(0) === "@" ? id.slice(1, 7) : id.slice(0, 6); }
|
||||
|
||||
function showMsg(m, live) {
|
||||
var key = m.mid ? "m" + m.mid : "s" + (m.seq || Math.random());
|
||||
if (seen[key]) return;
|
||||
seen[key] = 1;
|
||||
var li = document.createElement("li");
|
||||
li.className = "karvan-msg" + (m.from === fromLabel ? " karvan-msg-self" : "") + (live ? " karvan-msg-live" : "");
|
||||
var f = document.createElement("span"); f.className = "km-from"; f.textContent = shortId(m.from);
|
||||
var t = document.createElement("span"); t.className = "km-text"; t.textContent = esc(m.text);
|
||||
li.appendChild(f); li.appendChild(t);
|
||||
listEl.appendChild(li);
|
||||
listEl.scrollTop = listEl.scrollHeight;
|
||||
}
|
||||
|
||||
// --- relay por servidor (siempre activo) ---
|
||||
function pollMsgs() {
|
||||
fetch("/karvan/" + roomId + "/msgs?since=" + lastSeq, { headers: { Accept: "application/json" } })
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (d) {
|
||||
if (!d || !d.messages) return;
|
||||
d.messages.forEach(function (m) { if (m.seq > lastSeq) lastSeq = m.seq; showMsg(m, false); });
|
||||
})
|
||||
.catch(function () {});
|
||||
}
|
||||
|
||||
function sendMessage(text) {
|
||||
if (!text) return;
|
||||
var mid = peerId + "-" + Date.now().toString(36) + Math.random().toString(16).slice(2, 6);
|
||||
var m = { mid: mid, from: fromLabel, text: text };
|
||||
showMsg(m, false); // optimista
|
||||
broadcastDC({ kind: "chat", mid: mid, from: fromLabel, text: text }); // instantáneo a los pares
|
||||
fetch("/karvan/" + roomId + "/msg", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ from: fromLabel, text: text, mid: mid })
|
||||
}).catch(function () {});
|
||||
}
|
||||
|
||||
if (formEl) {
|
||||
formEl.addEventListener("submit", function (e) {
|
||||
e.preventDefault();
|
||||
var v = (textEl.value || "").trim();
|
||||
if (!v) return;
|
||||
textEl.value = "";
|
||||
sendMessage(v);
|
||||
});
|
||||
}
|
||||
|
||||
// --- señalización (mailbox RAM del servidor) ---
|
||||
function sendSig(to, payload) {
|
||||
fetch("/karvan/" + roomId + "/signal", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ from: peerId, to: to || "", payload: payload })
|
||||
}).catch(function () {});
|
||||
}
|
||||
function announce() { sendSig("", { kind: "hello" }); }
|
||||
|
||||
function pollSignals() {
|
||||
fetch("/karvan/" + roomId + "/signal?for=" + peerId + "&since=" + lastSig, { headers: { Accept: "application/json" } })
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (d) {
|
||||
if (!d) return;
|
||||
(d.signals || []).forEach(function (s) {
|
||||
if (s.seq > lastSig) lastSig = s.seq;
|
||||
onSignal(s.from, s.payload);
|
||||
});
|
||||
(d.members || []).forEach(function (mid2) { if (mid2 && mid2 !== peerId) ensurePeer(mid2); });
|
||||
})
|
||||
.catch(function () {});
|
||||
}
|
||||
|
||||
// --- WebRTC (data-channel, perfect negotiation) ---
|
||||
var RTC = window.RTCPeerConnection || window.webkitRTCPeerConnection;
|
||||
var rtcConfig = { iceServers: [{ urls: "stun:stun.l.google.com:19302" }] };
|
||||
|
||||
function setLive(on) {
|
||||
if (!liveTxt) return;
|
||||
liveTxt.textContent = on ? "🔒 P2P (WebRTC)" : "server relay";
|
||||
if (liveWrap) liveWrap.className = "karvan-live" + (on ? " on" : "");
|
||||
}
|
||||
|
||||
function broadcastDC(obj) {
|
||||
var s = JSON.stringify(obj);
|
||||
for (var k in peers) {
|
||||
var dc = peers[k] && peers[k].dc;
|
||||
if (dc && dc.readyState === "open") { try { dc.send(s); } catch (e) {} }
|
||||
}
|
||||
}
|
||||
|
||||
function attachDC(peer, dc) {
|
||||
peer.dc = dc;
|
||||
dc.onopen = function () { setLive(true); };
|
||||
dc.onmessage = function (e) {
|
||||
try { var m = JSON.parse(e.data); if (m && m.kind === "chat") showMsg(m, true); } catch (x) {}
|
||||
};
|
||||
dc.onclose = function () {
|
||||
var any = false;
|
||||
for (var k in peers) if (peers[k].dc && peers[k].dc.readyState === "open") any = true;
|
||||
if (!any) setLive(false);
|
||||
};
|
||||
}
|
||||
|
||||
function ensurePeer(remoteId) {
|
||||
if (peers[remoteId] || !RTC) return peers[remoteId];
|
||||
var polite = peerId > remoteId; // el id mayor es "polite"
|
||||
var pc = new RTC(rtcConfig);
|
||||
var peer = { pc: pc, dc: null, polite: polite, makingOffer: false, ignoreOffer: false };
|
||||
peers[remoteId] = peer;
|
||||
|
||||
if (!polite) { attachDC(peer, pc.createDataChannel("chat")); } // el impolite inicia el canal
|
||||
pc.ondatachannel = function (e) { attachDC(peer, e.channel); };
|
||||
pc.onicecandidate = function (e) { if (e.candidate) sendSig(remoteId, { kind: "ice", candidate: e.candidate }); };
|
||||
pc.onnegotiationneeded = function () {
|
||||
peer.makingOffer = true;
|
||||
Promise.resolve()
|
||||
.then(function () { return pc.setLocalDescription(); })
|
||||
.then(function () { sendSig(remoteId, { kind: "desc", desc: pc.localDescription }); })
|
||||
.catch(function () {})
|
||||
.then(function () { peer.makingOffer = false; });
|
||||
};
|
||||
pc.onconnectionstatechange = function () {
|
||||
var st = pc.connectionState;
|
||||
if (st === "failed" || st === "closed") { try { pc.close(); } catch (e) {} delete peers[remoteId]; }
|
||||
};
|
||||
return peer;
|
||||
}
|
||||
|
||||
function onSignal(fromRemote, payload) {
|
||||
if (!RTC || !payload) return;
|
||||
var peer = ensurePeer(fromRemote);
|
||||
if (!peer) return;
|
||||
var pc = peer.pc;
|
||||
if (payload.kind === "desc") {
|
||||
var desc = payload.desc;
|
||||
var collision = desc.type === "offer" && (peer.makingOffer || pc.signalingState !== "stable");
|
||||
peer.ignoreOffer = !peer.polite && collision;
|
||||
if (peer.ignoreOffer) return;
|
||||
Promise.resolve()
|
||||
.then(function () { return pc.setRemoteDescription(desc); })
|
||||
.then(function () {
|
||||
if (desc.type === "offer") {
|
||||
return pc.setLocalDescription().then(function () {
|
||||
sendSig(fromRemote, { kind: "desc", desc: pc.localDescription });
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(function () {});
|
||||
} else if (payload.kind === "ice") {
|
||||
Promise.resolve().then(function () { return pc.addIceCandidate(payload.candidate); }).catch(function () {});
|
||||
}
|
||||
}
|
||||
|
||||
// --- arranque ---
|
||||
showMsgInitialSeq();
|
||||
function showMsgInitialSeq() {
|
||||
// registrar los mensajes ya renderizados por el servidor para no duplicarlos
|
||||
var items = listEl ? listEl.querySelectorAll(".karvan-msg") : [];
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
var seq = parseInt(items[i].getAttribute("data-seq"), 10) || 0;
|
||||
var mid = items[i].getAttribute("data-mid") || "";
|
||||
if (seq > lastSeq) lastSeq = seq;
|
||||
seen[mid ? "m" + mid : "s" + seq] = 1;
|
||||
}
|
||||
}
|
||||
|
||||
pollMsgs();
|
||||
setInterval(pollMsgs, 2000);
|
||||
if (RTC) {
|
||||
setLive(false);
|
||||
announce();
|
||||
setInterval(announce, 8000);
|
||||
pollSignals();
|
||||
setInterval(pollSignals, 2000);
|
||||
}
|
||||
})();
|
||||
Loading…
Add table
Add a link
Reference in a new issue