From e938c8bb42c858168df451e92448c20447591b13 Mon Sep 17 00:00:00 2001 From: s1to Date: Thu, 6 Aug 2026 16:38:36 +0200 Subject: [PATCH] [0.9.1] FORK_IA_UX v2: fix top-bar (visibility sheet) + Karvan module (ephemeral messages + WebRTC) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/backend/backend.js | 56 ++++++ src/client/assets/themes/OasisMobile.css | 68 ++++++- src/client/assets/translations/oasis_ar.js | 1 + src/client/assets/translations/oasis_de.js | 1 + src/client/assets/translations/oasis_en.js | 1 + src/client/assets/translations/oasis_es.js | 1 + src/client/assets/translations/oasis_eu.js | 1 + src/client/assets/translations/oasis_fr.js | 1 + src/client/assets/translations/oasis_hi.js | 1 + src/client/assets/translations/oasis_it.js | 1 + src/client/assets/translations/oasis_pt.js | 1 + src/client/assets/translations/oasis_ru.js | 1 + src/client/assets/translations/oasis_zh.js | 1 + src/client/public/js/karvan.js | 201 +++++++++++++++++++++ src/configs/config-manager.js | 1 + src/configs/oasis-config.json | 1 + src/models/karvan_model.js | 149 +++++++++++++++ src/views/karvan_view.js | 91 ++++++++++ src/views/main_views.js | 22 ++- 19 files changed, 592 insertions(+), 8 deletions(-) create mode 100644 src/client/public/js/karvan.js create mode 100644 src/models/karvan_model.js create mode 100644 src/views/karvan_view.js diff --git a/src/backend/backend.js b/src/backend/backend.js index 6b01222..1c73f4d 100644 --- a/src/backend/backend.js +++ b/src/backend/backend.js @@ -637,6 +637,7 @@ const documentsModel = require("../models/documents_model")({ cooler, isPublic: const trendingModel = require('../models/trending_model')({ cooler, isPublic: config.public }); const statsModel = require('../models/stats_model')({ cooler, isPublic: config.public, tribeCrypto, tribesModel }); const padsModel = require('../models/pads_model')({ cooler, cipherModel, tribeCrypto, padCrypto, tribesModel }); +const karvanModel = require('../models/karvan_model')({}); // FORK_IA_UX: salas efímeras en RAM (mensajes temporales + señalización WebRTC) const tagsModel = require('../models/tags_model')({ cooler, isPublic: config.public, padsModel, tribesModel }); const tribesContentModel = require('../models/tribes_content_model')({ cooler, isPublic: config.public, tribeCrypto, tribesModel }); const searchModel = require('../models/search_model')({ cooler, isPublic: config.public, padsModel, tribeCrypto, tribesModel }); @@ -1406,6 +1407,7 @@ const { jobsView, singleJobsView, renderJobForm, clearnetJobView } = require(".. const { shopsView, singleShopView, singleProductView, editProductView, shopOrdersView, myPurchasesView, clearnetShopView, renderShopInvitePage } = require("../views/shops_view"); const { chatsView, singleChatView, renderChatInvitePage } = require("../views/chats_view"); const { padsView, singlePadView, renderPadInvitePage } = require("../views/pads_view"); +const { karvanView, karvanRoomView } = require("../views/karvan_view"); const { calendarsView, singleCalendarView, renderCalendarInvitePage } = require("../views/calendars_view"); const { projectsView, singleProjectView, clearnetProjectView } = require("../views/projects_view") const { industryView, singleFacilityView, singleBuildView, singleBlueprintView, blueprintEditView, buildEditView } = require("../views/industry_view") @@ -3635,6 +3637,60 @@ router } ctx.body = await mentionsView({ messages: combined, myFeedId }); }) + // ===== FORK_IA_UX: módulo KARVAN (mensajes temporales RAM + WebRTC data-channel) ===== + .get('/karvan', async (ctx) => { + if (!checkMod(ctx, 'karvanMod')) { ctx.redirect('/modules'); return; } + let selfId = ''; try { selfId = getViewerId(); } catch (e) {} + ctx.body = karvanView(karvanModel.listRooms(), { selfId }); + }) + .post('/karvan/create', koaBody(), async (ctx) => { + if (!checkMod(ctx, 'karvanMod')) { ctx.redirect('/modules'); return; } + const b = ctx.request.body || {}; + const room = karvanModel.createRoom({ + title: stripDangerousTags(String(b.title || '').trim()).slice(0, 80), + ttlMinutes: b.ttl, + }); + ctx.redirect('/karvan/' + room.id); + }) + .get('/karvan/:id', async (ctx) => { + if (!checkMod(ctx, 'karvanMod')) { ctx.redirect('/modules'); return; } + const room = karvanModel.getRoom(ctx.params.id); + if (!room) { ctx.redirect('/karvan'); return; } + let selfId = ''; try { selfId = getViewerId(); } catch (e) {} + const msgs = karvanModel.listMessages(ctx.params.id, 0) || []; + ctx.body = karvanRoomView(room, msgs, { selfId }); + }) + .post('/karvan/:id/msg', koaBody(), async (ctx) => { + if (!checkMod(ctx, 'karvanMod')) { ctx.status = 403; ctx.body = { error: 'off' }; return; } + const b = ctx.request.body || {}; + let selfId = ''; try { selfId = getViewerId(); } catch (e) {} + const msg = karvanModel.postMessage(ctx.params.id, { + from: String(b.from || selfId || '?').slice(0, 80), + text: stripDangerousTags(String(b.text || '')).slice(0, 2000), + mid: b.mid, + }); + if (!msg) { ctx.status = 404; ctx.body = { error: 'no room' }; return; } + const wantsJson = (ctx.get('accept') || '').indexOf('application/json') !== -1 + || (ctx.get('content-type') || '').indexOf('application/json') !== -1; + if (wantsJson) ctx.body = { ok: true, seq: msg.seq }; + else ctx.redirect('/karvan/' + ctx.params.id); + }) + .get('/karvan/:id/msgs', async (ctx) => { + if (!checkMod(ctx, 'karvanMod')) { ctx.status = 403; ctx.body = { error: 'off' }; return; } + const msgs = karvanModel.listMessages(ctx.params.id, ctx.query.since || 0); + ctx.body = { messages: msgs || [] }; + }) + .post('/karvan/:id/signal', koaBody(), async (ctx) => { + if (!checkMod(ctx, 'karvanMod')) { ctx.status = 403; ctx.body = { error: 'off' }; return; } + const b = ctx.request.body || {}; + karvanModel.postSignal(ctx.params.id, { from: b.from, to: b.to, payload: b.payload }); + ctx.body = { ok: true }; + }) + .get('/karvan/:id/signal', async (ctx) => { + if (!checkMod(ctx, 'karvanMod')) { ctx.status = 403; ctx.body = { error: 'off' }; return; } + const r = karvanModel.getSignals(ctx.params.id, { forId: ctx.query.for || '', since: ctx.query.since || 0 }); + ctx.body = r || { signals: [], members: [] }; + }) .get('/opinions', async (ctx) => { const filter = qf(ctx, 'TOP'); let opinions = await opinionsModel.listOpinions(filter); diff --git a/src/client/assets/themes/OasisMobile.css b/src/client/assets/themes/OasisMobile.css index e4fca55..548f1d1 100644 --- a/src/client/assets/themes/OasisMobile.css +++ b/src/client/assets/themes/OasisMobile.css @@ -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; } diff --git a/src/client/assets/translations/oasis_ar.js b/src/client/assets/translations/oasis_ar.js index 6ea9513..42a05eb 100644 --- a/src/client/assets/translations/oasis_ar.js +++ b/src/client/assets/translations/oasis_ar.js @@ -3511,6 +3511,7 @@ module.exports = { modulesMapLabel: "خرائط", modulesMapDescription: "وحدة لإدارة ومشاركة الخرائط غير المتصلة.", padsTitle: "الوسادات", + karvanTitle: "Karvan", padTitle: "وسادة", modulesPadsLabel: "الوسادات", modulesPadsDescription: "وحدة لإدارة محررات النصوص التعاونية.", diff --git a/src/client/assets/translations/oasis_de.js b/src/client/assets/translations/oasis_de.js index 9b6715c..869f0c1 100644 --- a/src/client/assets/translations/oasis_de.js +++ b/src/client/assets/translations/oasis_de.js @@ -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.", diff --git a/src/client/assets/translations/oasis_en.js b/src/client/assets/translations/oasis_en.js index c6740c1..7c18ac4 100644 --- a/src/client/assets/translations/oasis_en.js +++ b/src/client/assets/translations/oasis_en.js @@ -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.", diff --git a/src/client/assets/translations/oasis_es.js b/src/client/assets/translations/oasis_es.js index 93fa083..b2e4abe 100644 --- a/src/client/assets/translations/oasis_es.js +++ b/src/client/assets/translations/oasis_es.js @@ -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.", diff --git a/src/client/assets/translations/oasis_eu.js b/src/client/assets/translations/oasis_eu.js index 1bfd3f4..36a9df6 100644 --- a/src/client/assets/translations/oasis_eu.js +++ b/src/client/assets/translations/oasis_eu.js @@ -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.", diff --git a/src/client/assets/translations/oasis_fr.js b/src/client/assets/translations/oasis_fr.js index 0bf4040..bc7a78a 100644 --- a/src/client/assets/translations/oasis_fr.js +++ b/src/client/assets/translations/oasis_fr.js @@ -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.", diff --git a/src/client/assets/translations/oasis_hi.js b/src/client/assets/translations/oasis_hi.js index 4d8e499..0243a86 100644 --- a/src/client/assets/translations/oasis_hi.js +++ b/src/client/assets/translations/oasis_hi.js @@ -3511,6 +3511,7 @@ module.exports = { modulesMapLabel: "मानचित्र", modulesMapDescription: "ऑफ़लाइन मानचित्रों को प्रबंधित और साझा करने का मॉड्यूल।", padsTitle: "पैड्स", + karvanTitle: "Karvan", padTitle: "पैड", modulesPadsLabel: "पैड्स", modulesPadsDescription: "सहयोगी पाठ संपादकों को प्रबंधित करने का मॉड्यूल।", diff --git a/src/client/assets/translations/oasis_it.js b/src/client/assets/translations/oasis_it.js index 8e99c45..c2e04cb 100644 --- a/src/client/assets/translations/oasis_it.js +++ b/src/client/assets/translations/oasis_it.js @@ -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.", diff --git a/src/client/assets/translations/oasis_pt.js b/src/client/assets/translations/oasis_pt.js index 8039636..a834fae 100644 --- a/src/client/assets/translations/oasis_pt.js +++ b/src/client/assets/translations/oasis_pt.js @@ -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.", diff --git a/src/client/assets/translations/oasis_ru.js b/src/client/assets/translations/oasis_ru.js index f2f1f90..e01f1e9 100644 --- a/src/client/assets/translations/oasis_ru.js +++ b/src/client/assets/translations/oasis_ru.js @@ -3511,6 +3511,7 @@ module.exports = { modulesMapLabel: "Карты", modulesMapDescription: "Модуль для управления и обмена офлайн-картами.", padsTitle: "Пады", + karvanTitle: "Karvan", padTitle: "Пад", modulesPadsLabel: "Пады", modulesPadsDescription: "Модуль для управления совместными текстовыми редакторами.", diff --git a/src/client/assets/translations/oasis_zh.js b/src/client/assets/translations/oasis_zh.js index 6ba160c..a5489e9 100644 --- a/src/client/assets/translations/oasis_zh.js +++ b/src/client/assets/translations/oasis_zh.js @@ -3512,6 +3512,7 @@ module.exports = { modulesMapLabel: "地图", modulesMapDescription: "管理和分享离线地图的模块。", padsTitle: "协作板", + karvanTitle: "Karvan", padTitle: "协作板", modulesPadsLabel: "协作板", modulesPadsDescription: "管理协作文本编辑器的模块。", diff --git a/src/client/public/js/karvan.js b/src/client/public/js/karvan.js new file mode 100644 index 0000000..dfe7281 --- /dev/null +++ b/src/client/public/js/karvan.js @@ -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); + } +})(); diff --git a/src/configs/config-manager.js b/src/configs/config-manager.js index 5fb9cdc..8a335b4 100644 --- a/src/configs/config-manager.js +++ b/src/configs/config-manager.js @@ -39,6 +39,7 @@ if (!fs.existsSync(configFilePath)) { "reportsMod": "on", "opinionsMod": "on", "padsMod": "on", + "karvanMod": "on", "calendarsMod": "on", "transfersMod": "on", "feedMod": "on", diff --git a/src/configs/oasis-config.json b/src/configs/oasis-config.json index 812f3fa..b07c518 100644 --- a/src/configs/oasis-config.json +++ b/src/configs/oasis-config.json @@ -34,6 +34,7 @@ "reportsMod": "on", "opinionsMod": "on", "padsMod": "on", + "karvanMod": "on", "calendarsMod": "on", "transfersMod": "off", "feedMod": "on", diff --git a/src/models/karvan_model.js b/src/models/karvan_model.js new file mode 100644 index 0000000..baee641 --- /dev/null +++ b/src/models/karvan_model.js @@ -0,0 +1,149 @@ +"use strict"; +// Módulo KARVAN (self-contained) para Oasis Mobile — inspirado en karvan-protocol +// (packages/core/addons/ephemeral.js): salas EFÍMERAS en RAM que se AUTODESTRUYEN por TTL. +// - Nada se escribe a disco. Los mensajes viven solo en memoria y desaparecen con la sala. +// - TTL de inactividad (idle) que se resetea con cada actividad + TTL absoluto que nunca se resetea. +// - Mailbox RAM para señalización WebRTC (data-channel) entre pares. +// No depende de @karvan/core ni de cripto nativa: es una capa local sobre el backend de Oasis. + +const crypto = require("crypto"); + +const DEFAULT_IDLE_TTL_MS = 30 * 60 * 1000; // 30 min sin actividad -> autodestrucción +const DEFAULT_ABS_TTL_MS = 2 * 60 * 60 * 1000; // 2 h absoluto (tope) +const MAX_ABS_TTL_MS = 24 * 60 * 60 * 1000; // nunca más de 24 h +const MAX_MSGS = 250; // anillo: solo los últimos N en RAM +const MAX_SIGNALS = 500; +const MAX_ROOMS = 100; + +module.exports = ({ idleTtlMs = DEFAULT_IDLE_TTL_MS, absTtlMs = DEFAULT_ABS_TTL_MS } = {}) => { + const rooms = new Map(); // id -> room + + const publicRoom = (r) => ({ + id: r.id, title: r.title, createdAt: r.createdAt, expiresAt: r.expiresAt, + count: r.messages.length, members: r.members.size, + }); + + const resetIdle = (room) => { + if (room.idleTimer) clearTimeout(room.idleTimer); + room.idleTimer = setTimeout(() => destroy(room.id), idleTtlMs); + if (room.idleTimer.unref) room.idleTimer.unref(); + }; + + const destroy = (id) => { + const room = rooms.get(id); + if (!room) return; + clearTimeout(room.idleTimer); + clearTimeout(room.absTimer); + // AUTODESTRUCCIÓN: vaciar todo el material en RAM + room.messages.length = 0; + room.signals.length = 0; + room.members.clear(); + rooms.delete(id); + }; + + const createRoom = ({ title = "", ttlMinutes } = {}) => { + if (rooms.size >= MAX_ROOMS) { + // desalojar la más antigua para no crecer sin límite + const oldest = [...rooms.values()].sort((a, b) => a.createdAt - b.createdAt)[0]; + if (oldest) destroy(oldest.id); + } + const id = crypto.randomBytes(9).toString("hex"); // 18 hex + const createdAt = Date.now(); + let absMs = absTtlMs; + const t = parseInt(ttlMinutes, 10); + if (Number.isFinite(t) && t > 0) absMs = Math.min(t * 60000, MAX_ABS_TTL_MS); + const room = { + id, + title: String(title || "").slice(0, 80), + createdAt, + expiresAt: createdAt + absMs, + messages: [], + signals: [], + members: new Set(), + seq: 0, + sigSeq: 0, + idleTimer: null, + absTimer: null, + }; + rooms.set(id, room); + room.absTimer = setTimeout(() => destroy(id), absMs); + if (room.absTimer.unref) room.absTimer.unref(); + resetIdle(room); + return publicRoom(room); + }; + + const listRooms = () => + [...rooms.values()].map(publicRoom).sort((a, b) => b.createdAt - a.createdAt); + + const getRoom = (id) => { + const r = rooms.get(id); + return r ? publicRoom(r) : null; + }; + + const postMessage = (id, { from = "?", text = "", mid = "" } = {}) => { + const room = rooms.get(id); + if (!room) return null; + const clean = String(text || "").slice(0, 2000); + if (!clean) return null; + const msg = { + seq: ++room.seq, + mid: String(mid || "").slice(0, 40), + from: String(from || "?").slice(0, 80), + text: clean, + ts: Date.now(), + }; + room.messages.push(msg); + if (room.messages.length > MAX_MSGS) room.messages.shift(); + room.members.add(msg.from); + resetIdle(room); + return msg; + }; + + const listMessages = (id, since = 0) => { + const room = rooms.get(id); + if (!room) return null; + const s = parseInt(since, 10) || 0; + return room.messages.filter((m) => m.seq > s); + }; + + // --- señalización WebRTC (mailbox RAM) --- + const postSignal = (id, { from = "", to = "", payload = null } = {}) => { + const room = rooms.get(id); + if (!room) return null; + const sig = { + seq: ++room.sigSeq, + from: String(from || "").slice(0, 80), + to: String(to || "").slice(0, 80), + payload, + ts: Date.now(), + }; + room.signals.push(sig); + if (room.signals.length > MAX_SIGNALS) room.signals.shift(); + if (sig.from) room.members.add(sig.from); + resetIdle(room); + return sig; + }; + + const getSignals = (id, { forId = "", since = 0 } = {}) => { + const room = rooms.get(id); + if (!room) return null; + const s = parseInt(since, 10) || 0; + return { + signals: room.signals.filter( + (x) => x.seq > s && x.from !== forId && (!x.to || x.to === forId) + ), + members: [...room.members].filter((m) => m !== forId), + }; + }; + + return { + createRoom, + listRooms, + getRoom, + postMessage, + listMessages, + postSignal, + getSignals, + _destroy: destroy, + }; +}; diff --git a/src/views/karvan_view.js b/src/views/karvan_view.js new file mode 100644 index 0000000..3c0b2ea --- /dev/null +++ b/src/views/karvan_view.js @@ -0,0 +1,91 @@ +"use strict"; +// Vista del módulo KARVAN (mensajes temporales + WebRTC). El chat va por relay servidor (RAM, +// autodestrucción) y, si conectan pares, por data-channel WebRTC (instantáneo). El