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:
parent
3937ddab1e
commit
096a7d110b
30 changed files with 266 additions and 1918 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
57
README.md
57
README.md
|
|
@ -22,7 +22,7 @@ Versión base: **Oasis 1.3.3** · Rama: `PRUEBAS` ·
|
||||||
[Documentación completa](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki) ·
|
[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)
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
**[⬇ 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
|
||||||
|
|
||||||

|
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
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
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 |
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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
13
main.js
|
|
@ -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...');
|
||||||
|
|
|
||||||
|
|
@ -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
154
src/backend/fork_routes.js
Normal 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()]);
|
||||||
|
};
|
||||||
|
|
@ -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()]);
|
|
||||||
};
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
|
|
@ -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: "Экспортировать или импортировать личность",
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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' },
|
||||||
|
|
|
||||||
|
|
@ -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>');
|
|
||||||
|
|
@ -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" }),
|
||||||
|
|
|
||||||
|
|
@ -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"/>',
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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)),
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
})();
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue