[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
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -3511,6 +3511,7 @@ module.exports = {
|
|||
modulesMapLabel: "خرائط",
|
||||
modulesMapDescription: "وحدة لإدارة ومشاركة الخرائط غير المتصلة.",
|
||||
padsTitle: "الوسادات",
|
||||
karvanTitle: "Karvan",
|
||||
padTitle: "وسادة",
|
||||
modulesPadsLabel: "الوسادات",
|
||||
modulesPadsDescription: "وحدة لإدارة محررات النصوص التعاونية.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -3511,6 +3511,7 @@ module.exports = {
|
|||
modulesMapLabel: "मानचित्र",
|
||||
modulesMapDescription: "ऑफ़लाइन मानचित्रों को प्रबंधित और साझा करने का मॉड्यूल।",
|
||||
padsTitle: "पैड्स",
|
||||
karvanTitle: "Karvan",
|
||||
padTitle: "पैड",
|
||||
modulesPadsLabel: "पैड्स",
|
||||
modulesPadsDescription: "सहयोगी पाठ संपादकों को प्रबंधित करने का मॉड्यूल।",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -3511,6 +3511,7 @@ module.exports = {
|
|||
modulesMapLabel: "Карты",
|
||||
modulesMapDescription: "Модуль для управления и обмена офлайн-картами.",
|
||||
padsTitle: "Пады",
|
||||
karvanTitle: "Karvan",
|
||||
padTitle: "Пад",
|
||||
modulesPadsLabel: "Пады",
|
||||
modulesPadsDescription: "Модуль для управления совместными текстовыми редакторами.",
|
||||
|
|
|
|||
|
|
@ -3512,6 +3512,7 @@ module.exports = {
|
|||
modulesMapLabel: "地图",
|
||||
modulesMapDescription: "管理和分享离线地图的模块。",
|
||||
padsTitle: "协作板",
|
||||
karvanTitle: "Karvan",
|
||||
padTitle: "协作板",
|
||||
modulesPadsLabel: "协作板",
|
||||
modulesPadsDescription: "管理协作文本编辑器的模块。",
|
||||
|
|
|
|||
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);
|
||||
}
|
||||
})();
|
||||
|
|
@ -39,6 +39,7 @@ if (!fs.existsSync(configFilePath)) {
|
|||
"reportsMod": "on",
|
||||
"opinionsMod": "on",
|
||||
"padsMod": "on",
|
||||
"karvanMod": "on",
|
||||
"calendarsMod": "on",
|
||||
"transfersMod": "on",
|
||||
"feedMod": "on",
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@
|
|||
"reportsMod": "on",
|
||||
"opinionsMod": "on",
|
||||
"padsMod": "on",
|
||||
"karvanMod": "on",
|
||||
"calendarsMod": "on",
|
||||
"transfersMod": "off",
|
||||
"feedMod": "on",
|
||||
|
|
|
|||
149
src/models/karvan_model.js
Normal file
149
src/models/karvan_model.js
Normal file
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
91
src/views/karvan_view.js
Normal file
91
src/views/karvan_view.js
Normal file
|
|
@ -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 <script> solo
|
||||
// se carga en la página de sala; el WebRTC usa data-channel (sin cámara/mic => sin permisos extra).
|
||||
|
||||
const { div, section, h2, h3, p, a, form, input, button, span, ul, li, script, label } =
|
||||
require("../server/node_modules/hyperaxe");
|
||||
const { template, i18n } = require("./main_views");
|
||||
|
||||
const fmtExpires = (expiresAt) => {
|
||||
const mins = Math.max(0, Math.round((expiresAt - Date.now()) / 60000));
|
||||
if (mins >= 60) return `${Math.floor(mins / 60)}h ${mins % 60}m`;
|
||||
return `${mins}m`;
|
||||
};
|
||||
|
||||
const shortId = (id) => {
|
||||
id = String(id || "?");
|
||||
return id.startsWith("@") ? id.slice(1, 7) : id.slice(0, 6);
|
||||
};
|
||||
exports.karvanShortId = shortId;
|
||||
|
||||
const renderMsg = (m) =>
|
||||
li({ class: "karvan-msg", "data-seq": String(m.seq), "data-mid": m.mid || "" },
|
||||
span({ class: "km-from" }, shortId(m.from)),
|
||||
span({ class: "km-text" }, m.text)
|
||||
);
|
||||
|
||||
const ttlOpt = (val, lbl, checked) =>
|
||||
label({ class: "karvan-ttl-opt" },
|
||||
input(Object.assign({ type: "radio", name: "ttl", value: val }, checked ? { checked: "checked" } : {})),
|
||||
span(lbl)
|
||||
);
|
||||
|
||||
exports.karvanView = (rooms, params = {}) =>
|
||||
template(
|
||||
i18n.karvanTitle || "Karvan",
|
||||
section({ class: "karvan-intro" },
|
||||
h2("Karvan · " + (i18n.karvanEphemeral || "Ephemeral rooms")),
|
||||
p({ class: "karvan-sub" },
|
||||
i18n.karvanBlurb ||
|
||||
"Temporary rooms that self-destruct. Messages live only in memory — never saved — and vanish when the room expires. Real-time over WebRTC when a peer connects.")
|
||||
),
|
||||
section({ class: "karvan-create" },
|
||||
form({ method: "POST", action: "/karvan/create" },
|
||||
input({ type: "text", name: "title", placeholder: i18n.karvanRoomName || "Room name (optional)", maxlength: "80", autocomplete: "off" }),
|
||||
div({ class: "karvan-ttl" },
|
||||
span({ class: "karvan-ttl-lbl" }, i18n.karvanTtl || "Self-destruct in:"),
|
||||
ttlOpt("30", "30m", true), ttlOpt("120", "2h", false), ttlOpt("480", "8h", false)
|
||||
),
|
||||
button({ type: "submit" }, i18n.karvanCreate || "Create ephemeral room")
|
||||
)
|
||||
),
|
||||
section({ class: "karvan-list" },
|
||||
h3(i18n.karvanActive || "Active rooms"),
|
||||
rooms && rooms.length
|
||||
? ul({ class: "karvan-rooms" },
|
||||
...rooms.map((r) =>
|
||||
li(
|
||||
a({ href: "/karvan/" + r.id, class: "karvan-room-item" },
|
||||
span({ class: "kr-title" }, r.title || "room " + r.id.slice(0, 6)),
|
||||
span({ class: "kr-meta" }, `${r.count} · ⏳ ${fmtExpires(r.expiresAt)}`)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
: p({ class: "karvan-empty" }, i18n.karvanNone || "No active rooms. Create one — it disappears on its own.")
|
||||
)
|
||||
);
|
||||
|
||||
exports.karvanRoomView = (room, messages, params = {}) =>
|
||||
template(
|
||||
"Karvan · " + (room.title || room.id.slice(0, 6)),
|
||||
section({ class: "karvan-room", "data-room": room.id, "data-self": params.selfId || "" },
|
||||
div({ class: "karvan-room-head" },
|
||||
a({ href: "/karvan", class: "karvan-back" }, "‹ " + (i18n.karvanRooms || "Rooms")),
|
||||
span({ class: "karvan-room-title" }, room.title || "room " + room.id.slice(0, 6)),
|
||||
span({ class: "karvan-expire", id: "karvan-expire" }, "⏳ " + fmtExpires(room.expiresAt))
|
||||
),
|
||||
div({ class: "karvan-live", id: "karvan-live" },
|
||||
span({ class: "kl-dot" }),
|
||||
span({ class: "kl-txt", id: "karvan-live-txt" }, i18n.karvanRelay || "server relay")
|
||||
),
|
||||
ul({ class: "karvan-msgs", id: "karvan-msgs" }, ...(messages || []).map(renderMsg)),
|
||||
form({ class: "karvan-compose", method: "POST", action: "/karvan/" + room.id + "/msg", id: "karvan-form" },
|
||||
input({ type: "hidden", name: "from", value: params.selfId || "" }),
|
||||
input({ type: "text", name: "text", id: "karvan-text", placeholder: i18n.karvanSay || "Temporary message…", maxlength: "2000", autocomplete: "off" }),
|
||||
button({ type: "submit" }, i18n.karvanSend || "Send")
|
||||
)
|
||||
),
|
||||
script({ src: "/js/karvan.js", defer: "defer" })
|
||||
);
|
||||
|
|
@ -1105,14 +1105,12 @@ const renderTasksLink = () => {
|
|||
// === Oasis UX (portado de 0.8.7_UX): topbar + bottombar + hive ===
|
||||
// topbar fija arriba: [logo] [Personal] [Community] [avatar] en una línea (sin título OASIS)
|
||||
const renderMobileTopbar = () => {
|
||||
const hiveFilter = (typeof global !== "undefined" && global.__OASIS_HIVE_FILTER__) || "";
|
||||
// FORK_IA_UX v2: topbar SOLO identidad (logo→home, avatar→perfil). El filtro Personal/Community
|
||||
// se movió a la hoja "Explore" (junto al hive). Así el top no tiene botones "huérfanos" que confundan.
|
||||
return div(
|
||||
{ class: "oasis-mobile-topbar" },
|
||||
a({ class: "omt-logo", href: "/" }, img({ src: "/assets/images/snh-oasis.jpg", alt: "Oasis" })),
|
||||
div({ class: "omt-menus" },
|
||||
a({ class: hiveFilter === "personal" ? "oasis-menu-btn active" : "oasis-menu-btn", href: hiveFilter === "personal" ? "/activity" : "/activity?hive=personal" }, span(i18n.menuPersonal || "Personal")),
|
||||
a({ class: hiveFilter === "community" ? "oasis-menu-btn active" : "oasis-menu-btn", href: hiveFilter === "community" ? "/activity" : "/activity?hive=community" }, span(i18n.menuCommunity || "Community"))
|
||||
),
|
||||
div({ class: "omt-spacer" }),
|
||||
a({ class: "omt-avatar", href: "/profile" }, img({ src: (typeof global !== "undefined" && global.__OASIS_SELF_AVATAR__) || "/assets/images/default-avatar.png", alt: i18n.profile || "Profile" }))
|
||||
);
|
||||
};
|
||||
|
|
@ -1124,6 +1122,7 @@ const PINNABLE_MODULES = [
|
|||
{ key: 'activity', href: '/activity', glyph: 'ꔙ', labelKey: 'activityTitle', mod: null },
|
||||
{ key: 'tribes', href: '/tribes', glyph: 'ꖥ', labelKey: 'tribesTitle', mod: 'tribesMod' },
|
||||
{ key: 'chats', href: '/chats', glyph: 'ꖒ', labelKey: 'chatsTitle', mod: 'chatsMod' },
|
||||
{ key: 'karvan', href: '/karvan', glyph: '⧗', labelKey: 'karvanTitle', mod: 'karvanMod' },
|
||||
{ key: 'market', href: '/market', glyph: 'ꕻ', labelKey: 'marketTitle', mod: 'marketMod' },
|
||||
{ key: 'events', href: '/events', glyph: 'ꕆ', labelKey: 'eventsLabel', mod: 'eventsMod' },
|
||||
{ key: 'calendars', href: '/calendars', glyph: 'ꖯ', labelKey: 'calendarsTitle', mod: 'calendarsMod' },
|
||||
|
|
@ -1225,7 +1224,8 @@ const renderHiveNav = () => {
|
|||
{ href: '/activity', glyph: 'ꔙ', labelKey: 'activityTitle', mod: null }, { href: '/tags', glyph: 'ꖶ', labelKey: 'tagsLabel', mod: 'tagsMod' },
|
||||
{ href: '/trending', glyph: 'ꗝ', labelKey: 'trendingLabel', mod: 'trendingMod' }, { href: '/opinions', glyph: 'ꔍ', labelKey: 'opinionsTitle', mod: 'opinionsMod' },
|
||||
{ href: '/pads', glyph: 'ꔗ', labelKey: 'padsTitle', mod: 'padsMod' }, { href: '/forum', glyph: 'ꕒ', labelKey: 'forumTitle', mod: 'forumMod' },
|
||||
{ href: '/maps', glyph: 'ꔌ', labelKey: 'mapsLabel', mod: 'mapsMod' }, { href: '/chats', glyph: 'ꖒ', labelKey: 'chatsTitle', mod: 'chatsMod' } ] },
|
||||
{ href: '/maps', glyph: 'ꔌ', labelKey: 'mapsLabel', mod: 'mapsMod' }, { href: '/chats', glyph: 'ꖒ', labelKey: 'chatsTitle', mod: 'chatsMod' },
|
||||
{ href: '/karvan', glyph: '⧗', labelKey: 'karvanTitle', mod: 'karvanMod' } ] },
|
||||
{ id: 'blogs', emoji: '✦', title: i18n.menuBlogs, mods: [
|
||||
{ href: '/mentions', glyph: '✺', labelKey: 'mentions', mod: null }, { href: '/public/latest', glyph: '☄', labelKey: 'latest', mod: 'latestMod' },
|
||||
{ href: '/public/latest/threads', glyph: '♺', labelKey: 'threads', mod: 'threadsMod' }, { href: '/public/latest/topics', glyph: 'ϟ', labelKey: 'topics', mod: 'topicsMod' },
|
||||
|
|
@ -1274,6 +1274,16 @@ const renderHiveSheet = () => {
|
|||
label({ class: "hive-sheet-backdrop", for: "fork-explore-toggle", "aria-label": "Close" }),
|
||||
div({ class: "hive-sheet-panel" },
|
||||
label({ class: "hive-sheet-grip", for: "fork-explore-toggle", "aria-label": "Close" }),
|
||||
// filtro Personal/Community/Todo (movido desde el topbar; filtra el hive server-side via ?hive=)
|
||||
(() => {
|
||||
const hf = (typeof global !== "undefined" && global.__OASIS_HIVE_FILTER__) || "";
|
||||
const seg = (href, lbl, on) => a({ class: on ? "hs-seg active" : "hs-seg", href }, lbl);
|
||||
return div({ class: "hive-sheet-seg" },
|
||||
seg("/activity", i18n.menuAll || "All", hf === ""),
|
||||
seg("/activity?hive=personal", i18n.menuPersonal || "Personal", hf === "personal"),
|
||||
seg("/activity?hive=community", i18n.menuCommunity || "Community", hf === "community")
|
||||
);
|
||||
})(),
|
||||
// accesos que no están en el hive (CATS): red y estado
|
||||
div({ class: "hive-sheet-quick" },
|
||||
q("/peers", "⧖", "Peers"),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue