[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

149
src/models/karvan_model.js Normal file
View 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,
};
};