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

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

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

View file

@ -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 - `src/backend/backend.js`. Cambia en 34 de cada 39 versiones. Ni rutas nuevas, ni
middleware, ni envolver funciones suyas como `checkMod`. middleware, ni envolver funciones suyas como `checkMod`.
- La cadena de middleware del final de `backend.js`. Crece en cada versión. - 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`, - 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.
`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 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 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. 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`. `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 ## Lo que es nuestro
Aquí se puede hacer lo que haga falta. 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. piezas nuevas. Vale para los dos skins de móvil.
- `src/client/assets/themes/Acampada.css` y `styles/hive_acampada.css`: el skin - `src/client/assets/themes/Acampada.css` y `styles/hive_acampada.css`: el skin
Acampada. 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_ui.js`: lo que se enchufa en vistas del dev.
- `src/views/mobile_modules_view.js`, `identities_view.js`: pantallas propias. - `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/hive_views.js`: lanzador y barras de arriba y abajo.
- `src/views/mobile_icons.js`: los iconos. - `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 - `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 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/models/fork_workflows.js`: nuestros workflows.
- `src/client/assets/translations/i18n_fork.js`: nuestros textos. - `src/client/assets/translations/i18n_fork.js`: nuestros textos.
- `src/client/assets/images/ux-*.svg`: nuestras imágenes. - `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 Cambiar el HTML de una pantalla del dev. Función en `mobile_ui.js` y una línea en la
vista, como arriba. 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()`: router pasa al del dev con `next()`:
```js ```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 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. 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. del dev, solo líneas de enganche.
- `node --check` en los `.js` que hayáis cambiado. - `node --check` en los `.js` que hayáis cambiado.
- Arrancar en local y ver que `/activity`, `/tribes`, `/modules`, `/settings`, - 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. - Mirarlo a 412 px de ancho, y a 360 si es algo ancho.
- Los textos nuevos, en los 11 idiomas. - Los textos nuevos, en los 11 idiomas.
- Nada en línea, que la CSP lo bloquea. - 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 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 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, 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 no repetir fotos ya limpiadas, el modo chats y la línea que monta nuestro router.
escritorio y la línea que monta nuestro router. Lo demás ya está en Lo demás ya está en `src/backend/mobile_layer.js`.
`src/backend/mobile_layer.js`.
2. En `workflows_model.js` queda la logica de skins del movil (`themeOf`, `sameTheme`): 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 `settings_view.js` coge `currentWorkflow` al cargar y no se puede envolver desde
fuera sin tocar esa vista. Es poco, pero es del dev. 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.

View file

@ -22,7 +22,7 @@ Versión base: **Oasis 1.3.3** · Rama: `PRUEBAS` ·
[Documentación completa](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki) · [Documentación completa](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki) ·
[Versión de escritorio](https://gitea.laenre.net/hacklab/OASIS_LINUX) [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)** · **[⬇ 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 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 - **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 se hunden al pulsar, con los colores de acampada26s.net. Los otros temas siguen en
*Ajustes → Tema*. *Ajustes → Tema*.
- **Arranca en modo activistas**: chats, Karvan, pads, foro, imágenes, vídeos, wiki y - **Arranca en modo acampada**: chats, teléfono, salas, pads, foro, wiki, tribus,
blogs. Todo cabe en una pantalla; el resto se activa en *Ajustes → Workflows*. 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 - **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. 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. - **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, - **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. 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 - **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 | | | Oasis oficial | Esta rama |
|---|---|---| |---|---|---|
| **Salas efímeras** | — | Karvan: mensajes que solo viven en memoria y se autodestruyen | | **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 |
| **Videollamadas** | — | Audio y vídeo por WebRTC, sin servidores de terceros |
| **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 | | **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 | | **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 | | **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 | | **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 | | **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 Los nombres de las categorías, los módulos y el resto de la aplicación son los de
epsylon, sin tocar. 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) 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
Lo que se dice dentro **no toca el disco**, no entra en tu registro y no se replica a permisos de micrófono ni de cámara, así que en Android no se pueden usar. Esta rama
nadie. Dos relojes corren a la vez —inactividad y tiempo absoluto— y la sala muere con trae su propio envoltorio, que sí los pide, y enchufa el audio, la cámara y la emisión
el que llegue antes. del móvil en los puntos que el propio Oasis deja para eso (`setAudioFactory` y
compañía). La rama no tiene llamadas propias.
<p align="center">
<img src="docs/capturas/07-karvan-salas.png" width="270">
<img src="docs/capturas/03-karvan.png" width="270">
</p>
---
## 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.
<p align="center">
<img src="https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/raw/img/12-permiso-camara.png" width="270">
<img src="https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/raw/img/15-llamada-controles.png" width="270">
</p>
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.
--- ---
@ -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. 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 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, **acampada** deja lo justo para organizarse (chats, teléfono, salas, pads, foro, wiki,
vídeos, wiki y blogs). En el móvil un workflow cambia los módulos, no el aspecto. 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 | | [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 | | [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 | | [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 | | [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 | | [Identidades](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/Identidades) | Varias cuentas en el mismo móvil |

View file

@ -8,7 +8,7 @@ molde.
El APK que se venia usando es el de epsylon re-firmado, y su `classes.dex` **no 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 implementa `WebChromeClient.onPermissionRequest`**. Sin ese metodo el WebView de
Android deniega por defecto toda peticion de `getUserMedia()`, de modo que las 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. 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 Este wrapper resuelve las dos puertas: declara los permisos y los concede al

View file

@ -13,10 +13,10 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Camara y microfono para las videollamadas de Karvan. <!-- Camara y microfono para el Telefono y las Salas de Oasis.
Se declaran aqui porque Android exige que la lista sea estatica, pero NO se Se declaran aqui porque Android exige que la lista sea estatica, pero NO se
conceden al instalar: son permisos peligrosos y se piden en tiempo de ejecucion conceden al instalar: son permisos peligrosos y se piden en tiempo de ejecucion
la primera vez que se pulsa Llamar. Si nadie llama, no se pide nada y la app la primera vez que se llama o se entra en una sala. Si nadie llama, no se pide nada y la app
nunca accede a la camara ni al microfono. --> nunca accede a la camara ni al microfono. -->
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.permission.RECORD_AUDIO" />

View file

@ -76,15 +76,15 @@ reescribe la vista, se vuelve a poner una linea, no se reconstruye una funcion.
### Verde: nuestro, sin limite ### Verde: nuestro, sin limite
- Hojas propias: `themes/Acampada.css`, `styles/hive_acampada.css`, - Hojas propias: `themes/Acampada.css`, `styles/mobile_base.css`,
`styles/mobile_ux.css`, `styles/karvan.css`. `styles/hive_acampada.css`, `styles/mobile_ux.css`.
- Vistas propias: `hive_views.js`, `mobile_icons.js`, `karvan_view.js`, - Vistas propias: `hive_views.js`, `mobile_icons.js`, `identities_view.js` y las
`identities_view.js` y las que se creen (`mobile_*.js`). `mobile_*.js`.
- El router propio `src/backend/karvan_routes.js`: va justo antes del de upstream en la - 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 cadena y ve todas las peticiones. Puede atender rutas nuevas, o envolver una ruta de
upstream (dejarla hacer y retocar la respuesta) sin editarla. upstream (dejarla hacer y retocar la respuesta) sin editarla.
- Traducciones propias en `i18n_fork.js` (se mezclan encima de las de upstream). - 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/`. - Todo `android/`.
## 3. Como esta la rama hoy ## 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). - 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, 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 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 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 - 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 `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 coincide con `acampada`. Queda en `workflows_model.js` la logica de skins del
movil. 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`, - 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 `hive_acampada.css` y `Acampada.css` (287 lineas). Comparado pixel a pixel en 14
pantallas: identicas. pantallas: identicas.

13
main.js
View file

@ -102,6 +102,19 @@ try {
} }
} catch (e) {} } 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'); const backendPath = path.join(__dirname, 'src', 'backend', 'backend.js');
console.log('[Oasis] Launching Oasis Social Network Utopia...'); console.log('[Oasis] Launching Oasis Social Network Utopia...');

View file

@ -4664,7 +4664,7 @@ router
ctx.body = statsView(stats, filter); ctx.body = statsView(stats, filter);
}) })
.get("/modules", async (ctx) => { .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; const cfg = getConfig().modules;
ctx.body = modulesView(modules.reduce((acc, m) => { acc[`${m}Mod`] = cfg[`${m}Mod`]; return acc; }, {})); ctx.body = modulesView(modules.reduce((acc, m) => { acc[`${m}Mod`] = cfg[`${m}Mod`]; return acc; }, {}));
}) })
@ -15515,8 +15515,8 @@ const middleware = [
await next(); await next();
}, },
// la rama movil: su router y su capa de backend (ver GUIA_DE_DESARROLLO.md) // 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, require('./fork_routes')({ checkMod, getViewerId, getConfig, saveConfig, isLoopbackRequest, sendErrorPage,
isLoopbackRequest, sendErrorPage, about, backupModel, onboardingModel }), about, backupModel, onboardingModel }),
routes, routes,
async (ctx) => { async (ctx) => {
if (ctx.body != null || (ctx.method !== 'GET' && ctx.method !== 'HEAD')) return; if (ctx.body != null || (ctx.method !== 'GET' && ctx.method !== 'HEAD')) return;

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

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

View file

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

View file

@ -3,12 +3,12 @@
// //
// Antes eran trozos metidos en backend.js (rutas, middleware y refreshAll), que es // 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 // 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. // escritorio. Ver GUIA_DE_DESARROLLO.md.
// //
// Hace cuatro cosas: // Hace cuatro cosas:
// - corta en el movil rutas que alli no tienen sentido (blockexplorer, actualizar // - 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 // - 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) // - 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) // - retoca el HTML que sale (videos con fotograma, pestaña actual marcada)

View file

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

View file

@ -469,34 +469,6 @@ label.welcome-ux-option:has(input:checked) { background: var(--a26-neon) !import
/* el 📌 va en la esquina: el texto deja su hueco (el padding general de la burbuja lo pisaba) */ /* 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-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; } .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-reply-cancel,
.chat-message-meta .chat-reply-btn { color: var(--a26-fg) !important; } .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; } .oasis-mobile-topbar .omt-menus .oasis-menu-btn { font-size: 22px !important; min-height: 48px !important; }
.tags-header p { font-size: 16px !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; } .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; } input, textarea, select { font-size: 18px !important; min-height: 50px; }

View file

@ -1,103 +0,0 @@
/* Estilos del modulo Karvan (salas efimeras y llamadas).
*
* Viven aparte del tema para que el modulo se vea igual en cualquier tema y
* tambien en escritorio, donde OasisMobile.css no se carga. Mismo patron que
* highlight.css, que tampoco pertenece a ningun tema concreto.
*/
/* --- Reply a mensaje en chats (server-side ?replyTo=, sin JS) --- */
.chat-message-meta .chat-reply-btn { margin-left: auto; padding: 0 4px; color: #FFD700; text-decoration: none; font-size: 15px; opacity: .85; }
.chat-message-self .chat-message-meta .chat-reply-btn { margin-left: 8px; }
.chat-quote {
display: block; border-left: 3px solid #FFD700; padding: 3px 8px; margin: 0 0 5px;
background: rgba(255,215,0,.08); border-radius: 6px; font-size: 12px; line-height: 1.3;
}
.chat-quote-author, .chat-quote-author a { color: #FFD700; font-weight: 700; margin-right: 6px; }
.chat-quote-text { color: #FFDD44; }
.chat-replying-to {
display: flex; flex-wrap: wrap; align-items: center; gap: 6px;
border-left: 3px solid #FFD700; background: rgba(255,215,0,.10);
padding: 6px 10px; margin-bottom: 8px; border-radius: 8px; font-size: 12px;
}
.chat-replying-label { color: #FFD700; font-weight: 700; }
.chat-reply-cancel { margin-left: auto; color: #FFD700; text-decoration: none; font-weight: 700; font-size: 14px; }
/* ==========================================================================
FORK_IA_UX · Módulo KARVAN (mensajes temporales + WebRTC) — chat estilo burbujas
========================================================================== */
.karvan-intro h2 { color: #FFD700; margin: 0 0 6px; }
.karvan-sub { color: #FFDD44; font-size: 13px; }
.karvan-create input[type="text"] { width: 100%; box-sizing: border-box; padding: 10px 12px;
background: #161616; border: 1px solid #333; border-radius: 10px; color: #FFD700; }
.karvan-ttl { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; margin: 10px 0; }
.karvan-ttl-lbl { color: #FFDD44; font-size: 13px; }
.karvan-ttl-opt { display: inline-flex; align-items: center; gap: 4px; color: #FFD700; font-size: 13px; white-space: nowrap; }
.karvan-create button, .karvan-compose button { padding: 10px 16px; border: 1px solid #FFD700;
border-radius: 10px; background: #FFD700; color: #121212; font-weight: 700; cursor: pointer; }
.karvan-rooms { list-style: none; margin: 8px 0 0; padding: 0; }
.karvan-rooms li { margin: 0 0 8px; }
.karvan-room-item { display: flex; justify-content: space-between; align-items: center; gap: 10px;
padding: 12px 14px; background: #161616; border: 1px solid #333; border-radius: 12px;
color: #FFD700; text-decoration: none; min-height: 48px; box-sizing: border-box; }
.karvan-room-item .kr-title { font-weight: 600; }
.karvan-room-item .kr-meta { color: #FFDD44; font-size: 12px; white-space: nowrap; }
.karvan-empty { color: #FFDD44; }
/* sala */
.karvan-room-head { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
.karvan-back { color: #FFD700; text-decoration: none; font-weight: 600; }
.karvan-room-title { flex: 1 1 auto; color: #FFD700; font-weight: 700; text-align: center;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.karvan-expire { color: #FFDD44; font-size: 12px; white-space: nowrap; }
.karvan-live { display: inline-flex; align-items: center; gap: 6px; color: #FFDD44; font-size: 12px; margin-bottom: 8px; }
.karvan-live .kl-dot { width: 8px; height: 8px; border-radius: 50%; background: #777; }
.karvan-live.on .kl-dot { background: #35d07f; }
.karvan-live.on .kl-txt { color: #35d07f; }
.karvan-msgs { list-style: none; margin: 0; padding: 6px 0; display: flex; flex-direction: column; gap: 6px;
max-height: 58vh; overflow-y: auto; }
.karvan-msg { max-width: 82%; align-self: flex-start; background: #1c1c1c; border: 1px solid #333;
border-radius: 14px; padding: 7px 11px; box-sizing: border-box; }
.karvan-msg-self { align-self: flex-end; background: #2a2412; border-color: #FFD700; }
.karvan-msg-live { border-left: 2px solid #35d07f; }
.karvan-msg .km-from { display: block; font-size: 10px; color: #FFDD44; margin-bottom: 2px; }
.karvan-msg .km-text { color: #FFD700; word-break: break-word; }
.karvan-compose { display: flex; gap: 8px; margin-top: 10px; }
.karvan-compose input[type="text"] { flex: 1 1 auto; min-width: 0; padding: 10px 12px;
background: #161616; border: 1px solid #333; border-radius: 10px; color: #FFD700; }
/* Fase 2: invitar a un contacto por feed id */
.karvan-invite { display: flex; gap: 8px; margin-top: 8px; }
.karvan-invite input[type="text"] { flex: 1 1 auto; min-width: 0; padding: 9px 12px;
background: #161616; border: 1px solid #333; border-radius: 10px; color: #FFD700; font-family: monospace; font-size: 12px; }
.karvan-invite button { flex: 0 0 auto; padding: 9px 14px; border: 1px solid #FFD700; background: #161616; color: #FFD700; border-radius: 10px; cursor: pointer; }
.karvan-invite-msg { font-size: 12px; margin: 6px 0 0; }
.karvan-invite-msg.ok { color: #35d07f; }
.karvan-invite-msg.err { color: #ff8a8a; }
/* --- Karvan videollamada (Fase 1): media P2P sobre el mismo WebRTC del chat --- */
/* el div{background:#222;padding:20px} de style.css y el div{border} del tema
convierten cada contenedor del panel en una caja: aqui se neutralizan */
.karvan-call, .karvan-videos, .karvan-call-bar, .kc-status {
background-color: transparent; box-shadow: none; border: 0; border-radius: 0; padding: 0; margin: 0;
}
.karvan-call { margin: 10px 0; }
.karvan-videos { display: none; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 6px; margin-bottom: 8px; }
.karvan-call.active .karvan-videos { display: grid; }
.kc-video { width: 100%; aspect-ratio: 4 / 3; background: #000; border-radius: 10px; border: 1px solid #333; object-fit: cover; display: block; }
.kc-local { transform: scaleX(-1); }
.kc-remote { display: contents; }
.karvan-call-bar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.kc-btn { min-height: 44px; min-width: 44px; padding: 8px 12px; border-radius: 999px;
border: 1px solid #333; background: #161616; color: #FFD700; line-height: 1; cursor: pointer; }
.kc-btn.on { background: #FFD700; color: #121212; border-color: #FFD700; }
.kc-btn-start { font-weight: 600; }
.kc-btn-stop { background: #241414; border-color: #5a2a2a; }
.kc-status { font-size: 12px; color: #FFDD44; margin-top: 6px; min-height: 14px; }
/* ==========================================================================
Selector de identidades (ajustes)
========================================================================== */
.identity-list { list-style: none; margin: 10px 0; padding: 0; }
.identity-row { display: flex; align-items: center; justify-content: space-between;
gap: 12px; flex-wrap: wrap; padding: 8px 0; border-bottom: 1px solid #2a2a2a; }
.identity-info { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.identity-name { color: #FFD700; font-weight: 700; }
.identity-feed { color: #FFDD44; font-size: 12px; font-family: monospace; word-break: break-all; }
.identity-warn { color: #ff8a8a; font-size: 12px; }
.identity-active { color: #35d07f; font-size: 13px; font-weight: 600; white-space: nowrap; }

View file

@ -775,76 +775,7 @@ button, input[type="submit"], input[type="button"],
.header .launcher { width: 100%; max-width: 440px; box-sizing: border-box; margin: 4px auto 10px; padding: 0 14px; } .header .launcher { width: 100%; max-width: 440px; box-sizing: border-box; margin: 4px auto 10px; padding: 0 14px; }
/* holgura bajo la topbar fija */ /* holgura bajo la topbar fija */
body { padding-top: 74px; } body { padding-top: 74px; } /* espejo { display: contents; }
/* ==========================================================================
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; }
/* ========================================================================== /* ==========================================================================
1.3.x: upstream trae en mobile.css su propio chrome movil (cabecera fija, cajones 1.3.x: upstream trae en mobile.css su propio chrome movil (cabecera fija, cajones

View file

@ -1,6 +1,6 @@
/* Piezas de usabilidad de la rama movil, para los dos skins (Acampada y OasisMobile). /* 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 * 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. * 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 */ /* 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 :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 .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 { .main-column table th, .card-chips-row > span, .pm-exposition-text {
font-family: var(--ux-text) !important; font-family: var(--ux-text) !important;
font-weight: 700 !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 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 */ el final. Antes unos centrados,
.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"],
.main-column form[action$="/tribes/create"][method="post"] > button[type="submit"], .main-column form[action$="/tribes/create"][method="post"] > button[type="submit"],
.main-column form[action*="/tribes/update/"] > button[type="submit"] { .main-column form[action*="/tribes/update/"] > button[type="submit"] {
display: block !important; display: block !important;
@ -69,7 +67,7 @@
margin: 8px 0 0 !important; 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 { display: inline-flex !important; align-items: center; justify-content: center; }
.has-svg svg { width: 100%; height: 100%; display: block; } .has-svg svg { width: 100%; height: 100%; display: block; }
.ln-ico.has-svg { padding: 15px !important; } .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; } .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 ---------- */ /* ---------- foco visible para teclado y lectores ---------- */
a:focus-visible, button:focus-visible, summary:focus-visible, label.module-switch:focus-within { a:focus-visible, button:focus-visible, summary:focus-visible, label.module-switch:focus-within {
outline: 3px solid var(--ux-accent) !important; outline-offset: 2px; 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; font-weight: 700 !important;
letter-spacing: .02em !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; } .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) */ /* 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 div.confirm-bar { border: 2px solid var(--ux-danger) !important; }
.main-column .content-action-confirm[open] > summary { opacity: 1 !important; } .main-column .content-action-confirm[open] > summary { opacity: 1 !important; }

View file

@ -364,19 +364,6 @@ button.tribe-action-btn { background: transparent !important; border: 1px solid
.chat-topic-active { background: #FFF3E0 !important; } .chat-topic-active { background: #FFF3E0 !important; }
.chat-topic-title { color: #2D2D2D !important; } .chat-topic-title { color: #2D2D2D !important; }
.chat-workspace-empty { border-color: #DDD !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-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, .map-pick-cell:hover { border: 0 !important; padding: 0 !important; border-radius: 0 !important; box-shadow: none !important; }
.map-pick-cell { background: transparent !important; } .map-pick-cell { background: transparent !important; }

View file

@ -3,7 +3,7 @@
// ficheros oasis_*.js (upstream los reescribe entero en cada release). // ficheros oasis_*.js (upstream los reescribe entero en cada release).
// //
// Contiene tres grupos: // 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. // 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 // 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...). // codigo de la rama sigue usando (menuBlogs, spreadHint, chatShareUrl...).
@ -17,7 +17,7 @@
module.exports = { module.exports = {
en: { en: {
// usabilidad (19/10/2026): votaciones, borrar, modulos, Karvan, pie // usabilidad (19/10/2026): votaciones, borrar, modulos, pie
workflow_acampada: "Camp", workflow_acampada: "Camp",
tribeSectionsMore: "More", tribeSectionsMore: "More",
tribeSectionsLabel: "Sections", tribeSectionsLabel: "Sections",
@ -48,43 +48,11 @@ module.exports = {
moduleBlockedAction: "Turn on {name}", moduleBlockedAction: "Turn on {name}",
modulesOnCount: "{on} of {total} sections on", modulesOnCount: "{on} of {total} sections on",
modulesSaved: "Saved.", 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) // 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.", 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.", 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) // 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.", 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 // Identidades
identitiesActive: "In use", identitiesActive: "In use",
identitiesBackup: "Export or import an identity", identitiesBackup: "Export or import an identity",
@ -127,7 +95,7 @@ module.exports = {
spreadMore: "more" spreadMore: "more"
}, },
es: { es: {
// usabilidad (19/10/2026): votaciones, borrar, modulos, Karvan, pie // usabilidad (19/10/2026): votaciones, borrar, modulos, pie
workflow_acampada: "Acampada", workflow_acampada: "Acampada",
tribeSectionsMore: "Más", tribeSectionsMore: "Más",
tribeSectionsLabel: "Secciones", tribeSectionsLabel: "Secciones",
@ -158,43 +126,11 @@ module.exports = {
moduleBlockedAction: "Activar {name}", moduleBlockedAction: "Activar {name}",
modulesOnCount: "{on} de {total} secciones activas", modulesOnCount: "{on} de {total} secciones activas",
modulesSaved: "Guardado.", 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) // skins y workflows en el movil (settings_view.js)
skinIntroMobile: "Elige cómo se ve Oasis en tu móvil. Se aplica al tocarlo.", 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á.", 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) // 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.", 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 // Identidades
identitiesActive: "En uso", identitiesActive: "En uso",
identitiesBackup: "Exportar o importar una identidad", identitiesBackup: "Exportar o importar una identidad",
@ -243,7 +179,7 @@ module.exports = {
totalspreads: "Réplicas totales" totalspreads: "Réplicas totales"
}, },
fr: { fr: {
// usabilidad (19/10/2026): votaciones, borrar, modulos, Karvan, pie // usabilidad (19/10/2026): votaciones, borrar, modulos, pie
workflow_acampada: "Campement", workflow_acampada: "Campement",
tribeSectionsMore: "Plus", tribeSectionsMore: "Plus",
tribeSectionsLabel: "Sections", tribeSectionsLabel: "Sections",
@ -274,43 +210,11 @@ module.exports = {
moduleBlockedAction: "Activer {name}", moduleBlockedAction: "Activer {name}",
modulesOnCount: "{on} sections actives sur {total}", modulesOnCount: "{on} sections actives sur {total}",
modulesSaved: "Enregistré.", 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) // 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.", 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.", 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) // 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.", 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 // Identidades
identitiesActive: "En cours d'utilisation", identitiesActive: "En cours d'utilisation",
identitiesBackup: "Exporter ou importer une identité", identitiesBackup: "Exporter ou importer une identité",
@ -353,7 +257,7 @@ module.exports = {
spreadMore: "plus" spreadMore: "plus"
}, },
eu: { eu: {
// usabilidad (19/10/2026): votaciones, borrar, modulos, Karvan, pie // usabilidad (19/10/2026): votaciones, borrar, modulos, pie
workflow_acampada: "Kanpaldia", workflow_acampada: "Kanpaldia",
tribeSectionsMore: "Gehiago", tribeSectionsMore: "Gehiago",
tribeSectionsLabel: "Atalak", tribeSectionsLabel: "Atalak",
@ -384,43 +288,11 @@ module.exports = {
moduleBlockedAction: "Aktibatu {name}", moduleBlockedAction: "Aktibatu {name}",
modulesOnCount: "{total} ataletatik {on} aktibo", modulesOnCount: "{total} ataletatik {on} aktibo",
modulesSaved: "Gordeta.", 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) // skins y workflows en el movil (settings_view.js)
skinIntroMobile: "Aukeratu nolakoa den Oasis zure mugikorrean. Ukitu bezain laster aplikatzen da.", 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.", workflowsDescriptionMobile: "Workflow batek zein modulu dauden aktibo finkatzen du. Mugikorrean zure itxura ez da aldatzen.",
// fotos que no se pueden limpiar (blobHandler.js) // 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.", 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 // Identidades
identitiesActive: "Erabiltzen", identitiesActive: "Erabiltzen",
identitiesBackup: "Esportatu edo inportatu nortasun bat", identitiesBackup: "Esportatu edo inportatu nortasun bat",
@ -463,7 +335,7 @@ module.exports = {
spreadMore: "gehiago" spreadMore: "gehiago"
}, },
de: { de: {
// usabilidad (19/10/2026): votaciones, borrar, modulos, Karvan, pie // usabilidad (19/10/2026): votaciones, borrar, modulos, pie
workflow_acampada: "Camp", workflow_acampada: "Camp",
tribeSectionsMore: "Mehr", tribeSectionsMore: "Mehr",
tribeSectionsLabel: "Bereiche", tribeSectionsLabel: "Bereiche",
@ -494,43 +366,11 @@ module.exports = {
moduleBlockedAction: "{name} einschalten", moduleBlockedAction: "{name} einschalten",
modulesOnCount: "{on} von {total} Bereichen aktiv", modulesOnCount: "{on} von {total} Bereichen aktiv",
modulesSaved: "Gespeichert.", 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) // skins y workflows en el movil (settings_view.js)
skinIntroMobile: "Wähle, wie Oasis auf deinem Handy aussieht. Es wird beim Antippen übernommen.", 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.", 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) // 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.", 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 // Identidades
identitiesActive: "In Benutzung", identitiesActive: "In Benutzung",
identitiesBackup: "Identität exportieren oder importieren", identitiesBackup: "Identität exportieren oder importieren",
@ -573,7 +413,7 @@ module.exports = {
spreadMore: "mehr" spreadMore: "mehr"
}, },
it: { it: {
// usabilidad (19/10/2026): votaciones, borrar, modulos, Karvan, pie // usabilidad (19/10/2026): votaciones, borrar, modulos, pie
workflow_acampada: "Accampata", workflow_acampada: "Accampata",
tribeSectionsMore: "Altro", tribeSectionsMore: "Altro",
tribeSectionsLabel: "Sezioni", tribeSectionsLabel: "Sezioni",
@ -604,43 +444,11 @@ module.exports = {
moduleBlockedAction: "Attiva {name}", moduleBlockedAction: "Attiva {name}",
modulesOnCount: "{on} sezioni attive su {total}", modulesOnCount: "{on} sezioni attive su {total}",
modulesSaved: "Salvato.", 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) // skins y workflows en el movil (settings_view.js)
skinIntroMobile: "Scegli come appare Oasis sul tuo telefono. Si applica appena lo tocchi.", 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'è.", workflowsDescriptionMobile: "Un workflow stabilisce quali moduli sono attivi. Sul telefono la tua skin resta com'è.",
// fotos que no se pueden limpiar (blobHandler.js) // 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.", 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 // Identidades
identitiesActive: "In uso", identitiesActive: "In uso",
identitiesBackup: "Esporta o importa un'identità", identitiesBackup: "Esporta o importa un'identità",
@ -683,7 +491,7 @@ module.exports = {
spreadMore: "altro" spreadMore: "altro"
}, },
pt: { pt: {
// usabilidad (19/10/2026): votaciones, borrar, modulos, Karvan, pie // usabilidad (19/10/2026): votaciones, borrar, modulos, pie
workflow_acampada: "Acampada", workflow_acampada: "Acampada",
tribeSectionsMore: "Mais", tribeSectionsMore: "Mais",
tribeSectionsLabel: "Secções", tribeSectionsLabel: "Secções",
@ -714,43 +522,11 @@ module.exports = {
moduleBlockedAction: "Ativar {name}", moduleBlockedAction: "Ativar {name}",
modulesOnCount: "{on} de {total} secções ativas", modulesOnCount: "{on} de {total} secções ativas",
modulesSaved: "Guardado.", 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) // skins y workflows en el movil (settings_view.js)
skinIntroMobile: "Escolhe como o Oasis aparece no teu telemóvel. Aplica-se ao tocar.", 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á.", 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) // 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.", 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 // Identidades
identitiesActive: "Em uso", identitiesActive: "Em uso",
identitiesBackup: "Exportar ou importar uma identidade", identitiesBackup: "Exportar ou importar uma identidade",
@ -793,7 +569,7 @@ module.exports = {
spreadMore: "mais" spreadMore: "mais"
}, },
zh: { zh: {
// usabilidad (19/10/2026): votaciones, borrar, modulos, Karvan, pie // usabilidad (19/10/2026): votaciones, borrar, modulos, pie
workflow_acampada: "营地", workflow_acampada: "营地",
tribeSectionsMore: "更多", tribeSectionsMore: "更多",
tribeSectionsLabel: "板块", tribeSectionsLabel: "板块",
@ -824,43 +600,11 @@ module.exports = {
moduleBlockedAction: "开启 {name}", moduleBlockedAction: "开启 {name}",
modulesOnCount: "已开启 {on}/{total} 个板块", modulesOnCount: "已开启 {on}/{total} 个板块",
modulesSaved: "已保存。", modulesSaved: "已保存。",
karvanVoiceOn: "语音已开",
karvanCamOn: "摄像头已开",
karvanNoCam: "没有摄像头权限:通话仅以语音继续。",
karvanNoMic: "没有麦克风权限。可以在手机设置中允许;聊天仍可使用。",
karvanMic: "麦克风",
karvanCam: "摄像头",
karvanHangUp: "挂断",
karvanInviteLabel: "邀请某人",
karvanInviteHint: "粘贴对方的 @id:在其个人资料中,名字下方。",
karvanRelayHuman: "通过你的节点",
karvanP2pHuman: "直连且加密",
// skins y workflows en el movil (settings_view.js) // skins y workflows en el movil (settings_view.js)
skinIntroMobile: "选择 Oasis 在手机上的外观。点一下就会生效。", skinIntroMobile: "选择 Oasis 在手机上的外观。点一下就会生效。",
workflowsDescriptionMobile: "工作流决定启用哪些模块。在手机上,你的外观保持不变。", workflowsDescriptionMobile: "工作流决定启用哪些模块。在手机上,你的外观保持不变。",
// fotos que no se pueden limpiar (blobHandler.js) // fotos que no se pueden limpiar (blobHandler.js)
imageMetadataUnsafe: "无法清除这张图片的元数据(位置、相机等),因此没有上传。请另存为 JPEG 或 PNG 后重试。", 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 // Identidades
identitiesActive: "使用中", identitiesActive: "使用中",
identitiesBackup: "导出或导入身份", identitiesBackup: "导出或导入身份",
@ -903,7 +647,7 @@ module.exports = {
spreadMore: "更多" spreadMore: "更多"
}, },
ar: { ar: {
// usabilidad (19/10/2026): votaciones, borrar, modulos, Karvan, pie // usabilidad (19/10/2026): votaciones, borrar, modulos, pie
workflow_acampada: "مخيم", workflow_acampada: "مخيم",
tribeSectionsMore: "المزيد", tribeSectionsMore: "المزيد",
tribeSectionsLabel: "الأقسام", tribeSectionsLabel: "الأقسام",
@ -934,43 +678,11 @@ module.exports = {
moduleBlockedAction: "تفعيل {name}", moduleBlockedAction: "تفعيل {name}",
modulesOnCount: "{on} من {total} أقسام مفعّلة", modulesOnCount: "{on} من {total} أقسام مفعّلة",
modulesSaved: "تم الحفظ.", modulesSaved: "تم الحفظ.",
karvanVoiceOn: "الصوت مفعّل",
karvanCamOn: "الكاميرا مفعّلة",
karvanNoCam: "لا إذن للكاميرا: تستمر المكالمة بالصوت فقط.",
karvanNoMic: "لا إذن للميكروفون. يمكنك السماح به من إعدادات الهاتف؛ الدردشة تعمل.",
karvanMic: "ميكروفون",
karvanCam: "كاميرا",
karvanHangUp: "إنهاء",
karvanInviteLabel: "ادعُ شخصًا",
karvanInviteHint: "الصق معرّفه @id: تجده في ملفه تحت اسمه.",
karvanRelayHuman: "عبر عقدتك",
karvanP2pHuman: "مباشر ومشفّر",
// skins y workflows en el movil (settings_view.js) // skins y workflows en el movil (settings_view.js)
skinIntroMobile: "اختر مظهر Oasis على هاتفك. يُطبَّق بمجرد اللمس.", skinIntroMobile: "اختر مظهر Oasis على هاتفك. يُطبَّق بمجرد اللمس.",
workflowsDescriptionMobile: "يحدد سير العمل الوحدات المفعّلة. على الهاتف يبقى مظهرك كما هو.", workflowsDescriptionMobile: "يحدد سير العمل الوحدات المفعّلة. على الهاتف يبقى مظهرك كما هو.",
// fotos que no se pueden limpiar (blobHandler.js) // fotos que no se pueden limpiar (blobHandler.js)
imageMetadataUnsafe: "تعذّر إزالة البيانات الوصفية من هذه الصورة (الموقع، الكاميرا…)، لذلك لم تُرفع. احفظها بصيغة JPEG أو PNG وحاول مجددًا.", 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 // Identidades
identitiesActive: "قيد الاستخدام", identitiesActive: "قيد الاستخدام",
identitiesBackup: "تصدير هوية أو استيرادها", identitiesBackup: "تصدير هوية أو استيرادها",
@ -1013,7 +725,7 @@ module.exports = {
spreadMore: "المزيد" spreadMore: "المزيد"
}, },
hi: { hi: {
// usabilidad (19/10/2026): votaciones, borrar, modulos, Karvan, pie // usabilidad (19/10/2026): votaciones, borrar, modulos, pie
workflow_acampada: "शिविर", workflow_acampada: "शिविर",
tribeSectionsMore: "और", tribeSectionsMore: "और",
tribeSectionsLabel: "खंड", tribeSectionsLabel: "खंड",
@ -1044,43 +756,11 @@ module.exports = {
moduleBlockedAction: "{name} चालू करें", moduleBlockedAction: "{name} चालू करें",
modulesOnCount: "{total} में से {on} खंड चालू", modulesOnCount: "{total} में से {on} खंड चालू",
modulesSaved: "सहेजा गया।", modulesSaved: "सहेजा गया।",
karvanVoiceOn: "आवाज़ चालू",
karvanCamOn: "कैमरा चालू",
karvanNoCam: "कैमरे की अनुमति नहीं: कॉल केवल आवाज़ से जारी है।",
karvanNoMic: "माइक्रोफ़ोन की अनुमति नहीं। फ़ोन की सेटिंग में दे सकते हैं; चैट चलती रहेगी।",
karvanMic: "माइक",
karvanCam: "कैमरा",
karvanHangUp: "कॉल काटें",
karvanInviteLabel: "किसी को आमंत्रित करें",
karvanInviteHint: "उनका @id चिपकाएँ: यह उनकी प्रोफ़ाइल में नाम के नीचे है।",
karvanRelayHuman: "आपके नोड के ज़रिए",
karvanP2pHuman: "सीधा और एन्क्रिप्टेड",
// skins y workflows en el movil (settings_view.js) // skins y workflows en el movil (settings_view.js)
skinIntroMobile: "चुनें कि आपके फ़ोन पर Oasis कैसा दिखे। छूते ही लागू हो जाता है।", skinIntroMobile: "चुनें कि आपके फ़ोन पर Oasis कैसा दिखे। छूते ही लागू हो जाता है।",
workflowsDescriptionMobile: "वर्कफ़्लो तय करता है कि कौन-से मॉड्यूल चालू हैं। फ़ोन पर आपकी स्किन वैसी ही रहती है।", workflowsDescriptionMobile: "वर्कफ़्लो तय करता है कि कौन-से मॉड्यूल चालू हैं। फ़ोन पर आपकी स्किन वैसी ही रहती है।",
// fotos que no se pueden limpiar (blobHandler.js) // fotos que no se pueden limpiar (blobHandler.js)
imageMetadataUnsafe: "इस छवि का मेटाडेटा (स्थान, कैमरा…) हटाया नहीं जा सका, इसलिए इसे अपलोड नहीं किया गया। इसे JPEG या PNG के रूप में सहेजें और फिर कोशिश करें।", 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 // Identidades
identitiesActive: "उपयोग में", identitiesActive: "उपयोग में",
identitiesBackup: "पहचान निर्यात या आयात करें", identitiesBackup: "पहचान निर्यात या आयात करें",
@ -1123,7 +803,7 @@ module.exports = {
spreadMore: "और" spreadMore: "और"
}, },
ru: { ru: {
// usabilidad (19/10/2026): votaciones, borrar, modulos, Karvan, pie // usabilidad (19/10/2026): votaciones, borrar, modulos, pie
workflow_acampada: "Лагерь", workflow_acampada: "Лагерь",
tribeSectionsMore: "Ещё", tribeSectionsMore: "Ещё",
tribeSectionsLabel: "Разделы", tribeSectionsLabel: "Разделы",
@ -1154,43 +834,11 @@ module.exports = {
moduleBlockedAction: "Включить {name}", moduleBlockedAction: "Включить {name}",
modulesOnCount: "Включено {on} из {total} разделов", modulesOnCount: "Включено {on} из {total} разделов",
modulesSaved: "Сохранено.", modulesSaved: "Сохранено.",
karvanVoiceOn: "Голос включён",
karvanCamOn: "Камера включена",
karvanNoCam: "Нет доступа к камере: звонок продолжается только голосом.",
karvanNoMic: "Нет доступа к микрофону. Его можно разрешить в настройках телефона; чат продолжает работать.",
karvanMic: "Микрофон",
karvanCam: "Камера",
karvanHangUp: "Завершить",
karvanInviteLabel: "Пригласить кого-нибудь",
karvanInviteHint: "Вставьте его @id: он в профиле, под именем.",
karvanRelayHuman: "Через ваш узел",
karvanP2pHuman: "Напрямую и зашифровано",
// skins y workflows en el movil (settings_view.js) // skins y workflows en el movil (settings_view.js)
skinIntroMobile: "Выберите, как Oasis выглядит на телефоне. Применяется сразу по нажатию.", skinIntroMobile: "Выберите, как Oasis выглядит на телефоне. Применяется сразу по нажатию.",
workflowsDescriptionMobile: "Рабочий процесс задаёт, какие модули включены. На телефоне ваше оформление не меняется.", workflowsDescriptionMobile: "Рабочий процесс задаёт, какие модули включены. На телефоне ваше оформление не меняется.",
// fotos que no se pueden limpiar (blobHandler.js) // fotos que no se pueden limpiar (blobHandler.js)
imageMetadataUnsafe: "Не удалось удалить метаданные этого изображения (местоположение, камера…), поэтому оно не загружено. Сохраните его как JPEG или PNG и попробуйте снова.", 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 // Identidades
identitiesActive: "Используется", identitiesActive: "Используется",
identitiesBackup: "Экспортировать или импортировать личность", identitiesBackup: "Экспортировать или импортировать личность",

View file

@ -1,317 +0,0 @@
/* KARVAN client — mensajes temporales + WebRTC (data-channel).
* Capa fiable: relay por servidor (RAM, autodestrucción) con polling.
* Capa turbo: data-channel WebRTC P2P (instantáneo) con "perfect negotiation".
* Todo degrada: si no hay WebRTC o no conecta, el chat sigue por el relay.
* Sin cámara/mic (solo data-channel) => no necesita permisos del wrapper. */
(function () {
"use strict";
var root = document.querySelector(".karvan-room");
if (!root) return;
var roomId = root.getAttribute("data-room");
var selfId = root.getAttribute("data-self") || "";
var peerId = "p" + Math.random().toString(16).slice(2, 12);
var fromLabel = selfId || peerId;
var listEl = document.getElementById("karvan-msgs");
var formEl = document.getElementById("karvan-form");
var textEl = document.getElementById("karvan-text");
var liveTxt = document.getElementById("karvan-live-txt");
var liveWrap = document.getElementById("karvan-live");
var seen = Object.create(null);
var lastSeq = 0;
var lastSig = 0;
var peers = Object.create(null);
// --- Fase 1: videollamada (media sobre el MISMO WebRTC del chat) ---
var callRoot = document.getElementById("karvan-call");
var localVid = document.getElementById("karvan-local");
var remoteEl = document.getElementById("karvan-remote");
var statusEl = document.getElementById("kc-status");
var btnStart = document.getElementById("kc-start");
var btnMic = document.getElementById("kc-mic");
var btnCam = document.getElementById("kc-cam");
var btnStop = document.getElementById("kc-stop");
var localStream = (window.MediaStream ? new MediaStream() : null);
if (localVid && localStream) localVid.srcObject = localStream;
var micActive = false, camActive = false;
function esc(s) { return String(s == null ? "" : s); }
function shortId(id) { id = esc(id || "?"); return id.charAt(0) === "@" ? id.slice(1, 7) : id.slice(0, 6); }
function showMsg(m, live) {
var key = m.mid ? "m" + m.mid : "s" + (m.seq || Math.random());
if (seen[key]) return;
seen[key] = 1;
var li = document.createElement("li");
li.className = "karvan-msg" + (m.from === fromLabel ? " karvan-msg-self" : "") + (live ? " karvan-msg-live" : "");
var f = document.createElement("span"); f.className = "km-from"; f.textContent = shortId(m.from);
var t = document.createElement("span"); t.className = "km-text"; t.textContent = esc(m.text);
li.appendChild(f); li.appendChild(t);
listEl.appendChild(li);
listEl.scrollTop = listEl.scrollHeight;
}
// --- relay por servidor (siempre activo) ---
function pollMsgs() {
fetch("/karvan/" + roomId + "/msgs?since=" + lastSeq, { headers: { Accept: "application/json" } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
if (!d || !d.messages) return;
d.messages.forEach(function (m) { if (m.seq > lastSeq) lastSeq = m.seq; showMsg(m, false); });
})
.catch(function () {});
}
function sendMessage(text) {
if (!text) return;
var mid = peerId + "-" + Date.now().toString(36) + Math.random().toString(16).slice(2, 6);
var m = { mid: mid, from: fromLabel, text: text };
showMsg(m, false); // optimista
broadcastDC({ kind: "chat", mid: mid, from: fromLabel, text: text }); // instantáneo a los pares
fetch("/karvan/" + roomId + "/msg", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ from: fromLabel, text: text, mid: mid })
}).catch(function () {});
}
if (formEl) {
formEl.addEventListener("submit", function (e) {
e.preventDefault();
var v = (textEl.value || "").trim();
if (!v) return;
textEl.value = "";
sendMessage(v);
});
}
// --- señalización (mailbox RAM del servidor) ---
function sendSig(to, payload) {
fetch("/karvan/" + roomId + "/signal", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ from: peerId, to: to || "", payload: payload })
}).catch(function () {});
}
function announce() { sendSig("", { kind: "hello" }); }
function pollSignals() {
fetch("/karvan/" + roomId + "/signal?for=" + peerId + "&since=" + lastSig, { headers: { Accept: "application/json" } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
if (!d) return;
(d.signals || []).forEach(function (s) {
if (s.seq > lastSig) lastSig = s.seq;
onSignal(s.from, s.payload);
});
(d.members || []).forEach(function (mid2) { if (mid2 && mid2 !== peerId) ensurePeer(mid2); });
})
.catch(function () {});
}
// --- WebRTC (data-channel, perfect negotiation) ---
var RTC = window.RTCPeerConnection || window.webkitRTCPeerConnection;
// Sin servidores ICE por defecto: solo candidatos host (LAN y NAT amable).
// Un STUN de terceros aprende la IP publica y el momento de cada llamada, asi que
// no se pone ninguno de fabrica. Si el nodo ofrece TURN propio, /karvan/ice lo da.
var rtcConfig = { iceServers: [] };
function setLive(on) {
if (!liveTxt) return;
liveTxt.textContent = on ? (liveWrap && liveWrap.getAttribute("data-txt-p2p") || "🔒 P2P (WebRTC)") : (liveWrap && liveWrap.getAttribute("data-txt-relay") || "server relay");
if (liveWrap) liveWrap.className = "karvan-live" + (on ? " on" : "");
}
function broadcastDC(obj) {
var s = JSON.stringify(obj);
for (var k in peers) {
var dc = peers[k] && peers[k].dc;
if (dc && dc.readyState === "open") { try { dc.send(s); } catch (e) {} }
}
}
function attachDC(peer, dc) {
peer.dc = dc;
dc.onopen = function () { setLive(true); };
dc.onmessage = function (e) {
try { var m = JSON.parse(e.data); if (m && m.kind === "chat") showMsg(m, true); } catch (x) {}
};
dc.onclose = function () {
var any = false;
for (var k in peers) if (peers[k].dc && peers[k].dc.readyState === "open") any = true;
if (!any) setLive(false);
};
}
function ensurePeer(remoteId) {
if (peers[remoteId] || !RTC) return peers[remoteId];
var polite = peerId > remoteId; // el id mayor es "polite"
var pc = new RTC(rtcConfig);
var peer = { pc: pc, dc: null, polite: polite, makingOffer: false, ignoreOffer: false, vid: null, remoteStream: null };
peers[remoteId] = peer;
if (!polite) { attachDC(peer, pc.createDataChannel("chat")); } // el impolite inicia el canal
pc.ondatachannel = function (e) { attachDC(peer, e.channel); };
// media: publicar los tracks locales que ya tengamos (si la llamada está activa) + recibir los del par
if (localStream) { localStream.getTracks().forEach(function (t) { try { pc.addTrack(t, localStream); } catch (e) {} }); }
pc.ontrack = function (e) { attachRemote(peer, remoteId, e); };
pc.onicecandidate = function (e) { if (e.candidate) sendSig(remoteId, { kind: "ice", candidate: e.candidate }); };
pc.onnegotiationneeded = function () {
peer.makingOffer = true;
Promise.resolve()
.then(function () { return pc.setLocalDescription(); })
.then(function () { sendSig(remoteId, { kind: "desc", desc: pc.localDescription }); })
.catch(function () {})
.then(function () { peer.makingOffer = false; });
};
pc.onconnectionstatechange = function () {
var st = pc.connectionState;
if (st === "failed" || st === "closed") { try { pc.close(); } catch (e) {} removeRemoteVideo(peer); delete peers[remoteId]; }
};
return peer;
}
// --- media / videollamada (Fase 1) ---
function setCallActive(on) { if (callRoot) callRoot.className = "karvan-call" + (on ? " active" : "") + (camActive ? " has-cam" : ""); }
function setStatus(msg) { if (statusEl) statusEl.textContent = msg || ""; }
// textos traducidos: los pone la vista en data-txt-* de #karvan-call
function txt(k, def) { return (callRoot && callRoot.getAttribute("data-txt-" + k)) || def; }
function statusText() { var p = []; if (micActive) p.push(txt("mic", "Voice on")); if (camActive) p.push(txt("cam", "Camera on")); return p.length ? p.join(" · ") : ""; }
function updateBtns() {
if (btnMic) btnMic.className = "kc-btn" + (micActive ? " on" : "");
if (btnCam) btnCam.className = "kc-btn" + (camActive ? " on" : "");
// sin camara no se ensena el video propio: era un recuadro gris con el icono de play
if (callRoot) callRoot.classList.toggle("has-cam", !!camActive);
}
function attachRemote(peer, remoteId, e) {
if (!remoteEl) return;
if (!peer.vid) {
peer.vid = document.createElement("video");
peer.vid.autoplay = true; peer.vid.playsInline = true; peer.vid.className = "kc-video kc-remote-vid";
peer.vid.setAttribute("data-peer", remoteId);
remoteEl.appendChild(peer.vid);
}
if (e.streams && e.streams[0]) peer.vid.srcObject = e.streams[0];
else { if (!peer.remoteStream) peer.remoteStream = new MediaStream(); peer.remoteStream.addTrack(e.track); peer.vid.srcObject = peer.remoteStream; }
setCallActive(true);
}
function removeRemoteVideo(peer) {
if (peer && peer.vid && peer.vid.parentNode) { try { peer.vid.parentNode.removeChild(peer.vid); } catch (_) {} peer.vid = null; }
}
function addLocalTrack(track) {
if (!localStream) return;
localStream.addTrack(track);
for (var k in peers) {
var pc = peers[k].pc, senders = pc.getSenders(), existing = null;
for (var s = 0; s < senders.length; s++) if (senders[s].track && senders[s].track.kind === track.kind) { existing = senders[s]; break; }
if (existing) { try { existing.replaceTrack(track); } catch (_) {} } // swap (cambio de cámara) — sin renegociar
else { try { pc.addTrack(track, localStream); } catch (_) {} } // kind nuevo — dispara onnegotiationneeded
}
}
function removeLocalKind(kind) {
if (!localStream) return;
localStream.getTracks().filter(function (t) { return t.kind === kind; }).forEach(function (t) { t.stop(); localStream.removeTrack(t); });
for (var k in peers) {
var senders = peers[k].pc.getSenders();
for (var s = 0; s < senders.length; s++) if (senders[s].track && senders[s].track.kind === kind) { try { senders[s].replaceTrack(null); } catch (_) {} }
}
}
function onMediaErr(kind, x) {
var name = (x && x.name) || "", msg = (x && x.message) || String(x);
if (name === "NotAllowedError" || /denied|permission/i.test(msg)) setStatus(kind === "video" ? txt("nocam", "No camera permission: the call goes on with voice only.") : txt("nomic", "No microphone permission. You can allow it in the phone settings; the chat still works."));
else if (name === "NotFoundError") setStatus("⚠ No hay " + (kind === "audio" ? "micrófono" : "cámara") + ".");
else if (name === "NotReadableError") setStatus("⚠ Dispositivo ocupado.");
else setStatus("⚠ " + (name || "error") + ": " + msg);
}
function getMedia(kind) {
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { setStatus("⚠ Cámara/micro no disponibles aquí."); return; }
var c = (kind === "audio") ? { audio: true, video: false } : { audio: false, video: true };
setStatus((kind === "audio" ? "🎤" : "🎥") + " pidiendo permiso…");
navigator.mediaDevices.getUserMedia(c).then(function (s) {
s.getTracks().forEach(addLocalTrack);
if (kind === "audio") micActive = true; else camActive = true;
setCallActive(true); updateBtns(); setStatus(statusText());
}).catch(function (x) { onMediaErr(kind, x); });
}
// Llamar empieza solo con voz: pedir la camara de entrada hacia que, si se
// negaba, no arrancara nada aunque el micro estuviera permitido. El video se
// anade despues con su boton
function startCall() {
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { setStatus("⚠ Cámara/micro no disponibles aquí."); return; }
if (micActive) return;
getMedia("audio");
}
function hangUp() {
if (localStream) localStream.getTracks().forEach(function (t) { t.stop(); localStream.removeTrack(t); });
for (var k in peers) { var sn = peers[k].pc.getSenders(); for (var s = 0; s < sn.length; s++) { try { sn[s].replaceTrack(null); } catch (_) {} } }
micActive = false; camActive = false; setCallActive(false); updateBtns(); setStatus("");
}
if (btnStart) btnStart.addEventListener("click", startCall);
if (btnMic) btnMic.addEventListener("click", function () { if (micActive) { removeLocalKind("audio"); micActive = false; updateBtns(); setStatus(statusText()); } else getMedia("audio"); });
if (btnCam) btnCam.addEventListener("click", function () { if (camActive) { removeLocalKind("video"); camActive = false; updateBtns(); setStatus(statusText()); } else getMedia("video"); });
if (btnStop) btnStop.addEventListener("click", hangUp);
function onSignal(fromRemote, payload) {
if (!RTC || !payload) return;
var peer = ensurePeer(fromRemote);
if (!peer) return;
var pc = peer.pc;
if (payload.kind === "desc") {
var desc = payload.desc;
if (!desc) return; // señal malformada (sin desc) — no reventar el lote de sondeo
var collision = desc.type === "offer" && (peer.makingOffer || pc.signalingState !== "stable");
peer.ignoreOffer = !peer.polite && collision;
if (peer.ignoreOffer) return;
Promise.resolve()
.then(function () { return pc.setRemoteDescription(desc); })
.then(function () {
if (desc.type === "offer") {
return pc.setLocalDescription().then(function () {
sendSig(fromRemote, { kind: "desc", desc: pc.localDescription });
});
}
})
.catch(function () {});
} else if (payload.kind === "ice") {
Promise.resolve().then(function () { return pc.addIceCandidate(payload.candidate); }).catch(function () {});
}
}
// --- arranque ---
showMsgInitialSeq();
function showMsgInitialSeq() {
// registrar los mensajes ya renderizados por el servidor para no duplicarlos
var items = listEl ? listEl.querySelectorAll(".karvan-msg") : [];
for (var i = 0; i < items.length; i++) {
var seq = parseInt(items[i].getAttribute("data-seq"), 10) || 0;
var mid = items[i].getAttribute("data-mid") || "";
if (seq > lastSeq) lastSeq = seq;
seen[mid ? "m" + mid : "s" + seq] = 1;
}
}
pollMsgs();
setInterval(pollMsgs, 2000);
if (RTC) {
setLive(false);
// La configuracion ICE tiene que estar resuelta antes de la primera
// RTCPeerConnection: no se puede cambiar de forma fiable una ya creada.
var iceReady = fetch("/karvan/ice", { headers: { accept: "application/json" } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
if (d && Array.isArray(d.iceServers) && d.iceServers.length) {
rtcConfig = { iceServers: d.iceServers };
if (d.relayOnly) rtcConfig.iceTransportPolicy = "relay";
}
})
.catch(function () {});
var iceTimeout = new Promise(function (res) { setTimeout(res, 2000); });
Promise.race([iceReady, iceTimeout]).then(function () {
announce();
setInterval(announce, 8000);
pollSignals();
setInterval(pollSignals, 2000);
});
}
})();

View file

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

View file

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

View file

@ -11,30 +11,20 @@ const wf = require('./workflows_model');
const FORK_WORKFLOWS = [ const FORK_WORKFLOWS = [
{ {
// una acampada: hablar, escribir juntos, compartir lo que pasa y decidir // una acampada: hablar (chats, telefono y salas de voz del dev), escribir juntos,
// (tribus = comisiones, parlamento y votaciones para la asamblea) // compartir lo que pasa y decidir (tribus = comisiones, parlamento y votaciones)
key: 'acampada', key: 'acampada',
theme: 'Acampada', theme: 'Acampada',
homePage: 'activity', 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'] '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; let done = false;
const register = () => { const register = () => {
if (done) return; if (done) return;
done = true; done = true;
addKarvan();
for (const w of FORK_WORKFLOWS) { for (const w of FORK_WORKFLOWS) {
if (wf.WORKFLOWS.some(x => x.key === w.key)) continue; if (wf.WORKFLOWS.some(x => x.key === w.key)) continue;
wf.WORKFLOWS.push(w); wf.WORKFLOWS.push(w);

View file

@ -1,198 +0,0 @@
"use strict";
// Módulo KARVAN (self-contained) para Oasis Mobile — inspirado en karvan-protocol
// (packages/core/addons/ephemeral.js): salas EFÍMERAS en RAM que se AUTODESTRUYEN por TTL.
// - Nada se escribe a disco. Los mensajes viven solo en memoria y desaparecen con la sala.
// - TTL de inactividad (idle) que se resetea con cada actividad + TTL absoluto que nunca se resetea.
// - Mailbox RAM para señalización WebRTC (data-channel) entre pares.
// No depende de @karvan/core ni de cripto nativa: es una capa local sobre el backend de Oasis.
const crypto = require("crypto");
const DEFAULT_IDLE_TTL_MS = 30 * 60 * 1000; // 30 min sin actividad -> autodestrucción
const DEFAULT_ABS_TTL_MS = 2 * 60 * 60 * 1000; // 2 h absoluto (tope)
const MAX_ABS_TTL_MS = 24 * 60 * 60 * 1000; // nunca más de 24 h
const MAX_MSGS = 250; // anillo: solo los últimos N en RAM
const MAX_SIGNALS = 500;
const MAX_ROOMS = 100;
const MAX_MEMBERS = 50; // tope de miembros por sala (evita crecimiento sin límite del Set)
const MAX_SIGNAL_BYTES = 16384; // SDP/ICE son pequeños; rechaza payloads gigantes (anti-DoS de memoria)
module.exports = ({ idleTtlMs = DEFAULT_IDLE_TTL_MS, absTtlMs = DEFAULT_ABS_TTL_MS } = {}) => {
const rooms = new Map(); // id -> room
const publicRoom = (r) => ({
id: r.id, title: r.title, createdAt: r.createdAt, expiresAt: r.expiresAt,
count: r.messages.length, members: r.members.size,
});
const resetIdle = (room) => {
if (room.idleTimer) clearTimeout(room.idleTimer);
room.idleTimer = setTimeout(() => destroy(room.id), idleTtlMs);
if (room.idleTimer.unref) room.idleTimer.unref();
};
const destroy = (id) => {
const room = rooms.get(id);
if (!room) return;
clearTimeout(room.idleTimer);
clearTimeout(room.absTimer);
// AUTODESTRUCCIÓN: vaciar todo el material en RAM
room.messages.length = 0;
room.signals.length = 0;
room.members.clear();
room.remoteFeeds.clear();
rooms.delete(id);
};
const createRoom = ({ title = "", ttlMinutes } = {}) => {
if (rooms.size >= MAX_ROOMS) {
// desalojar la más antigua para no crecer sin límite
const oldest = [...rooms.values()].sort((a, b) => a.createdAt - b.createdAt)[0];
if (oldest) destroy(oldest.id);
}
const id = crypto.randomBytes(9).toString("hex"); // 18 hex
const createdAt = Date.now();
let absMs = absTtlMs;
const t = parseInt(ttlMinutes, 10);
if (Number.isFinite(t) && t > 0) absMs = Math.min(t * 60000, MAX_ABS_TTL_MS);
const room = {
id,
title: String(title || "").slice(0, 80),
createdAt,
expiresAt: createdAt + absMs,
messages: [],
signals: [],
members: new Set(),
remoteFeeds: new Set(), // feeds SSB de otros nodos con esta sala (para relay cross-device por privados)
seq: 0,
sigSeq: 0,
idleTimer: null,
absTimer: null,
};
rooms.set(id, room);
room.absTimer = setTimeout(() => destroy(id), absMs);
if (room.absTimer.unref) room.absTimer.unref();
resetIdle(room);
return publicRoom(room);
};
// Fase 2 (cross-device): registra el feed SSB de otro participante (de una invitación) para relayarle.
const addRemoteFeed = (id, feed) => {
const room = rooms.get(id);
if (!room || typeof feed !== "string" || !/^@[A-Za-z0-9+/=]{20,}\.ed25519$/.test(feed)) return false;
if (room.remoteFeeds.has(feed) || room.remoteFeeds.size < MAX_MEMBERS) { room.remoteFeeds.add(feed); return true; }
return false;
};
const getRemoteFeeds = (id) => { const r = rooms.get(id); return r ? [...r.remoteFeeds] : []; };
// ¿hay ALGUNA sala con feeds remotos? (para no montar el relay si no hace falta)
const anyRemoteRooms = () => { for (const r of rooms.values()) if (r.remoteFeeds.size) return true; return false; };
// Fase 2 (invitar): unirse a una sala por su id creando un ESPEJO local (desde un enlace/invitación).
// El id es la "capacidad": quien lo tiene puede unirse. Aún NO conecta con la sala del otro nodo
// (eso es la señalización cross-device, pendiente); crea la sala local para poder abrir el enlace.
const adoptRoom = ({ id, title = "", ttlMinutes } = {}) => {
if (!id || typeof id !== "string" || !/^[0-9a-f]{6,64}$/.test(id)) return null;
const existing = rooms.get(id);
if (existing) return publicRoom(existing);
if (rooms.size >= MAX_ROOMS) {
const oldest = [...rooms.values()].sort((a, b) => a.createdAt - b.createdAt)[0];
if (oldest) destroy(oldest.id);
}
const createdAt = Date.now();
let absMs = absTtlMs;
const t = parseInt(ttlMinutes, 10);
if (Number.isFinite(t) && t > 0) absMs = Math.min(t * 60000, MAX_ABS_TTL_MS);
const room = {
id, title: String(title || "").slice(0, 80), createdAt, expiresAt: createdAt + absMs,
messages: [], signals: [], members: new Set(), remoteFeeds: new Set(), seq: 0, sigSeq: 0, idleTimer: null, absTimer: null,
};
rooms.set(id, room);
room.absTimer = setTimeout(() => destroy(id), absMs);
if (room.absTimer.unref) room.absTimer.unref();
resetIdle(room);
return publicRoom(room);
};
const listRooms = () =>
[...rooms.values()].map(publicRoom).sort((a, b) => b.createdAt - a.createdAt);
const getRoom = (id) => {
const r = rooms.get(id);
return r ? publicRoom(r) : null;
};
const postMessage = (id, { from = "?", text = "", mid = "" } = {}) => {
const room = rooms.get(id);
if (!room) return null;
const clean = String(text || "").slice(0, 2000);
if (!clean) return null;
const msg = {
seq: ++room.seq,
mid: String(mid || "").slice(0, 40),
from: String(from || "?").slice(0, 80),
text: clean,
ts: Date.now(),
};
room.messages.push(msg);
if (room.messages.length > MAX_MSGS) room.messages.shift();
if (room.members.has(msg.from) || room.members.size < MAX_MEMBERS) room.members.add(msg.from);
resetIdle(room);
return msg;
};
const listMessages = (id, since = 0) => {
const room = rooms.get(id);
if (!room) return null;
const s = parseInt(since, 10) || 0;
return room.messages.filter((m) => m.seq > s);
};
// --- señalización WebRTC (mailbox RAM) ---
const postSignal = (id, { from = "", to = "", payload = null } = {}) => {
const room = rooms.get(id);
if (!room) return null;
if (payload != null) { // rechaza payloads gigantes (SDP/ICE son pequeños)
try { if (JSON.stringify(payload).length > MAX_SIGNAL_BYTES) return null; }
catch (e) { return null; }
}
const sig = {
seq: ++room.sigSeq,
from: String(from || "").slice(0, 80),
to: String(to || "").slice(0, 80),
payload,
ts: Date.now(),
};
room.signals.push(sig);
if (room.signals.length > MAX_SIGNALS) room.signals.shift();
if (sig.from && (room.members.has(sig.from) || room.members.size < MAX_MEMBERS)) room.members.add(sig.from);
resetIdle(room);
return sig;
};
const getSignals = (id, { forId = "", since = 0 } = {}) => {
const room = rooms.get(id);
if (!room) return null;
const s = parseInt(since, 10) || 0;
return {
signals: room.signals.filter(
(x) => x.seq > s && x.from !== forId && (!x.to || x.to === forId)
),
members: [...room.members].filter((m) => m !== forId),
};
};
return {
createRoom,
adoptRoom,
addRemoteFeed,
getRemoteFeeds,
anyRemoteRooms,
listRooms,
getRoom,
postMessage,
listMessages,
postSignal,
getSignals,
_destroy: destroy,
};
};

View file

@ -80,7 +80,7 @@ const Server = SecretStack({ caps })
.use(require('ssb-unix-socket')) .use(require('ssb-unix-socket'))
.use(require('ssb-no-auth')) .use(require('ssb-no-auth'))
.use(require('./snapshot_plugin')) .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')); .use(require('./network_pause'));
if (!config.pub) { if (!config.pub) {

View file

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

View file

@ -1,134 +0,0 @@
"use strict";
// Vista del módulo KARVAN (mensajes temporales + WebRTC). El chat va por relay servidor (RAM,
// autodestrucción) y, si conectan pares, por data-channel WebRTC (instantáneo). El <script> solo
// se carga en la página de sala; el WebRTC usa data-channel (sin cámara/mic => sin permisos extra).
const { div, section, h2, h3, p, a, form, input, button, span, ul, li, script, label, video } =
require("../server/node_modules/hyperaxe");
const { template, i18n } = require("./main_views");
const { iconNode } = require("./mobile_icons");
// boton de la barra de llamada: icono y su nombre debajo (antes 🎤 🎥 ⛔ sin texto)
const callBtn = (id, cls, icon, text) =>
button({ type: "button", class: "kc-btn " + cls, id }, iconNode("kc-ico", icon), span({ class: "kc-lbl" }, text));
const fmtExpires = (expiresAt) => {
const mins = Math.max(0, Math.round((expiresAt - Date.now()) / 60000));
if (mins >= 60) return `${Math.floor(mins / 60)}h ${mins % 60}m`;
return `${mins}m`;
};
const shortId = (id) => {
id = String(id || "?");
return id.startsWith("@") ? id.slice(1, 7) : id.slice(0, 6);
};
// los propios se marcan ya en el servidor: karvan.js solo marca los que llegan
// despues, y al recargar la sala tus mensajes salian como de otra persona
const renderMsg = (m, selfId) =>
li({ class: "karvan-msg" + (selfId && m.from === selfId ? " karvan-msg-self" : ""), "data-seq": String(m.seq), "data-mid": m.mid || "" },
span({ class: "km-from" }, shortId(m.from)),
span({ class: "km-text" }, m.text)
);
const ttlOpt = (val, lbl, checked) =>
label({ class: "karvan-ttl-opt" },
input(Object.assign({ type: "radio", name: "ttl", value: val }, checked ? { checked: "checked" } : {})),
span(lbl)
);
exports.karvanView = (rooms) =>
template(
i18n.karvanTitle || "Karvan",
section({ class: "karvan-intro" },
h2("Karvan · " + (i18n.karvanEphemeral || "Ephemeral rooms")),
p({ class: "karvan-sub" },
i18n.karvanBlurb ||
"Temporary rooms that self-destruct. Messages live only in memory — never saved — and vanish when the room expires. Real-time over WebRTC when a peer connects.")
),
section({ class: "karvan-create" },
form({ method: "POST", action: "/karvan/create" },
input({ type: "text", name: "title", placeholder: i18n.karvanRoomName || "Room name (optional)", maxlength: "80", autocomplete: "off" }),
div({ class: "karvan-ttl" },
span({ class: "karvan-ttl-lbl" }, i18n.karvanTtl || "Self-destruct in:"),
ttlOpt("30", "30m", true), ttlOpt("120", "2h", false), ttlOpt("480", "8h", false)
),
button({ type: "submit" }, i18n.karvanCreate || "Create ephemeral room")
)
),
section({ class: "karvan-list" },
h3(i18n.karvanActive || "Active rooms"),
rooms && rooms.length
? ul({ class: "karvan-rooms" },
...rooms.map((r) =>
li(
a({ href: "/karvan/" + r.id, class: "karvan-room-item" },
span({ class: "kr-title" }, r.title || "room " + r.id.slice(0, 6)),
span({ class: "kr-meta" }, `${r.count} · ⏳ ${fmtExpires(r.expiresAt)}`)
)
)
)
)
: p({ class: "karvan-empty" }, i18n.karvanNone || "No active rooms. Create one — it disappears on its own.")
)
);
exports.karvanRoomView = (room, messages, params = {}) =>
template(
"Karvan · " + (room.title || room.id.slice(0, 6)),
section({ class: "karvan-room", "data-room": room.id, "data-self": params.selfId || "" },
div({ class: "karvan-room-head" },
a({ href: "/karvan", class: "karvan-back" }, "‹ " + (i18n.karvanRooms || "Rooms")),
span({ class: "karvan-room-title" }, room.title || "room " + room.id.slice(0, 6)),
span({ class: "karvan-expire", id: "karvan-expire" }, "⏳ " + fmtExpires(room.expiresAt))
),
div({ class: "karvan-live", id: "karvan-live",
"data-txt-relay": i18n.karvanRelayHuman || "server relay", "data-txt-p2p": i18n.karvanP2pHuman || "🔒 P2P (WebRTC)" },
span({ class: "kl-dot" }),
span({ class: "kl-txt", id: "karvan-live-txt" }, i18n.karvanRelayHuman || i18n.karvanRelay || "server relay")
),
// Fase 1: panel de videollamada (media P2P sobre el mismo WebRTC del chat). La galería
// aparece al iniciar; los botones cablean cámara/micro. En el móvil actual getUserMedia
// fallará hasta que el wrapper conceda permisos (Fases 2/3) → se avisa en #kc-status.
div({ class: "karvan-call", id: "karvan-call",
"data-txt-mic": i18n.karvanVoiceOn || "Voice on", "data-txt-cam": i18n.karvanCamOn || "Camera on",
"data-txt-nocam": i18n.karvanNoCam || "No camera permission: the call goes on with voice only.",
"data-txt-nomic": i18n.karvanNoMic || "No microphone permission. You can allow it in the phone settings; the chat still works." },
div({ class: "karvan-videos", id: "karvan-videos" },
video({ id: "karvan-local", class: "kc-video kc-local", muted: "muted", autoplay: "autoplay", playsinline: "playsinline" }),
div({ class: "kc-remote", id: "karvan-remote" })
),
div({ class: "karvan-call-bar" },
callBtn("kc-start", "kc-btn-start", "phone", i18n.karvanCall || "Call"),
callBtn("kc-mic", "", "mic", i18n.karvanMic || "Mic"),
callBtn("kc-cam", "", "camera", i18n.karvanCam || "Camera"),
callBtn("kc-stop", "kc-btn-stop", "hangup", i18n.karvanHangUp || "Hang up")
),
div({ class: "kc-status", id: "kc-status" }, "")
),
ul({ class: "karvan-msgs", id: "karvan-msgs" }, ...(messages || []).map(m => renderMsg(m, params.selfId))),
form({ class: "karvan-compose", method: "POST", action: "/karvan/" + room.id + "/msg", id: "karvan-form" },
input({ type: "hidden", name: "from", value: params.selfId || "" }),
input({ type: "text", name: "text", id: "karvan-text", placeholder: i18n.karvanSay || "Temporary message…", maxlength: "2000", autocomplete: "off" }),
button({ type: "submit" }, i18n.karvanSend || "Send")
),
// Fase 2: invitar a un contacto por su feed id → le llega al inbox un enlace a esta sala
form({ class: "karvan-invite", method: "POST", action: "/karvan/" + room.id + "/invite" },
label({ class: "karvan-invite-lbl", for: "karvan-invite-to" }, i18n.karvanInviteLabel || "Invite someone"),
input({ type: "text", name: "to", id: "karvan-invite-to", placeholder: i18n.karvanInvitePlaceholder || "Invite a contact by @feed-id…", autocomplete: "off", spellcheck: "false" }),
p({ class: "form-hint" }, i18n.karvanInviteHint || "Paste their @id: it is on their profile, under their name."),
button({ type: "submit" }, i18n.karvanInvite || "Invite")
),
params.invite === "ok"
? p({ class: "karvan-invite-msg ok" }, i18n.karvanInviteSent || "✓ Invitation sent to their inbox.")
: params.invite === "bad"
? p({ class: "karvan-invite-msg err" }, i18n.karvanInviteBad || "✗ That is not a valid @feed-id.")
: params.invite === "fail"
? p({ class: "karvan-invite-msg err" }, i18n.karvanInviteFail || "✗ Could not send the invitation.")
: ""
),
script({ src: "/js/karvan.js", defer: "defer" })
// sin JavaScript no hay data-channel ni sondeo, asi que la sala se refresca sola.
// Va en <noscript> para que con JS no recargue encima del chat en vivo.
// Mismo recurso que usa upstream en indexing_view.
).replace('</head>', '<noscript><meta http-equiv="refresh" content="10"></noscript></head>');

View file

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

View file

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

View file

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

View file

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

View file

@ -1,183 +0,0 @@
"use strict";
// Tests del módulo KARVAN (salas efímeras + mensajes temporales + señalización WebRTC).
// Sin framework (Oasis no tiene): `node test/karvan_model.test.js` o `npm test`.
// Cubre: creación/listado de salas, mensajes efímeros, buffer circular, truncado, validación,
// AUTODESTRUCCIÓN por TTL (idle y absoluto), desalojo por límite, y el mailbox de señalización.
const assert = require("assert");
const path = require("path");
const makeModel = require(path.join(__dirname, "..", "src", "models", "karvan_model.js"));
let passed = 0, failed = 0;
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
async function test(name, fn) {
try { await fn(); passed++; console.log("ok - " + name); }
catch (e) { failed++; console.log("FAIL - " + name + "\n " + (e && e.message)); }
}
(async () => {
await test("createRoom: id de 18 hex, count 0, expira en el futuro", () => {
const m = makeModel();
const r = m.createRoom({ title: "hola" });
assert.strictEqual(typeof r.id, "string");
assert.strictEqual(r.id.length, 18);
assert.strictEqual(r.title, "hola");
assert.strictEqual(r.count, 0);
assert.ok(r.expiresAt > r.createdAt);
});
await test("postMessage + listMessages: seq incremental y contenido", () => {
const m = makeModel();
const r = m.createRoom({});
const msg = m.postMessage(r.id, { from: "alice", text: "hi" });
assert.strictEqual(msg.seq, 1);
assert.strictEqual(msg.from, "alice");
assert.strictEqual(msg.text, "hi");
const list = m.listMessages(r.id, 0);
assert.strictEqual(list.length, 1);
assert.strictEqual(list[0].text, "hi");
});
await test("listMessages(since) filtra por seq", () => {
const m = makeModel();
const r = m.createRoom({});
m.postMessage(r.id, { from: "a", text: "1" });
m.postMessage(r.id, { from: "a", text: "2" });
assert.strictEqual(m.listMessages(r.id, 1).length, 1); // solo seq > 1
assert.strictEqual(m.listMessages(r.id, 0).length, 2);
});
await test("rechaza texto vacío y sala inexistente (devuelve null)", () => {
const m = makeModel();
const r = m.createRoom({});
assert.strictEqual(m.postMessage(r.id, { from: "a", text: "" }), null);
assert.strictEqual(m.postMessage("noexiste", { from: "a", text: "x" }), null);
assert.strictEqual(m.listMessages("noexiste", 0), null);
});
await test("trunca texto a 2000 y 'from' a 80", () => {
const m = makeModel();
const r = m.createRoom({});
const big = "x".repeat(3000);
const msg = m.postMessage(r.id, { from: "y".repeat(200), text: big });
assert.strictEqual(msg.text.length, 2000);
assert.strictEqual(msg.from.length, 80);
});
await test("buffer circular: máx 250 en RAM, seq sigue creciendo", () => {
const m = makeModel();
const r = m.createRoom({});
for (let i = 1; i <= 260; i++) m.postMessage(r.id, { from: "a", text: "m" + i });
const all = m.listMessages(r.id, 0);
assert.strictEqual(all.length, 250); // solo los últimos 250
assert.strictEqual(all[all.length - 1].seq, 260); // el seq no se reinicia
assert.strictEqual(all[0].seq, 11); // los 10 primeros se descartaron
});
await test("ttlMinutes fija expiresAt y se capa a 24h", () => {
const m = makeModel();
const r5 = m.createRoom({ ttlMinutes: 5 });
const dur = r5.expiresAt - r5.createdAt;
assert.ok(Math.abs(dur - 5 * 60000) < 50);
const rBig = m.createRoom({ ttlMinutes: 999999 });
assert.ok(rBig.expiresAt - rBig.createdAt <= 24 * 60 * 60 * 1000);
});
await test("señalización: getSignals excluye los propios y respeta 'to'", () => {
const m = makeModel();
const r = m.createRoom({});
m.postSignal(r.id, { from: "A", to: "", payload: { k: "hello" } }); // broadcast de A
m.postSignal(r.id, { from: "B", to: "A", payload: { k: "desc" } }); // dirigido a A
const forA = m.getSignals(r.id, { forId: "A", since: 0 });
// A no debe ver su propio broadcast; sí el dirigido a A
assert.strictEqual(forA.signals.length, 1);
assert.strictEqual(forA.signals[0].from, "B");
// los miembros incluyen a B pero no a A (uno mismo)
assert.ok(forA.members.indexOf("B") !== -1);
assert.ok(forA.members.indexOf("A") === -1);
// para B: ve el broadcast de A pero no el dirigido a A
const forB = m.getSignals(r.id, { forId: "B", since: 0 });
assert.strictEqual(forB.signals.length, 1);
assert.strictEqual(forB.signals[0].payload.k, "hello");
});
await test("AUTODESTRUCCIÓN por TTL de inactividad (idle)", async () => {
const m = makeModel({ idleTtlMs: 40, absTtlMs: 100000 });
const r = m.createRoom({});
assert.ok(m.getRoom(r.id)); // existe
await wait(90); // > idle (40ms) sin actividad
assert.strictEqual(m.getRoom(r.id), null); // se autodestruyó
assert.strictEqual(m.listMessages(r.id, 0), null);
});
await test("la actividad RESETEA el idle (no se destruye si hay tráfico)", async () => {
const m = makeModel({ idleTtlMs: 60, absTtlMs: 100000 });
const r = m.createRoom({});
await wait(40); m.postMessage(r.id, { from: "a", text: "keepalive" }); // resetea a los 40ms
await wait(40); // 80ms totales pero el idle se reseteó -> sigue viva
assert.ok(m.getRoom(r.id), "debe seguir viva tras actividad");
await wait(90); // ahora sí, sin actividad > idle
assert.strictEqual(m.getRoom(r.id), null);
});
await test("AUTODESTRUCCIÓN por TTL absoluto (no lo resetea la actividad)", async () => {
const m = makeModel({ idleTtlMs: 100000, absTtlMs: 60 });
const r = m.createRoom({});
await wait(30); m.postMessage(r.id, { from: "a", text: "hola" }); // actividad, NO resetea abs
await wait(60); // 90ms > abs (60ms)
assert.strictEqual(m.getRoom(r.id), null);
});
await test("desalojo por MAX_ROOMS (100): la más antigua se descarta", () => {
const m = makeModel();
const ids = [];
for (let i = 0; i < 101; i++) ids.push(m.createRoom({ title: "r" + i }).id);
assert.strictEqual(m.listRooms().length, 100); // capado a 100
assert.strictEqual(m.getRoom(ids[0]), null); // la primera (más antigua) fuera
assert.ok(m.getRoom(ids[100])); // la última sigue
});
await test("adoptRoom: espejo local con id dado, idempotente, rechaza id inválido", () => {
const m = makeModel();
const id = "abc123def456abc789";
const r = m.adoptRoom({ id, title: "espejo" });
assert.strictEqual(r.id, id);
assert.strictEqual(r.title, "espejo");
assert.ok(m.getRoom(id));
const again = m.adoptRoom({ id, title: "otro" }); // idempotente
assert.strictEqual(again.id, id);
assert.strictEqual(m.listRooms().filter((x) => x.id === id).length, 1);
assert.strictEqual(m.adoptRoom({ id: "NO-hex!" }), null); // id inválido
assert.ok(m.postMessage(id, { from: "a", text: "hola" }), "se puede postear en la adoptada");
});
await test("cap de miembros: no crece más allá de MAX_MEMBERS (50)", () => {
const m = makeModel();
const r = m.createRoom({});
for (let i = 0; i < 80; i++) m.postSignal(r.id, { from: "peer" + i, to: "", payload: { k: "hi" } });
const g = m.getSignals(r.id, { forId: "nadie", since: 0 });
assert.ok(g.members.length <= 50, "miembros capados a 50, got " + g.members.length);
});
await test("rechaza payload de señal gigante (>16KB), acepta pequeño", () => {
const m = makeModel();
const r = m.createRoom({});
assert.strictEqual(m.postSignal(r.id, { from: "a", to: "b", payload: { blob: "x".repeat(20000) } }), null);
assert.ok(m.postSignal(r.id, { from: "a", to: "b", payload: { sdp: "small" } }), "payload pequeño pasa");
});
await test("feeds remotos: addRemoteFeed / getRemoteFeeds / anyRemoteRooms + validación", () => {
const m = makeModel();
const r = m.createRoom({});
assert.strictEqual(m.anyRemoteRooms(), false);
const feed = "@" + "A".repeat(43) + "=.ed25519";
assert.strictEqual(m.addRemoteFeed(r.id, feed), true);
assert.deepStrictEqual(m.getRemoteFeeds(r.id), [feed]);
assert.strictEqual(m.anyRemoteRooms(), true);
assert.strictEqual(m.addRemoteFeed(r.id, "no-es-feed"), false); // formato inválido
assert.strictEqual(m.addRemoteFeed("noexiste", feed), false); // sala inexistente
});
console.log("\n" + passed + " passed, " + failed + " failed");
process.exit(failed ? 1 : 0);
})();