diff --git a/GUIA_DE_DESARROLLO.md b/GUIA_DE_DESARROLLO.md index 3de1c51..44d8117 100644 --- a/GUIA_DE_DESARROLLO.md +++ b/GUIA_DE_DESARROLLO.md @@ -17,8 +17,7 @@ Los números y de dónde salen están en `docs/FRONTERA_UPSTREAM.md`. - `src/backend/backend.js`. Cambia en 34 de cada 39 versiones. Ni rutas nuevas, ni middleware, ni envolver funciones suyas como `checkMod`. - La cadena de middleware del final de `backend.js`. Crece en cada versión. -- Los modelos de `src/models/`, salvo los nuestros (`karvan_model.js`, - `fork_workflows.js`). Ahí se decide qué se publica en la red y cómo se cuenta. +- Los modelos de `src/models/`, salvo el nuestro (`fork_workflows.js`). Ahí se decide qué se publica en la red y cómo se cuenta. Si lo cambiamos, nuestro móvil cuenta distinto que el escritorio de al lado. Un ejemplo real: quien crea una votación no puede votar en ella. No nos gusta, pero es una regla del modelo, así que se explica en pantalla y punto. @@ -52,6 +51,17 @@ Ahora mismo hay enganches en `main_views.js`, `tribes_view.js`, `vote_view.js`, `settings_view.js` y `welcome_view.js`. Para verlos: `grep -n mobileUi src/views/*.js`. +## Llamadas: las del dev + +La rama tuvo un módulo de salas y llamadas propio (Karvan). Se quitó el 19/10/2026 +porque el dev ya trae Teléfono y Salas. Lo nuestro es solo que funcionen en Android: +los permisos del envoltorio y el puente de audio y cámara. Si falta algo en las +llamadas, se arregla en ese puente o se le pide al dev, no se monta otro módulo. + +Lo mismo vale para cualquier módulo: antes de hacer uno propio, mirad si el dev ya lo +tiene o lo está haciendo. + + ## Lo que es nuestro Aquí se puede hacer lo que haga falta. @@ -62,16 +72,17 @@ Aquí se puede hacer lo que haga falta. piezas nuevas. Vale para los dos skins de móvil. - `src/client/assets/themes/Acampada.css` y `styles/hive_acampada.css`: el skin Acampada. -- `styles/karvan.css`, `src/views/karvan_view.js`, `src/models/karvan_model.js`, - `src/client/public/js/karvan.js`: Karvan entero. - `src/views/mobile_ui.js`: lo que se enchufa en vistas del dev. - `src/views/mobile_modules_view.js`, `identities_view.js`: pantallas propias. - `src/views/hive_views.js`: lanzador y barras de arriba y abajo. - `src/views/mobile_icons.js`: los iconos. -- `src/backend/karvan_routes.js`: nuestro router. Va justo delante del del dev. +- `src/backend/fork_routes.js`: nuestro router. Va justo delante del del dev. - `src/backend/mobile_layer.js`: lo que el móvil necesita del backend (rutas que se cortan, módulos que no van, avatar y lanzador, retoques del HTML, descargas de la - copia de seguridad). La monta `karvan_routes.js`. + copia de seguridad). La monta `fork_routes.js`. +- `main.js`: arranca el backend en el móvil. Ahí se enchufan el audio, la cámara y la + emisión de Android (`src/server/phone_*_android.js`) en el Teléfono y las Salas del + dev, con `setAudioFactory` y compañía, sin tocar `SSB_server.js`. - `src/models/fork_workflows.js`: nuestros workflows. - `src/client/assets/translations/i18n_fork.js`: nuestros textos. - `src/client/assets/images/ux-*.svg`: nuestras imágenes. @@ -98,7 +109,7 @@ antes se iban sumando y una ficha perdía 74 px por lado. Cambiar el HTML de una pantalla del dev. Función en `mobile_ui.js` y una línea en la vista, como arriba. -Una pantalla o una ruta nueva. En `karvan_routes.js`. Lo que no atiende nuestro +Una pantalla o una ruta nueva. En `fork_routes.js`. Lo que no atiende nuestro router pasa al del dev con `next()`: ```js @@ -108,7 +119,7 @@ router pasa al del dev con `next()`: }) ``` -Retocar lo que contesta el dev. También en `karvan_routes.js`, en `wrapUpstream`: deja +Retocar lo que contesta el dev. También en `fork_routes.js`, en `wrapUpstream`: deja que el dev conteste y luego mira la respuesta. Así sabemos a qué sección apagada se quería entrar (el dev solo manda a `/modules`) o añadimos `?saved=1` al guardar. @@ -130,7 +141,7 @@ nombre es el de la ruta: `/tribes` es `tribes`. del dev, solo líneas de enganche. - `node --check` en los `.js` que hayáis cambiado. - Arrancar en local y ver que `/activity`, `/tribes`, `/modules`, `/settings`, - `/votes`, `/karvan` e `/inbox` cargan. + `/votes`, `/phone`, `/rooms` e `/inbox` cargan. - Mirarlo a 412 px de ancho, y a 360 si es algo ancho. - Los textos nuevos, en los 11 idiomas. - Nada en línea, que la CSP lo bloquea. @@ -178,9 +189,13 @@ duele: 1. En `backend.js` quedan 9 trozos pequeños (21 líneas) que no se pueden sacar desde fuera: tres expresiones regulares que con el Node 18 de Android tumbaban el arranque (esto habría que contárselo al dev), no abrir el navegador al arrancar, - no repetir fotos ya limpiadas, el modo chats, Karvan en la lista de Módulos de - escritorio y la línea que monta nuestro router. Lo demás ya está en - `src/backend/mobile_layer.js`. + no repetir fotos ya limpiadas, el modo chats y la línea que monta nuestro router. + Lo demás ya está en `src/backend/mobile_layer.js`. 2. En `workflows_model.js` queda la logica de skins del movil (`themeOf`, `sameTheme`): `settings_view.js` coge `currentWorkflow` al cargar y no se puede envolver desde fuera sin tocar esa vista. Es poco, pero es del dev. +3. `src/server/phone_module.js` escribe el estado de la llamada en + `.phone_state.json` para que la app Android enseñe la notificación (9 líneas). El + módulo del dev no avisa hacia fuera de los cambios de estado; habría que pedírselo. +4. `src/server/SSB_server.js` quita la publicación de la dirección del banco al + arrancar. Hay que mirar por qué se quitó y si sigue haciendo falta. diff --git a/README.md b/README.md index a6b7c5f..9c7fd71 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Versión base: **Oasis 1.3.3** · Rama: `PRUEBAS` · [Documentación completa](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki) · [Versión de escritorio](https://gitea.laenre.net/hacklab/OASIS_LINUX) -![Oasis Mobile: lanzador, Karvan, filtros y barra de atajos](docs/capturas/00-portada.png) +![Oasis Mobile: lanzador, filtros y barra de atajos](docs/capturas/00-portada.png) **[⬇ Descargar la APK](https://gitea.laenre.net/hacklab/OASIS_MOBILE/releases)** · Android 7 o superior, 64 bits · se instala junto al Oasis oficial sin pisarlo @@ -34,12 +34,14 @@ Android 7 o superior, 64 bits · se instala junto al Oasis oficial sin pisarlo - **Tema Acampada por defecto**: blanco, negro y cian, títulos en Anton y botones que se hunden al pulsar, con los colores de acampada26s.net. Los otros temas siguen en *Ajustes → Tema*. -- **Arranca en modo activistas**: chats, Karvan, pads, foro, imágenes, vídeos, wiki y - blogs. Todo cabe en una pantalla; el resto se activa en *Ajustes → Workflows*. +- **Arranca en modo acampada**: chats, teléfono, salas, pads, foro, wiki, tribus, + parlamento, votaciones y eventos. El resto se activa en *Ajustes → Workflows*. +- **Llamadas y salas del dev**: fuera Karvan, nuestras salas propias. Ahora se usan el + Teléfono y las Salas de Oasis, con el audio y la cámara de Android enchufados por la + rama. - **Filtros en fichas**: en chats, feed, foros o pads los filtros ya no son botones gigantes que tapan el contenido, sino fichas pequeñas en fila. - **Tarjetas más limpias**: borrar, fijar, enlazar y abrir van en una fila de iconos. -- **Karvan**: tus mensajes salen a la derecha también al volver a entrar en la sala. - **Fotos más seguras**: si una foto no se puede limpiar de metadatos (ubicación, cámara…), no se sube y se avisa. Antes se subía tal cual. - **DNS**: primero el resolutor del sistema, que respeta la VPN y el DNS privado de @@ -51,15 +53,13 @@ Android 7 o superior, 64 bits · se instala junto al Oasis oficial sin pisarlo | | Oasis oficial | Esta rama | |---|---|---| -| **Salas efímeras** | — | Karvan: mensajes que solo viven en memoria y se autodestruyen | -| **Videollamadas** | — | Audio y vídeo por WebRTC, sin servidores de terceros | +| **Llamadas y salas** | Teléfono y Salas, sin micrófono ni cámara en Android | Los mismos, con el audio y la cámara del móvil | | **Interfaz de móvil** | El menú de escritorio en una pantalla pequeña | Tema Acampada, lanzador de módulos, filtros en fichas y barra de cuatro atajos | -| **Primer arranque** | Casi sesenta módulos encendidos | Modo activistas: lo justo para organizarse | +| **Primer arranque** | Casi sesenta módulos encendidos | Modo acampada: lo justo para organizarse | | **Varias identidades** | Una por instalación | Varias en el mismo móvil, con aviso si dos comparten clave | | **Respuesta citada** | — | Responder a un mensaje concreto dentro de un chat | | **Envoltorio Android** | APK sin permisos de cámara ni micrófono | Proyecto Gradle propio: permisos, subida de ficheros, servicio que sobrevive | | **Metadatos de las fotos** | Se quitan solo si está `sharp`; en Android **nunca** | Se quitan siempre: GPS, marca, modelo, número de serie | -| **Servidor STUN** | Uno de Google, por defecto | Ninguno. TURN propio si lo configuras | Los nombres de las categorías, los módulos y el resto de la aplicación son los de epsylon, sin tocar. @@ -93,35 +93,14 @@ mensaje en tu nombre ni cambiar lo que dijiste. --- -## Karvan: salas que se borran solas +## Llamadas y salas -![Cómo funciona Karvan](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/raw/img/esq/11-karvan.png) - -Lo que se dice dentro **no toca el disco**, no entra en tu registro y no se replica a -nadie. Dos relojes corren a la vez —inactividad y tiempo absoluto— y la sala muere con -el que llegue antes. - -

- - -

- ---- - -## Videollamadas sin terceros - -![Cómo funciona una llamada](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/raw/img/esq/12-llamadas.png) - -El servidor solo sirve para que os encontréis: **la voz y el vídeo van directos** entre -los participantes, cifrados de extremo a extremo. Sin STUN de Google. - -

- - -

- -Esto **no es posible con la APK oficial**: no declara los permisos de cámara y su -navegador interno los deniega. Por eso esta rama trae su propio envoltorio Android. +El Teléfono (llamadas de uno a uno, con buzón de voz) y las Salas (hasta cincuenta +personas, con vídeo, grabación y emisión) son de Oasis. La APK oficial no pide +permisos de micrófono ni de cámara, así que en Android no se pueden usar. Esta rama +trae su propio envoltorio, que sí los pide, y enchufa el audio, la cámara y la emisión +del móvil en los puntos que el propio Oasis deja para eso (`setAudioFactory` y +compañía). La rama no tiene llamadas propias. --- @@ -141,8 +120,9 @@ queda una barra de cuatro atajos que eliges tú. Dentro de cada módulo los filt para que el contenido se vea sin tener que bajar. Solo aparecen los módulos activos. En **Ajustes → Workflows** se eligen de golpe: el de -**activistas** deja lo justo para organizarse (chats, Karvan, pads, foro, imágenes, -vídeos, wiki y blogs). En el móvil un workflow cambia los módulos, no el aspecto. +**acampada** deja lo justo para organizarse (chats, teléfono, salas, pads, foro, wiki, +tribus, parlamento, votaciones y eventos). En el móvil un workflow cambia los módulos, +no el aspecto. --- @@ -213,7 +193,6 @@ Todo lo de arriba, en detalle, está en la |---|---| | [Entender Oasis](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/Entender) | La red, el gossip, los saltos, los pubs, las tribus y el LARP | | [Empezar desde cero](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/Empezar) | Invitación, seguir gente, abrir tu blog o tu tienda | -| [Karvan](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/Karvan) | Salas efímeras, paso a paso | | [Llamadas](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/Llamadas) | Cómo se llama y cómo montar un TURN | | [La interfaz](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/Interfaz) | Botón por botón | | [Identidades](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/Identidades) | Varias cuentas en el mismo móvil | diff --git a/android/README.md b/android/README.md index 18b4815..046d879 100644 --- a/android/README.md +++ b/android/README.md @@ -8,7 +8,7 @@ molde. El APK que se venia usando es el de epsylon re-firmado, y su `classes.dex` **no implementa `WebChromeClient.onPermissionRequest`**. Sin ese metodo el WebView de Android deniega por defecto toda peticion de `getUserMedia()`, de modo que las -videollamadas de Karvan no pueden funcionar por mucho que se parchee el manifest: +llamadas no pueden funcionar por mucho que se parchee el manifest: la lista de permisos es estatica y hay que declararla al compilar. Este wrapper resuelve las dos puertas: declara los permisos y los concede al diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 9875364..8d2a56b 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -13,10 +13,10 @@ - diff --git a/docs/FRONTERA_UPSTREAM.md b/docs/FRONTERA_UPSTREAM.md index 83a713c..9f67da1 100644 --- a/docs/FRONTERA_UPSTREAM.md +++ b/docs/FRONTERA_UPSTREAM.md @@ -76,15 +76,15 @@ reescribe la vista, se vuelve a poner una linea, no se reconstruye una funcion. ### Verde: nuestro, sin limite -- Hojas propias: `themes/Acampada.css`, `styles/hive_acampada.css`, - `styles/mobile_ux.css`, `styles/karvan.css`. -- Vistas propias: `hive_views.js`, `mobile_icons.js`, `karvan_view.js`, - `identities_view.js` y las que se creen (`mobile_*.js`). -- El router propio `src/backend/karvan_routes.js`: va justo antes del de upstream en la +- Hojas propias: `themes/Acampada.css`, `styles/mobile_base.css`, + `styles/hive_acampada.css`, `styles/mobile_ux.css`. +- Vistas propias: `hive_views.js`, `mobile_icons.js`, `identities_view.js` y las + `mobile_*.js`. +- El router propio `src/backend/fork_routes.js`: va justo antes del de upstream en la cadena y ve todas las peticiones. Puede atender rutas nuevas, o envolver una ruta de upstream (dejarla hacer y retocar la respuesta) sin editarla. - Traducciones propias en `i18n_fork.js` (se mezclan encima de las de upstream). -- Modelos propios (`karvan_model.js`) y JS de cliente propio (`public/js/karvan.js`). +- Modelo propio `fork_workflows.js`. - Todo `android/`. ## 3. Como esta la rama hoy @@ -131,13 +131,18 @@ nada, pero son peso muerto y confunden. - Fase C (hecha el 19/10): de los 20 trozos de `backend.js` quedan 9 (21 lineas). El resto (rutas cortadas en el movil, modulos que no van, avatar, lanzador, retoques del HTML, descargas de la copia de seguridad, cambio de tema) esta en - `backend/mobile_layer.js` y en `karvan_routes.js`. Los que quedan no se pueden + `backend/mobile_layer.js` y en `fork_routes.js`. Los que quedan no se pueden hacer desde fuera: expresiones regulares para Node 18, no abrir el navegador, fotos - duplicadas, modo chats, Karvan en Modulos de escritorio y el montaje del router. + duplicadas, modo chats y el montaje del router. - Fase D (hecha el 19/10): `activists` vuelve a ser el del dev. Karvan y el workflow `acampada` se registran desde `models/fork_workflows.js`. La config de serie del movil coincide con `acampada`. Queda en `workflows_model.js` la logica de skins del movil. +- Fuera Karvan (19/10): el dev ya trae Telefono y Salas, asi que se quita el modulo + propio de salas y llamadas (modelo, vista, JS, CSS, router, pruebas, textos). + `Clear-SNH.css` y `modules_view.js` vuelven a ser identicos a los del dev y la linea + de `SSB_server.js` que enchufaba el audio de Android pasa a `main.js`, usando los + `setAudioFactory`, `setVideoFactory` y `setStreamFactory` que el propio dev exporta. - Fase E (hecha el 19/10): fuera 153 selectores muertos de `mobile_base.css`, `hive_acampada.css` y `Acampada.css` (287 lineas). Comparado pixel a pixel en 14 pantallas: identicas. diff --git a/main.js b/main.js index c174b81..35fcda6 100644 --- a/main.js +++ b/main.js @@ -102,6 +102,19 @@ try { } } catch (e) {} +// Telefono y Salas del dev: en Android el audio, la camara y la emision van por el +// puente de la app (src/server/phone_*_android.js). El modulo del dev deja enchufarlos +// con setAudioFactory y compania; se hace aqui, antes de arrancar, para no tocar +// SSB_server.js. Ver GUIA_DE_DESARROLLO.md. +try { + const phone = require('./src/server/phone_module'); + phone.setAudioFactory(require('./src/server/phone_audio_android')); + phone.setVideoFactory(require('./src/server/phone_video_android')); + phone.setStreamFactory(require('./src/server/phone_stream_android')); +} catch (e) { + console.error('[Oasis] Phone bridge not available:', e && e.message); +} + const backendPath = path.join(__dirname, 'src', 'backend', 'backend.js'); console.log('[Oasis] Launching Oasis Social Network Utopia...'); diff --git a/src/backend/backend.js b/src/backend/backend.js index 5c20d34..6374a55 100644 --- a/src/backend/backend.js +++ b/src/backend/backend.js @@ -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; diff --git a/src/backend/fork_routes.js b/src/backend/fork_routes.js new file mode 100644 index 0000000..7a064e8 --- /dev/null +++ b/src/backend/fork_routes.js @@ -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()]); +}; diff --git a/src/backend/karvan_routes.js b/src/backend/karvan_routes.js deleted file mode 100644 index 6deda6b..0000000 --- a/src/backend/karvan_routes.js +++ /dev/null @@ -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 /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()]); -}; diff --git a/src/backend/mobile_layer.js b/src/backend/mobile_layer.js index f234da3..c89f8ed 100644 --- a/src/backend/mobile_layer.js +++ b/src/backend/mobile_layer.js @@ -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) diff --git a/src/backend/turnCredentials.js b/src/backend/turnCredentials.js deleted file mode 100644 index d1b6ea1..0000000 --- a/src/backend/turnCredentials.js +++ /dev/null @@ -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 = ":" -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 }; diff --git a/src/client/assets/styles/hive_acampada.css b/src/client/assets/styles/hive_acampada.css index 666fff2..cc65393 100644 --- a/src/client/assets/styles/hive_acampada.css +++ b/src/client/assets/styles/hive_acampada.css @@ -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; } diff --git a/src/client/assets/styles/karvan.css b/src/client/assets/styles/karvan.css deleted file mode 100644 index 2f33974..0000000 --- a/src/client/assets/styles/karvan.css +++ /dev/null @@ -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; } diff --git a/src/client/assets/styles/mobile_base.css b/src/client/assets/styles/mobile_base.css index a308bf9..0089f58 100644 --- a/src/client/assets/styles/mobile_base.css +++ b/src/client/assets/styles/mobile_base.css @@ -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 diff --git a/src/client/assets/styles/mobile_ux.css b/src/client/assets/styles/mobile_ux.css index ad7fec2..d56e0d0 100644 --- a/src/client/assets/styles/mobile_ux.css +++ b/src/client/assets/styles/mobile_ux.css @@ -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; } diff --git a/src/client/assets/themes/Clear-SNH.css b/src/client/assets/themes/Clear-SNH.css index 3c4b2cc..c64f10b 100644 --- a/src/client/assets/themes/Clear-SNH.css +++ b/src/client/assets/themes/Clear-SNH.css @@ -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; } diff --git a/src/client/assets/translations/i18n_fork.js b/src/client/assets/translations/i18n_fork.js index e73f2a7..122c053 100644 --- a/src/client/assets/translations/i18n_fork.js +++ b/src/client/assets/translations/i18n_fork.js @@ -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: "Экспортировать или импортировать личность", diff --git a/src/client/public/js/karvan.js b/src/client/public/js/karvan.js deleted file mode 100644 index fdae948..0000000 --- a/src/client/public/js/karvan.js +++ /dev/null @@ -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); - }); - } -})(); diff --git a/src/configs/config-manager.js b/src/configs/config-manager.js index 2a4582c..0861991 100644 --- a/src/configs/config-manager.js +++ b/src/configs/config-manager.js @@ -65,7 +65,6 @@ if (!fs.existsSync(configFilePath)) { "modules": { "blogsMod": "on", "housingMod": "off", - "karvanMod": "on", "popularMod": "on", "topicsMod": "on", "summariesMod": "on", diff --git a/src/configs/oasis-config.json b/src/configs/oasis-config.json index 0acd6ef..2ee9846 100644 --- a/src/configs/oasis-config.json +++ b/src/configs/oasis-config.json @@ -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", diff --git a/src/models/fork_workflows.js b/src/models/fork_workflows.js index 5bbbf87..7163fae 100644 --- a/src/models/fork_workflows.js +++ b/src/models/fork_workflows.js @@ -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); diff --git a/src/models/karvan_model.js b/src/models/karvan_model.js deleted file mode 100644 index 529e650..0000000 --- a/src/models/karvan_model.js +++ /dev/null @@ -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, - }; -}; diff --git a/src/server/SSB_server.js b/src/server/SSB_server.js index ea12deb..00e5c95 100644 --- a/src/server/SSB_server.js +++ b/src/server/SSB_server.js @@ -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) { diff --git a/src/views/hive_views.js b/src/views/hive_views.js index d67fbc2..e3e8a3d 100644 --- a/src/views/hive_views.js +++ b/src/views/hive_views.js @@ -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' }, diff --git a/src/views/karvan_view.js b/src/views/karvan_view.js deleted file mode 100644 index d727f42..0000000 --- a/src/views/karvan_view.js +++ /dev/null @@ -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