fuera Karvan: se usan el Telefono y las Salas del dev

El dev ya trae Telefono (llamadas uno a uno, buzon de voz) y Salas (hasta 50,
con video, grabacion y emision). La rama quita su modulo propio de salas y
llamadas: modelo, vista, JS de cliente, CSS, rutas, credenciales TURN, pruebas
y textos.

- el router propio pasa a llamarse fork_routes.js, sin Karvan
- el audio, la camara y la emision de Android se enchufan desde main.js con
  setAudioFactory/setVideoFactory/setStreamFactory, que exporta el propio dev;
  SSB_server.js vuelve a tener su linea original
- Clear-SNH.css y modules_view.js vuelven a ser identicos a los del dev
- el workflow acampada y la config de serie cambian Karvan por Telefono y Salas
- README y guia: las llamadas son las del dev; antes de hacer un modulo propio,
  mirar si el dev ya lo tiene
This commit is contained in:
hacklab 2026-10-19 13:39:41 +02:00
parent 3937ddab1e
commit 096a7d110b
30 changed files with 266 additions and 1918 deletions

View file

@ -4664,7 +4664,7 @@ router
ctx.body = statsView(stats, filter);
})
.get("/modules", async (ctx) => {
const modules = ['blogs', 'polls', 'karvan', 'fediverse', 'invites', 'wallet', 'backup', 'dev', 'cipher', 'bookmarks', 'calendars', 'chats', 'videos', 'docs', 'audios', 'tags', 'images', 'maps', 'trending', 'events', 'tasks', 'market', 'tribes', 'larp', 'votes', 'reports', 'opinions', 'pads', 'transfers', 'feed', 'pixelia', 'melody', 'agenda', 'favorites', 'ai', 'forum', 'games', 'housing', 'jobs', 'projects', 'industry', 'shops', 'banking', 'school', 'parliament', 'courts'];
const modules = ['blogs', 'polls', 'fediverse', 'invites', 'wallet', 'backup', 'dev', 'cipher', 'bookmarks', 'calendars', 'chats', 'videos', 'docs', 'audios', 'tags', 'images', 'maps', 'trending', 'events', 'tasks', 'market', 'tribes', 'larp', 'votes', 'reports', 'opinions', 'pads', 'transfers', 'feed', 'pixelia', 'melody', 'agenda', 'favorites', 'ai', 'forum', 'games', 'housing', 'jobs', 'projects', 'industry', 'shops', 'banking', 'school', 'parliament', 'courts'];
const cfg = getConfig().modules;
ctx.body = modulesView(modules.reduce((acc, m) => { acc[`${m}Mod`] = cfg[`${m}Mod`]; return acc; }, {}));
})
@ -15515,8 +15515,8 @@ const middleware = [
await next();
},
// la rama movil: su router y su capa de backend (ver GUIA_DE_DESARROLLO.md)
require('./karvan_routes')({ cooler, pull, pmModel, checkMod, getViewerId, getConfig, saveConfig,
isLoopbackRequest, sendErrorPage, about, backupModel, onboardingModel }),
require('./fork_routes')({ checkMod, getViewerId, getConfig, saveConfig, isLoopbackRequest, sendErrorPage,
about, backupModel, onboardingModel }),
routes,
async (ctx) => {
if (ctx.body != null || (ctx.method !== 'GET' && ctx.method !== 'HEAD')) return;

154
src/backend/fork_routes.js Normal file
View file

@ -0,0 +1,154 @@
"use strict";
// Router propio de la rama movil.
//
// Va en la cadena justo delante de las rutas del dev (backend.js lo monta con una
// sola linea). Lo que no atiende pasa al del dev con next(). Aqui viven:
// - mobile_layer.js: lo que el movil necesita del backend
// - wrapUpstream: mira lo que contesta el dev (seccion apagada, guardar modulos)
// - las rutas propias: tema y Modulos en el movil, barra de abajo, identidades
//
// backend.js es del dev y cambia en 34 de cada 39 versiones: nada de esto va alli.
// Ver GUIA_DE_DESARROLLO.md.
const koaRouter = require("../server/node_modules/@koa/router");
const { koaBody } = require("../server/node_modules/koa-body");
const compose = require("../server/node_modules/koa-compose");
const { isLocalPath } = require("./request_guards");
const { ALL_MODULES } = require("../models/workflows_model");
const { mobileModulesView, STEMS } = require("../views/mobile_modules_view");
require("../models/fork_workflows").register();
const isMobile = () => process.env.OASIS_MOBILE === '1';
// Seccion apagada: upstream responde con una redireccion a /modules sin decir de
// donde venia. Este envoltorio la ve pasar y anota que modulo era, para que la
// pantalla de Modulos del movil lo diga y ofrezca activarlo. La app es de una
// persona, asi que basta con recordar el ultimo unos segundos.
const SEGMENT_MOD = { tribe: 'tribes', vote: 'votes', document: 'docs', documents: 'docs', blog: 'blogs', event: 'events',
task: 'tasks', calendar: 'calendars', image: 'images', video: 'videos', audio: 'audios', bookmark: 'bookmarks',
file: 'files', poll: 'polls', report: 'reports', project: 'projects', shop: 'shops', job: 'jobs', campaign: 'campaigns',
podcast: 'podcasts', room: 'rooms', map: 'maps', transfer: 'transfers', pad: 'pads', chat: 'chats', torrent: 'torrents' };
const modOfPath = (p) => {
const seg = String(p || '').split('/')[1] || '';
const mod = STEMS[seg] ? seg : SEGMENT_MOD[seg];
return mod && ALL_MODULES.includes(mod) ? mod : null;
};
let lastBlocked = null;
const takeBlocked = () => {
const b = lastBlocked && Date.now() - lastBlocked.at < 15000 ? lastBlocked : null;
lastBlocked = null;
return b;
};
const wrapUpstream = async (ctx, next) => {
await next();
if (!isMobile() || ctx.status < 300 || ctx.status >= 400) return;
const to = String(ctx.response.get('Location') || '');
// guardar en Modulos: volver con el aviso de guardado
if (ctx.method === 'POST' && ctx.path === '/save-modules') { ctx.redirect('/modules?saved=1'); return; }
if (ctx.method === 'GET' && ctx.path !== '/modules' && /^(https?:\/\/[^/]+)?\/modules$/.test(to)) {
const mod = modOfPath(ctx.path);
if (mod) lastBlocked = { mod, path: ctx.path, at: Date.now() };
}
};
module.exports = ({ checkMod, getViewerId, getConfig, saveConfig, isLoopbackRequest, sendErrorPage,
about, backupModel, onboardingModel }) => {
const mobileLayer = require('./mobile_layer')({ getConfig, saveConfig, getViewerId, about, backupModel, onboardingModel,
checkMod, isLoopbackRequest, sendErrorPage });
const forkRouter = new koaRouter();
forkRouter
// tema en el movil: solo los skins de movil (uno de escritorio deja la app sin
// maqueta). Hace lo mismo que la ruta del dev, con esa comprobacion delante
.post('/settings/theme', koaBody(), async (ctx, next) => {
if (!isMobile()) return next();
const theme = String((ctx.request.body || {}).theme || '').trim();
const { MOBILE_THEMES } = require('../views/main_views');
if (!MOBILE_THEMES.some(([value]) => value === theme)) { ctx.redirect('/settings#theme'); return; }
const cfg = getConfig();
cfg.themes.current = theme;
saveConfig(cfg);
ctx.cookies.set('theme', theme, { httpOnly: true, sameSite: 'strict', secure: ctx.secure });
ctx.redirect('/settings#theme');
})
// Modulos en el movil: la vista propia (mobile_modules_view.js); en escritorio, la de upstream
.get('/modules', async (ctx, next) => {
if (!isMobile()) return next();
ctx.body = mobileModulesView({ blocked: takeBlocked(), saved: ctx.query.saved === '1' });
})
// activar un solo modulo desde el aviso de Modulos y volver a donde se queria ir
.post('/modules/enable', koaBody(), async (ctx) => {
if (!isLoopbackRequest(ctx)) { ctx.status = 403; ctx.body = ''; return; }
const mod = String((ctx.request.body || {}).mod || '').replace(/Mod$/, '');
if (!ALL_MODULES.includes(mod)) { ctx.redirect('/modules'); return; }
const cfg = getConfig();
cfg.modules[`${mod}Mod`] = 'on';
saveConfig(cfg);
const back = String((ctx.request.body || {}).returnTo || '');
ctx.redirect(isLocalPath(back) ? back : `/${mod}`);
})
.get("/settings/bottombar", async (ctx) => {
ctx.body = require("../views/main_views").bottomBarPickerView();
})
.post("/settings/bottombar", koaBody(), async (ctx) => {
const cfg = getConfig();
const MAX = 4;
const body = ctx.request.body || {};
const action = String(body.action || '').trim();
const key = String(body.key || '').trim();
const catalog = require("../views/main_views").PINNABLE_MODULES || [];
let pins = Array.isArray(cfg.bottomBarPins) ? cfg.bottomBarPins.slice() : [];
if (action === 'add') {
if (key && catalog.some(m => m.key === key) && !pins.includes(key) && pins.length < MAX) pins.push(key);
} else if (action === 'remove') {
pins = pins.filter(k => k !== key);
} else if (action === 'clear') {
pins = [];
}
cfg.bottomBarPins = pins.slice(0, MAX);
saveConfig(cfg);
ctx.redirect('/settings/bottombar');
})
.post("/settings/identity/use", koaBody(), async (ctx) => {
// solo anota cual sera la del proximo arranque: cambiarla en caliente dejaria
// media aplicacion hablando con el sbot anterior
const accounts = require('./accounts.js');
const name = String((ctx.request.body || {}).account || '').trim();
if (!accounts.isValid(name) || !accounts.list().includes(name)) {
sendErrorPage(ctx, require('../views/main_views').i18n.identitiesInvalidName, { status: 400 });
return;
}
accounts.setActive(name);
ctx.redirect('/settings');
})
.post("/settings/identity/create", koaBody(), async (ctx) => {
const accounts = require('./accounts.js');
const name = String((ctx.request.body || {}).account || '').trim();
if (!accounts.isValid(name)) {
sendErrorPage(ctx, require('../views/main_views').i18n.identitiesInvalidName, { status: 400 });
return;
}
// no se genera el secret aqui: lo crea ssb-config al arrancar con esa cuenta,
// que es quien sabe como hacerlo. Basta con anotarla y reiniciar.
accounts.setActive(name);
ctx.redirect('/settings');
})
.post("/settings/identity/restart", koaBody(), async (ctx) => {
// Solo en movil: alli el backend vive en un servicio en primer plano que
// sobrevive a cerrar la app, asi que la unica forma de arrancar con otra
// identidad es terminar el proceso. nodejs-mobile no permite volver a
// levantar el runtime dentro del mismo proceso: hay que salir y que la app
// lo relance al abrirse.
if (process.env.OASIS_MOBILE !== '1') { ctx.status = 404; ctx.body = ''; return; }
ctx.body = require("../views/main_views").template(
require('../views/main_views').i18n.identitiesTitle,
require("../server/node_modules/hyperaxe").section(
require("../server/node_modules/hyperaxe").p(require('../views/main_views').i18n.identitiesRestarting)
)
);
// se responde primero: si se sale antes, el navegador se queda sin pagina
setTimeout(() => process.exit(0), 500);
});
return compose([mobileLayer, wrapUpstream, forkRouter.routes()]);
};

View file

@ -1,307 +0,0 @@
"use strict";
// Rutas del modulo Karvan (salas efimeras en RAM, mensajes temporales y
// senalizacion WebRTC), mas las dos pantallas que la rama anade a los ajustes.
//
// Upstream pone todas sus rutas en backend.js: 625 en una sola cadena fluida.
// Estas viven aparte porque ese fichero se reescribe entero en cada release
// (+833/-286 en la 0.9.2, +266/-31 en la 0.9.1), asi que una ruta anadida en
// medio entra en conflicto cada vez. Se montan en un router propio insertado
// antes del de upstream; lo que no casa cae a next() y sigue su curso.
//
// El nombre sigue a la familia del modulo: karvan_model.js, karvan_view.js,
// karvan.js, karvan.css.
//
// Las dependencias se inyectan en lugar de reconstruirse: cooler abre la conexion
// al sbot y duplicarla abriria un segundo cliente muxrpc.
const koaRouter = require("../server/node_modules/@koa/router");
const { koaBody } = require("../server/node_modules/koa-body");
const { stripDangerousTags } = require("./sanitizeHtml");
const { buildIceServers } = require("./turnCredentials");
const karvanModel = require('../models/karvan_model')({}); // FORK_IA_UX: salas efímeras en RAM (mensajes temporales + señalización WebRTC)
const { karvanView, karvanRoomView } = require("../views/karvan_view");
const compose = require("../server/node_modules/koa-compose");
const { isLocalPath } = require("./request_guards");
const { ALL_MODULES } = require("../models/workflows_model");
const { mobileModulesView, STEMS } = require("../views/mobile_modules_view");
require("../models/fork_workflows").register();
const isMobile = () => process.env.OASIS_MOBILE === '1';
// Seccion apagada: upstream responde con una redireccion a /modules sin decir de
// donde venia. Este envoltorio la ve pasar y anota que modulo era, para que la
// pantalla de Modulos del movil lo diga y ofrezca activarlo. La app es de una
// persona, asi que basta con recordar el ultimo unos segundos.
const SEGMENT_MOD = { tribe: 'tribes', vote: 'votes', document: 'docs', documents: 'docs', blog: 'blogs', event: 'events',
task: 'tasks', calendar: 'calendars', image: 'images', video: 'videos', audio: 'audios', bookmark: 'bookmarks',
file: 'files', poll: 'polls', report: 'reports', project: 'projects', shop: 'shops', job: 'jobs', campaign: 'campaigns',
podcast: 'podcasts', room: 'rooms', map: 'maps', transfer: 'transfers', pad: 'pads', chat: 'chats', torrent: 'torrents' };
const modOfPath = (p) => {
const seg = String(p || '').split('/')[1] || '';
const mod = STEMS[seg] ? seg : SEGMENT_MOD[seg];
return mod && ALL_MODULES.includes(mod) ? mod : null;
};
let lastBlocked = null;
const takeBlocked = () => {
const b = lastBlocked && Date.now() - lastBlocked.at < 15000 ? lastBlocked : null;
lastBlocked = null;
return b;
};
const wrapUpstream = async (ctx, next) => {
await next();
if (!isMobile() || ctx.status < 300 || ctx.status >= 400) return;
const to = String(ctx.response.get('Location') || '');
// guardar en Modulos: volver con el aviso de guardado
if (ctx.method === 'POST' && ctx.path === '/save-modules') { ctx.redirect('/modules?saved=1'); return; }
if (ctx.method === 'GET' && ctx.path !== '/modules' && /^(https?:\/\/[^/]+)?\/modules$/.test(to)) {
const mod = modOfPath(ctx.path);
if (mod) lastBlocked = { mod, path: ctx.path, at: Date.now() };
}
};
module.exports = ({ cooler, pull, pmModel, checkMod, getViewerId,
getConfig, saveConfig, isLoopbackRequest, sendErrorPage,
about, backupModel, onboardingModel }) => {
const mobileLayer = require('./mobile_layer')({ getConfig, saveConfig, getViewerId, about, backupModel, onboardingModel,
checkMod, isLoopbackRequest, sendErrorPage });
// FORK_IA_UX Fase 2 (cross-device): relay de MENSAJES de Karvan por privados SSB. Funciona vía un pub
// (el privado se replica al otro móvil); los SIGNAL de WebRTC NO se relayan (serían demasiados/lentos por el log).
// No toca el arranque del SSB. Ingesta por stream EN VIVO (no polling) → eficiente.
const karvanSeenRelay = new Set(); // mids ya inyectados (evita duplicados/bucles)
let karvanRelaySub = false; // ¿suscriptor del log en vivo montado?
async function karvanPublishRelay(roomId, mid, from, text) {
try {
const feeds = karvanModel.getRemoteFeeds(roomId);
if (!feeds.length) return;
const ssbClient = await cooler.open();
const recps = [...new Set([ssbClient.id, ...feeds])].slice(0, 8);
await new Promise((res, rej) => ssbClient.private.publish(
{ type: 'karvan-relay', roomId, mid: String(mid || '').slice(0, 60), from: String(from || '?').slice(0, 80), text: String(text || '').slice(0, 2000), private: true },
recps, (e, m) => e ? rej(e) : res(m)));
} catch (e) {}
}
async function karvanStartRelay() {
if (karvanRelaySub) return;
karvanRelaySub = true;
try {
const ssbClient = await cooler.open();
const me = ssbClient.id;
pull(
ssbClient.createLogStream({ old: false, live: true }),
pull.drain((m) => {
try {
if (!m || !m.value) return;
let dec; try { dec = ssbClient.private.unbox({ key: m.key, value: m.value, timestamp: m.timestamp }); } catch (_) { return; }
const c = dec && dec.value && dec.value.content;
if (!c || c.type !== 'karvan-relay' || !c.roomId || !c.mid) return;
if (dec.value.author === me) return; // no reinyectar lo propio (evita bucle A→B→A)
if (karvanSeenRelay.has(c.mid)) return;
if (!karvanModel.getRoom(c.roomId)) return; // solo si tenemos esa sala localmente
karvanSeenRelay.add(c.mid);
if (karvanSeenRelay.size > 5000) karvanSeenRelay.clear();
karvanModel.postMessage(c.roomId, { from: c.from, text: c.text, mid: c.mid });
} catch (e) {}
})
);
} catch (e) { karvanRelaySub = false; }
}
// ¿quién me invitó a esta sala? (para registrar su feed en el espejo local) — reutiliza el lector de privados
async function karvanInviteAuthor(roomId) {
try {
const msgs = await pmModel.listAllPrivate();
for (const m of (msgs || [])) {
const c = m && m.value && m.value.content;
if (c && c.subject === 'KARVAN_INVITE' && typeof c.text === 'string' && c.text.indexOf('/karvan/' + roomId) !== -1) return m.value.author;
}
} catch (e) {}
return null;
}
const forkRouter = new koaRouter();
forkRouter
// tema en el movil: solo los skins de movil (uno de escritorio deja la app sin
// maqueta). Hace lo mismo que la ruta del dev, con esa comprobacion delante
.post('/settings/theme', koaBody(), async (ctx, next) => {
if (!isMobile()) return next();
const theme = String((ctx.request.body || {}).theme || '').trim();
const { MOBILE_THEMES } = require('../views/main_views');
if (!MOBILE_THEMES.some(([value]) => value === theme)) { ctx.redirect('/settings#theme'); return; }
const cfg = getConfig();
cfg.themes.current = theme;
saveConfig(cfg);
ctx.cookies.set('theme', theme, { httpOnly: true, sameSite: 'strict', secure: ctx.secure });
ctx.redirect('/settings#theme');
})
// Modulos en el movil: la vista propia (mobile_modules_view.js); en escritorio, la de upstream
.get('/modules', async (ctx, next) => {
if (!isMobile()) return next();
ctx.body = mobileModulesView({ blocked: takeBlocked(), saved: ctx.query.saved === '1' });
})
// activar un solo modulo desde el aviso de Modulos y volver a donde se queria ir
.post('/modules/enable', koaBody(), async (ctx) => {
if (!isLoopbackRequest(ctx)) { ctx.status = 403; ctx.body = ''; return; }
const mod = String((ctx.request.body || {}).mod || '').replace(/Mod$/, '');
if (!ALL_MODULES.includes(mod)) { ctx.redirect('/modules'); return; }
const cfg = getConfig();
cfg.modules[`${mod}Mod`] = 'on';
saveConfig(cfg);
const back = String((ctx.request.body || {}).returnTo || '');
ctx.redirect(isLocalPath(back) ? back : `/${mod}`);
})
// Configuracion ICE para las llamadas. Solo loopback: la credencial TURN va
// firmada con el secreto del nodo y no debe salir de la maquina.
.get('/karvan/ice', async (ctx) => {
if (!checkMod(ctx, 'karvanMod')) { ctx.status = 403; ctx.body = { error: 'off' }; return; }
if (!isLoopbackRequest(ctx)) { ctx.status = 403; ctx.body = { error: 'forbidden' }; return; }
let who = ''; try { who = getViewerId() || ''; } catch (e) {}
let rtc = null; try { rtc = (getConfig() || {}).rtc || null; } catch (e) {}
ctx.body = buildIceServers(rtc, who);
})
// ===== 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; }
let room = karvanModel.getRoom(ctx.params.id);
// unirse por enlace/invitación crea un espejo local, pero SOLO en una navegación real (documento):
// un <img>/subrecurso de una web maliciosa NO debe poder crear/expulsar salas por GET (CSRF). El navegador
// controla sec-fetch-dest/accept, así que son señales fiables.
let justAdopted = false;
if (!room) {
const isNav = ctx.get('sec-fetch-dest') === 'document' || (ctx.get('accept') || '').includes('text/html');
if (isNav) { room = karvanModel.adoptRoom({ id: ctx.params.id }); justAdopted = !!room; }
}
if (!room) { ctx.redirect('/karvan'); return; }
if (justAdopted) { // al unirte por enlace, registra el feed de quien te invitó → relay cross-device
const author = await karvanInviteAuthor(ctx.params.id);
if (author) { karvanModel.addRemoteFeed(ctx.params.id, author); karvanStartRelay(); }
}
let selfId = ''; try { selfId = getViewerId(); } catch (e) {}
const msgs = karvanModel.listMessages(room.id, 0) || [];
ctx.body = karvanRoomView(room, msgs, { selfId, invite: ctx.query.invite || '' });
})
.post('/karvan/:id/invite', koaBody(), async (ctx) => {
if (!checkMod(ctx, 'karvanMod')) { ctx.status = 403; ctx.body = { error: 'off' }; return; }
const id = ctx.params.id;
const room = karvanModel.getRoom(id);
if (!room) { ctx.redirect('/karvan'); return; }
const to = String((ctx.request.body || {}).to || '').trim();
if (!/^@[A-Za-z0-9+/=]{20,}\.ed25519$/.test(to)) { ctx.redirect('/karvan/' + id + '?invite=bad'); return; }
try {
// reutiliza el patrón de invitación por mensaje privado de Oasis (como el módulo industry):
// le llega al INBOX del contacto con un enlace a la sala. Sin código SSB nuevo.
await pmModel.sendMessage([to], 'KARVAN_INVITE',
'You have been invited to an ephemeral Karvan room' + (room.title ? ' ("' + room.title + '")' : '') + ' -> /karvan/' + id);
karvanModel.addRemoteFeed(id, to); karvanStartRelay(); // relay cross-device hacia el invitado
ctx.redirect('/karvan/' + id + '?invite=ok');
} catch (e) { ctx.redirect('/karvan/' + id + '?invite=fail'); }
})
.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; }
karvanPublishRelay(ctx.params.id, msg.mid || (msg.from + '-' + msg.ts + '-' + msg.seq), msg.from, msg.text); // → feeds remotos (si hay)
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("/settings/bottombar", async (ctx) => {
ctx.body = require("../views/main_views").bottomBarPickerView();
})
.post("/settings/bottombar", koaBody(), async (ctx) => {
const cfg = getConfig();
const MAX = 4;
const body = ctx.request.body || {};
const action = String(body.action || '').trim();
const key = String(body.key || '').trim();
const catalog = require("../views/main_views").PINNABLE_MODULES || [];
let pins = Array.isArray(cfg.bottomBarPins) ? cfg.bottomBarPins.slice() : [];
if (action === 'add') {
if (key && catalog.some(m => m.key === key) && !pins.includes(key) && pins.length < MAX) pins.push(key);
} else if (action === 'remove') {
pins = pins.filter(k => k !== key);
} else if (action === 'clear') {
pins = [];
}
cfg.bottomBarPins = pins.slice(0, MAX);
saveConfig(cfg);
ctx.redirect('/settings/bottombar');
})
.post("/settings/identity/use", koaBody(), async (ctx) => {
// solo anota cual sera la del proximo arranque: cambiarla en caliente dejaria
// media aplicacion hablando con el sbot anterior
const accounts = require('./accounts.js');
const name = String((ctx.request.body || {}).account || '').trim();
if (!accounts.isValid(name) || !accounts.list().includes(name)) {
sendErrorPage(ctx, require('../views/main_views').i18n.identitiesInvalidName, { status: 400 });
return;
}
accounts.setActive(name);
ctx.redirect('/settings');
})
.post("/settings/identity/create", koaBody(), async (ctx) => {
const accounts = require('./accounts.js');
const name = String((ctx.request.body || {}).account || '').trim();
if (!accounts.isValid(name)) {
sendErrorPage(ctx, require('../views/main_views').i18n.identitiesInvalidName, { status: 400 });
return;
}
// no se genera el secret aqui: lo crea ssb-config al arrancar con esa cuenta,
// que es quien sabe como hacerlo. Basta con anotarla y reiniciar.
accounts.setActive(name);
ctx.redirect('/settings');
})
.post("/settings/identity/restart", koaBody(), async (ctx) => {
// Solo en movil: alli el backend vive en un servicio en primer plano que
// sobrevive a cerrar la app, asi que la unica forma de arrancar con otra
// identidad es terminar el proceso. nodejs-mobile no permite volver a
// levantar el runtime dentro del mismo proceso: hay que salir y que la app
// lo relance al abrirse.
if (process.env.OASIS_MOBILE !== '1') { ctx.status = 404; ctx.body = ''; return; }
ctx.body = require("../views/main_views").template(
require('../views/main_views').i18n.identitiesTitle,
require("../server/node_modules/hyperaxe").section(
require("../server/node_modules/hyperaxe").p(require('../views/main_views').i18n.identitiesRestarting)
)
);
// se responde primero: si se sale antes, el navegador se queda sin pagina
setTimeout(() => process.exit(0), 500);
});
return compose([mobileLayer, wrapUpstream, forkRouter.routes()]);
};

View file

@ -3,12 +3,12 @@
//
// Antes eran trozos metidos en backend.js (rutas, middleware y refreshAll), que es
// del dev y cambia en 34 de cada 39 versiones. Ahora va aqui y lo monta el router
// propio (karvan_routes.js) delante de las rutas del dev. Nada de esto corre en
// propio (fork_routes.js) delante de las rutas del dev. Nada de esto corre en
// escritorio. Ver GUIA_DE_DESARROLLO.md.
//
// Hace cuatro cosas:
// - corta en el movil rutas que alli no tienen sentido (blockexplorer, actualizar
// con git, presets de modulos); el cambio de tema lo atiende karvan_routes.js
// con git, presets de modulos); el cambio de tema lo atiende fork_routes.js
// - mantiene apagados los modulos que no van en el movil, al arrancar y al guardar
// - prepara lo que leen la barra de arriba y el lanzador (avatar, filtro, lanzador)
// - retoca el HTML que sale (videos con fotograma, pestaña actual marcada)

View file

@ -1,50 +0,0 @@
"use strict";
// Credenciales efimeras para un TURN propio (coturn con use-auth-secret).
//
// Es el mecanismo REST de coturn: el servidor no guarda usuarios, valida que
// credential == base64(HMAC-SHA1(static-auth-secret, username))
// y que el timestamp del username no haya caducado. Asi las credenciales se
// generan sin hablar con coturn y caducan solas.
//
// Portado del turn.js de karvan-web, cambiando sessionId por el feed id: lo que
// identifica a quien llama en Oasis es su feed, no una sesion anonima.
const crypto = require("crypto");
const DEFAULT_TTL = 3600;
// username = <caduca en unix> ":" <identificador>
function mintTurnCredentials({ urls, secret, ttlSec = DEFAULT_TTL }, who, now = Date.now()) {
if (!secret || !Array.isArray(urls) || !urls.length) return null;
const expiry = Math.floor(now / 1000) + ttlSec;
const username = expiry + ":" + String(who || "anon").slice(0, 64);
const credential = crypto.createHmac("sha1", secret).update(username).digest("base64");
return { urls, username, credential, ttl: ttlSec, expiry };
}
// Lee la configuracion de rtc y devuelve lo que el navegador espera en iceServers.
// Formas admitidas en oasis-config.json:
// "rtc": { "stun": ["stun:mi.pub:3478"] }
// "rtc": { "turn": { "urls": [...], "secret": "...", "ttlSec": 3600 }, "relayOnly": true }
// "rtc": { "turn": { "urls": [...], "username": "...", "credential": "..." } } (estatico)
function buildIceServers(rtc, who, now = Date.now()) {
const out = [];
if (!rtc || typeof rtc !== "object") return { iceServers: out, relayOnly: false };
if (Array.isArray(rtc.stun)) {
for (const u of rtc.stun) if (typeof u === "string" && u) out.push({ urls: u });
}
const t = rtc.turn;
if (t && Array.isArray(t.urls) && t.urls.length) {
if (t.secret) {
const c = mintTurnCredentials(t, who, now);
if (c) out.push({ urls: c.urls, username: c.username, credential: c.credential });
} else if (t.username && t.credential) {
out.push({ urls: t.urls, username: t.username, credential: t.credential });
}
}
return { iceServers: out, relayOnly: !!rtc.relayOnly && out.some(s => s.credential) };
}
module.exports = { mintTurnCredentials, buildIceServers };

View file

@ -469,34 +469,6 @@ label.welcome-ux-option:has(input:checked) { background: var(--a26-neon) !import
/* el 📌 va en la esquina: el texto deja su hueco (el padding general de la burbuja lo pisaba) */
.chat-bubble-row .chat-message.chat-has-pin-toggle { position: relative !important; padding-right: 44px !important; }
.chat-day-chip { background: var(--a26-fg) !important; color: var(--a26-bg) !important; border: 0 !important; }
/* ---------- Karvan (karvan.css trae su paleta amarilla sobre negro) ---------- */
.karvan-intro h2, .karvan-room-title, .karvan-back { color: var(--a26-fg) !important; }
.karvan-sub, .karvan-ttl-lbl, .karvan-ttl-opt, .karvan-empty, .karvan-expire, .karvan-live,
.karvan-room-item .kr-meta, .kc-status { color: var(--a26-muted) !important; }
.karvan-room-item {
background: var(--a26-bg) !important; color: var(--a26-fg) !important;
border: var(--a26-bd) !important; border-radius: var(--a26-radius) !important;
box-shadow: 0 4px 0 var(--a26-fg) !important;
font-family: var(--a26-tit) !important; font-size: 18px !important; text-transform: uppercase;
}
.karvan-room-item:active { background: var(--a26-neon) !important; transform: translateY(3px); box-shadow: 0 1px 0 var(--a26-fg) !important; }
.karvan-room-item .kr-meta { font-family: var(--a26-txt) !important; text-transform: none; }
.karvan-room-head { border-bottom: var(--a26-bd) !important; padding-bottom: 8px !important; }
.karvan-room-title { font-family: var(--a26-tit) !important; font-weight: 400 !important; font-size: 20px !important; text-transform: uppercase; }
.karvan-msg {
background: var(--a26-bg) !important; border: var(--a26-bd-fino) !important;
border-radius: 14px 14px 14px 4px !important;
}
.karvan-msg-self { background: #e5fcff !important; border-color: var(--a26-fg) !important; border-radius: 14px 14px 4px 14px !important; }
.karvan-msg .km-from { color: var(--a26-muted) !important; font-weight: 700; }
.karvan-msg .km-text { color: var(--a26-fg) !important; }
.karvan-ttl, .karvan-compose input, .karvan-compose textarea, .karvan-invite input {
background: var(--a26-bg) !important; color: var(--a26-fg) !important; border: var(--a26-bd-fino) !important;
}
.kc-btn { background: var(--a26-bg) !important; color: var(--a26-fg) !important; border: var(--a26-bd-fino) !important; }
.kc-btn.on { background: var(--a26-neon) !important; }
.kc-btn-stop { background: var(--a26-bg) !important; color: #c4003b !important; border-color: #c4003b !important; }
.chat-reply-cancel,
.chat-message-meta .chat-reply-btn { color: var(--a26-fg) !important; }
@ -532,5 +504,4 @@ body { font-size: 18px; }
.oasis-mobile-topbar .omt-menus .oasis-menu-btn { font-size: 22px !important; min-height: 48px !important; }
.tags-header p { font-size: 16px !important; }
.card-footer .date-link, .card-footer a.user-link, a.user-link, .date-link { font-size: 15px !important; }
.karvan-room-item { font-size: 21px !important; min-height: 60px !important; }
input, textarea, select { font-size: 18px !important; min-height: 50px; }

View file

@ -1,103 +0,0 @@
/* Estilos del modulo Karvan (salas efimeras y llamadas).
*
* Viven aparte del tema para que el modulo se vea igual en cualquier tema y
* tambien en escritorio, donde OasisMobile.css no se carga. Mismo patron que
* highlight.css, que tampoco pertenece a ningun tema concreto.
*/
/* --- Reply a mensaje en chats (server-side ?replyTo=, sin JS) --- */
.chat-message-meta .chat-reply-btn { margin-left: auto; padding: 0 4px; color: #FFD700; text-decoration: none; font-size: 15px; opacity: .85; }
.chat-message-self .chat-message-meta .chat-reply-btn { margin-left: 8px; }
.chat-quote {
display: block; border-left: 3px solid #FFD700; padding: 3px 8px; margin: 0 0 5px;
background: rgba(255,215,0,.08); border-radius: 6px; font-size: 12px; line-height: 1.3;
}
.chat-quote-author, .chat-quote-author a { color: #FFD700; font-weight: 700; margin-right: 6px; }
.chat-quote-text { color: #FFDD44; }
.chat-replying-to {
display: flex; flex-wrap: wrap; align-items: center; gap: 6px;
border-left: 3px solid #FFD700; background: rgba(255,215,0,.10);
padding: 6px 10px; margin-bottom: 8px; border-radius: 8px; font-size: 12px;
}
.chat-replying-label { color: #FFD700; font-weight: 700; }
.chat-reply-cancel { margin-left: auto; color: #FFD700; text-decoration: none; font-weight: 700; font-size: 14px; }
/* ==========================================================================
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; white-space: nowrap; }
.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-live { border-left: 2px solid #35d07f; }
.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; }
/* Fase 2: invitar a un contacto por feed id */
.karvan-invite { display: flex; gap: 8px; margin-top: 8px; }
.karvan-invite input[type="text"] { flex: 1 1 auto; min-width: 0; padding: 9px 12px;
background: #161616; border: 1px solid #333; border-radius: 10px; color: #FFD700; font-family: monospace; font-size: 12px; }
.karvan-invite button { flex: 0 0 auto; padding: 9px 14px; border: 1px solid #FFD700; background: #161616; color: #FFD700; border-radius: 10px; cursor: pointer; }
.karvan-invite-msg { font-size: 12px; margin: 6px 0 0; }
.karvan-invite-msg.ok { color: #35d07f; }
.karvan-invite-msg.err { color: #ff8a8a; }
/* --- Karvan videollamada (Fase 1): media P2P sobre el mismo WebRTC del chat --- */
/* el div{background:#222;padding:20px} de style.css y el div{border} del tema
convierten cada contenedor del panel en una caja: aqui se neutralizan */
.karvan-call, .karvan-videos, .karvan-call-bar, .kc-status {
background-color: transparent; box-shadow: none; border: 0; border-radius: 0; padding: 0; margin: 0;
}
.karvan-call { margin: 10px 0; }
.karvan-videos { display: none; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 6px; margin-bottom: 8px; }
.karvan-call.active .karvan-videos { display: grid; }
.kc-video { width: 100%; aspect-ratio: 4 / 3; background: #000; border-radius: 10px; border: 1px solid #333; object-fit: cover; display: block; }
.kc-local { transform: scaleX(-1); }
.kc-remote { display: contents; }
.karvan-call-bar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.kc-btn { min-height: 44px; min-width: 44px; padding: 8px 12px; border-radius: 999px;
border: 1px solid #333; background: #161616; color: #FFD700; line-height: 1; cursor: pointer; }
.kc-btn.on { background: #FFD700; color: #121212; border-color: #FFD700; }
.kc-btn-start { font-weight: 600; }
.kc-btn-stop { background: #241414; border-color: #5a2a2a; }
.kc-status { font-size: 12px; color: #FFDD44; margin-top: 6px; min-height: 14px; }
/* ==========================================================================
Selector de identidades (ajustes)
========================================================================== */
.identity-list { list-style: none; margin: 10px 0; padding: 0; }
.identity-row { display: flex; align-items: center; justify-content: space-between;
gap: 12px; flex-wrap: wrap; padding: 8px 0; border-bottom: 1px solid #2a2a2a; }
.identity-info { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.identity-name { color: #FFD700; font-weight: 700; }
.identity-feed { color: #FFDD44; font-size: 12px; font-family: monospace; word-break: break-all; }
.identity-warn { color: #ff8a8a; font-size: 12px; }
.identity-active { color: #35d07f; font-size: 13px; font-weight: 600; white-space: nowrap; }

View file

@ -775,76 +775,7 @@ button, input[type="submit"], input[type="button"],
.header .launcher { width: 100%; max-width: 440px; box-sizing: border-box; margin: 4px auto 10px; padding: 0 14px; }
/* holgura bajo la topbar fija */
body { padding-top: 74px; }
/* ==========================================================================
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-live { border-left: 2px solid #35d07f; } /* recibido al instante por WebRTC (P2P) */
.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; }
/* Fase 2: invitar a un contacto por feed id */
.karvan-invite { display: flex; gap: 8px; margin-top: 8px; }
.karvan-invite input[type="text"] { flex: 1 1 auto; min-width: 0; padding: 9px 12px;
background: #161616; border: 1px solid #333; border-radius: 10px; color: #FFD700; font-family: monospace; font-size: 12px; }
.karvan-invite button { flex: 0 0 auto; padding: 9px 14px; border: 1px solid #FFD700; background: #161616; color: #FFD700; border-radius: 10px; cursor: pointer; }
.karvan-invite-msg { font-size: 12px; margin: 6px 0 0; }
.karvan-invite-msg.ok { color: #35d07f; }
.karvan-invite-msg.err { color: #ff8a8a; }
/* --- Karvan videollamada (Fase 1): media P2P sobre el mismo WebRTC del chat --- */
.karvan-call, .karvan-videos, .karvan-call-bar, .kc-status {
background-color: transparent; box-shadow: none; border-radius: 0; padding: 0; margin: 0; /* anular div{#222} */
}
.karvan-call { margin: 10px 0; }
.karvan-videos { display: none; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 6px; margin-bottom: 8px; }
.karvan-call.active .karvan-videos { display: grid; }
.kc-video { width: 100%; aspect-ratio: 4 / 3; background: #000; border-radius: 10px; border: 1px solid #333; object-fit: cover; display: block; }
.kc-local { transform: scaleX(-1); } /* espejo, como cámara frontal */
.kc-remote { display: contents; } /* los vídeos remotos entran en la misma rejilla */
.karvan-call-bar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.kc-btn { min-height: 44px; min-width: 44px; padding: 8px 12px; border-radius: 999px;
border: 1px solid #333; background: #161616; color: #FFD700; line-height: 1; cursor: pointer; }
.kc-btn.on { background: #FFD700; color: #121212; border-color: #FFD700; }
.kc-btn-start { font-weight: 600; }
.kc-btn-stop { background: #241414; border-color: #5a2a2a; }
.kc-status { font-size: 12px; color: #FFDD44; margin-top: 6px; min-height: 14px; }
body { padding-top: 74px; } /* espejo { display: contents; }
/* ==========================================================================
1.3.x: upstream trae en mobile.css su propio chrome movil (cabecera fija, cajones

View file

@ -1,6 +1,6 @@
/* Piezas de usabilidad de la rama movil, para los dos skins (Acampada y OasisMobile).
*
* Va la ultima, detras de karvan.css. Los colores salen de las variables de
* Va la ultima, detras del skin. Los colores salen de las variables de
* Acampada (--a26-*) si el tema las define y, si no, de la paleta oscura con oro
* de OasisMobile; asi cada pieza nueva se ve bien en los dos sin duplicarla.
*
@ -31,7 +31,7 @@
/* fichas de filtro, pestañas y botones secundarios: letra del sistema */
.main-column :is(.tribe-mode-buttons, .mode-buttons, .mode-buttons-row, .filter-group, .filters) .filter-btn:not(.create-button),
.main-column .filter-box__button, .main-column .doc-export-actions .filter-btn,
.main-column .update-btn, .main-column .tribe-action-btn, .lang-chip, .kc-btn,
.main-column .update-btn, .main-column .tribe-action-btn, .lang-chip,
.main-column table th, .card-chips-row > span, .pm-exposition-text {
font-family: var(--ux-text) !important;
font-weight: 700 !important;
@ -59,9 +59,7 @@
}
/* el boton de enviar de los formularios: a todo lo ancho, abajo, que se vea que es
el final. Antes unos centrados, otros a la izquierda y otros enteros */
.main-column :is(.vote-form, .karvan-create, .karvan-compose, .karvan-invite) form > button[type="submit"],
.main-column :is(.vote-form, .karvan-create, .karvan-compose, .karvan-invite) > form > button[type="submit"],
el final. Antes unos centrados,
.main-column form[action$="/tribes/create"][method="post"] > button[type="submit"],
.main-column form[action*="/tribes/update/"] > button[type="submit"] {
display: block !important;
@ -69,7 +67,7 @@
margin: 8px 0 0 !important;
}
/* ---------- iconos SVG (lanzador, barra, modulos, Karvan) ---------- */
/* ---------- iconos SVG (lanzador, barra, modulos) ---------- */
.has-svg { display: inline-flex !important; align-items: center; justify-content: center; }
.has-svg svg { width: 100%; height: 100%; display: block; }
.ln-ico.has-svg { padding: 15px !important; }
@ -263,22 +261,6 @@
}
.main-column .lang-pick .lang-chip.is-current, .lang-pick .lang-chip.is-current { background: var(--ux-accent) !important; color: var(--ux-on-accent) !important; }
/* ---------- Karvan: barra de llamada con nombre en cada boton ---------- */
.karvan-call-bar { display: grid !important; grid-template-columns: repeat(4, 1fr); gap: 8px !important; }
.main-column .karvan-call-bar .kc-btn, .karvan-call-bar .kc-btn {
display: flex !important; flex-direction: column; align-items: center; justify-content: center; gap: 4px;
width: 100% !important; min-height: 64px !important; padding: 8px 4px !important; margin: 0 !important;
font-size: 13px !important; text-transform: none !important; box-shadow: none !important;
}
.kc-btn .kc-ico { width: 24px; height: 24px; }
.kc-btn .kc-lbl { line-height: 1.1; }
.main-column .karvan-call-bar .kc-btn-start { background: var(--ux-ok) !important; color: #fff !important; border-color: var(--ux-ok) !important; }
.main-column .karvan-call-bar .kc-btn.on { background: var(--ux-accent) !important; color: var(--ux-on-accent) !important; }
.main-column .karvan-call-bar .kc-btn-stop { color: var(--ux-danger) !important; border-color: var(--ux-danger) !important; }
.kc-status:not(:empty) { margin-top: 10px; padding: 8px 12px; border-radius: 10px; background: var(--ux-soft); font-size: 14px; font-weight: 700; }
.karvan-invite { margin-top: 24px !important; padding-top: 16px !important; border-top: 2px solid var(--ux-line) !important; }
.karvan-invite-lbl { display: block; margin-bottom: 6px; font-weight: 700; }
/* ---------- foco visible para teclado y lectores ---------- */
a:focus-visible, button:focus-visible, summary:focus-visible, label.module-switch:focus-within {
outline: 3px solid var(--ux-accent) !important; outline-offset: 2px;
@ -407,15 +389,7 @@ body .main-content > .main-column { padding-left: 0 !important; padding-right: 0
font-weight: 700 !important;
letter-spacing: .02em !important;
}
.main-column :is(button, input[type="submit"]):not(.create-button):not(.filter-btn):not(.btn-singleview):not(.chat-react-btn):not(.lang-chip):not(.kc-btn):not(.confirm-yes):not(.welcome-skip-btn) {
font-size: 16px !important;
}
.main-column form.welcome-skip-end { display: flex !important; justify-content: center !important; }
/* llamada solo con voz: sin el video propio vacio */
.karvan-call:not(.has-cam) .kc-local { display: none !important; }
.karvan-call:not(.has-cam) .karvan-videos:not(:has(.kc-remote video)) { display: none !important; }
.karvan-call.active .kc-btn-start { opacity: .45; pointer-events: none; }
/* la barra de confirmar borrado con su marco rojo (Acampada quita el borde a los div) */
.main-column div.confirm-bar { border: 2px solid var(--ux-danger) !important; }
.main-column .content-action-confirm[open] > summary { opacity: 1 !important; }

View file

@ -364,19 +364,6 @@ button.tribe-action-btn { background: transparent !important; border: 1px solid
.chat-topic-active { background: #FFF3E0 !important; }
.chat-topic-title { color: #2D2D2D !important; }
.chat-workspace-empty { border-color: #DDD !important; }
/* Karvan: su hoja (karvan.css) esta pensada para fondo oscuro; aqui se traduce
a la paleta clara, igual que este tema hace con el resto de la interfaz */
.karvan-back, .karvan-room-title, .kr-title { color: #B25000 !important; }
.karvan-sub, .karvan-expire, .karvan-live, .kr-meta,
.karvan-ttl-lbl, .karvan-ttl-opt { color: #6B5300 !important; }
.karvan-room-item, .karvan-create input[type="text"] {
background-color: #FFFFFF !important; border: 1px solid #E0E0E0 !important; color: #2C2C2C !important;
}
.karvan-msg { background-color: #FFF8E1 !important; border: 1px solid #E6CE86 !important; }
.karvan-msg-self { background-color: #FFEFC2 !important; border-color: #D9A400 !important; }
.karvan-msg .km-from { color: #8A6D00 !important; }
.karvan-msg .km-text { color: #2C2C2C !important; }
.map-viewer, .map-stage div, .map-hero, .map-toolbar, .map-toolbar div, .map-place-results, .map-markers-list, .map-markers-list div, .map-detail-grid, .map-card-thumb .map-viewer { background: transparent !important; border: 0 !important; }
.map-pick-cell, .map-pick-cell:hover { border: 0 !important; padding: 0 !important; border-radius: 0 !important; box-shadow: none !important; }
.map-pick-cell { background: transparent !important; }

View file

@ -3,7 +3,7 @@
// ficheros oasis_*.js (upstream los reescribe entero en cada release).
//
// Contiene tres grupos:
// 1. Claves que anade la rama: el modulo Karvan, el selector de identidades,
// 1. Claves que anade la rama: el selector de identidades,
// la barra inferior (bb*) y el filtro Personal/Social de la topbar.
// 2. Claves que upstream tenia en 0.9.1 y elimino en 0.9.5, pero que el
// codigo de la rama sigue usando (menuBlogs, spreadHint, chatShareUrl...).
@ -17,7 +17,7 @@
module.exports = {
en: {
// usabilidad (19/10/2026): votaciones, borrar, modulos, Karvan, pie
// usabilidad (19/10/2026): votaciones, borrar, modulos, pie
workflow_acampada: "Camp",
tribeSectionsMore: "More",
tribeSectionsLabel: "Sections",
@ -48,43 +48,11 @@ module.exports = {
moduleBlockedAction: "Turn on {name}",
modulesOnCount: "{on} of {total} sections on",
modulesSaved: "Saved.",
karvanVoiceOn: "Voice on",
karvanCamOn: "Camera on",
karvanNoCam: "No camera permission: the call goes on with voice only.",
karvanNoMic: "No microphone permission. You can allow it in the phone settings; the chat still works.",
karvanMic: "Mic",
karvanCam: "Camera",
karvanHangUp: "Hang up",
karvanInviteLabel: "Invite someone",
karvanInviteHint: "Paste their @id: it is on their profile, under their name.",
karvanRelayHuman: "Through your node",
karvanP2pHuman: "Direct and encrypted",
// skins y workflows en el movil (settings_view.js)
skinIntroMobile: "Pick how Oasis looks on your phone. It applies as soon as you tap it.",
workflowsDescriptionMobile: "A workflow sets which modules are on. On the phone your skin stays as it is.",
// fotos que no se pueden limpiar (blobHandler.js)
imageMetadataUnsafe: "This image could not be cleaned of its metadata (location, camera…), so it has not been uploaded. Save it as JPEG or PNG and try again.",
// Karvan
karvanActive: "Active rooms",
karvanBlurb: "Rooms that live only in memory and destroy themselves.",
karvanCall: "Call",
karvanCreate: "Create ephemeral room",
karvanEphemeral: "Ephemeral rooms",
karvanInvite: "Invite",
karvanInviteBad: "✗ That is not a valid @feed-id.",
karvanInviteFail: "✗ Could not send the invitation.",
karvanInvitePlaceholder: "Invite a contact by @feed-id…",
karvanInviteSent: "✓ Invitation sent to their inbox.",
karvanNone: "No active rooms. Create one — it disappears on its own.",
karvanRelay: "server relay",
karvanRoomName: "Room name (optional)",
karvanRooms: "Rooms",
karvanSay: "Temporary message…",
karvanSend: "Send",
karvanTitle: "Karvan",
karvanTtl: "Self-destruct in:",
modulesKarvanDescription: "Ephemeral rooms: messages live only in memory and self-destruct.",
modulesKarvanLabel: "Karvan",
// Identidades
identitiesActive: "In use",
identitiesBackup: "Export or import an identity",
@ -127,7 +95,7 @@ module.exports = {
spreadMore: "more"
},
es: {
// usabilidad (19/10/2026): votaciones, borrar, modulos, Karvan, pie
// usabilidad (19/10/2026): votaciones, borrar, modulos, pie
workflow_acampada: "Acampada",
tribeSectionsMore: "Más",
tribeSectionsLabel: "Secciones",
@ -158,43 +126,11 @@ module.exports = {
moduleBlockedAction: "Activar {name}",
modulesOnCount: "{on} de {total} secciones activas",
modulesSaved: "Guardado.",
karvanVoiceOn: "Voz activa",
karvanCamOn: "Cámara activa",
karvanNoCam: "Sin permiso de cámara: la llamada sigue solo con voz.",
karvanNoMic: "Sin permiso de micrófono. Puedes darlo en los ajustes del móvil; el chat sigue funcionando.",
karvanMic: "Micro",
karvanCam: "Cámara",
karvanHangUp: "Colgar",
karvanInviteLabel: "Invitar a alguien",
karvanInviteHint: "Pega su @id: está en su perfil, debajo del nombre.",
karvanRelayHuman: "A través de tu nodo",
karvanP2pHuman: "Directo y cifrado",
// skins y workflows en el movil (settings_view.js)
skinIntroMobile: "Elige cómo se ve Oasis en tu móvil. Se aplica al tocarlo.",
workflowsDescriptionMobile: "Un workflow fija qué módulos están activos. En el móvil tu skin se queda como está.",
// fotos que no se pueden limpiar (blobHandler.js)
imageMetadataUnsafe: "No se han podido quitar los metadatos de esta imagen (ubicación, cámara…), así que no se ha subido. Guárdala como JPEG o PNG y vuelve a intentarlo.",
// Karvan
karvanActive: "Salas activas",
karvanBlurb: "Salas que viven solo en memoria y se destruyen solas.",
karvanCall: "Llamar",
karvanCreate: "Crear sala efímera",
karvanEphemeral: "Salas efímeras",
karvanInvite: "Invitar",
karvanInviteBad: "✗ Eso no es un @feed-id válido.",
karvanInviteFail: "✗ No se ha podido enviar la invitación.",
karvanInvitePlaceholder: "Invitar a un contacto por @feed-id…",
karvanInviteSent: "✓ Invitación enviada a su bandeja.",
karvanNone: "No hay salas activas. Crea una: desaparece sola.",
karvanRelay: "a través del servidor",
karvanRoomName: "Nombre de la sala (opcional)",
karvanRooms: "Salas",
karvanSay: "Mensaje temporal…",
karvanSend: "Enviar",
karvanTitle: "Karvan",
karvanTtl: "Se autodestruye en:",
modulesKarvanDescription: "Salas efímeras: los mensajes viven solo en memoria y se autodestruyen.",
modulesKarvanLabel: "Karvan",
// Identidades
identitiesActive: "En uso",
identitiesBackup: "Exportar o importar una identidad",
@ -243,7 +179,7 @@ module.exports = {
totalspreads: "Réplicas totales"
},
fr: {
// usabilidad (19/10/2026): votaciones, borrar, modulos, Karvan, pie
// usabilidad (19/10/2026): votaciones, borrar, modulos, pie
workflow_acampada: "Campement",
tribeSectionsMore: "Plus",
tribeSectionsLabel: "Sections",
@ -274,43 +210,11 @@ module.exports = {
moduleBlockedAction: "Activer {name}",
modulesOnCount: "{on} sections actives sur {total}",
modulesSaved: "Enregistré.",
karvanVoiceOn: "Voix active",
karvanCamOn: "Caméra active",
karvanNoCam: "Pas d'accès à la caméra : l'appel continue en voix seule.",
karvanNoMic: "Pas d'accès au micro. Vous pouvez l'autoriser dans les réglages du téléphone ; le chat fonctionne toujours.",
karvanMic: "Micro",
karvanCam: "Caméra",
karvanHangUp: "Raccrocher",
karvanInviteLabel: "Inviter quelqu'un",
karvanInviteHint: "Collez son @id : il est sur son profil, sous son nom.",
karvanRelayHuman: "Via votre nœud",
karvanP2pHuman: "Direct et chiffré",
// skins y workflows en el movil (settings_view.js)
skinIntroMobile: "Choisis l'apparence d'Oasis sur ton téléphone. Elle s'applique dès que tu touches.",
workflowsDescriptionMobile: "Un workflow fixe les modules actifs. Sur le téléphone, ton thème ne change pas.",
// fotos que no se pueden limpiar (blobHandler.js)
imageMetadataUnsafe: "Impossible de retirer les métadonnées de cette image (position, appareil…), elle n'a donc pas été envoyée. Enregistrez-la en JPEG ou PNG et réessayez.",
// Karvan
karvanActive: "Salons actifs",
karvanBlurb: "Des salons qui ne vivent qu'en mémoire et se détruisent d'eux-mêmes.",
karvanCall: "Appeler",
karvanCreate: "Créer un salon éphémère",
karvanEphemeral: "Salons éphémères",
karvanInvite: "Inviter",
karvanInviteBad: "✗ Ce n'est pas un @feed-id valide.",
karvanInviteFail: "✗ Impossible d'envoyer l'invitation.",
karvanInvitePlaceholder: "Inviter un contact par @feed-id…",
karvanInviteSent: "✓ Invitation envoyée dans sa boîte de réception.",
karvanNone: "Aucun salon actif. Créez-en un : il disparaîtra tout seul.",
karvanRelay: "relais par le serveur",
karvanRoomName: "Nom du salon (facultatif)",
karvanRooms: "Salons",
karvanSay: "Message temporaire…",
karvanSend: "Envoyer",
karvanTitle: "Karvan",
karvanTtl: "Autodestruction dans :",
modulesKarvanDescription: "Salons éphémères : les messages ne vivent qu'en mémoire et s'autodétruisent.",
modulesKarvanLabel: "Karvan",
// Identidades
identitiesActive: "En cours d'utilisation",
identitiesBackup: "Exporter ou importer une identité",
@ -353,7 +257,7 @@ module.exports = {
spreadMore: "plus"
},
eu: {
// usabilidad (19/10/2026): votaciones, borrar, modulos, Karvan, pie
// usabilidad (19/10/2026): votaciones, borrar, modulos, pie
workflow_acampada: "Kanpaldia",
tribeSectionsMore: "Gehiago",
tribeSectionsLabel: "Atalak",
@ -384,43 +288,11 @@ module.exports = {
moduleBlockedAction: "Aktibatu {name}",
modulesOnCount: "{total} ataletatik {on} aktibo",
modulesSaved: "Gordeta.",
karvanVoiceOn: "Ahotsa piztuta",
karvanCamOn: "Kamera piztuta",
karvanNoCam: "Kamerarako baimenik ez: deiak ahotsarekin bakarrik jarraitzen du.",
karvanNoMic: "Mikrofonorako baimenik ez. Mugikorraren ezarpenetan eman dezakezu; txatak funtzionatzen jarraitzen du.",
karvanMic: "Mikroa",
karvanCam: "Kamera",
karvanHangUp: "Eskegi",
karvanInviteLabel: "Norbait gonbidatu",
karvanInviteHint: "Itsatsi bere @id-a: bere profilean dago, izenaren azpian.",
karvanRelayHuman: "Zure nodoaren bidez",
karvanP2pHuman: "Zuzena eta zifratua",
// skins y workflows en el movil (settings_view.js)
skinIntroMobile: "Aukeratu nolakoa den Oasis zure mugikorrean. Ukitu bezain laster aplikatzen da.",
workflowsDescriptionMobile: "Workflow batek zein modulu dauden aktibo finkatzen du. Mugikorrean zure itxura ez da aldatzen.",
// fotos que no se pueden limpiar (blobHandler.js)
imageMetadataUnsafe: "Ezin izan dira irudi honen metadatuak kendu (kokapena, kamera…), beraz ez da igo. Gorde JPEG edo PNG gisa eta saiatu berriro.",
// Karvan
karvanActive: "Gela aktiboak",
karvanBlurb: "Memorian bakarrik bizi diren eta beren kabuz suntsitzen diren gelak.",
karvanCall: "Deitu",
karvanCreate: "Sortu gela iragankorra",
karvanEphemeral: "Gela iragankorrak",
karvanInvite: "Gonbidatu",
karvanInviteBad: "✗ Hori ez da @feed-id baliozkoa.",
karvanInviteFail: "✗ Ezin izan da gonbidapena bidali.",
karvanInvitePlaceholder: "Gonbidatu kontaktu bat @feed-id bidez…",
karvanInviteSent: "✓ Gonbidapena bere sarrera-ontzira bidali da.",
karvanNone: "Ez dago gela aktiborik. Sortu bat: bere kabuz desagertuko da.",
karvanRelay: "zerbitzariaren bidez",
karvanRoomName: "Gelaren izena (aukerakoa)",
karvanRooms: "Gelak",
karvanSay: "Aldi baterako mezua…",
karvanSend: "Bidali",
karvanTitle: "Karvan",
karvanTtl: "Bere burua suntsituko du:",
modulesKarvanDescription: "Gela iragankorrak: mezuak memorian bakarrik bizi dira eta beren burua suntsitzen dute.",
modulesKarvanLabel: "Karvan",
// Identidades
identitiesActive: "Erabiltzen",
identitiesBackup: "Esportatu edo inportatu nortasun bat",
@ -463,7 +335,7 @@ module.exports = {
spreadMore: "gehiago"
},
de: {
// usabilidad (19/10/2026): votaciones, borrar, modulos, Karvan, pie
// usabilidad (19/10/2026): votaciones, borrar, modulos, pie
workflow_acampada: "Camp",
tribeSectionsMore: "Mehr",
tribeSectionsLabel: "Bereiche",
@ -494,43 +366,11 @@ module.exports = {
moduleBlockedAction: "{name} einschalten",
modulesOnCount: "{on} von {total} Bereichen aktiv",
modulesSaved: "Gespeichert.",
karvanVoiceOn: "Stimme an",
karvanCamOn: "Kamera an",
karvanNoCam: "Keine Kameraberechtigung: der Anruf läuft nur mit Stimme weiter.",
karvanNoMic: "Keine Mikrofonberechtigung. Du kannst sie in den Telefoneinstellungen erlauben; der Chat funktioniert weiter.",
karvanMic: "Mikro",
karvanCam: "Kamera",
karvanHangUp: "Auflegen",
karvanInviteLabel: "Jemanden einladen",
karvanInviteHint: "Füge die @id ein: sie steht im Profil, unter dem Namen.",
karvanRelayHuman: "Über deinen Knoten",
karvanP2pHuman: "Direkt und verschlüsselt",
// skins y workflows en el movil (settings_view.js)
skinIntroMobile: "Wähle, wie Oasis auf deinem Handy aussieht. Es wird beim Antippen übernommen.",
workflowsDescriptionMobile: "Ein Workflow legt fest, welche Module aktiv sind. Auf dem Handy bleibt dein Skin, wie er ist.",
// fotos que no se pueden limpiar (blobHandler.js)
imageMetadataUnsafe: "Die Metadaten dieses Bildes (Standort, Kamera…) konnten nicht entfernt werden, daher wurde es nicht hochgeladen. Speichere es als JPEG oder PNG und versuche es erneut.",
// Karvan
karvanActive: "Aktive Räume",
karvanBlurb: "Räume, die nur im Speicher leben und sich von selbst zerstören.",
karvanCall: "Anrufen",
karvanCreate: "Flüchtigen Raum erstellen",
karvanEphemeral: "Flüchtige Räume",
karvanInvite: "Einladen",
karvanInviteBad: "✗ Das ist keine gültige @feed-id.",
karvanInviteFail: "✗ Einladung konnte nicht gesendet werden.",
karvanInvitePlaceholder: "Kontakt per @feed-id einladen…",
karvanInviteSent: "✓ Einladung an den Posteingang gesendet.",
karvanNone: "Keine aktiven Räume. Erstelle einen — er verschwindet von selbst.",
karvanRelay: "über den Server",
karvanRoomName: "Raumname (optional)",
karvanRooms: "Räume",
karvanSay: "Temporäre Nachricht…",
karvanSend: "Senden",
karvanTitle: "Karvan",
karvanTtl: "Selbstzerstörung in:",
modulesKarvanDescription: "Flüchtige Räume: Nachrichten leben nur im Speicher und zerstören sich selbst.",
modulesKarvanLabel: "Karvan",
// Identidades
identitiesActive: "In Benutzung",
identitiesBackup: "Identität exportieren oder importieren",
@ -573,7 +413,7 @@ module.exports = {
spreadMore: "mehr"
},
it: {
// usabilidad (19/10/2026): votaciones, borrar, modulos, Karvan, pie
// usabilidad (19/10/2026): votaciones, borrar, modulos, pie
workflow_acampada: "Accampata",
tribeSectionsMore: "Altro",
tribeSectionsLabel: "Sezioni",
@ -604,43 +444,11 @@ module.exports = {
moduleBlockedAction: "Attiva {name}",
modulesOnCount: "{on} sezioni attive su {total}",
modulesSaved: "Salvato.",
karvanVoiceOn: "Voce attiva",
karvanCamOn: "Fotocamera attiva",
karvanNoCam: "Nessun permesso per la fotocamera: la chiamata continua solo in voce.",
karvanNoMic: "Nessun permesso per il microfono. Puoi darlo nelle impostazioni del telefono; la chat continua a funzionare.",
karvanMic: "Micro",
karvanCam: "Fotocamera",
karvanHangUp: "Riaggancia",
karvanInviteLabel: "Invita qualcuno",
karvanInviteHint: "Incolla il suo @id: è nel suo profilo, sotto il nome.",
karvanRelayHuman: "Tramite il tuo nodo",
karvanP2pHuman: "Diretto e cifrato",
// skins y workflows en el movil (settings_view.js)
skinIntroMobile: "Scegli come appare Oasis sul tuo telefono. Si applica appena lo tocchi.",
workflowsDescriptionMobile: "Un workflow stabilisce quali moduli sono attivi. Sul telefono la tua skin resta com'è.",
// fotos que no se pueden limpiar (blobHandler.js)
imageMetadataUnsafe: "Non è stato possibile rimuovere i metadati di questa immagine (posizione, fotocamera…), quindi non è stata caricata. Salvala come JPEG o PNG e riprova.",
// Karvan
karvanActive: "Stanze attive",
karvanBlurb: "Stanze che vivono solo in memoria e si distruggono da sole.",
karvanCall: "Chiama",
karvanCreate: "Crea stanza effimera",
karvanEphemeral: "Stanze effimere",
karvanInvite: "Invita",
karvanInviteBad: "✗ Non è un @feed-id valido.",
karvanInviteFail: "✗ Impossibile inviare l'invito.",
karvanInvitePlaceholder: "Invita un contatto con @feed-id…",
karvanInviteSent: "✓ Invito inviato alla sua casella.",
karvanNone: "Nessuna stanza attiva. Creane una: sparisce da sola.",
karvanRelay: "tramite il server",
karvanRoomName: "Nome della stanza (facoltativo)",
karvanRooms: "Stanze",
karvanSay: "Messaggio temporaneo…",
karvanSend: "Invia",
karvanTitle: "Karvan",
karvanTtl: "Autodistruzione tra:",
modulesKarvanDescription: "Stanze effimere: i messaggi vivono solo in memoria e si autodistruggono.",
modulesKarvanLabel: "Karvan",
// Identidades
identitiesActive: "In uso",
identitiesBackup: "Esporta o importa un'identità",
@ -683,7 +491,7 @@ module.exports = {
spreadMore: "altro"
},
pt: {
// usabilidad (19/10/2026): votaciones, borrar, modulos, Karvan, pie
// usabilidad (19/10/2026): votaciones, borrar, modulos, pie
workflow_acampada: "Acampada",
tribeSectionsMore: "Mais",
tribeSectionsLabel: "Secções",
@ -714,43 +522,11 @@ module.exports = {
moduleBlockedAction: "Ativar {name}",
modulesOnCount: "{on} de {total} secções ativas",
modulesSaved: "Guardado.",
karvanVoiceOn: "Voz ativa",
karvanCamOn: "Câmara ativa",
karvanNoCam: "Sem permissão de câmara: a chamada continua só com voz.",
karvanNoMic: "Sem permissão de microfone. Podes dá-la nas definições do telemóvel; o chat continua a funcionar.",
karvanMic: "Micro",
karvanCam: "Câmara",
karvanHangUp: "Desligar",
karvanInviteLabel: "Convidar alguém",
karvanInviteHint: "Cola o @id da pessoa: está no perfil, por baixo do nome.",
karvanRelayHuman: "Através do teu nó",
karvanP2pHuman: "Direto e cifrado",
// skins y workflows en el movil (settings_view.js)
skinIntroMobile: "Escolhe como o Oasis aparece no teu telemóvel. Aplica-se ao tocar.",
workflowsDescriptionMobile: "Um workflow define que módulos estão ativos. No telemóvel o teu tema fica como está.",
// fotos que no se pueden limpiar (blobHandler.js)
imageMetadataUnsafe: "Não foi possível remover os metadados desta imagem (localização, câmara…), por isso não foi enviada. Guarda-a como JPEG ou PNG e tenta de novo.",
// Karvan
karvanActive: "Salas ativas",
karvanBlurb: "Salas que vivem apenas em memória e destroem-se sozinhas.",
karvanCall: "Chamar",
karvanCreate: "Criar sala efémera",
karvanEphemeral: "Salas efémeras",
karvanInvite: "Convidar",
karvanInviteBad: "✗ Isso não é um @feed-id válido.",
karvanInviteFail: "✗ Não foi possível enviar o convite.",
karvanInvitePlaceholder: "Convidar um contacto por @feed-id…",
karvanInviteSent: "✓ Convite enviado para a caixa de entrada.",
karvanNone: "Não há salas ativas. Cria uma: desaparece sozinha.",
karvanRelay: "através do servidor",
karvanRoomName: "Nome da sala (opcional)",
karvanRooms: "Salas",
karvanSay: "Mensagem temporária…",
karvanSend: "Enviar",
karvanTitle: "Karvan",
karvanTtl: "Autodestrói-se em:",
modulesKarvanDescription: "Salas efémeras: as mensagens vivem apenas em memória e autodestroem-se.",
modulesKarvanLabel: "Karvan",
// Identidades
identitiesActive: "Em uso",
identitiesBackup: "Exportar ou importar uma identidade",
@ -793,7 +569,7 @@ module.exports = {
spreadMore: "mais"
},
zh: {
// usabilidad (19/10/2026): votaciones, borrar, modulos, Karvan, pie
// usabilidad (19/10/2026): votaciones, borrar, modulos, pie
workflow_acampada: "营地",
tribeSectionsMore: "更多",
tribeSectionsLabel: "板块",
@ -824,43 +600,11 @@ module.exports = {
moduleBlockedAction: "开启 {name}",
modulesOnCount: "已开启 {on}/{total} 个板块",
modulesSaved: "已保存。",
karvanVoiceOn: "语音已开",
karvanCamOn: "摄像头已开",
karvanNoCam: "没有摄像头权限:通话仅以语音继续。",
karvanNoMic: "没有麦克风权限。可以在手机设置中允许;聊天仍可使用。",
karvanMic: "麦克风",
karvanCam: "摄像头",
karvanHangUp: "挂断",
karvanInviteLabel: "邀请某人",
karvanInviteHint: "粘贴对方的 @id:在其个人资料中,名字下方。",
karvanRelayHuman: "通过你的节点",
karvanP2pHuman: "直连且加密",
// skins y workflows en el movil (settings_view.js)
skinIntroMobile: "选择 Oasis 在手机上的外观。点一下就会生效。",
workflowsDescriptionMobile: "工作流决定启用哪些模块。在手机上,你的外观保持不变。",
// fotos que no se pueden limpiar (blobHandler.js)
imageMetadataUnsafe: "无法清除这张图片的元数据(位置、相机等),因此没有上传。请另存为 JPEG 或 PNG 后重试。",
// Karvan
karvanActive: "活跃房间",
karvanBlurb: "只存在于内存中、会自行销毁的房间。",
karvanCall: "通话",
karvanCreate: "创建临时房间",
karvanEphemeral: "临时房间",
karvanInvite: "邀请",
karvanInviteBad: "✗ 这不是有效的 @feed-id。",
karvanInviteFail: "✗ 无法发送邀请。",
karvanInvitePlaceholder: "用 @feed-id 邀请联系人…",
karvanInviteSent: "✓ 邀请已发送至对方收件箱。",
karvanNone: "没有活跃房间。创建一个吧——它会自行消失。",
karvanRelay: "经服务器中转",
karvanRoomName: "房间名称(可选)",
karvanRooms: "房间",
karvanSay: "临时消息…",
karvanSend: "发送",
karvanTitle: "Karvan",
karvanTtl: "自动销毁于:",
modulesKarvanDescription: "临时房间:消息只存在于内存中,并会自动销毁。",
modulesKarvanLabel: "Karvan",
// Identidades
identitiesActive: "使用中",
identitiesBackup: "导出或导入身份",
@ -903,7 +647,7 @@ module.exports = {
spreadMore: "更多"
},
ar: {
// usabilidad (19/10/2026): votaciones, borrar, modulos, Karvan, pie
// usabilidad (19/10/2026): votaciones, borrar, modulos, pie
workflow_acampada: "مخيم",
tribeSectionsMore: "المزيد",
tribeSectionsLabel: "الأقسام",
@ -934,43 +678,11 @@ module.exports = {
moduleBlockedAction: "تفعيل {name}",
modulesOnCount: "{on} من {total} أقسام مفعّلة",
modulesSaved: "تم الحفظ.",
karvanVoiceOn: "الصوت مفعّل",
karvanCamOn: "الكاميرا مفعّلة",
karvanNoCam: "لا إذن للكاميرا: تستمر المكالمة بالصوت فقط.",
karvanNoMic: "لا إذن للميكروفون. يمكنك السماح به من إعدادات الهاتف؛ الدردشة تعمل.",
karvanMic: "ميكروفون",
karvanCam: "كاميرا",
karvanHangUp: "إنهاء",
karvanInviteLabel: "ادعُ شخصًا",
karvanInviteHint: "الصق معرّفه @id: تجده في ملفه تحت اسمه.",
karvanRelayHuman: "عبر عقدتك",
karvanP2pHuman: "مباشر ومشفّر",
// skins y workflows en el movil (settings_view.js)
skinIntroMobile: "اختر مظهر Oasis على هاتفك. يُطبَّق بمجرد اللمس.",
workflowsDescriptionMobile: "يحدد سير العمل الوحدات المفعّلة. على الهاتف يبقى مظهرك كما هو.",
// fotos que no se pueden limpiar (blobHandler.js)
imageMetadataUnsafe: "تعذّر إزالة البيانات الوصفية من هذه الصورة (الموقع، الكاميرا…)، لذلك لم تُرفع. احفظها بصيغة JPEG أو PNG وحاول مجددًا.",
// Karvan
karvanActive: "الغرف النشطة",
karvanBlurb: "غرف تعيش في الذاكرة فقط وتدمّر نفسها بنفسها.",
karvanCall: "اتصال",
karvanCreate: "إنشاء غرفة مؤقتة",
karvanEphemeral: "غرف مؤقتة",
karvanInvite: "دعوة",
karvanInviteBad: "✗ هذا ليس @feed-id صالحًا.",
karvanInviteFail: "✗ تعذّر إرسال الدعوة.",
karvanInvitePlaceholder: "ادعُ جهة اتصال عبر @feed-id…",
karvanInviteSent: "✓ تم إرسال الدعوة إلى صندوق الوارد.",
karvanNone: "لا توجد غرف نشطة. أنشئ واحدة، وستختفي وحدها.",
karvanRelay: "عبر الخادم",
karvanRoomName: "اسم الغرفة (اختياري)",
karvanRooms: "الغرف",
karvanSay: "رسالة مؤقتة…",
karvanSend: "إرسال",
karvanTitle: "Karvan",
karvanTtl: "التدمير الذاتي خلال:",
modulesKarvanDescription: "غرف مؤقتة: الرسائل تعيش في الذاكرة فقط وتدمّر نفسها.",
modulesKarvanLabel: "Karvan",
// Identidades
identitiesActive: "قيد الاستخدام",
identitiesBackup: "تصدير هوية أو استيرادها",
@ -1013,7 +725,7 @@ module.exports = {
spreadMore: "المزيد"
},
hi: {
// usabilidad (19/10/2026): votaciones, borrar, modulos, Karvan, pie
// usabilidad (19/10/2026): votaciones, borrar, modulos, pie
workflow_acampada: "शिविर",
tribeSectionsMore: "और",
tribeSectionsLabel: "खंड",
@ -1044,43 +756,11 @@ module.exports = {
moduleBlockedAction: "{name} चालू करें",
modulesOnCount: "{total} में से {on} खंड चालू",
modulesSaved: "सहेजा गया।",
karvanVoiceOn: "आवाज़ चालू",
karvanCamOn: "कैमरा चालू",
karvanNoCam: "कैमरे की अनुमति नहीं: कॉल केवल आवाज़ से जारी है।",
karvanNoMic: "माइक्रोफ़ोन की अनुमति नहीं। फ़ोन की सेटिंग में दे सकते हैं; चैट चलती रहेगी।",
karvanMic: "माइक",
karvanCam: "कैमरा",
karvanHangUp: "कॉल काटें",
karvanInviteLabel: "किसी को आमंत्रित करें",
karvanInviteHint: "उनका @id चिपकाएँ: यह उनकी प्रोफ़ाइल में नाम के नीचे है।",
karvanRelayHuman: "आपके नोड के ज़रिए",
karvanP2pHuman: "सीधा और एन्क्रिप्टेड",
// skins y workflows en el movil (settings_view.js)
skinIntroMobile: "चुनें कि आपके फ़ोन पर Oasis कैसा दिखे। छूते ही लागू हो जाता है।",
workflowsDescriptionMobile: "वर्कफ़्लो तय करता है कि कौन-से मॉड्यूल चालू हैं। फ़ोन पर आपकी स्किन वैसी ही रहती है।",
// fotos que no se pueden limpiar (blobHandler.js)
imageMetadataUnsafe: "इस छवि का मेटाडेटा (स्थान, कैमरा…) हटाया नहीं जा सका, इसलिए इसे अपलोड नहीं किया गया। इसे JPEG या PNG के रूप में सहेजें और फिर कोशिश करें।",
// Karvan
karvanActive: "सक्रिय कक्ष",
karvanBlurb: "ऐसे कक्ष जो केवल मेमोरी में रहते हैं और स्वयं नष्ट हो जाते हैं।",
karvanCall: "कॉल",
karvanCreate: "अस्थायी कक्ष बनाएँ",
karvanEphemeral: "अस्थायी कक्ष",
karvanInvite: "आमंत्रित करें",
karvanInviteBad: "✗ यह मान्य @feed-id नहीं है।",
karvanInviteFail: "✗ आमंत्रण नहीं भेजा जा सका।",
karvanInvitePlaceholder: "@feed-id से संपर्क आमंत्रित करें…",
karvanInviteSent: "✓ आमंत्रण उनके इनबॉक्स में भेजा गया।",
karvanNone: "कोई सक्रिय कक्ष नहीं। एक बनाएँ — यह स्वयं गायब हो जाएगा।",
karvanRelay: "सर्वर के माध्यम से",
karvanRoomName: "कक्ष का नाम (वैकल्पिक)",
karvanRooms: "कक्ष",
karvanSay: "अस्थायी संदेश…",
karvanSend: "भेजें",
karvanTitle: "Karvan",
karvanTtl: "स्वतः नष्ट होगा:",
modulesKarvanDescription: "अस्थायी कक्ष: संदेश केवल मेमोरी में रहते हैं और स्वयं नष्ट हो जाते हैं।",
modulesKarvanLabel: "Karvan",
// Identidades
identitiesActive: "उपयोग में",
identitiesBackup: "पहचान निर्यात या आयात करें",
@ -1123,7 +803,7 @@ module.exports = {
spreadMore: "और"
},
ru: {
// usabilidad (19/10/2026): votaciones, borrar, modulos, Karvan, pie
// usabilidad (19/10/2026): votaciones, borrar, modulos, pie
workflow_acampada: "Лагерь",
tribeSectionsMore: "Ещё",
tribeSectionsLabel: "Разделы",
@ -1154,43 +834,11 @@ module.exports = {
moduleBlockedAction: "Включить {name}",
modulesOnCount: "Включено {on} из {total} разделов",
modulesSaved: "Сохранено.",
karvanVoiceOn: "Голос включён",
karvanCamOn: "Камера включена",
karvanNoCam: "Нет доступа к камере: звонок продолжается только голосом.",
karvanNoMic: "Нет доступа к микрофону. Его можно разрешить в настройках телефона; чат продолжает работать.",
karvanMic: "Микрофон",
karvanCam: "Камера",
karvanHangUp: "Завершить",
karvanInviteLabel: "Пригласить кого-нибудь",
karvanInviteHint: "Вставьте его @id: он в профиле, под именем.",
karvanRelayHuman: "Через ваш узел",
karvanP2pHuman: "Напрямую и зашифровано",
// skins y workflows en el movil (settings_view.js)
skinIntroMobile: "Выберите, как Oasis выглядит на телефоне. Применяется сразу по нажатию.",
workflowsDescriptionMobile: "Рабочий процесс задаёт, какие модули включены. На телефоне ваше оформление не меняется.",
// fotos que no se pueden limpiar (blobHandler.js)
imageMetadataUnsafe: "Не удалось удалить метаданные этого изображения (местоположение, камера…), поэтому оно не загружено. Сохраните его как JPEG или PNG и попробуйте снова.",
// Karvan
karvanActive: "Активные комнаты",
karvanBlurb: "Комнаты, которые живут только в памяти и уничтожаются сами.",
karvanCall: "Позвонить",
karvanCreate: "Создать эфемерную комнату",
karvanEphemeral: "Эфемерные комнаты",
karvanInvite: "Пригласить",
karvanInviteBad: "✗ Это неверный @feed-id.",
karvanInviteFail: "✗ Не удалось отправить приглашение.",
karvanInvitePlaceholder: "Пригласить контакт по @feed-id…",
karvanInviteSent: "✓ Приглашение отправлено во входящие.",
karvanNone: "Активных комнат нет. Создайте — она исчезнет сама.",
karvanRelay: "через сервер",
karvanRoomName: "Название комнаты (необязательно)",
karvanRooms: "Комнаты",
karvanSay: "Временное сообщение…",
karvanSend: "Отправить",
karvanTitle: "Karvan",
karvanTtl: "Самоуничтожение через:",
modulesKarvanDescription: "Эфемерные комнаты: сообщения живут только в памяти и самоуничтожаются.",
modulesKarvanLabel: "Karvan",
// Identidades
identitiesActive: "Используется",
identitiesBackup: "Экспортировать или импортировать личность",

View file

@ -1,317 +0,0 @@
/* 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);
// --- Fase 1: videollamada (media sobre el MISMO WebRTC del chat) ---
var callRoot = document.getElementById("karvan-call");
var localVid = document.getElementById("karvan-local");
var remoteEl = document.getElementById("karvan-remote");
var statusEl = document.getElementById("kc-status");
var btnStart = document.getElementById("kc-start");
var btnMic = document.getElementById("kc-mic");
var btnCam = document.getElementById("kc-cam");
var btnStop = document.getElementById("kc-stop");
var localStream = (window.MediaStream ? new MediaStream() : null);
if (localVid && localStream) localVid.srcObject = localStream;
var micActive = false, camActive = false;
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;
// Sin servidores ICE por defecto: solo candidatos host (LAN y NAT amable).
// Un STUN de terceros aprende la IP publica y el momento de cada llamada, asi que
// no se pone ninguno de fabrica. Si el nodo ofrece TURN propio, /karvan/ice lo da.
var rtcConfig = { iceServers: [] };
function setLive(on) {
if (!liveTxt) return;
liveTxt.textContent = on ? (liveWrap && liveWrap.getAttribute("data-txt-p2p") || "🔒 P2P (WebRTC)") : (liveWrap && liveWrap.getAttribute("data-txt-relay") || "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, vid: null, remoteStream: null };
peers[remoteId] = peer;
if (!polite) { attachDC(peer, pc.createDataChannel("chat")); } // el impolite inicia el canal
pc.ondatachannel = function (e) { attachDC(peer, e.channel); };
// media: publicar los tracks locales que ya tengamos (si la llamada está activa) + recibir los del par
if (localStream) { localStream.getTracks().forEach(function (t) { try { pc.addTrack(t, localStream); } catch (e) {} }); }
pc.ontrack = function (e) { attachRemote(peer, remoteId, e); };
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) {} removeRemoteVideo(peer); delete peers[remoteId]; }
};
return peer;
}
// --- media / videollamada (Fase 1) ---
function setCallActive(on) { if (callRoot) callRoot.className = "karvan-call" + (on ? " active" : "") + (camActive ? " has-cam" : ""); }
function setStatus(msg) { if (statusEl) statusEl.textContent = msg || ""; }
// textos traducidos: los pone la vista en data-txt-* de #karvan-call
function txt(k, def) { return (callRoot && callRoot.getAttribute("data-txt-" + k)) || def; }
function statusText() { var p = []; if (micActive) p.push(txt("mic", "Voice on")); if (camActive) p.push(txt("cam", "Camera on")); return p.length ? p.join(" · ") : ""; }
function updateBtns() {
if (btnMic) btnMic.className = "kc-btn" + (micActive ? " on" : "");
if (btnCam) btnCam.className = "kc-btn" + (camActive ? " on" : "");
// sin camara no se ensena el video propio: era un recuadro gris con el icono de play
if (callRoot) callRoot.classList.toggle("has-cam", !!camActive);
}
function attachRemote(peer, remoteId, e) {
if (!remoteEl) return;
if (!peer.vid) {
peer.vid = document.createElement("video");
peer.vid.autoplay = true; peer.vid.playsInline = true; peer.vid.className = "kc-video kc-remote-vid";
peer.vid.setAttribute("data-peer", remoteId);
remoteEl.appendChild(peer.vid);
}
if (e.streams && e.streams[0]) peer.vid.srcObject = e.streams[0];
else { if (!peer.remoteStream) peer.remoteStream = new MediaStream(); peer.remoteStream.addTrack(e.track); peer.vid.srcObject = peer.remoteStream; }
setCallActive(true);
}
function removeRemoteVideo(peer) {
if (peer && peer.vid && peer.vid.parentNode) { try { peer.vid.parentNode.removeChild(peer.vid); } catch (_) {} peer.vid = null; }
}
function addLocalTrack(track) {
if (!localStream) return;
localStream.addTrack(track);
for (var k in peers) {
var pc = peers[k].pc, senders = pc.getSenders(), existing = null;
for (var s = 0; s < senders.length; s++) if (senders[s].track && senders[s].track.kind === track.kind) { existing = senders[s]; break; }
if (existing) { try { existing.replaceTrack(track); } catch (_) {} } // swap (cambio de cámara) — sin renegociar
else { try { pc.addTrack(track, localStream); } catch (_) {} } // kind nuevo — dispara onnegotiationneeded
}
}
function removeLocalKind(kind) {
if (!localStream) return;
localStream.getTracks().filter(function (t) { return t.kind === kind; }).forEach(function (t) { t.stop(); localStream.removeTrack(t); });
for (var k in peers) {
var senders = peers[k].pc.getSenders();
for (var s = 0; s < senders.length; s++) if (senders[s].track && senders[s].track.kind === kind) { try { senders[s].replaceTrack(null); } catch (_) {} }
}
}
function onMediaErr(kind, x) {
var name = (x && x.name) || "", msg = (x && x.message) || String(x);
if (name === "NotAllowedError" || /denied|permission/i.test(msg)) setStatus(kind === "video" ? txt("nocam", "No camera permission: the call goes on with voice only.") : txt("nomic", "No microphone permission. You can allow it in the phone settings; the chat still works."));
else if (name === "NotFoundError") setStatus("⚠ No hay " + (kind === "audio" ? "micrófono" : "cámara") + ".");
else if (name === "NotReadableError") setStatus("⚠ Dispositivo ocupado.");
else setStatus("⚠ " + (name || "error") + ": " + msg);
}
function getMedia(kind) {
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { setStatus("⚠ Cámara/micro no disponibles aquí."); return; }
var c = (kind === "audio") ? { audio: true, video: false } : { audio: false, video: true };
setStatus((kind === "audio" ? "🎤" : "🎥") + " pidiendo permiso…");
navigator.mediaDevices.getUserMedia(c).then(function (s) {
s.getTracks().forEach(addLocalTrack);
if (kind === "audio") micActive = true; else camActive = true;
setCallActive(true); updateBtns(); setStatus(statusText());
}).catch(function (x) { onMediaErr(kind, x); });
}
// Llamar empieza solo con voz: pedir la camara de entrada hacia que, si se
// negaba, no arrancara nada aunque el micro estuviera permitido. El video se
// anade despues con su boton
function startCall() {
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { setStatus("⚠ Cámara/micro no disponibles aquí."); return; }
if (micActive) return;
getMedia("audio");
}
function hangUp() {
if (localStream) localStream.getTracks().forEach(function (t) { t.stop(); localStream.removeTrack(t); });
for (var k in peers) { var sn = peers[k].pc.getSenders(); for (var s = 0; s < sn.length; s++) { try { sn[s].replaceTrack(null); } catch (_) {} } }
micActive = false; camActive = false; setCallActive(false); updateBtns(); setStatus("");
}
if (btnStart) btnStart.addEventListener("click", startCall);
if (btnMic) btnMic.addEventListener("click", function () { if (micActive) { removeLocalKind("audio"); micActive = false; updateBtns(); setStatus(statusText()); } else getMedia("audio"); });
if (btnCam) btnCam.addEventListener("click", function () { if (camActive) { removeLocalKind("video"); camActive = false; updateBtns(); setStatus(statusText()); } else getMedia("video"); });
if (btnStop) btnStop.addEventListener("click", hangUp);
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;
if (!desc) return; // señal malformada (sin desc) — no reventar el lote de sondeo
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);
// La configuracion ICE tiene que estar resuelta antes de la primera
// RTCPeerConnection: no se puede cambiar de forma fiable una ya creada.
var iceReady = fetch("/karvan/ice", { headers: { accept: "application/json" } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
if (d && Array.isArray(d.iceServers) && d.iceServers.length) {
rtcConfig = { iceServers: d.iceServers };
if (d.relayOnly) rtcConfig.iceTransportPolicy = "relay";
}
})
.catch(function () {});
var iceTimeout = new Promise(function (res) { setTimeout(res, 2000); });
Promise.race([iceReady, iceTimeout]).then(function () {
announce();
setInterval(announce, 8000);
pollSignals();
setInterval(pollSignals, 2000);
});
}
})();

View file

@ -65,7 +65,6 @@ if (!fs.existsSync(configFilePath)) {
"modules": {
"blogsMod": "on",
"housingMod": "off",
"karvanMod": "on",
"popularMod": "on",
"topicsMod": "on",
"summariesMod": "on",

View file

@ -28,7 +28,6 @@
"reportsMod": "off",
"opinionsMod": "off",
"padsMod": "on",
"karvanMod": "on",
"calendarsMod": "off",
"transfersMod": "off",
"feedMod": "on",
@ -54,8 +53,8 @@
"chatsMod": "on",
"torrentsMod": "off",
"filesMod": "off",
"phoneMod": "off",
"roomsMod": "off",
"phoneMod": "on",
"roomsMod": "on",
"graphosMod": "off",
"larpMod": "off",
"backupMod": "on",

View file

@ -11,30 +11,20 @@ const wf = require('./workflows_model');
const FORK_WORKFLOWS = [
{
// una acampada: hablar, escribir juntos, compartir lo que pasa y decidir
// (tribus = comisiones, parlamento y votaciones para la asamblea)
// una acampada: hablar (chats, telefono y salas de voz del dev), escribir juntos,
// compartir lo que pasa y decidir (tribus = comisiones, parlamento y votaciones)
key: 'acampada',
theme: 'Acampada',
homePage: 'activity',
modules: ['chats', 'karvan', 'pads', 'forum', 'images', 'videos', 'wiki', 'blogs', 'feed', 'invites', 'backup',
modules: ['chats', 'phone', 'rooms', 'pads', 'forum', 'images', 'videos', 'wiki', 'blogs', 'feed', 'invites', 'backup',
'tribes', 'parliament', 'votes', 'events']
}
];
// Karvan es un modulo de la rama: se anade a las listas del dev (todos los modulos,
// los del movil y el workflow "default", que se calcula al cargar con una copia)
const addKarvan = () => {
if (!wf.ALL_MODULES.includes('karvan')) wf.ALL_MODULES.push('karvan');
if (!wf.MOBILE_MODULES.includes('karvan')) wf.MOBILE_MODULES.push('karvan');
const def = wf.WORKFLOWS.find(w => w.key === 'default');
if (def && !def.modules.includes('karvan')) def.modules.push('karvan');
};
let done = false;
const register = () => {
if (done) return;
done = true;
addKarvan();
for (const w of FORK_WORKFLOWS) {
if (wf.WORKFLOWS.some(x => x.key === w.key)) continue;
wf.WORKFLOWS.push(w);

View file

@ -1,198 +0,0 @@
"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;
const MAX_MEMBERS = 50; // tope de miembros por sala (evita crecimiento sin límite del Set)
const MAX_SIGNAL_BYTES = 16384; // SDP/ICE son pequeños; rechaza payloads gigantes (anti-DoS de memoria)
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();
room.remoteFeeds.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(),
remoteFeeds: new Set(), // feeds SSB de otros nodos con esta sala (para relay cross-device por privados)
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);
};
// Fase 2 (cross-device): registra el feed SSB de otro participante (de una invitación) para relayarle.
const addRemoteFeed = (id, feed) => {
const room = rooms.get(id);
if (!room || typeof feed !== "string" || !/^@[A-Za-z0-9+/=]{20,}\.ed25519$/.test(feed)) return false;
if (room.remoteFeeds.has(feed) || room.remoteFeeds.size < MAX_MEMBERS) { room.remoteFeeds.add(feed); return true; }
return false;
};
const getRemoteFeeds = (id) => { const r = rooms.get(id); return r ? [...r.remoteFeeds] : []; };
// ¿hay ALGUNA sala con feeds remotos? (para no montar el relay si no hace falta)
const anyRemoteRooms = () => { for (const r of rooms.values()) if (r.remoteFeeds.size) return true; return false; };
// Fase 2 (invitar): unirse a una sala por su id creando un ESPEJO local (desde un enlace/invitación).
// El id es la "capacidad": quien lo tiene puede unirse. Aún NO conecta con la sala del otro nodo
// (eso es la señalización cross-device, pendiente); crea la sala local para poder abrir el enlace.
const adoptRoom = ({ id, title = "", ttlMinutes } = {}) => {
if (!id || typeof id !== "string" || !/^[0-9a-f]{6,64}$/.test(id)) return null;
const existing = rooms.get(id);
if (existing) return publicRoom(existing);
if (rooms.size >= MAX_ROOMS) {
const oldest = [...rooms.values()].sort((a, b) => a.createdAt - b.createdAt)[0];
if (oldest) destroy(oldest.id);
}
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(), remoteFeeds: 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();
if (room.members.has(msg.from) || room.members.size < MAX_MEMBERS) 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;
if (payload != null) { // rechaza payloads gigantes (SDP/ICE son pequeños)
try { if (JSON.stringify(payload).length > MAX_SIGNAL_BYTES) return null; }
catch (e) { 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.has(sig.from) || room.members.size < MAX_MEMBERS)) 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,
adoptRoom,
addRemoteFeed,
getRemoteFeeds,
anyRemoteRooms,
listRooms,
getRoom,
postMessage,
listMessages,
postSignal,
getSignals,
_destroy: destroy,
};
};

View file

@ -80,7 +80,7 @@ const Server = SecretStack({ caps })
.use(require('ssb-unix-socket'))
.use(require('ssb-no-auth'))
.use(require('./snapshot_plugin'))
.use((() => { const pm = require('./phone_module'); try { if (process.env.OASIS_MOBILE === '1') { pm.setAudioFactory(require('./phone_audio_android')); pm.setVideoFactory(require('./phone_video_android')); pm.setStreamFactory(require('./phone_stream_android')); } } catch (_) {} return pm; })())
.use(require('./phone_module'))
.use(require('./network_pause'));
if (!config.pub) {

View file

@ -43,7 +43,6 @@ module.exports = ({ i18n }) => {
{ 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: 'rooms', href: '/rooms', glyph: 'ꕢ', labelKey: 'roomsTitle', mod: 'roomsMod' },
{ key: 'wiki', href: '/wiki', glyph: 'ꖿ', labelKey: 'wikiTitle', mod: 'wikiMod' },
{ key: 'polls', href: '/polls', glyph: 'ꖙ', labelKey: 'pollsTitle', mod: 'pollsMod' },
@ -129,7 +128,6 @@ module.exports = ({ i18n }) => {
{ href: '/activity', glyph: 'ꔙ', labelKey: 'activityTitle', mod: null },
{ href: '/feed', glyph: 'ꕿ', labelKey: 'feedTitle', mod: 'feedMod' },
{ href: '/chats', glyph: 'ꖒ', labelKey: 'chatsTitle', mod: 'chatsMod' },
{ href: '/karvan', glyph: '⧗', labelKey: 'karvanTitle', mod: 'karvanMod' },
{ href: '/rooms', glyph: 'ꕢ', labelKey: 'roomsTitle', mod: 'roomsMod' },
{ href: '/forum', glyph: 'ꕒ', labelKey: 'forumTitle', mod: 'forumMod' },
{ href: '/pads', glyph: 'ꔗ', labelKey: 'padsTitle', mod: 'padsMod' },

View file

@ -1,134 +0,0 @@
"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, video } =
require("../server/node_modules/hyperaxe");
const { template, i18n } = require("./main_views");
const { iconNode } = require("./mobile_icons");
// boton de la barra de llamada: icono y su nombre debajo (antes 🎤 🎥 ⛔ sin texto)
const callBtn = (id, cls, icon, text) =>
button({ type: "button", class: "kc-btn " + cls, id }, iconNode("kc-ico", icon), span({ class: "kc-lbl" }, text));
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);
};
// los propios se marcan ya en el servidor: karvan.js solo marca los que llegan
// despues, y al recargar la sala tus mensajes salian como de otra persona
const renderMsg = (m, selfId) =>
li({ class: "karvan-msg" + (selfId && m.from === selfId ? " karvan-msg-self" : ""), "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) =>
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",
"data-txt-relay": i18n.karvanRelayHuman || "server relay", "data-txt-p2p": i18n.karvanP2pHuman || "🔒 P2P (WebRTC)" },
span({ class: "kl-dot" }),
span({ class: "kl-txt", id: "karvan-live-txt" }, i18n.karvanRelayHuman || i18n.karvanRelay || "server relay")
),
// Fase 1: panel de videollamada (media P2P sobre el mismo WebRTC del chat). La galería
// aparece al iniciar; los botones cablean cámara/micro. En el móvil actual getUserMedia
// fallará hasta que el wrapper conceda permisos (Fases 2/3) → se avisa en #kc-status.
div({ class: "karvan-call", id: "karvan-call",
"data-txt-mic": i18n.karvanVoiceOn || "Voice on", "data-txt-cam": i18n.karvanCamOn || "Camera on",
"data-txt-nocam": i18n.karvanNoCam || "No camera permission: the call goes on with voice only.",
"data-txt-nomic": i18n.karvanNoMic || "No microphone permission. You can allow it in the phone settings; the chat still works." },
div({ class: "karvan-videos", id: "karvan-videos" },
video({ id: "karvan-local", class: "kc-video kc-local", muted: "muted", autoplay: "autoplay", playsinline: "playsinline" }),
div({ class: "kc-remote", id: "karvan-remote" })
),
div({ class: "karvan-call-bar" },
callBtn("kc-start", "kc-btn-start", "phone", i18n.karvanCall || "Call"),
callBtn("kc-mic", "", "mic", i18n.karvanMic || "Mic"),
callBtn("kc-cam", "", "camera", i18n.karvanCam || "Camera"),
callBtn("kc-stop", "kc-btn-stop", "hangup", i18n.karvanHangUp || "Hang up")
),
div({ class: "kc-status", id: "kc-status" }, "")
),
ul({ class: "karvan-msgs", id: "karvan-msgs" }, ...(messages || []).map(m => renderMsg(m, params.selfId))),
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")
),
// Fase 2: invitar a un contacto por su feed id → le llega al inbox un enlace a esta sala
form({ class: "karvan-invite", method: "POST", action: "/karvan/" + room.id + "/invite" },
label({ class: "karvan-invite-lbl", for: "karvan-invite-to" }, i18n.karvanInviteLabel || "Invite someone"),
input({ type: "text", name: "to", id: "karvan-invite-to", placeholder: i18n.karvanInvitePlaceholder || "Invite a contact by @feed-id…", autocomplete: "off", spellcheck: "false" }),
p({ class: "form-hint" }, i18n.karvanInviteHint || "Paste their @id: it is on their profile, under their name."),
button({ type: "submit" }, i18n.karvanInvite || "Invite")
),
params.invite === "ok"
? p({ class: "karvan-invite-msg ok" }, i18n.karvanInviteSent || "✓ Invitation sent to their inbox.")
: params.invite === "bad"
? p({ class: "karvan-invite-msg err" }, i18n.karvanInviteBad || "✗ That is not a valid @feed-id.")
: params.invite === "fail"
? p({ class: "karvan-invite-msg err" }, i18n.karvanInviteFail || "✗ Could not send the invitation.")
: ""
),
script({ src: "/js/karvan.js", defer: "defer" })
// sin JavaScript no hay data-channel ni sondeo, asi que la sala se refresca sola.
// Va en <noscript> para que con JS no recargue encima del chat en vivo.
// Mismo recurso que usa upstream en indexing_view.
).replace('</head>', '<noscript><meta http-equiv="refresh" content="10"></noscript></head>');

View file

@ -1101,17 +1101,6 @@ const navGroup = ({ id, emoji, title, defaultOpen = false }, ...items) => {
const renderKarvanLink = () => {
const karvanMod = getConfig().modules.karvanMod === "on";
return karvanMod
? navLink({
href: "/karvan",
emoji: "⧗",
text: i18n.karvanTitle,
class: "karvan-link enabled"
})
: "";
};
const renderPollsLink = () => {
const pollsMod = getConfig().modules.pollsMod === "on";
@ -2020,9 +2009,6 @@ const template = (titlePrefix, ...elements) => {
...(process.env.OASIS_MOBILE === '1'
? [ link({ rel: "stylesheet", href: `/assets/styles/mobile.css?v=${assetVersion()}` }), acampadaBase(theme), themeLink, acampadaLink(theme) ]
: [ themeLink, link({ rel: "stylesheet", href: `/assets/styles/mobile.css?v=${assetVersion()}`, media: "(max-width: 768px)" }) ]),
// el modulo Karvan trae su propia hoja: asi se ve igual en cualquier tema
// y tambien en escritorio, donde no se carga OasisMobile.css
link({ rel: "stylesheet", href: `/assets/styles/karvan.css?v=${assetVersion()}` }),
process.env.OASIS_MOBILE === '1' ? link({ rel: "stylesheet", href: `/assets/styles/mobile_ux.css?v=${assetVersion()}` }) : null,
link({ rel: "icon", href: "/assets/images/favicon.svg" }),
meta({ charset: "utf-8" }),
@ -2348,8 +2334,7 @@ const template = (titlePrefix, ...elements) => {
renderWikiLink(),
renderMapsLink(),
renderRoomsLink(),
renderChatsLink(),
renderKarvanLink()
renderChatsLink()
),
navGroup(
{
@ -2412,9 +2397,6 @@ exports.ainavHomeView = ({ recentTags = [] } = {}) => {
...(process.env.OASIS_MOBILE === '1'
? [ link({ rel: "stylesheet", href: `/assets/styles/mobile.css?v=${assetVersion()}` }), acampadaBase(theme), link({ rel: "stylesheet", href: `/assets/themes/${theme}.css?v=${assetVersion()}` }), acampadaLink(theme) ]
: [ link({ rel: "stylesheet", href: `/assets/themes/${theme}.css?v=${assetVersion()}` }), link({ rel: "stylesheet", href: `/assets/styles/mobile.css?v=${assetVersion()}`, media: "(max-width: 768px)" }) ]),
// el modulo Karvan trae su propia hoja: asi se ve igual en cualquier tema
// y tambien en escritorio, donde no se carga OasisMobile.css
link({ rel: "stylesheet", href: `/assets/styles/karvan.css?v=${assetVersion()}` }),
process.env.OASIS_MOBILE === '1' ? link({ rel: "stylesheet", href: `/assets/styles/mobile_ux.css?v=${assetVersion()}` }) : null,
link({ rel: "icon", href: "/assets/images/favicon.svg" }),
meta({ charset: "utf-8" }),

View file

@ -14,7 +14,6 @@ const ICONS = {
activity: '<polyline points="3 12 7 12 10 5 14 19 17 12 21 12"/>',
feed: '<path d="M4 6h16M4 12h16M4 18h10"/>',
chats: '<path d="M21 11.5a8.5 8.5 0 0 1-12.3 7.6L3 20.5l1.4-5A8.5 8.5 0 1 1 21 11.5z"/>',
karvan: '<path d="M21 11.5a8.5 8.5 0 0 1-12.3 7.6L3 20.5l1.4-5A8.5 8.5 0 1 1 21 11.5z"/><path d="M12 7.5v4l2.5 2"/>',
rooms: '<path d="M6 21V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v17M3 21h18"/><circle cx="14.5" cy="12" r="1"/>',
forum: '<path d="M14 9a2 2 0 0 1-2 2H6l-3 3V4a2 2 0 0 1 2-2h7a2 2 0 0 1 2 2z"/><path d="M18 9h1a2 2 0 0 1 2 2v10l-3-3h-6a2 2 0 0 1-2-2v-1"/>',
pads: '<path d="M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><path d="M14 3v6h6M8 13h8M8 17h5"/>',

View file

@ -1,5 +1,5 @@
"use strict";
// Pantalla de Modulos del movil (la sirve el router propio, karvan_routes.js).
// Pantalla de Modulos del movil (la sirve el router propio, fork_routes.js).
//
// Los modulos agrupados como en el lanzador, cada uno una fila que se toca entera con
// un interruptor, y Guardar siempre a la vista. La tabla de upstream eran 52 filas
@ -20,7 +20,7 @@ const STEMS = {
bookmarks: 'Bookmarks', calendars: 'Calendars', chats: 'Chats', cipher: 'Cipher', courts: 'Courts', dev: 'Dev',
docs: 'Docs', events: 'Events', favorites: 'Favorites', blogs: 'Blogs', fediverse: 'Fediverse', feed: 'Feed',
files: 'Files', forum: 'Forum', games: 'Games', graphos: 'Graphos', housing: 'Housing', images: 'Images',
industry: 'Industry', invites: 'Invites', jobs: 'Jobs', karvan: 'Karvan', larp: 'Larp', backup: 'Backup',
industry: 'Industry', invites: 'Invites', jobs: 'Jobs', larp: 'Larp', backup: 'Backup',
logs: 'Logs', maps: 'Map', market: 'Market', melody: 'Melody', opinions: 'Opinions', pads: 'Pads',
parliament: 'Parliament', pixelia: 'Pixelia', polls: 'Polls', projects: 'Projects', reports: 'Reports', school: 'School',
shops: 'Shops', tags: 'Tags', tasks: 'Tasks', torrents: 'Torrents', transfers: 'Transfers', trending: 'Trending',

View file

@ -39,7 +39,6 @@ const modulesView = () => {
{ name: 'industry', label: i18n.modulesIndustryLabel, description: i18n.modulesIndustryDescription },
{ name: 'invites', label: i18n.modulesInvitesLabel, description: i18n.modulesInvitesDescription },
{ name: 'jobs', label: i18n.modulesJobsLabel, description: i18n.modulesJobsDescription },
{ name: 'karvan', label: i18n.modulesKarvanLabel, description: i18n.modulesKarvanDescription },
{ name: 'larp', label: i18n.modulesLarpLabel, description: i18n.modulesLarpDescription },
{ name: 'backup', label: i18n.modulesBackupLabel, description: i18n.modulesBackupDescription },
{ name: 'logs', label: i18n.modulesLogsLabel, description: i18n.modulesLogsDescription },
@ -67,17 +66,13 @@ const modulesView = () => {
{ name: 'wiki', label: i18n.modulesWikiLabel, description: i18n.modulesWikiDescription }
].sort((x, y) => String(x.label || x.name).localeCompare(String(y.label || y.name), undefined, { sensitivity: 'base' }));
const MOBILE_UNAVAILABLE = ['ai', 'aiNav', 'dev', 'games', 'logs', 'maps', 'pixelia'];
const isMobile = process.env.OASIS_MOBILE === '1';
const visibleModules = isMobile ? modules.filter(mod => !MOBILE_UNAVAILABLE.includes(mod.name)) : modules;
const moduleStates = visibleModules.reduce((acc, mod) => {
const moduleStates = modules.reduce((acc, mod) => {
acc[`${mod.name}Mod`] = config[`${mod.name}Mod`] === 'on' ? 'on' : 'off';
return acc;
}, {});
const activeModulesCount = visibleModules.filter(mod => moduleStates[`${mod.name}Mod`] === 'on').length;
const totalModulesCount = visibleModules.length;
const activeModulesCount = modules.filter(mod => moduleStates[`${mod.name}Mod`] === 'on').length;
const totalModulesCount = modules.length;
const disabledModulesCount = totalModulesCount - activeModulesCount;
const header = [div({ class: 'tags-header module-header-line' },
@ -105,7 +100,7 @@ const modulesView = () => {
const presetButtons = div({ class: 'preset-group' },
Object.entries(PRESETS).map(([key, mods]) => {
const presetLabel = (i18n[`modulesPreset_${key}`] || key).toUpperCase();
const isActive = visibleModules.every(m => mods.includes(m.name) === (moduleStates[`${m.name}Mod`] === 'on'));
const isActive = modules.every(m => mods.includes(m.name) === (moduleStates[`${m.name}Mod`] === 'on'));
return form({ action: "/modules/preset", method: "post", class: "modules-preset-form" },
input({ type: "hidden", name: "preset", value: key }),
button({
@ -120,7 +115,8 @@ const modulesView = () => {
i18n.modules,
section(header),
section(
...(isMobile ? [] : [h2(i18n.modulesPresetTitle || "Common Configurations"), presetButtons]),
h2(i18n.modulesPresetTitle || "Common Configurations"),
presetButtons,
counts,
form(
{ action: "/save-modules", method: "post" },
@ -131,7 +127,7 @@ const modulesView = () => {
td(i18n.modulesModuleDescription),
td({ class: 'module-status-cell' }, i18n.modulesModuleStatus)
),
visibleModules.map(mod =>
modules.map(mod =>
tr(
td(a({ href: `/${mod.name}` }, mod.label)),
td(p(mod.description)),