[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:
s1to 2026-08-06 16:38:36 +02:00
parent 745c340917
commit e938c8bb42
19 changed files with 592 additions and 8 deletions

View file

@ -1082,7 +1082,9 @@ body { padding-top: 66px; }
.fork-sheet-toggle { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; margin: 0; }
.hive-sheet {
position: fixed; inset: 0; z-index: 1200; pointer-events: none;
position: fixed; inset: 0; z-index: 1200;
visibility: hidden; /* CERRADA: fuera del hit-testing → el topbar y el contenido de abajo SIEMPRE reciben taps (fix del bug de "botones de arriba no funcionan") */
transition: visibility 0s linear .3s; /* al cerrar, mantiene visible durante la animación de bajada */
background-color: transparent; box-shadow: none; border-radius: 0; /* anula el div{#222} global */
}
.hive-sheet-backdrop { position: absolute; inset: 0; background: rgba(0,0,0,.6); opacity: 0; transition: opacity .25s ease; }
@ -1095,7 +1097,7 @@ body { padding-top: 66px; }
box-shadow: 0 -6px 24px rgba(0,0,0,.5);
}
/* abierto */
.fork-sheet-toggle:checked ~ .hive-sheet { pointer-events: auto; }
.fork-sheet-toggle:checked ~ .hive-sheet { visibility: visible; transition: visibility 0s; }
.fork-sheet-toggle:checked ~ .hive-sheet .hive-sheet-backdrop { opacity: 1; }
.fork-sheet-toggle:checked ~ .hive-sheet .hive-sheet-panel { transform: translateY(0); }
@ -1118,3 +1120,65 @@ body { padding-top: 66px; }
/* el hive dentro de la hoja */
.hive-sheet .hive-nav { margin: 2px auto 0; max-width: 360px; }
.hive-sheet .hive-mods { background-color: transparent; box-shadow: none; border-radius: 0; } /* por si el div{#222} lo alcanza */
/* topbar v2: solo identidad (logo … avatar); el spacer empuja el avatar a la derecha */
.oasis-mobile-topbar .omt-spacer {
flex: 1 1 auto; min-width: 0;
background-color: transparent; box-shadow: none; border-radius: 0; padding: 0; margin: 0; /* anula div{#222} */
}
/* control segmentado Personal/Community/Todo dentro de la hoja (movido desde el topbar) */
.hive-sheet-seg {
display: flex; gap: 6px; margin: 2px 0 10px;
background-color: transparent; box-shadow: none; border-radius: 0; /* anula div{#222} */
}
.hive-sheet-seg .hs-seg {
flex: 1 1 0; text-align: center;
min-height: 40px; display: flex; align-items: center; justify-content: center;
border: 1px solid #FFD700; border-radius: 10px;
background: #161616; color: #FFD700; font-weight: 600; font-size: 13px;
text-decoration: none;
}
.hive-sheet-seg .hs-seg.active { background: #FFD700; color: #121212; }
/* ==========================================================================
FORK_IA_UX · Módulo KARVAN (mensajes temporales + WebRTC) — chat estilo burbujas
========================================================================== */
.karvan-intro h2 { color: #FFD700; margin: 0 0 6px; }
.karvan-sub { color: #FFDD44; font-size: 13px; }
.karvan-create input[type="text"] { width: 100%; box-sizing: border-box; padding: 10px 12px;
background: #161616; border: 1px solid #333; border-radius: 10px; color: #FFD700; }
.karvan-ttl { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; margin: 10px 0; }
.karvan-ttl-lbl { color: #FFDD44; font-size: 13px; }
.karvan-ttl-opt { display: inline-flex; align-items: center; gap: 4px; color: #FFD700; font-size: 13px; }
.karvan-create button, .karvan-compose button { padding: 10px 16px; border: 1px solid #FFD700;
border-radius: 10px; background: #FFD700; color: #121212; font-weight: 700; cursor: pointer; }
.karvan-rooms { list-style: none; margin: 8px 0 0; padding: 0; }
.karvan-rooms li { margin: 0 0 8px; }
.karvan-room-item { display: flex; justify-content: space-between; align-items: center; gap: 10px;
padding: 12px 14px; background: #161616; border: 1px solid #333; border-radius: 12px;
color: #FFD700; text-decoration: none; min-height: 48px; box-sizing: border-box; }
.karvan-room-item .kr-title { font-weight: 600; }
.karvan-room-item .kr-meta { color: #FFDD44; font-size: 12px; white-space: nowrap; }
.karvan-empty { color: #FFDD44; }
/* sala */
.karvan-room-head { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
.karvan-back { color: #FFD700; text-decoration: none; font-weight: 600; }
.karvan-room-title { flex: 1 1 auto; color: #FFD700; font-weight: 700; text-align: center;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.karvan-expire { color: #FFDD44; font-size: 12px; white-space: nowrap; }
.karvan-live { display: inline-flex; align-items: center; gap: 6px; color: #FFDD44; font-size: 12px; margin-bottom: 8px; }
.karvan-live .kl-dot { width: 8px; height: 8px; border-radius: 50%; background: #777; }
.karvan-live.on .kl-dot { background: #35d07f; }
.karvan-live.on .kl-txt { color: #35d07f; }
.karvan-msgs { list-style: none; margin: 0; padding: 6px 0; display: flex; flex-direction: column; gap: 6px;
max-height: 58vh; overflow-y: auto; }
.karvan-msg { max-width: 82%; align-self: flex-start; background: #1c1c1c; border: 1px solid #333;
border-radius: 14px; padding: 7px 11px; box-sizing: border-box; }
.karvan-msg-self { align-self: flex-end; background: #2a2412; border-color: #FFD700; }
.karvan-msg .km-from { display: block; font-size: 10px; color: #FFDD44; margin-bottom: 2px; }
.karvan-msg .km-text { color: #FFD700; word-break: break-word; }
.karvan-compose { display: flex; gap: 8px; margin-top: 10px; }
.karvan-compose input[type="text"] { flex: 1 1 auto; min-width: 0; padding: 10px 12px;
background: #161616; border: 1px solid #333; border-radius: 10px; color: #FFD700; }

View file

@ -3511,6 +3511,7 @@ module.exports = {
modulesMapLabel: "خرائط",
modulesMapDescription: "وحدة لإدارة ومشاركة الخرائط غير المتصلة.",
padsTitle: "الوسادات",
karvanTitle: "Karvan",
padTitle: "وسادة",
modulesPadsLabel: "الوسادات",
modulesPadsDescription: "وحدة لإدارة محررات النصوص التعاونية.",

View file

@ -3509,6 +3509,7 @@ module.exports = {
modulesMapLabel: "Karten",
modulesMapDescription: "Modul zum Verwalten und Teilen von Offline-Karten.",
padsTitle: "Pads",
karvanTitle: "Karvan",
padTitle: "Pad",
modulesPadsLabel: "Pads",
modulesPadsDescription: "Modul zur Verwaltung kollaborativer Texteditoren.",

View file

@ -3794,6 +3794,7 @@ module.exports = {
modulesMapLabel: "Maps",
modulesMapDescription: "Module to manage and share offline maps.",
padsTitle: "Pads",
karvanTitle: "Karvan",
padTitle: "Pad",
modulesPadsLabel: "Pads",
modulesPadsDescription: "Module to manage collaborative text editors.",

View file

@ -3511,6 +3511,7 @@ module.exports = {
modulesMapLabel: "Mapas",
modulesMapDescription: "Módulo para gestionar y compartir mapas offline.",
padsTitle: "Pads",
karvanTitle: "Karvan",
padTitle: "Pad",
modulesPadsLabel: "Pads",
modulesPadsDescription: "Módulo para gestionar editores de texto colaborativos.",

View file

@ -3507,6 +3507,7 @@ module.exports = {
modulesMapLabel: "Mapak",
modulesMapDescription: "Lineaz kanpoko mapak kudeatzeko eta partekatzeko modulua.",
padsTitle: "Padak",
karvanTitle: "Karvan",
padTitle: "Pad",
modulesPadsLabel: "Padak",
modulesPadsDescription: "Testu editoreak lankidetzan kudeatzeko modulua.",

View file

@ -3501,6 +3501,7 @@ module.exports = {
modulesMapLabel: "Cartes",
modulesMapDescription: "Module pour gérer et partager des cartes hors ligne.",
padsTitle: "Pads",
karvanTitle: "Karvan",
padTitle: "Pad",
modulesPadsLabel: "Pads",
modulesPadsDescription: "Module pour gérer les éditeurs de texte collaboratifs.",

View file

@ -3511,6 +3511,7 @@ module.exports = {
modulesMapLabel: "मानचित्र",
modulesMapDescription: "ऑफ़लाइन मानचित्रों को प्रबंधित और साझा करने का मॉड्यूल।",
padsTitle: "पैड्स",
karvanTitle: "Karvan",
padTitle: "पैड",
modulesPadsLabel: "पैड्स",
modulesPadsDescription: "सहयोगी पाठ संपादकों को प्रबंधित करने का मॉड्यूल।",

View file

@ -3510,6 +3510,7 @@ module.exports = {
modulesMapLabel: "Mappe",
modulesMapDescription: "Modulo per gestire e condividere mappe offline.",
padsTitle: "Pad",
karvanTitle: "Karvan",
padTitle: "Pad",
modulesPadsLabel: "Pad",
modulesPadsDescription: "Modulo per gestire editor di testo collaborativi.",

View file

@ -3510,6 +3510,7 @@ module.exports = {
modulesMapLabel: "Mapas",
modulesMapDescription: "Módulo para gerenciar e compartilhar mapas offline.",
padsTitle: "Pads",
karvanTitle: "Karvan",
padTitle: "Pad",
modulesPadsLabel: "Pads",
modulesPadsDescription: "Módulo para gerir editores de texto colaborativos.",

View file

@ -3511,6 +3511,7 @@ module.exports = {
modulesMapLabel: "Карты",
modulesMapDescription: "Модуль для управления и обмена офлайн-картами.",
padsTitle: "Пады",
karvanTitle: "Karvan",
padTitle: "Пад",
modulesPadsLabel: "Пады",
modulesPadsDescription: "Модуль для управления совместными текстовыми редакторами.",

View file

@ -3512,6 +3512,7 @@ module.exports = {
modulesMapLabel: "地图",
modulesMapDescription: "管理和分享离线地图的模块。",
padsTitle: "协作板",
karvanTitle: "Karvan",
padTitle: "协作板",
modulesPadsLabel: "协作板",
modulesPadsDescription: "管理协作文本编辑器的模块。",

View 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);
}
})();