Compare commits
No commits in common. "PRUEBAS" and "v1.3.3-76" have entirely different histories.
|
|
@ -1,8 +1,4 @@
|
|||
# Construye la APK y la publica en las releases.
|
||||
#
|
||||
# Con un tag v* se publica directamente: crear el tag ya es la decision de sacar
|
||||
# version, y es la que enlaza el README (releases/latest). Lanzada a mano desde
|
||||
# la interfaz se queda en borrador, para revisarla antes.
|
||||
# Construye la APK y la deja publicada como borrador en las releases.
|
||||
#
|
||||
# NO se dispara sola con cada commit: la APK son mas de 100 MB y el backend tarda
|
||||
# en empaquetarse. Se lanza a mano desde la interfaz de gitea, o al crear un tag.
|
||||
|
|
@ -33,13 +29,6 @@ jobs:
|
|||
with:
|
||||
fetch-depth: 0 # release.sh cuenta commits para el versionCode
|
||||
|
||||
- name: Traer el APK oficial de la misma version (node_modules y libnode.so)
|
||||
run: |
|
||||
v=$(node -p "require('./src/server/package.json').version")
|
||||
curl -sfL -o /tmp/oasis-base.apk \
|
||||
"https://github.com/epsylon/oasis/releases/download/oasis-android-v$v/oasis-v$v.apk"
|
||||
echo "OASIS_APK_BASE=/tmp/oasis-base.apk" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Restaurar el almacen de claves
|
||||
run: |
|
||||
echo "${{ secrets.OASIS_KEYSTORE_B64 }}" | base64 -d > /tmp/oasis.jks
|
||||
|
|
@ -57,12 +46,8 @@ jobs:
|
|||
OASIS_GITEA_REPO: ${{ github.repository }}
|
||||
run: |
|
||||
cd android
|
||||
if [ "${{ github.ref_type }}" = "tag" ]; then
|
||||
./scripts/release.sh --release --publish --public
|
||||
else
|
||||
./scripts/release.sh --release --publish
|
||||
fi
|
||||
|
||||
- name: Borrar el almacen de claves
|
||||
if: always()
|
||||
run: rm -f /tmp/oasis.jks /tmp/oasis-base.apk
|
||||
run: rm -f /tmp/oasis.jks
|
||||
|
|
|
|||
5
.gitignore
vendored
|
|
@ -1,6 +1 @@
|
|||
node_modules/
|
||||
|
||||
# pruebas en local: el node_modules suele ser un enlace y el estado de la llamada lo escribe el movil
|
||||
src/server/node_modules
|
||||
.phone_state.json
|
||||
android/.tools/
|
||||
|
|
|
|||
|
|
@ -1,228 +0,0 @@
|
|||
# Guía de desarrollo
|
||||
|
||||
Notas para quien vaya a meter mano en la rama del móvil. Leedlas antes de empezar,
|
||||
que nos ahorramos disgustos.
|
||||
|
||||
Esta rama va encima de Oasis, que es de epsylon. Él saca versión casi cada día
|
||||
(39 entre la 1.0.1 y la 1.3.3, en siete semanas) y cada versión llega en un solo
|
||||
commit. Si metemos cosas en sus ficheros, en cada integración hay que volver a
|
||||
pelearse con ellas. Por eso la idea es sencilla: lo de Oasis lo cambia él; nosotros
|
||||
cambiamos cómo se ve y se usa en el móvil, y lo hacemos en ficheros nuestros.
|
||||
|
||||
Los números y de dónde salen están en `docs/FRONTERA_UPSTREAM.md`.
|
||||
|
||||
|
||||
## Lo que no se toca
|
||||
|
||||
- `src/backend/backend.js`. Cambia en 34 de cada 39 versiones. Ni rutas nuevas, ni
|
||||
middleware, ni envolver funciones suyas como `checkMod`.
|
||||
- La cadena de middleware del final de `backend.js`. Crece en cada versión.
|
||||
- Los modelos de `src/models/`, salvo el nuestro (`fork_workflows.js`). Ahí se decide qué se publica en la red y cómo se cuenta.
|
||||
Si lo cambiamos, nuestro móvil cuenta distinto que el escritorio de al lado. Un
|
||||
ejemplo real: quien crea una votación no puede votar en ella. No nos gusta, pero
|
||||
es una regla del modelo, así que se explica en pantalla y punto.
|
||||
- `src/server/` (arranque de SSB, `ssb_config.js`, `package.json`).
|
||||
- `src/backend/request_guards.js`, que lleva la CSP. Nada de `style=""` ni de
|
||||
`<script>` en la página.
|
||||
- Las traducciones del dev, `translations/oasis_*.js`. Se reescriben casi en cada
|
||||
versión.
|
||||
- `styles/style.css` y `styles/mobile.css`.
|
||||
- Los temas del dev: Dark, Clear, Purple, Matrix y también `OasisMobile.css`, que
|
||||
aunque parezca nuestro es suyo (lo toca en 21 de 39 versiones).
|
||||
|
||||
|
||||
## Las vistas del dev: una línea y ya
|
||||
|
||||
`src/views/*_view.js` y `main_views.js` son del dev y las reescribe a menudo
|
||||
(`tribes_view.js` en 23 versiones, `main_views.js` en 31). Si en una pantalla suya
|
||||
hace falta otro HTML en el móvil, el código va en `src/views/mobile_ui.js` y en la
|
||||
vista solo queda la llamada:
|
||||
|
||||
```js
|
||||
const mobileUi = require('./mobile_ui');
|
||||
// ...
|
||||
if (mobileUi.isMobile()) return mobileUi.tribeTabs(tribe, section, sections);
|
||||
```
|
||||
|
||||
Cuando el dev reescribe la vista, se vuelve a poner esa línea y listo. En escritorio
|
||||
no cambia nada porque la bandera `OASIS_MOBILE` solo la pone `main.js` en el móvil.
|
||||
|
||||
Ahora mismo hay enganches en `main_views.js`, `tribes_view.js`, `vote_view.js`,
|
||||
`settings_view.js` y `welcome_view.js`. Para verlos: `grep -n mobileUi src/views/*.js`.
|
||||
|
||||
|
||||
## Llamadas: las del dev
|
||||
|
||||
La rama tuvo un módulo de salas y llamadas propio (Karvan). Se quitó el 19/10/2026
|
||||
porque el dev ya trae Teléfono y Salas. En Android el sonido y la cámara pasan por un
|
||||
puente nativo de la app del dev (`.audio_bridge`; los ficheros
|
||||
`src/server/phone_*_android.js` también son suyos). Por eso la APK se monta sobre la
|
||||
oficial con `android/scripts/sobre_oficial.sh`: su app tal cual, nuestro backend
|
||||
dentro y paquete propio. Si falta algo en las llamadas, se le pide al dev, no se monta
|
||||
otro módulo.
|
||||
|
||||
Ojo al comparar con el dev: para el móvil la referencia es el `nodejs-project` de su
|
||||
APK oficial, no su repositorio git. Hay ficheros, como `SSB_server.js` o
|
||||
`phone_module.js`, cuya versión de móvil solo está en la APK.
|
||||
|
||||
Lo mismo vale para cualquier módulo: antes de hacer uno propio, mirad si el dev ya lo
|
||||
tiene o lo está haciendo.
|
||||
|
||||
|
||||
## Navegación
|
||||
|
||||
Al abrir se ve la actividad. Los módulos están en la quinta pestaña de abajo, Menú,
|
||||
que abre una hoja con el lanzador (`hive_views.js`: una casilla y CSS). Un script
|
||||
mínimo, `public/js/mobile_menu.js`, hace que el botón atrás de Android cierre la hoja;
|
||||
sin él el menú funciona igual. La barra tiene siempre cuatro atajos elegidos por cada cual más Menú. Por qué
|
||||
así: `docs/ESTUDIO_NAVEGACION.md`. Si se cambia, que sea con una prueba que lo
|
||||
justifique, no por gusto.
|
||||
|
||||
|
||||
## Lo que es nuestro
|
||||
|
||||
Aquí se puede hacer lo que haga falta.
|
||||
|
||||
- `src/client/assets/styles/mobile_base.css`: la maqueta del móvil (lanzador, barras,
|
||||
tarjetas, formularios). Se carga siempre en el móvil, antes del skin.
|
||||
- `src/client/assets/styles/mobile_ux.css`: márgenes, letra, botones y todas las
|
||||
piezas nuevas. Vale para los dos skins de móvil.
|
||||
- `src/client/assets/themes/Acampada.css` y `styles/hive_acampada.css`: el skin
|
||||
Acampada.
|
||||
- `src/views/mobile_ui.js`: lo que se enchufa en vistas del dev.
|
||||
- `src/views/mobile_modules_view.js`, `identities_view.js`: pantallas propias.
|
||||
- `src/views/hive_views.js`: lanzador y barras de arriba y abajo.
|
||||
- `src/views/mobile_icons.js`: los iconos.
|
||||
- `src/backend/fork_routes.js`: nuestro router. Va justo delante del del dev.
|
||||
- `src/backend/mobile_layer.js`: lo que nuestra interfaz necesita del backend
|
||||
(avatar, filtro y lanzador de la barra de arriba). La monta `fork_routes.js`.
|
||||
- `src/models/fork_workflows.js`: nuestros workflows.
|
||||
- `src/client/assets/translations/i18n_fork.js`: nuestros textos.
|
||||
- `src/client/assets/images/ux-*.svg`: nuestras imágenes.
|
||||
- `android/scripts/sobre_oficial.sh`: monta la APK sobre la oficial del dev. El resto
|
||||
de `android/` (el proyecto Gradle propio) queda como estaba, sin uso.
|
||||
|
||||
|
||||
## Cómo hacer cada cosa
|
||||
|
||||
Cambiar cómo se ve algo. Se busca la clase en el HTML (servidor local e inspector) y
|
||||
se escribe la regla en `mobile_ux.css`. Para los colores usad las variables `--ux-*`
|
||||
(`--ux-bg`, `--ux-fg`, `--ux-accent`, `--ux-muted`, `--ux-line`, `--ux-danger`), que
|
||||
tiran de Acampada si está y si no de la paleta de OasisMobile. Si la regla no gana,
|
||||
es que `mobile.css` o `Acampada.css` mandan con `!important` y selectores largos:
|
||||
hay que subir la especificidad y poner `!important` también. Ojo con una trampa:
|
||||
`Acampada.css` quita el borde a todos los `div`, así que para ponérselo a uno hace
|
||||
falta `.main-column div.la-clase`.
|
||||
|
||||
Letra: Anton para títulos y para el botón negro principal de cada pantalla
|
||||
(`.create-button`). Lo demás, letra del sistema en negrita.
|
||||
|
||||
Márgenes: 14 px a cada lado y ya. Los contenedores de en medio sin relleno, que
|
||||
antes se iban sumando y una ficha perdía 74 px por lado.
|
||||
|
||||
Cambiar el HTML de una pantalla del dev. Función en `mobile_ui.js` y una línea en la
|
||||
vista, como arriba.
|
||||
|
||||
Una pantalla o una ruta nueva. En `fork_routes.js`. Lo que no atiende nuestro
|
||||
router pasa al del dev con `next()`:
|
||||
|
||||
```js
|
||||
.get('/modules', async (ctx, next) => {
|
||||
if (!isMobile()) return next(); // escritorio: la del dev
|
||||
ctx.body = mobileModulesView({ ... });
|
||||
})
|
||||
```
|
||||
|
||||
Retocar lo que contesta el dev. También en `fork_routes.js`, en `wrapUpstream`: deja
|
||||
que el dev conteste y luego mira la respuesta. Así sabemos a qué sección apagada se
|
||||
quería entrar (el dev solo manda a `/modules`) o añadimos `?saved=1` al guardar.
|
||||
|
||||
Textos. En `i18n_fork.js` y en los 11 idiomas (en, es, fr, eu, de, it, pt, zh, ar,
|
||||
hi, ru). Antes, mirad que la clave no exista ya en `oasis_en.js`, porque si existe la
|
||||
estaríais cambiando también en escritorio. En el código siempre con texto de reserva:
|
||||
`i18n.miClave || 'My text'`.
|
||||
|
||||
Un workflow. En `fork_workflows.js`, que lo mete en las listas que exporta el modelo
|
||||
del dev. No se toca `workflows_model.js`.
|
||||
|
||||
Un icono. En `mobile_icons.js`, trazo de 2 px sobre rejilla de 24 y `currentColor`. El
|
||||
nombre es el de la ruta: `/tribes` es `tribes`.
|
||||
|
||||
|
||||
## Antes de hacer commit
|
||||
|
||||
- Que `git diff --stat` no toque nada de la lista de lo que no se toca. En las vistas
|
||||
del dev, solo líneas de enganche.
|
||||
- `node --check` en los `.js` que hayáis cambiado.
|
||||
- Arrancar en local y ver que `/activity`, `/tribes`, `/modules`, `/settings`,
|
||||
`/votes`, `/phone`, `/rooms` e `/inbox` cargan.
|
||||
- Mirarlo a 412 px de ancho, y a 360 si es algo ancho.
|
||||
- Si es CSS nuevo, mirarlo también en un WebView viejo (Android 9 trae el 69): no
|
||||
hay `:has`, ni `gap` en flex, ni `inset`, ni `dvh`. Lo que dependa de eso va con
|
||||
alternativa; hay un bloque `@supports not (inset: 0)` al final de `mobile_ux.css`.
|
||||
En emulador x86 la APK no arranca por debajo de Android 12 (el traductor ARM no
|
||||
sabe ejecutar el node del dev), así que el viejo se prueba con el servidor local y
|
||||
el navegador de pruebas del WebView.
|
||||
- Los textos nuevos, en los 11 idiomas.
|
||||
- Nada en línea, que la CSP lo bloquea.
|
||||
- Commit en castellano, sin coautorías de herramientas.
|
||||
- Sin claves, contraseñas ni nada de `.ssb`.
|
||||
|
||||
|
||||
## Cuando sale versión nueva del dev
|
||||
|
||||
1. Lo suyo se coge tal cual, y para los ficheros con versión de móvil, la de su APK
|
||||
oficial (`assets/nodejs-project.zip`), no la de su git.
|
||||
2. En sus vistas se vuelven a poner los enganches. El `grep -n mobileUi` sobre la
|
||||
versión anterior de la rama dice dónde iban.
|
||||
3. Se repite la medición de `docs/FRONTERA_UPSTREAM.md`: qué ha tocado y si ha
|
||||
desaparecido alguna clase que usen nuestras hojas.
|
||||
4. Se repasan a 412 px las pantallas de la lista de arriba.
|
||||
5. Se monta la APK sobre la oficial de esa misma versión:
|
||||
`OASIS_VERSION=x.y.z android/scripts/sobre_oficial.sh`.
|
||||
|
||||
|
||||
## Probar en local
|
||||
|
||||
```sh
|
||||
HOME=/tmp/oasis-prueba OASIS_NETWORK_PAUSED=1 node main.js
|
||||
```
|
||||
|
||||
Y en el navegador `http://localhost:3000` con el inspector en modo móvil a 412x915.
|
||||
Va con una identidad de usar y tirar y con la red parada.
|
||||
|
||||
Cosas que pasan:
|
||||
- `src/server/node_modules` no está en git. Sirve el de la APK base.
|
||||
- Si al reiniciar sale "Another Oasis instance is already running", el anterior sigue
|
||||
vivo y estáis viendo código viejo. Matadlo primero.
|
||||
- Al arrancar desaparecen `src/configs/banking-eco-history.json` y
|
||||
`fediverse-accounts.json`. Antes del commit, `git checkout -- src/configs/`.
|
||||
|
||||
En el emulador (`Pixel_9_API_36` de Android Studio), la APK de depuración deja
|
||||
inspeccionar la WebView desde `chrome://inspect`.
|
||||
|
||||
|
||||
## Lo que todavía está mal
|
||||
|
||||
Para el móvil, la referencia es el `nodejs-project` de la APK oficial del dev, no su
|
||||
git: él mete cambios de móvil (en `backend.js`, `SSB_server.js`, `phone_module.js`...)
|
||||
que solo están en la APK. Comparado con eso, la rama cambia 17 ficheros suyos, 561
|
||||
líneas (19/10/2026). Por tamaño:
|
||||
|
||||
- `backend/blobHandler.js` (190): limpiar los metadatos de las fotos también en
|
||||
Android. Es una función nuestra metida en su fichero; habría que proponérsela al dev.
|
||||
- `configs/oasis-config.json` (87) y `configs/config-manager.js` (18): la config de
|
||||
serie del móvil (tema Acampada, workflow acampada, barra de abajo).
|
||||
- `views/main_views.js` (62), `settings_view.js` (34), `welcome_view.js` (11),
|
||||
`tribes_view.js` (9), `vote_view.js` (7): enganches a `mobile_ui.js` y el orden de
|
||||
carga de las hojas. Es lo que la guía permite, pero hay que revisarlo en cada versión.
|
||||
- `main.js` (45): DNS del sistema primero, para respetar la VPN.
|
||||
- `server/ssb_config.js` (30): varias identidades en el mismo móvil.
|
||||
- `models/workflows_model.js` (17): la lógica de skins del móvil (`themeOf`,
|
||||
`sameTheme`). `settings_view.js` coge `currentWorkflow` al cargar y no se puede
|
||||
envolver desde fuera.
|
||||
- `styles/mobile.css` (14), `bookmark_view.js` (13), `forum_view.js` (7),
|
||||
`translations/i18n.js` (5, la mezcla con `i18n_fork.js`): arreglos sueltos de antes.
|
||||
- `backend/backend.js` (2): la línea que monta nuestro router.
|
||||
|
||||
Cuando el dev saque versión, se repite esta comparación con su APK nueva.
|
||||
206
README.md
|
|
@ -13,49 +13,25 @@
|
|||
>
|
||||
> **Los fallos de esta rama se reportan aquí, nunca a epsylon.**
|
||||
|
||||
Si vas a desarrollar, lee antes la [guía de desarrollo](GUIA_DE_DESARROLLO.md). Ahí
|
||||
está qué se puede tocar y qué no. En corto: el backend, el middleware, los modelos y
|
||||
las traducciones son de epsylon y no se tocan; lo nuestro va en hojas, vistas y rutas
|
||||
propias.
|
||||
|
||||
Versión base: Oasis 1.3.3 · Rama: `PRUEBAS` ·
|
||||
Versión base: **Oasis 1.3.3** · Rama: `PRUEBAS` ·
|
||||
[Documentación completa](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki) ·
|
||||
[Versión de escritorio](https://gitea.laenre.net/hacklab/OASIS_LINUX)
|
||||
|
||||

|
||||

|
||||
|
||||
[⬇ 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
|
||||
|
||||
### Novedades de esta versión
|
||||
|
||||
- Al día con Oasis 1.3.3: karma en el perfil, salas con directo, fotos selladas en
|
||||
- **Al día con Oasis 1.3.3**: karma en el perfil, salas con directo, fotos selladas en
|
||||
chats privados y tribus, visibilidad de menciones y las traducciones nuevas.
|
||||
- Arranca en modo acampada: chats, teléfono, salas, pads, foro, wiki, tribus,
|
||||
parlamento, votaciones y eventos. El resto se enciende en *Módulos* o en
|
||||
*Ajustes → Workflows*.
|
||||
- Las llamadas son las de Oasis. Fuera Karvan, el módulo propio de salas: ahora se usan
|
||||
el Teléfono y las Salas del dev, con su levantar la mano, grabación y emisión.
|
||||
- La APK se monta sobre la oficial: se coge la del dev, se le cambia el backend por el
|
||||
de la rama y se le da otro nombre de paquete. Así trae todo lo nativo del dev (el
|
||||
puente de audio y cámara, las notificaciones de llamada) y convive con la oficial.
|
||||
- Al abrir se ve la actividad, no un menú. Los módulos están en la quinta pestaña de
|
||||
abajo, Menú, que abre una hoja desde abajo en cualquier pantalla.
|
||||
- Votaciones como en asamblea: al crear una se elige Sí/No, semáforo (verde, amarillo,
|
||||
rojo) o termómetro (del 1 al 5). Los resultados salen en barras.
|
||||
- Borrar pide confirmación. Antes la X de una tribu la borraba de un toque.
|
||||
- Iconos que se entienden en el lanzador y en la barra, en vez de letras del alfabeto
|
||||
vai. La barra marca dónde estás y se esconde al escribir.
|
||||
- Tribus en filas: doce comisiones caben en tres pantallas, no en diez. Dentro de una
|
||||
tribu, las secciones van en una tira de fichas.
|
||||
- Módulos con interruptores, agrupados como en el lanzador. Si entras en una sección
|
||||
apagada, te lo dice y la enciendes de un toque.
|
||||
- Pantallas más aprovechadas: un margen de 14 px en vez de los 74 que se sumaban, una
|
||||
sola letra para botones, buscador en una línea y el buzón en pocas líneas por mensaje.
|
||||
- Fotos más seguras: si una foto no se puede limpiar de metadatos (ubicación,
|
||||
cámara…), no se sube y se avisa.
|
||||
- DNS: primero el resolutor del sistema, que respeta la VPN y el DNS privado de
|
||||
Android; los públicos quedan de respaldo.
|
||||
- **Arranca en modo activistas**: chats, Karvan, pads, foro, imágenes, vídeos, wiki y
|
||||
blogs. Todo cabe en una pantalla; el resto se activa en *Ajustes → Workflows*.
|
||||
- **Filtros en fichas**: en chats, feed, foros o pads los filtros ya no son botones
|
||||
gigantes que tapan el contenido, sino fichas pequeñas en fila.
|
||||
- **Tarjetas más limpias**: borrar, fijar, enlazar y abrir van en una fila de iconos.
|
||||
- **Karvan**: tus mensajes salen a la derecha también al volver a entrar en la sala.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -63,62 +39,77 @@ Android 7 o superior, 64 bits · se instala junto al Oasis oficial sin pisarlo
|
|||
|
||||
| | Oasis oficial | Esta rama |
|
||||
|---|---|---|
|
||||
| Llamadas y salas | Teléfono y Salas, con micrófono y cámara | Los mismos, tal cual |
|
||||
| Interfaz de móvil | El menú de escritorio en una pantalla pequeña | Tema Acampada, lanzador con iconos, fichas, barra de cuatro atajos |
|
||||
| Primer arranque | Casi sesenta módulos encendidos | Modo acampada: lo justo para organizarse |
|
||||
| Votaciones | Seis opciones fijas en una tabla | Sí/No, semáforo o termómetro, con barras |
|
||||
| Varias identidades | Una por instalación | Varias en el mismo móvil, con aviso si dos comparten clave |
|
||||
| App Android | La del dev | La del dev, con el backend de la rama y paquete propio (`net.laenre.oasis`) |
|
||||
| Metadatos de las fotos | Se quitan solo si está `sharp`; en Android nunca | Se quitan siempre: GPS, marca, modelo, número de serie |
|
||||
| **Salas efímeras** | — | Karvan: mensajes que solo viven en memoria y se autodestruyen |
|
||||
| **Videollamadas** | — | Audio y vídeo por WebRTC, sin servidores de terceros |
|
||||
| **Interfaz de móvil** | El menú de escritorio en una pantalla pequeña | 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 |
|
||||
| **Varias identidades** | Una por instalación | Varias en el mismo móvil, con aviso si dos comparten clave |
|
||||
| **Respuesta citada** | — | Responder a un mensaje concreto dentro de un chat |
|
||||
| **Envoltorio Android** | APK sin permisos de cámara ni micrófono | Proyecto Gradle propio: permisos, subida de ficheros, servicio que sobrevive |
|
||||
| **Metadatos de las fotos** | Se quitan solo si está `sharp`; en Android **nunca** | Se quitan siempre: GPS, marca, modelo, número de serie |
|
||||
| **Servidor STUN** | Uno de Google, por defecto | Ninguno. TURN propio si lo configuras |
|
||||
|
||||
Los nombres de las categorías, los módulos y el resto de la aplicación son los de
|
||||
epsylon, sin tocar. Con el tema OasisMobile se ve el del propio dev.
|
||||
epsylon, sin tocar.
|
||||
|
||||
---
|
||||
|
||||
## Qué es Oasis, en cuatro dibujos
|
||||
|
||||
Una red social sin servidor: publicas en tu propio equipo y los demás se traen una
|
||||
Una red social **sin servidor**: publicas en tu propio equipo y los demás se traen una
|
||||
copia. No hay cuenta, no hay contraseña y nadie puede borrarte.
|
||||
|
||||

|
||||
|
||||
Los mensajes viajan de vecino en vecino, así que llegan a gente con la que nunca te
|
||||
Los mensajes viajan **de vecino en vecino**, así que llegan a gente con la que nunca te
|
||||
has conectado. Por eso funciona incluso sin internet, entre dos móviles en la misma wifi.
|
||||
|
||||

|
||||
|
||||
Un pub es solo un nodo que no se apaga nunca. No es un servidor con tus datos: no
|
||||
Un **pub** es solo un nodo que no se apaga nunca. No es un servidor con tus datos: no
|
||||
guarda tu identidad, no decide qué ves y no puede borrarte.
|
||||
|
||||

|
||||
|
||||
Todo lo que publicas se encadena y va firmado con tu clave. Nadie puede colar un
|
||||
Todo lo que publicas se encadena y va **firmado con tu clave**. Nadie puede colar un
|
||||
mensaje en tu nombre ni cambiar lo que dijiste.
|
||||
|
||||

|
||||
|
||||
> La explicación completa —los saltos, las tribus, el LARP, los módulos— está en
|
||||
> [Entender Oasis en diez minutos](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/Entender).
|
||||
> **[Entender Oasis en diez minutos](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/Entender)**.
|
||||
|
||||
---
|
||||
|
||||
## Llamadas y salas
|
||||
## Karvan: salas que se borran solas
|
||||
|
||||

|
||||

|
||||
|
||||
El Teléfono (llamadas de uno a uno, con buzón de voz) y las Salas (hasta cincuenta
|
||||
personas, con vídeo, levantar la mano, grabación y emisión) son de Oasis. En Android
|
||||
el sonido y la cámara no los maneja Node: el servidor se conecta a un puente nativo de
|
||||
la app (`.audio_bridge`), que graba, reproduce, saca fotogramas de la cámara y emite
|
||||
ficheros.
|
||||
Lo que se dice dentro **no toca el disco**, no entra en tu registro y no se replica a
|
||||
nadie. Dos relojes corren a la vez —inactividad y tiempo absoluto— y la sala muere con
|
||||
el que llegue antes.
|
||||
|
||||
Ese puente está en la app Android del dev, que no tiene el código publicado. Por eso la
|
||||
APK de esta rama se monta sobre la oficial: se queda con su app tal cual y solo cambia
|
||||
lo que corre dentro (el backend con nuestra capa) y el nombre del paquete. Probado en
|
||||
el emulador: se entra en una sala y el micrófono graba.
|
||||
<p align="center">
|
||||
<img src="docs/capturas/07-karvan-salas.png" width="270">
|
||||
<img src="docs/capturas/03-karvan.png" width="270">
|
||||
</p>
|
||||
|
||||
En la barra de arriba, junto al avatar, está el botón de *No molestar* del Teléfono.
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -127,43 +118,19 @@ En la barra de arriba, junto al avatar, está el botón de *No molestar* del Tel
|
|||

|
||||
|
||||
<p align="center">
|
||||
<img src="docs/capturas/01-portada.png" width="200">
|
||||
<img src="docs/capturas/02-tribus.png" width="200">
|
||||
<img src="docs/capturas/03-votacion.png" width="200">
|
||||
<img src="docs/capturas/04-modulos.png" width="200">
|
||||
</p>
|
||||
<p align="center">
|
||||
<img src="docs/capturas/05-ajustes.png" width="200">
|
||||
<img src="docs/capturas/06-barra.png" width="200">
|
||||
<img src="docs/capturas/08-salas.png" width="200">
|
||||
<img src="docs/capturas/07-oasismobile.png" width="200">
|
||||
<img src="docs/capturas/01-portada.png" width="240">
|
||||
<img src="docs/capturas/04-foros.png" width="240">
|
||||
<img src="docs/capturas/06-barra.png" width="240">
|
||||
</p>
|
||||
|
||||
La actividad al abrir, tribus, una votación de semáforo, módulos, ajustes, la barra de
|
||||
atajos, las salas del dev y el menú con el tema OasisMobile del dev.
|
||||
Casi sesenta módulos no caben en la pantalla de un teléfono: la portada los ordena por
|
||||
secciones en una rejilla de iconos, como el cajón de apps del móvil. Abajo
|
||||
queda una barra de cuatro atajos que eliges tú. Dentro de cada módulo los filtros van en fichas pequeñas,
|
||||
para que el contenido se vea sin tener que bajar.
|
||||
|
||||
Casi sesenta módulos no caben en la pantalla de un teléfono. Al abrir la app se ve la
|
||||
actividad, lo que ha pasado en la red. Abajo hay cuatro atajos que eliges tú y una
|
||||
quinta pestaña fija, Menú, que abre desde abajo una hoja con todos los módulos por
|
||||
secciones, como el cajón de apps del móvil. Está en todas las pantallas. Por qué así,
|
||||
y no un menú arriba o una hamburguesa: [docs/ESTUDIO_NAVEGACION.md](docs/ESTUDIO_NAVEGACION.md).
|
||||
|
||||
Solo aparecen los módulos activos. En *Ajustes → Workflows* se eligen de golpe: el de
|
||||
acampada deja lo justo para organizarse. En el móvil un workflow cambia los módulos,
|
||||
no el aspecto: el aspecto se elige en *Ajustes → Tema* (Acampada u OasisMobile).
|
||||
|
||||
---
|
||||
|
||||
## Cómo está hecha la rama
|
||||
|
||||

|
||||
|
||||
Oasis lo desarrolla epsylon y saca versión casi cada día. Para no tener que rehacer la
|
||||
rama en cada una, lo nuestro va en ficheros propios: hojas de estilo, unas pocas
|
||||
vistas, un router que va delante del suyo y el envoltorio de Android. El backend, el
|
||||
middleware, los modelos y las traducciones son suyos y no se tocan. Las normas están
|
||||
en la [guía de desarrollo](GUIA_DE_DESARROLLO.md) y las cifras en
|
||||
[docs/FRONTERA_UPSTREAM.md](docs/FRONTERA_UPSTREAM.md).
|
||||
Solo aparecen los módulos activos. En **Ajustes → Workflows** se eligen de golpe: el de
|
||||
**activistas** deja lo justo para organizarse (chats, Karvan, pads, foro, imágenes,
|
||||
vídeos, wiki y blogs). En el móvil un workflow cambia los módulos, no el aspecto.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -173,66 +140,49 @@ en la [guía de desarrollo](GUIA_DE_DESARROLLO.md) y las cifras en
|
|||
|
||||
| | |
|
||||
|---|---|
|
||||
| [oasis-project.pub](https://oasis-project.pub) | Directorio de pubs, con invitaciones y estado de cada uno |
|
||||
| [0asis.net](https://0asis.net) | El proyecto explicado desde el principio |
|
||||
| [wiki.solarnethub.com](https://wiki.solarnethub.com/socialnet/overview) | La documentación oficial, módulo por módulo |
|
||||
| **[oasis-project.pub](https://oasis-project.pub)** | Directorio de pubs, con invitaciones y estado de cada uno |
|
||||
| **[0asis.net](https://0asis.net)** | El proyecto explicado desde el principio |
|
||||
| **[wiki.solarnethub.com](https://wiki.solarnethub.com/socialnet/overview)** | La documentación oficial, módulo por módulo |
|
||||
|
||||
---
|
||||
|
||||
## Instalar
|
||||
|
||||
La APK no está en ninguna tienda: se descarga de las *releases* de este repositorio
|
||||
y se instala a mano, con orígenes desconocidos activado. Se instala junto al Oasis
|
||||
oficial sin pisarlo —el identificador es `net.laenre.oasis`—, así que puedes tener
|
||||
La APK **no está en ninguna tienda**: se descarga de las *releases* de este repositorio
|
||||
y se instala a mano, con orígenes desconocidos activado. Se instala **junto al Oasis
|
||||
oficial** sin pisarlo —el identificador es `net.laenre.oasis`—, así que puedes tener
|
||||
los dos y comparar.
|
||||
|
||||
Necesita unos 350 MB libres. El primer arranque tarda entre 15 y 60 segundos porque
|
||||
Necesita unos **350 MB libres**. El primer arranque tarda entre 15 y 60 segundos porque
|
||||
descomprime el servidor.
|
||||
|
||||
> Verifica la huella de la firma antes de instalar una APK bajada de un servidor
|
||||
> **Verifica la huella de la firma** antes de instalar una APK bajada de un servidor
|
||||
> propio: `apksigner verify --print-certs`
|
||||
|
||||
### Compilar
|
||||
|
||||
La APK se monta sobre la oficial de la misma versión. Hace falta Java 17 o más y las
|
||||
build-tools de Android (zipalign y apksigner); apktool se baja solo.
|
||||
|
||||
```sh
|
||||
cd android
|
||||
./scripts/sobre_oficial.sh # baja la oficial, le pone el backend de la rama, la firma
|
||||
# para publicar, con la clave del hacklab:
|
||||
OASIS_KEYSTORE=... OASIS_KEYSTORE_PASS=... OASIS_KEY_ALIAS=... ./scripts/sobre_oficial.sh
|
||||
# la primera vez hace falta la APK oficial de la MISMA versión (ahora la 1.3.3):
|
||||
# de ella salen las librerías nativas y el node_modules podado del móvil
|
||||
OASIS_APK_BASE=/ruta/oasis-v1.3.3.apk ./scripts/release.sh # compila, verifica y calcula la huella
|
||||
```
|
||||
|
||||
Sale en `android/build/sobre-oficial/oasis-<versión>-hacklab.apk`. Sin clave se firma
|
||||
con la de depuración y queda depurable, para poder inspeccionarla desde
|
||||
`chrome://inspect`.
|
||||
|
||||
El proyecto Gradle propio de `android/` (`release.sh`, Android Studio) sigue ahí, pero
|
||||
ya no es el camino: no tiene el puente de audio y en él no hay llamadas.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## Documentación
|
||||
|
||||
Para desarrollar, en el propio repositorio:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| [GUIA_DE_DESARROLLO.md](GUIA_DE_DESARROLLO.md) | Qué se toca, qué no y cómo hacer cada cosa |
|
||||
| [docs/FRONTERA_UPSTREAM.md](docs/FRONTERA_UPSTREAM.md) | Cuánto cambia el dev en cada versión, medido |
|
||||
| [docs/USABILIDAD_PAD.txt](docs/USABILIDAD_PAD.txt) | Pruebas de usabilidad en el móvil |
|
||||
|
||||
Todo lo de arriba, en detalle, está en la
|
||||
[wiki](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki):
|
||||
**[wiki](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki)**:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| [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 |
|
||||
| [Llamadas](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/Llamadas) | Teléfono y Salas en el móvil |
|
||||
| [Karvan](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/Karvan) | Salas efímeras, paso a paso |
|
||||
| [Llamadas](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/Llamadas) | Cómo se llama y cómo montar un TURN |
|
||||
| [La interfaz](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/Interfaz) | Botón por botón |
|
||||
| [Identidades](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/Identidades) | Varias cuentas en el mismo móvil |
|
||||
| [Compilar la APK](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/Compilar) | Y cómo se publica una versión |
|
||||
|
|
@ -244,12 +194,12 @@ Todo lo de arriba, en detalle, está en la
|
|||
|
||||
## El proyecto original
|
||||
|
||||
Esta rama existe gracias a y por encima de el trabajo de epsylon. El código, la
|
||||
Esta rama existe **gracias a** y **por encima de** el trabajo de epsylon. El código, la
|
||||
arquitectura, los módulos y la red son suyos.
|
||||
|
||||
- Código: https://github.com/epsylon/oasis
|
||||
- Proyecto: https://solarnethub.com
|
||||
- Wiki: https://wiki.solarnethub.com/socialnet/overview
|
||||
- **Código:** https://github.com/epsylon/oasis
|
||||
- **Proyecto:** https://solarnethub.com
|
||||
- **Wiki:** https://wiki.solarnethub.com/socialnet/overview
|
||||
|
||||
## Licencia
|
||||
|
||||
|
|
|
|||
|
|
@ -5,15 +5,10 @@ molde.
|
|||
|
||||
## Por que existe
|
||||
|
||||
Nota (19/10/2026): desde la 1.3.x la APK oficial ya pide microfono y camara y trae un
|
||||
puente nativo de audio y camara (`.audio_bridge`) para el Telefono y las Salas. Por eso
|
||||
la APK de la rama se monta ahora sobre la oficial con `scripts/sobre_oficial.sh`, y el
|
||||
proyecto Gradle de esta carpeta queda sin uso. Lo de abajo describe la situacion anterior.
|
||||
|
||||
El APK que se venia usando es el de epsylon re-firmado, y su `classes.dex` **no
|
||||
implementa `WebChromeClient.onPermissionRequest`**. Sin ese metodo el WebView de
|
||||
Android deniega por defecto toda peticion de `getUserMedia()`, de modo que las
|
||||
llamadas no pueden funcionar por mucho que se parchee el manifest:
|
||||
videollamadas de Karvan no pueden funcionar por mucho que se parchee el manifest:
|
||||
la lista de permisos es estatica y hay que declararla al compilar.
|
||||
|
||||
Este wrapper resuelve las dos puertas: declara los permisos y los concede al
|
||||
|
|
@ -62,10 +57,6 @@ el NDK.
|
|||
|
||||
## Construir
|
||||
|
||||
Con Android Studio basta con `./scripts/android-studio.sh`: clona el Oasis oficial,
|
||||
descarga su APK de la misma version y empaqueta el backend. Luego *File > Open >*
|
||||
esta carpeta y *Build > Build APK(s)*. A mano, paso a paso:
|
||||
|
||||
```sh
|
||||
# 1. dependencias del backend (una vez)
|
||||
(cd ../src/server && npm install)
|
||||
|
|
|
|||
|
|
@ -14,12 +14,9 @@ android {
|
|||
applicationId = "net.laenre.oasis"
|
||||
minSdk = 24
|
||||
targetSdk = 35
|
||||
// release.sh los pasa por entorno; a mano, el nombre sale de package.json (como
|
||||
// en el backend) para que la APK no diga ser otra version que la que lleva dentro
|
||||
val pkgVersion = Regex("\"version\"\\s*:\\s*\"([^\"]+)\"")
|
||||
.find(rootProject.file("../package.json").readText())?.groupValues?.get(1) ?: "0.0.0"
|
||||
// el pipeline los pasa por entorno; a mano valen los de aqui
|
||||
versionCode = (System.getenv("OASIS_VERSION_CODE") ?: "1").toInt()
|
||||
versionName = System.getenv("OASIS_VERSION_NAME") ?: pkgVersion
|
||||
versionName = System.getenv("OASIS_VERSION_NAME") ?: "1.3.3"
|
||||
ndk { abiFilters += listOf("arm64-v8a") }
|
||||
}
|
||||
|
||||
|
|
@ -46,10 +43,8 @@ android {
|
|||
|
||||
packaging {
|
||||
jniLibs {
|
||||
// Comprimidas, como en la APK oficial: libnode.so pasa de 50 a 16 MB en la
|
||||
// descarga. Sin comprimir (false) se cargaria por mmap desde la APK, pero eso
|
||||
// solo ahorra unos milisegundos al arrancar y cuesta 35 MB a cada descarga.
|
||||
useLegacyPackaging = true
|
||||
// libnode.so son 49 MB: sin comprimir se carga por mmap y arranca mas rapido
|
||||
useLegacyPackaging = false
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,20 +3,14 @@
|
|||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<!-- descubrir otros moviles en la misma wifi: el anuncio LAN de SSB va por UDP
|
||||
broadcast/multicast, y Android lo filtra si no se coge un MulticastLock -->
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<!-- solo para guardar descargas en Android 9 o anterior; desde el 10 no hace falta -->
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<!-- Camara y microfono para el Telefono y las Salas de Oasis.
|
||||
<!-- Camara y microfono para las videollamadas de Karvan.
|
||||
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
|
||||
la primera vez que se llama o se entra en una sala. Si nadie llama, no se pide nada y la app
|
||||
la primera vez que se pulsa Llamar. Si nadie llama, no se pide nada y la app
|
||||
nunca accede a la camara ni al microfono. -->
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
|
|
@ -52,8 +46,7 @@
|
|||
android:name="net.laenre.oasis.MainActivity"
|
||||
android:exported="true"
|
||||
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|density|keyboardHidden|keyboard|navigation|uiMode|fontScale|layoutDirection"
|
||||
android:launchMode="singleTask"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
android:launchMode="singleTask">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
|
|
|
|||
|
|
@ -1,33 +1,24 @@
|
|||
package net.laenre.oasis
|
||||
|
||||
import android.Manifest
|
||||
import android.app.DownloadManager
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.Color
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Environment
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import android.webkit.PermissionRequest
|
||||
import android.webkit.URLUtil
|
||||
import android.webkit.ValueCallback
|
||||
import android.webkit.WebChromeClient
|
||||
import android.webkit.WebResourceError
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import com.solarnethub.oasis.BuildConfig
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
|
@ -66,7 +57,7 @@ class MainActivity : AppCompatActivity() {
|
|||
startService(Intent(this, NodeService::class.java))
|
||||
|
||||
web = WebView(this)
|
||||
setContentView(edgeToEdge(web))
|
||||
setContentView(web)
|
||||
WebView.setWebContentsDebuggingEnabled(BuildConfig.DEBUG)
|
||||
|
||||
web.settings.apply {
|
||||
|
|
@ -152,60 +143,9 @@ class MainActivity : AppCompatActivity() {
|
|||
}
|
||||
}
|
||||
|
||||
// Lo que la pagina manda descargar (un PDF, un fichero adjunto) va al gestor de
|
||||
// descargas de Android; sin esto el WebView no hace nada al tocar el enlace.
|
||||
web.setDownloadListener { url, userAgent, disposition, mime, _ -> download(url, userAgent, disposition, mime) }
|
||||
|
||||
waitAndLoad()
|
||||
}
|
||||
|
||||
/**
|
||||
* La pantalla entera, por debajo de las barras del sistema (Android 15 lo impone
|
||||
* con targetSdk 35, y en versiones anteriores se pide igual para que se vea lo
|
||||
* mismo). El contenedor deja de margen lo que ocupan la barra de estado, la de
|
||||
* navegacion, el recorte de la camara y el teclado cuando esta abierto, asi la
|
||||
* barra de abajo de Oasis sube con el teclado en vez de quedar tapada.
|
||||
* El fondo blanco y los iconos oscuros son los del tema Acampada.
|
||||
*/
|
||||
private fun edgeToEdge(content: WebView): FrameLayout {
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
window.statusBarColor = Color.TRANSPARENT
|
||||
window.navigationBarColor = Color.TRANSPARENT
|
||||
val root = FrameLayout(this)
|
||||
root.setBackgroundColor(Color.WHITE)
|
||||
root.addView(content, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT))
|
||||
WindowCompat.getInsetsController(window, root).apply {
|
||||
isAppearanceLightStatusBars = true
|
||||
isAppearanceLightNavigationBars = true
|
||||
}
|
||||
ViewCompat.setOnApplyWindowInsetsListener(root) { v, insets ->
|
||||
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout())
|
||||
val ime = insets.getInsets(WindowInsetsCompat.Type.ime())
|
||||
v.setPadding(bars.left, bars.top, bars.right, maxOf(bars.bottom, ime.bottom))
|
||||
WindowInsetsCompat.CONSUMED
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
/** Descarga por el gestor de Android, a la carpeta Descargas, con aviso al terminar. */
|
||||
private fun download(url: String, userAgent: String?, disposition: String?, mime: String?) {
|
||||
try {
|
||||
val name = URLUtil.guessFileName(url, disposition, mime)
|
||||
val req = DownloadManager.Request(Uri.parse(url))
|
||||
.setTitle(name)
|
||||
.setMimeType(mime)
|
||||
.addRequestHeader("User-Agent", userAgent ?: "")
|
||||
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
|
||||
.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, name)
|
||||
(getSystemService(DOWNLOAD_SERVICE) as DownloadManager).enqueue(req)
|
||||
Toast.makeText(this, name, Toast.LENGTH_SHORT).show()
|
||||
} catch (e: Exception) {
|
||||
Log.w("OasisNode", "no se pudo descargar ${'$'}url: ${'$'}e")
|
||||
// si el gestor no puede, que lo abra otra aplicacion
|
||||
try { startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) } catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
|
||||
private fun has(p: String) =
|
||||
ContextCompat.checkSelfPermission(this, p) == PackageManager.PERMISSION_GRANTED
|
||||
|
||||
|
|
@ -218,10 +158,9 @@ class MainActivity : AppCompatActivity() {
|
|||
val s = attempt / 2
|
||||
web.loadDataWithBaseURL(null, """
|
||||
<html><head><meta name='viewport' content='width=device-width,initial-scale=1'>
|
||||
<style>body{background:#fff;color:#000;font-family:sans-serif;display:flex;
|
||||
<style>body{background:#121212;color:#FFB400;font-family:sans-serif;display:flex;
|
||||
height:100vh;margin:0;align-items:center;justify-content:center;text-align:center}
|
||||
div{padding:24px}h2{text-transform:uppercase;border-bottom:3px solid #000;padding-bottom:6px}
|
||||
p{font-size:15px;line-height:1.5}</style></head>
|
||||
div{padding:24px}p{opacity:.7;font-size:14px;line-height:1.5}</style></head>
|
||||
<body><div><h2>Oasis</h2>
|
||||
<p>Preparando la aplicación…<br>El primer arranque descomprime el
|
||||
servidor y puede tardar un minuto. No hace falta hacer nada.</p><p>${s}s</p></div></body></html>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import android.app.NotificationManager
|
|||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.wifi.WifiManager
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.system.Os
|
||||
|
|
@ -34,37 +33,16 @@ class NodeService : Service() {
|
|||
@Volatile private var started = false
|
||||
}
|
||||
|
||||
// Sin esto Android tira los paquetes multicast/broadcast que llegan por wifi y el
|
||||
// descubrimiento LAN de SSB no ve a nadie: dos moviles en la misma red no se encuentran.
|
||||
private var multicastLock: WifiManager.MulticastLock? = null
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
startForeground(NOTIF_ID, buildNotification())
|
||||
acquireMulticastLock()
|
||||
if (started) return
|
||||
started = true
|
||||
Thread({ runNode() }, "node-main").start()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
try { multicastLock?.takeIf { it.isHeld }?.release() } catch (e: Throwable) { Log.w(TAG, "soltando MulticastLock", e) }
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun acquireMulticastLock() {
|
||||
if (multicastLock?.isHeld == true) return
|
||||
try {
|
||||
val wifi = applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
|
||||
multicastLock = wifi.createMulticastLock("oasis-lan").apply { setReferenceCounted(false); acquire() }
|
||||
} catch (e: Throwable) {
|
||||
// sin wifi o sin permiso: los pubs siguen funcionando, solo se pierde la LAN
|
||||
Log.w(TAG, "no se ha podido coger el MulticastLock; sin descubrimiento LAN", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildNotification(): Notification {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val ch = NotificationChannel(CHANNEL, getString(R.string.node_channel), NotificationManager.IMPORTANCE_LOW)
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.Oasis" parent="Theme.AppCompat.NoActionBar">
|
||||
<item name="android:windowBackground">#FFFFFF</item>
|
||||
<item name="android:statusBarColor">#FFFFFF</item>
|
||||
<item name="android:windowLightStatusBar">true</item>
|
||||
<item name="android:windowBackground">#121212</item>
|
||||
<item name="android:statusBarColor">#121212</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -1,76 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# Deja el proyecto listo para compilarlo desde Android Studio.
|
||||
#
|
||||
# Android Studio solo compila: no sabe traer el Oasis oficial, ni sacar libnode.so
|
||||
# del APK de epsylon, ni empaquetar el backend. Esto lo hace todo de una vez:
|
||||
#
|
||||
# 1. clona el Oasis oficial en ../upstream, en el commit de .upstream-commit
|
||||
# (de ahi salen las dependencias del backend, src/base/node_modules)
|
||||
# 2. descarga el APK oficial de la misma version a android/native/
|
||||
# (de ahi salen libnode.so y los modulos nativos de Node para Android)
|
||||
# 3. lanza prepare.sh, que copia las librerias y empaqueta el backend
|
||||
#
|
||||
# Uso, desde la raiz del repo o desde android/:
|
||||
# ./android/scripts/android-studio.sh
|
||||
#
|
||||
# Despues, en Android Studio: File > Open > la carpeta android/, esperar a que
|
||||
# termine el Gradle Sync, y Build > Build App Bundle(s) / APK(s) > Build APK(s).
|
||||
#
|
||||
# Cada vez que cambies algo en src/ o en main.js, vuelve a lanzarlo: la APK lleva
|
||||
# el backend empaquetado y Android Studio no lo rehace solo.
|
||||
set -euo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "$0")/.." && pwd)" # android/
|
||||
REPO="$(cd "$HERE/.." && pwd)" # raiz del repo
|
||||
UPSTREAM="${OASIS_UPSTREAM:-$REPO/../upstream}"
|
||||
|
||||
say() { printf '\n== %s\n' "$*"; }
|
||||
die() { printf '\n!! %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
for cmd in git node curl zip unzip; do
|
||||
command -v "$cmd" >/dev/null || die "falta '$cmd': instalalo y vuelve a lanzarlo"
|
||||
done
|
||||
|
||||
# --- 1) el Oasis oficial, en el mismo commit que src/ -----------------------------
|
||||
COMMIT="$(cat "$REPO/.upstream-commit")"
|
||||
if [ ! -d "$UPSTREAM/.git" ]; then
|
||||
say "clonando el Oasis oficial en $UPSTREAM (unos 300 MB, solo la primera vez)"
|
||||
git clone https://github.com/epsylon/oasis "$UPSTREAM"
|
||||
fi
|
||||
if [ "$(git -C "$UPSTREAM" rev-parse HEAD)" != "$COMMIT" ]; then
|
||||
say "poniendo el Oasis oficial en el commit $COMMIT"
|
||||
git -C "$UPSTREAM" fetch --quiet origin || true
|
||||
git -C "$UPSTREAM" checkout --quiet "$COMMIT"
|
||||
fi
|
||||
|
||||
# --- 2) el APK oficial de la misma version ----------------------------------------
|
||||
VERSION="$(node -p "require('$REPO/src/server/package.json').version")"
|
||||
BASE="$HERE/native/oasis-v$VERSION.apk"
|
||||
if [ ! -f "$BASE" ]; then
|
||||
say "descargando el APK oficial $VERSION (unos 50 MB, solo la primera vez)"
|
||||
mkdir -p "$HERE/native"
|
||||
curl -fL --progress-bar -o "$BASE.part" \
|
||||
"https://github.com/epsylon/oasis/releases/download/oasis-android-v$VERSION/oasis-v$VERSION.apk" \
|
||||
|| die "no se ha podido descargar el APK oficial de la $VERSION"
|
||||
mv "$BASE.part" "$BASE"
|
||||
fi
|
||||
|
||||
# --- 3) librerias y backend --------------------------------------------------------
|
||||
say "empaquetando el backend (un minuto o dos)"
|
||||
OASIS_UPSTREAM="$UPSTREAM" "$HERE/scripts/prepare.sh" "$BASE"
|
||||
|
||||
cat <<EOF
|
||||
|
||||
== listo
|
||||
|
||||
Ahora, en Android Studio:
|
||||
1. File > Open > $HERE
|
||||
2. espera a que termine el Gradle Sync (la primera vez descarga bastante)
|
||||
3. Build > Build App Bundle(s) / APK(s) > Build APK(s)
|
||||
|
||||
La APK sale en:
|
||||
$HERE/app/build/outputs/apk/debug/app-debug.apk
|
||||
|
||||
Si cambias algo en src/ o en main.js, vuelve a lanzar este script antes de compilar.
|
||||
EOF
|
||||
|
|
@ -7,8 +7,7 @@
|
|||
# Uso:
|
||||
# ./scripts/release.sh # compila debug y verifica
|
||||
# ./scripts/release.sh --release # compila release firmada
|
||||
# ./scripts/release.sh --release --publish # borrador, para revisarla
|
||||
# ./scripts/release.sh --release --publish --public # publicada: la descarga del README
|
||||
# ./scripts/release.sh --release --publish
|
||||
#
|
||||
# Variables:
|
||||
# OASIS_KEYSTORE, OASIS_KEYSTORE_PASS, OASIS_KEY_ALIAS firma (obligatorias con --release)
|
||||
|
|
@ -27,12 +26,10 @@ cd "$HERE"
|
|||
|
||||
MODE=debug
|
||||
PUBLISH=0
|
||||
PUBLIC=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--release) MODE=release ;;
|
||||
--publish) PUBLISH=1 ;;
|
||||
--public) PUBLIC=1 ;;
|
||||
*) echo "opcion desconocida: $arg"; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
|
@ -58,9 +55,6 @@ say "version ${VERSION_NAME} (codigo ${VERSION_CODE}), modo ${MODE}"
|
|||
# --- 2) comprobaciones previas ---------------------------------------------
|
||||
[ -n "${JAVA_HOME:-}" ] || die "define JAVA_HOME apuntando a un JDK con compilador"
|
||||
[ -x "${JAVA_HOME}/bin/javac" ] || die "en ${JAVA_HOME} no hay javac: es un runtime, no un JDK"
|
||||
# apksigner y aapt2 son scripts que llaman a "java" a pelo: sin esto fallaban con
|
||||
# "java: no encontrado" y parecia que la APK estaba mal firmada
|
||||
export PATH="$JAVA_HOME/bin:$PATH"
|
||||
[ -d "$SDK" ] || die "no encuentro el SDK de Android en $SDK (define ANDROID_HOME)"
|
||||
|
||||
if [ "$MODE" = release ]; then
|
||||
|
|
@ -118,21 +112,10 @@ grep -q "assets/nodejs-project.zip" "$LISTING" || die "la APK no lleva el backen
|
|||
grep -q "android.permission.CAMERA" "$PERMS" || die "la APK no declara CAMERA: no podria hacer videollamadas"
|
||||
grep -q "android.permission.RECORD_AUDIO" "$PERMS" || die "la APK no declara RECORD_AUDIO"
|
||||
grep -q "libnode.so" "$LISTING" || die "la APK no lleva el runtime de node"
|
||||
grep -q "CHANGE_WIFI_MULTICAST_STATE" "$PERMS" || die "la APK no declara CHANGE_WIFI_MULTICAST_STATE: no veria otros moviles en la misma wifi"
|
||||
|
||||
# Una APK debuggable deja leer por adb los datos de la app, clave secreta incluida.
|
||||
if "$BT/aapt2" dump badging "$APK" 2>/dev/null | grep -q "application-debuggable"; then
|
||||
[ "$MODE" = release ] && die "la APK release ha salido debuggable"
|
||||
say "OJO: APK de depuracion (debuggable). Solo para probar en tu movil, no la repartas"
|
||||
fi
|
||||
|
||||
# La oficial pesa ~54 MB; si esta se dispara es que prepare.sh ha dejado de adelgazar
|
||||
APK_MB=$(( $(stat -c%s "$APK") / 1024 / 1024 ))
|
||||
[ "$APK_MB" -le 80 ] || say "OJO: la APK son ${APK_MB} MB (la oficial ~54): revisa que mete prepare.sh"
|
||||
|
||||
if [ "$MODE" = release ]; then
|
||||
"$BT/apksigner" verify --print-certs "$APK" > /tmp/oasis-signer.txt 2>&1 \
|
||||
|| { cat /tmp/oasis-signer.txt >&2; die "la APK no esta firmada correctamente"; }
|
||||
|| die "la APK no esta firmada correctamente"
|
||||
FINGERPRINT=$(grep -m1 "SHA-256 digest" /tmp/oasis-signer.txt | awk '{print $NF}')
|
||||
say "huella del certificado: $FINGERPRINT"
|
||||
fi
|
||||
|
|
@ -156,20 +139,13 @@ NOTES=$(printf 'Rama experimental del hacklab sobre el Oasis de epsylon.\n\nNO e
|
|||
RELEASE_ID=$(curl -sf -X POST "$API/releases" -H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d "$(node -e '
|
||||
const [tag, name, body] = process.argv.slice(1);
|
||||
const pub = process.argv[4] === "1";
|
||||
process.stdout.write(JSON.stringify({ tag_name: tag, name, body, draft: !pub, prerelease: !pub }));
|
||||
' "$TAG" "Oasis Mobile $TAG" "$NOTES" "$PUBLIC")" | node -pe 'JSON.parse(require("fs").readFileSync(0)).id' 2>/dev/null) \
|
||||
process.stdout.write(JSON.stringify({ tag_name: tag, name, body, draft: true, prerelease: true }));
|
||||
' "$TAG" "Oasis Mobile $TAG" "$NOTES")" | node -pe 'JSON.parse(require("fs").readFileSync(0)).id' 2>/dev/null) \
|
||||
|| die "no se ha podido crear la release (token sin permiso, o el tag ya existe)"
|
||||
|
||||
# Siempre con el mismo nombre: el README enlaza a releases/download/latest/oasis-mobile.apk,
|
||||
# y la version ya va en el tag y en el titulo de la release.
|
||||
curl -sf -X POST "$API/releases/$RELEASE_ID/assets?name=oasis-mobile.apk" \
|
||||
curl -sf -X POST "$API/releases/$RELEASE_ID/assets?name=oasis-$TAG.apk" \
|
||||
-H "$AUTH" -F "attachment=@$APK" >/dev/null \
|
||||
|| die "la release se ha creado pero no se ha podido subir la APK"
|
||||
|
||||
if [ "$PUBLIC" = 1 ]; then
|
||||
say "publicada: $GITEA_URL/$GITEA_REPO/releases/latest"
|
||||
else
|
||||
say "publicada como borrador: $GITEA_URL/$GITEA_REPO/releases"
|
||||
say "revisala y quita el borrador a mano (o usa --public); no se publica sola a proposito"
|
||||
fi
|
||||
say "publicada como borrador: $GITEA_URL/$GITEA_REPO/releases"
|
||||
say "revisala y quita el borrador a mano; no se publica sola a proposito"
|
||||
|
|
|
|||
|
|
@ -1,126 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# Construye la APK de la rama encima de la APK oficial del dev.
|
||||
#
|
||||
# Desde la 1.3.x la app Android de epsylon trae cosas que la nuestra no tiene: el
|
||||
# puente nativo de audio y camara (.audio_bridge) que usan el Telefono y las Salas,
|
||||
# las notificaciones de llamada y sus permisos. Su codigo no esta publicado, asi que
|
||||
# en vez de imitarlo se parte de su APK:
|
||||
#
|
||||
# 1. se baja la APK oficial de la misma version (github, epsylon/oasis)
|
||||
# 2. se desmonta con apktool
|
||||
# 3. su backend (assets/nodejs-project.zip) se cambia por el de la rama, conservando
|
||||
# su node_modules, que es el podado para el movil
|
||||
# 4. se le cambia el paquete a net.laenre.oasis (y lo que depende de el) para que
|
||||
# conviva con la oficial
|
||||
# 5. se vuelve a montar, se alinea y se firma
|
||||
#
|
||||
# Uso:
|
||||
# ./scripts/sobre_oficial.sh # firma con la clave de depuracion
|
||||
# OASIS_KEYSTORE=... OASIS_KEYSTORE_PASS=... OASIS_KEY_ALIAS=... ./scripts/sobre_oficial.sh
|
||||
#
|
||||
# Variables:
|
||||
# OASIS_VERSION version del dev (por defecto la de package.json)
|
||||
# OASIS_APK_OFICIAL ruta a la APK oficial, si ya se tiene (si no, se baja)
|
||||
# ANDROID_HOME por defecto ~/Android/Sdk
|
||||
# JAVA_HOME un JDK 17 o superior
|
||||
set -euo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
REPO="$(cd "$HERE/.." && pwd)"
|
||||
SDK="${ANDROID_HOME:-$HOME/Android/Sdk}"
|
||||
JAVA="${JAVA_HOME:+$JAVA_HOME/bin/}java"
|
||||
TOOLS="$HERE/.tools"
|
||||
OUT="$HERE/build/sobre-oficial"
|
||||
PKG_DEV="com.solarnethub.oasis"
|
||||
PKG="net.laenre.oasis"
|
||||
APKTOOL_VER="3.0.3"
|
||||
APKTOOL_SHA="dbf930b076c6b9be08d57c449cacefc3bdd6b71ebd59b3066fc0e1f5b14f9423"
|
||||
|
||||
say() { printf '\n== %s\n' "$*"; }
|
||||
die() { printf '\n!! %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
VERSION="${OASIS_VERSION:-$(node -p "require('$REPO/package.json').version")}"
|
||||
BT="$(ls -d "$SDK"/build-tools/*/ | sort -V | tail -1)"
|
||||
[ -x "$BT/zipalign" ] || die "no encuentro zipalign en $BT"
|
||||
mkdir -p "$TOOLS" "$OUT"
|
||||
|
||||
# --- apktool, version fija y comprobada ---------------------------------------
|
||||
APKTOOL="$TOOLS/apktool_$APKTOOL_VER.jar"
|
||||
if [ ! -f "$APKTOOL" ]; then
|
||||
say "bajando apktool $APKTOOL_VER"
|
||||
curl -fsSL -o "$APKTOOL" "https://github.com/iBotPeaches/Apktool/releases/download/v$APKTOOL_VER/apktool_$APKTOOL_VER.jar"
|
||||
fi
|
||||
echo "$APKTOOL_SHA $APKTOOL" | sha256sum -c --quiet - || die "apktool no coincide con la huella esperada"
|
||||
|
||||
# --- la APK oficial -------------------------------------------------------------
|
||||
OFICIAL="${OASIS_APK_OFICIAL:-$TOOLS/oasis-v$VERSION.apk}"
|
||||
if [ ! -f "$OFICIAL" ]; then
|
||||
say "bajando la APK oficial $VERSION"
|
||||
curl -fsSL -o "$OFICIAL" "https://github.com/epsylon/oasis/releases/download/oasis-android-v$VERSION/oasis-v$VERSION.apk"
|
||||
fi
|
||||
say "APK oficial: $OFICIAL"
|
||||
"$BT/apksigner" verify --print-certs "$OFICIAL" | grep -m2 -E "certificate DN|SHA-256" || die "la APK oficial no verifica"
|
||||
|
||||
# --- desmontar ------------------------------------------------------------------
|
||||
say "desmontando"
|
||||
rm -rf "$OUT/dec"
|
||||
"$JAVA" -jar "$APKTOOL" d -q -f -o "$OUT/dec" "$OFICIAL"
|
||||
DEC="$OUT/dec"
|
||||
|
||||
# --- backend de la rama, con el node_modules del dev ---------------------------
|
||||
say "cambiando el backend por el de la rama"
|
||||
work="$(mktemp -d)"
|
||||
( cd "$work" && unzip -q "$DEC/assets/nodejs-project.zip" )
|
||||
mv "$work/nodejs-project/src/server/node_modules" "$work/_nm"
|
||||
rm -rf "$work/nodejs-project/src"
|
||||
cp -r "$REPO/src" "$work/nodejs-project/src"
|
||||
rm -rf "$work/nodejs-project/src/server/node_modules"
|
||||
mv "$work/_nm" "$work/nodejs-project/src/server/node_modules"
|
||||
cp "$REPO/main.js" "$REPO/package.json" "$REPO/README.md" "$work/nodejs-project/"
|
||||
rm -f "$work/nodejs-project/.phone_state.json"
|
||||
rm -f "$DEC/assets/nodejs-project.zip"
|
||||
( cd "$work" && zip -q -r "$DEC/assets/nodejs-project.zip" nodejs-project )
|
||||
rm -rf "$work"
|
||||
|
||||
# --- paquete propio, para convivir con la oficial ------------------------------
|
||||
# Las clases se quedan como estan (com.solarnethub.oasis.OasisActivity...); cambia
|
||||
# el paquete, los authorities, el permiso propio y los nombres de las acciones de
|
||||
# llamada, que si no se cruzarian con los de la app oficial.
|
||||
say "paquete $PKG"
|
||||
sed -i \
|
||||
-e "s|package=\"$PKG_DEV\"|package=\"$PKG\"|" \
|
||||
-e "s|$PKG_DEV.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION|$PKG.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION|g" \
|
||||
-e "s|android:authorities=\"$PKG_DEV.|android:authorities=\"$PKG.|g" \
|
||||
"$DEC/AndroidManifest.xml"
|
||||
grep -rl "\"$PKG_DEV\.[A-Z_]*\"" "$DEC"/smali* | xargs -r sed -i -E "s|\"$PKG_DEV\.([A-Z_]+)\"|\"$PKG.\1\"|g"
|
||||
|
||||
# las de prueba (firmadas con la clave de depuracion) se marcan depurables: asi se
|
||||
# puede inspeccionar la WebView desde chrome://inspect. Las de publicar, nunca
|
||||
if [ -z "${OASIS_KEYSTORE:-}" ]; then
|
||||
sed -i 's|<application |<application android:debuggable="true" |' "$DEC/AndroidManifest.xml"
|
||||
fi
|
||||
|
||||
# version: la del dev por mil mas los commits de la rama, para que suba siempre
|
||||
CODE_DEV="$(sed -n 's/^ versionCode: //p' "$DEC/apktool.yml")"
|
||||
CODE="$(( CODE_DEV * 1000 + $(git -C "$REPO" rev-list --count HEAD) % 1000 ))"
|
||||
sed -i -e "s/^ versionCode: .*/ versionCode: $CODE/" -e "s/^ versionName: .*/ versionName: $VERSION-hacklab/" "$DEC/apktool.yml"
|
||||
say "version $VERSION-hacklab (codigo $CODE)"
|
||||
|
||||
# --- montar, alinear y firmar --------------------------------------------------
|
||||
say "montando"
|
||||
"$JAVA" -jar "$APKTOOL" b -q -o "$OUT/sin-firmar.apk" "$DEC"
|
||||
"$BT/zipalign" -f -p 4 "$OUT/sin-firmar.apk" "$OUT/alineada.apk"
|
||||
FINAL="$OUT/oasis-$VERSION-hacklab.apk"
|
||||
if [ -n "${OASIS_KEYSTORE:-}" ]; then
|
||||
"$BT/apksigner" sign --ks "$OASIS_KEYSTORE" --ks-pass "env:OASIS_KEYSTORE_PASS" \
|
||||
--ks-key-alias "${OASIS_KEY_ALIAS:-oasis}" --out "$FINAL" "$OUT/alineada.apk"
|
||||
else
|
||||
"$BT/apksigner" sign --ks "$HOME/.android/debug.keystore" --ks-pass pass:android \
|
||||
--ks-key-alias androiddebugkey --out "$FINAL" "$OUT/alineada.apk"
|
||||
fi
|
||||
rm -f "$OUT/sin-firmar.apk" "$OUT/alineada.apk" "$FINAL.idsig"
|
||||
"$BT/apksigner" verify "$FINAL" || die "la APK firmada no verifica"
|
||||
|
||||
say "APK: $FINAL ($(du -h "$FINAL" | cut -f1))"
|
||||
echo "sha256: $(sha256sum "$FINAL" | cut -d' ' -f1)"
|
||||
|
|
@ -1,106 +0,0 @@
|
|||
# Cómo moverse por Oasis en el móvil
|
||||
|
||||
Estudio para decidir la navegación de la rama. 19/10/2026.
|
||||
|
||||
## La pregunta
|
||||
|
||||
Oasis tiene casi sesenta módulos. En un escritorio caben en un menú lateral; en un
|
||||
móvil no. ¿Qué tiene que ver alguien al abrir la app, y cómo llega al resto?
|
||||
|
||||
## Lo que hay ahora
|
||||
|
||||
Medido en el emulador (Pixel 9, 412 px de ancho) con el workflow acampada (16 módulos):
|
||||
|
||||
- Al abrir, la portada es el lanzador: una rejilla de iconos por secciones que ocupa
|
||||
unos 1.300 px, casi pantalla y media. La actividad, que es lo que ha pasado en la
|
||||
red, empieza debajo y no se ve sin bajar.
|
||||
- Abajo hay una barra de cuatro atajos que elige cada cual (Actividad, Chats,
|
||||
Publicar, Buzón de serie).
|
||||
- Para ir a un módulo que no está en la barra hay que volver a la portada y buscarlo
|
||||
en la rejilla. Desde dentro de un módulo no hay acceso directo al resto.
|
||||
|
||||
O sea: la portada es un menú, no un sitio donde pase algo, y el menú solo está en la
|
||||
portada.
|
||||
|
||||
## Cómo lo hacen otras
|
||||
|
||||
- Instagram, TikTok, X: el contenido es la portada. Abajo, cinco destinos fijos
|
||||
(inicio, buscar, crear, vídeos, perfil). Lo demás vive en el perfil o en ajustes.
|
||||
- WhatsApp, Signal, Telegram: pestañas con las tres o cuatro cosas que se usan
|
||||
siempre. Lo demás, en un menú de tres puntos.
|
||||
- Google Maps, Gmail y la guía de Material Design: una barra abajo con entre tres y
|
||||
cinco destinos; si hay más, el último es "Más" y abre una hoja con el resto.
|
||||
- El menú hamburguesa arriba a la izquierda (muy usado en 2012-2016) se ha ido
|
||||
abandonando: lo que no se ve se usa menos, y arriba queda lejos del pulgar.
|
||||
|
||||
## Qué pedimos a la navegación
|
||||
|
||||
1. Al abrir, ver qué pasa (actividad), no un menú.
|
||||
2. Llegar a cualquier módulo desde cualquier pantalla, en dos toques como mucho.
|
||||
3. Que el sitio para hacerlo se vea sin explicarlo.
|
||||
4. Que se alcance con el pulgar con el móvil en una mano.
|
||||
5. No tapar contenido ni quitar sitio de pantalla.
|
||||
|
||||
## Opciones
|
||||
|
||||
| | Portada | Llegar a un módulo | Pulgar | Se ve | Comentario |
|
||||
|---|---|---|---|---|---|
|
||||
| A. Como ahora | Lanzador | Volver a portada y buscar | Medio | Sí, en portada | La actividad queda enterrada |
|
||||
| B. Botón "Menú" bajo la barra de arriba | Actividad | 2 toques | Lejos (arriba) | Sí | La idea inicial; ocupa una fila arriba |
|
||||
| C. "Menú" como quinta pestaña de abajo | Actividad | 2 toques | Cerca | Sí | Patrón "Más" de Android y de las apps grandes |
|
||||
| D. Hamburguesa arriba | Actividad | 2 toques | Lejos | Poco | Lo que se esconde se usa menos |
|
||||
|
||||
## Recomendación
|
||||
|
||||
C: la portada pasa a ser la actividad, y la barra de abajo tiene siempre una quinta
|
||||
pestaña, "Menú", que abre desde abajo una hoja con todos los módulos agrupados (el
|
||||
mismo lanzador de ahora). Se cierra tocando fuera, con la X o volviendo a tocar Menú.
|
||||
|
||||
Es la idea de B, un botón que despliega las opciones, pero puesto donde llega el
|
||||
pulgar y donde está el resto de la navegación. Funciona igual en todas las pantallas.
|
||||
Está hecho con una casilla y CSS; un script mínimo hace que el botón atrás de Android
|
||||
cierre la hoja en vez de cambiar de página.
|
||||
|
||||
## Lo que salió en la prueba (emulador, 19/10/2026)
|
||||
|
||||
- Portada a Tribus: dos toques (Menú, Tribus). Desde Foros a Votaciones: dos toques.
|
||||
- En la primera versión la hoja dejaba ver 690 px y los módulos medían 1.200: Tribus
|
||||
quedaba fuera. Con cinco iconos por fila y los grupos pequeños de dos en dos, los 20
|
||||
módulos caben a 412 y a 360 px.
|
||||
- Se cierra tocando fuera, con la X y volviendo a tocar Menú.
|
||||
- Con la hoja abierta, atrás cambiaba de página. Ahora la cierra.
|
||||
- Al volver atrás desde un módulo abierto desde la hoja, la portada salía con la hoja
|
||||
abierta (la entrada del historial de la hoja seguía ahí). Ahora se quita antes de
|
||||
ir al módulo: atrás lleva a la portada, cerrada.
|
||||
|
||||
## Tamaños y sistemas (19/10/2026)
|
||||
|
||||
| Dónde | Motor | Resultado |
|
||||
|---|---|---|
|
||||
| Pixel 9, Android 16, 412 dp, gestos y tres botones | WebView actual | Bien. APK real |
|
||||
| 360 × 540 dp | WebView actual | La hoja se desliza; todo se alcanza |
|
||||
| Tableta 800 dp | WebView actual | Contenido y hoja centrados a 720 y 640 px |
|
||||
| Letra al 130 % | WebView actual | Bien |
|
||||
| Pixel 3a, Android 9, 412 dp | WebView 69 | Tras los arreglos, bien (ver abajo) |
|
||||
| 360 dp, Android 11 | Chrome/WebView 83 | Bien: 9 pantallas sin desbordar, 5 pestañas de 70 dp |
|
||||
|
||||
En Android 9 y 11 de emulador la APK no arranca: el node del dev es arm64 y el
|
||||
traductor ARM de las imágenes x86 se para con una instrucción que no conoce (SIGILL).
|
||||
Pasa igual con la APK oficial, así que no es de la rama. Se probó el motor con el
|
||||
servidor local.
|
||||
|
||||
Lo que fallaba en WebView 69 y está arreglado:
|
||||
|
||||
- Fichas y filtros pegados unos a otros (no hay `gap` en flex): márgenes.
|
||||
- La hoja del menú no se veía (no hay `inset` ni `dvh`): top/left/right/bottom y `vh`.
|
||||
- Los grupos pequeños del menú se montaban (no hay `:has`): de dos en dos solo donde
|
||||
hay `:has`; en el viejo, cada grupo a lo ancho.
|
||||
- El botón No molestar quedaba encima de la hoja: ahora va por debajo de ella.
|
||||
|
||||
## Qué mirar en la prueba
|
||||
|
||||
- Desde la portada, cuántos toques hacen falta para llegar a Tribus, a Votaciones y a
|
||||
Ajustes, y desde dentro de un foro.
|
||||
- Si alguien que no conoce la app encuentra el menú sin que se lo digan.
|
||||
- Si al volver atrás la hoja se queda abierta (no debería).
|
||||
- Que la barra de cinco quepa a 360 px de ancho.
|
||||
|
|
@ -1,163 +0,0 @@
|
|||
# Frontera con upstream: que se puede tocar y que no
|
||||
|
||||
Las normas del dia a dia estan en [GUIA_DE_DESARROLLO.md](../GUIA_DE_DESARROLLO.md); esto
|
||||
es la medicion de la que salen.
|
||||
|
||||
Medido el 19/10/2026 sobre el repositorio del dev (code.03c8.net/krakenslab/oasis),
|
||||
de la release 1.0.1 (29/08) a la 1.3.3 (18/10), y comparado con la rama PRUEBAS.
|
||||
Los scripts estan en el historial de la sesion; para repetirlo basta clonar upstream y
|
||||
contar con `git log --numstat` por fichero.
|
||||
|
||||
## 1. Como trabaja el dev
|
||||
|
||||
- Una release casi cada dia: 39 entre la 1.0.1 y la 1.3.3, en siete semanas.
|
||||
Cada una es un solo commit ("Oasis release X.Y.Z"), sin historia intermedia: no
|
||||
hay forma de coger un cambio suelto, siempre llega todo junto.
|
||||
- El backend crece sin parar: `backend.js` paso de ~9.900 a ~15.400 lineas y se
|
||||
toca en 34 de 39 releases. La cadena de middleware (el array `middleware` del final)
|
||||
ha ganado capas en este tiempo: contexto por peticion (`requestScope`), idioma de
|
||||
primera ejecucion, CSP, etc.
|
||||
- Ya piensa en el movil: tiene su propio tema `OasisMobile.css`, usa la misma
|
||||
bandera `process.env.OASIS_MOBILE` que la rama y tiene workflows `activists` y
|
||||
`mobile`. Va en nuestra direccion; cuanto mas nos pisemos con el, peor.
|
||||
- Las clases CSS cambian, pero poco a poco: de la 1.0.1 a la 1.2.0 aparecen 335
|
||||
clases y desaparecen 98; hasta la 1.3.1, +304 / -94; de la 1.3.1 a la 1.3.3, +47 / -8.
|
||||
Unas 100 clases muertas cada tres semanas, sobre ~2.200: un 5 %.
|
||||
|
||||
Ficheros que mas toca (releases que lo tocan de 39 / lineas movidas):
|
||||
|
||||
| Fichero | Releases | Lineas |
|
||||
|---|---|---|
|
||||
| `src/backend/backend.js` | 34 | 11.621 |
|
||||
| `src/server/package.json` + lock | 34 | 22.700 |
|
||||
| `src/client/assets/styles/style.css` | 32 | 2.522 |
|
||||
| `translations/oasis_*.js` (los 11) | 31 cada uno | ~2.000 cada uno |
|
||||
| `src/views/main_views.js` | 31 | 2.634 |
|
||||
| `src/views/activity_view.js` | 27 | 1.183 |
|
||||
| `src/views/tribes_view.js` | 23 | 2.675 |
|
||||
| `src/client/assets/themes/OasisMobile.css` | 21 | 3.040 |
|
||||
| `src/views/settings_view.js` | 17 | 849 |
|
||||
| `src/views/vote_view.js` | 14 | 155 |
|
||||
| `src/configs/config-manager.js` | 11 | 120 |
|
||||
| `src/client/assets/styles/mobile.css` | 10 | 514 |
|
||||
| `src/views/parliament_view.js` | 10 | 282 |
|
||||
| `src/models/workflows_model.js` | 8 | 133 |
|
||||
| `src/views/welcome_view.js` | 8 | 440 |
|
||||
| `src/server/SSB_server.js` | 7 | 183 |
|
||||
| `src/models/votes_model.js` | 4 | 33 |
|
||||
| `src/backend/request_guards.js` (CSP) | 4 | 111 |
|
||||
|
||||
## 2. El mapa
|
||||
|
||||
### Rojo: no se toca
|
||||
|
||||
Cada linea nuestra aqui es un conflicto casi seguro en la siguiente release, y ademas
|
||||
cambia el comportamiento de la red, no solo como se ve.
|
||||
|
||||
- `src/backend/backend.js`: rutas y, sobre todo, la cadena de `middleware`.
|
||||
- `src/models/*` de upstream: lo que se publica en el log SSB y como se cuenta. Si dos
|
||||
nodos cuentan distinto (votos, tribus, gobierno), la red se rompe. Ejemplo: "quien
|
||||
crea una votacion no vota" es una regla de `votes_model.js`; cambiarla en la rama
|
||||
haria que nuestro movil y un escritorio dieran resultados distintos.
|
||||
- `src/server/*` (arranque de SSB, `ssb_config`, `package.json` y dependencias).
|
||||
- `src/backend/request_guards.js` (CSP: no hay estilos ni scripts en linea, y asi
|
||||
debe seguir).
|
||||
- `translations/oasis_*.js`: se reescriben en 31 de 39 releases.
|
||||
- `src/client/assets/styles/style.css` y `mobile.css`: son del dev.
|
||||
- `src/client/assets/themes/*.css` de upstream, incluido `OasisMobile.css`.
|
||||
|
||||
### Ambar: solo un gancho de una linea, con la bandera de movil
|
||||
|
||||
Las vistas de upstream (`main_views.js`, `tribes_view.js`, `vote_view.js`,
|
||||
`settings_view.js`...). Se reescriben a menudo, asi que lo que la rama necesite ahi va
|
||||
como una llamada a una funcion nuestra, envuelta en
|
||||
`process.env.OASIS_MOBILE === '1'`, y la logica vive en un fichero propio. Si el dev
|
||||
reescribe la vista, se vuelve a poner una linea, no se reconstruye una funcion.
|
||||
|
||||
### Verde: nuestro, sin limite
|
||||
|
||||
- Hojas propias: `themes/Acampada.css`, `styles/mobile_base.css`,
|
||||
`styles/hive_acampada.css`, `styles/mobile_ux.css`.
|
||||
- Vistas propias: `hive_views.js`, `mobile_icons.js`, `identities_view.js` y las
|
||||
`mobile_*.js`.
|
||||
- El router propio `src/backend/fork_routes.js`: va justo antes del de upstream en la
|
||||
cadena y ve todas las peticiones. Puede atender rutas nuevas, o envolver una ruta de
|
||||
upstream (dejarla hacer y retocar la respuesta) sin editarla.
|
||||
- Traducciones propias en `i18n_fork.js` (se mezclan encima de las de upstream).
|
||||
- Modelo propio `fork_workflows.js`.
|
||||
- Todo `android/`.
|
||||
|
||||
## 3. Como esta la rama hoy
|
||||
|
||||
43 ficheros de upstream modificados y 22 propios. Lo que mas pesa:
|
||||
|
||||
| Fichero de upstream | Lineas distintas | Riesgo |
|
||||
|---|---|---|
|
||||
| `themes/OasisMobile.css` | 4.873 | Alto: es casi otro fichero. El dev lo toca en 21/39 releases y cada vez hay que elegir entre su version y la nuestra. |
|
||||
| `views/main_views.js` | 173 | Alto (31/39) |
|
||||
| `backend/backend.js` | 133 (21 trozos) | Alto (34/39) |
|
||||
| `configs/oasis-config.json` | 135 | Medio: es la config de serie del movil |
|
||||
| `backend/blobHandler.js` | 190 | Medio (limpieza de fotos) |
|
||||
| `backend/updater.js` | 133 | Bajo (3/39) |
|
||||
| `views/modules_view.js` | 91 | Bajo (5/39) |
|
||||
| `views/parliament_view.js`, `settings_view.js`, `vote_view.js`, `tribes_view.js` | 50-75 cada una | Medio-alto |
|
||||
| `models/workflows_model.js` | 31 | Medio: redefine el workflow `activists` del dev |
|
||||
| `server/ssb_config.js`, `SSB_server.js` | 40 | Medio (multicuenta) |
|
||||
|
||||
Las hojas propias y `OasisMobile.css` llevan selectores a clases que ya no pinta nadie:
|
||||
61 en `OasisMobile.css`, 29 en `hive_acampada.css`, 5 en `Acampada.css`. No rompen
|
||||
nada, pero son peso muerto y confunden.
|
||||
|
||||
## 4. Reglas para cada cambio
|
||||
|
||||
1. Primero CSS. Si se arregla con una hoja propia, se arregla ahi.
|
||||
2. Si hace falta HTML distinto: funcion en un fichero propio + una linea en la vista
|
||||
de upstream, con la bandera de movil.
|
||||
3. Si hace falta una ruta o retocar una respuesta: en el router propio. Nunca una
|
||||
ruta nueva ni un middleware en `backend.js`.
|
||||
4. Nunca cambiar lo que se publica ni como se cuenta (modelos de upstream).
|
||||
5. Textos nuevos en `i18n_fork.js`, en los 11 idiomas.
|
||||
6. Nada en linea: la CSP no deja `style=""` ni `<script>` en la pagina.
|
||||
7. Antes de integrar una release, repetir esta medicion y mirar si alguna clase de la
|
||||
que dependemos ha desaparecido.
|
||||
|
||||
## 5. Plan para sanear la rama
|
||||
|
||||
- Fase A (hecha el 19/10): lo del 19/10 que se metio en ficheros del dev sale a
|
||||
ficheros propios. Ver la seccion 6.
|
||||
- Fase B (hecha el 19/10): `OasisMobile.css` vuelve a ser la del dev, identica a la
|
||||
1.3.3. Nuestra maqueta esta en `styles/mobile_base.css`, que el movil carga siempre
|
||||
antes del skin. Con el skin OasisMobile se ve el tema del dev encima de la maqueta.
|
||||
- Fase C (hecha el 19/10): de los 20 trozos de `backend.js` quedan 9 (21 lineas).
|
||||
El resto (rutas cortadas en el movil, modulos que no van, avatar, lanzador,
|
||||
retoques del HTML, descargas de la copia de seguridad, cambio de tema) esta en
|
||||
`backend/mobile_layer.js` y en `fork_routes.js`. Los que quedan no se pueden
|
||||
hacer desde fuera: expresiones regulares para Node 18, no abrir el navegador, fotos
|
||||
duplicadas, modo chats y el montaje del router.
|
||||
- Fase D (hecha el 19/10): `activists` vuelve a ser el del dev. Karvan y el workflow
|
||||
`acampada` se registran desde `models/fork_workflows.js`. La config de serie del
|
||||
movil coincide con `acampada`. Queda en `workflows_model.js` la logica de skins del
|
||||
movil.
|
||||
- Correccion (19/10, noche): para el movil la referencia buena es el nodejs-project de
|
||||
la APK oficial, no el git del dev. Parte de lo que la fase C saco de backend.js era
|
||||
codigo de movil del propio dev; backend.js vuelve a ser su version de movil mas dos
|
||||
lineas (montar nuestro router), y modules_view.js su version de movil. Contra la APK
|
||||
oficial la rama cambia 17 ficheros y 561 lineas; la lista esta en la guia.
|
||||
- Fuera Karvan (19/10): el dev ya trae Telefono y Salas, asi que se quita el modulo
|
||||
propio de salas y llamadas (modelo, vista, JS, CSS, router, pruebas, textos).
|
||||
`Clear-SNH.css` y `modules_view.js` vuelven a ser identicos a los del dev y la linea
|
||||
de `SSB_server.js` que enchufaba el audio de Android pasa a `main.js`, usando los
|
||||
`setAudioFactory`, `setVideoFactory` y `setStreamFactory` que el propio dev exporta.
|
||||
- Fase E (hecha el 19/10): fuera 153 selectores muertos de `mobile_base.css`,
|
||||
`hive_acampada.css` y `Acampada.css` (287 lineas). Comparado pixel a pixel en 14
|
||||
pantallas: identicas.
|
||||
|
||||
## 6. Fase A: lo que se ha movido
|
||||
|
||||
| Antes (en ficheros del dev) | Ahora |
|
||||
|---|---|
|
||||
| `backend.js`: `checkMod` envuelto para recordar la seccion apagada | El router propio mira las redirecciones a `/modules` de vuelta |
|
||||
| `backend.js`: ruta `POST /modules/enable` y `?saved=1` tras guardar | Router propio |
|
||||
| `modules_view.js`: vista de Modulos del movil | `views/mobile_modules_view.js`, servida por el router propio |
|
||||
| `workflows_model.js`: cuatro modulos mas en `activists` | Workflow propio `acampada` registrado desde `models/fork_workflows.js` |
|
||||
| `vote_view.js`, `tribes_view.js`, `settings_view.js`, `main_views.js`, `welcome_view.js`: funciones nuevas | Funciones en `views/mobile_ui.js`; en la vista de upstream queda la llamada |
|
||||
|
|
@ -1,254 +0,0 @@
|
|||
Prueba de usabilidad - Oasis Mobile 1.3.3 (rama PRUEBAS, tema Acampada)
|
||||
19/10/2026 - emulador Pixel 9, Android 16, 1080x2424; tambien 720x1280 y letra al 130 %
|
||||
Escenario: alguien de la acampada que entra, cambia a castellano, activa tribus
|
||||
y parlamento, crea tribus, vota y abre una sala de voz.
|
||||
|
||||
=================================
|
||||
Botones complejos de interpretar
|
||||
=================================
|
||||
|
||||
- Los iconos del lanzador son letras del alfabeto vai (ꔙ ꕿ ꖒ ꕒ ꔗ...). No dicen
|
||||
nada de lo que hacen: hay que leer siempre la etiqueta de debajo.
|
||||
- Karvan (⧗) y Nodos (⧖) llevan casi el mismo reloj de arena.
|
||||
Videos lleva algo que parece un simbolo de dolar.
|
||||
- En la sala de voz los botones 🎤 🎥 ⛔ no tienen texto ni descripcion.
|
||||
⛔ no se sabe si es colgar, silenciar o salir.
|
||||
- En cada tarjeta de tribu el primer boton es una ✕ roja: es BORRAR TRIBU y
|
||||
borra con un solo toque, sin preguntar. Probado: la tribu desaparece.
|
||||
- En "Crear tribu" el texto CREAR TRIBU sale tres veces (boton de arriba,
|
||||
titulo y boton de enviar). El de arriba recarga la pagina y borra lo escrito.
|
||||
- Dos campos seguidos se llaman "Ubicacion" (lugar y enlace de mapa).
|
||||
- "Estado: Privada/Publica" y "Modo: Estricto/Abierta" no explican que
|
||||
significan. Por defecto sale Privada + Estricto.
|
||||
- Votacion: las opciones salen mezcladas en ingles y castellano
|
||||
(YES, NO, ABSTENTION, CONFUNDIDO, SEGUIR MAYORIA, SIN INTERES), partidas por
|
||||
silabas y la tabla se corta por la derecha.
|
||||
- Jerga a la vista: "server relay", "Synced-peers", "HcT/HcH", "TASA ECO",
|
||||
la clave @...ed25519 completa en el pie y en el buzon.
|
||||
|
||||
=================================
|
||||
Inicio intuitivo "empezar"
|
||||
=================================
|
||||
|
||||
- El aviso "Tu avatar aun no esta configurado - EMPEZAR" sale arriba en TODAS
|
||||
las paginas. En pantalla pequena se come casi una quinta parte. EMPEZAR no
|
||||
dice que va a pasar (es poner foto y nombre).
|
||||
- En Bienvenida el primer boton, el mas grande, es "AHORA NO". Lo facil es
|
||||
saltarse todo.
|
||||
- El nombre viene relleno con un trozo de la clave (jlYELyx4). Parece un nombre
|
||||
ya puesto y no lo es.
|
||||
- El idioma sale en el del movil la primera vez (el emulador estaba en ingles).
|
||||
Cambiarlo despues costaba: Ajustes, bajar a la segunda pantalla, desplegable,
|
||||
Espanol y boton "Establecer idioma".
|
||||
- La pantalla de carga si sale en castellano y tarda unos 10 s: bien.
|
||||
- Ajustes mide unas 7 pantallas. Lo que mas se toca (idioma, aspecto) queda
|
||||
debajo de Identidades, que es lo mas tecnico.
|
||||
- Al abrir una seccion apagada (p. ej. Tribus) te manda sin avisar a la tabla
|
||||
de Modulos. Deberia decir "Tribus esta apagado - Activar".
|
||||
- Invitar a una sala pide el @feed-id de la otra persona. Nadie lo sabe de
|
||||
memoria: hace falta elegir de los contactos o compartir un enlace / QR.
|
||||
|
||||
=================================
|
||||
Reduccion de funcionalidades
|
||||
"skin activistas"
|
||||
=================================
|
||||
|
||||
- El flujo "activistas" NO activa tribus, parlamento ni votaciones. Para una
|
||||
acampada con 12 tribus es justo lo que falta. Propuesta: anadir tribes,
|
||||
parliament y votes al flujo activistas (o hacer un flujo "acampada").
|
||||
- Activar un modulo a mano: la pagina Modulos son 52 filas, unas 11 pantallas.
|
||||
Tribus esta a 7800 px de altura y la casilla mide 16x16 px (lo minimo
|
||||
recomendable son 48). El boton Guardar esta al final del todo.
|
||||
- Propuesta: en movil, Modulos como interruptores grandes agrupados
|
||||
(Hablar / Organizarse / Compartir) y guardado al tocar.
|
||||
- Cada tribu que creas te manda a ti mismo un mensaje del POLITICALBOT
|
||||
("Ha comenzado un nuevo ciclo de gobierno... Anarchy"). 12 tribus = 12 avisos
|
||||
en el buzon. En movil sobra o deberia agruparse.
|
||||
|
||||
=================================
|
||||
Voz y turnos de palabra
|
||||
=================================
|
||||
|
||||
- LLAMAR en Karvan pide primero la CAMARA aunque solo quieras hablar. Si dices
|
||||
que no a la camara la llamada no arranca, aunque des el micro. Sale el aviso
|
||||
"pendiente del wrapper", que es una nota interna.
|
||||
Arreglo sencillo: LLAMAR empieza solo con audio y 🎥 anade video.
|
||||
(src/client/public/js/karvan.js, funcion startCall)
|
||||
- Solo el boton 🎤 suelto funciona con audio.
|
||||
- La llamada de Karvan se corta al cambiar de pagina (probado: el micro se para
|
||||
al entrar en Parlamento). La sala que sigue viva al navegar es la de Mumble,
|
||||
que va en otra app.
|
||||
- Propuesta: en Parlamento y en cada tribu, boton "Entrar a la sala de voz" que
|
||||
abra mumble://mumble.acampada26s.net/<canal de la tribu>.
|
||||
- Turnos de palabra con tiempo: no existe. Encaja como cola de "pido palabra"
|
||||
dentro de la sala (lista + cronometro visible para todas).
|
||||
|
||||
=================================
|
||||
Listado de tribus
|
||||
=================================
|
||||
|
||||
- Confirmado: cada tribu ocupa casi una pantalla porque todas repiten la misma
|
||||
imagen generica grande. 12 tribus = 10 pantallas de scroll.
|
||||
- Propuesta: lista compacta (nombre + miembros + publica/privada, una linea por
|
||||
tribu), imagen solo si la tribu tiene una propia, y el buscador arriba fijo.
|
||||
|
||||
=================================
|
||||
Votaciones: semaforo / termometro
|
||||
=================================
|
||||
|
||||
- El servidor ya acepta opciones propias (campo "options" separado por comas),
|
||||
pero el formulario no lo ofrece: siempre salen las 6 opciones fijas.
|
||||
- Propuesta: al crear, elegir tipo: Si/No - Semaforo (verde, amarillo, rojo) -
|
||||
Termometro (1 a 5). Sale casi gratis.
|
||||
- Quien crea la votacion no puede votar en ella y la pantalla no lo explica.
|
||||
En una asamblea, quien facilita se queda sin voto.
|
||||
- El selector de fecha es el nativo de Android y propone una semana: bien.
|
||||
|
||||
=================================
|
||||
Lo que va bien
|
||||
=================================
|
||||
|
||||
- Sin desbordes horizontales en 1080 ni en 720 de ancho (salvo tablas).
|
||||
- Tema Acampada legible, botones grandes, buen contraste.
|
||||
- La barra de abajo (Actividad, Chats, Publicar, Buzon) se entiende sola.
|
||||
- Actualizar encima de la 0.9.5 no pierde la identidad ni los datos.
|
||||
|
||||
Pendiente de probar: primer arranque en limpio (instalacion nueva), dos moviles
|
||||
en la misma sala, y la barra de abajo que tapa media pantalla con el teclado
|
||||
abierto (visto en Crear tribu).
|
||||
|
||||
=================================
|
||||
Arreglado en PRUEBAS (19/10/2026)
|
||||
=================================
|
||||
|
||||
- Borrar pide confirmacion ("¿Borrar para siempre? Si, borrar") y la X va la
|
||||
ultima de la fila. Vale para tribus y para todo el contenido.
|
||||
- Karvan: Llamar empieza solo con voz y la camara se anade con su boton.
|
||||
Botones con icono y nombre (Llamar, Micro, Camara, Colgar). Sin el recuadro
|
||||
gris en llamadas de voz. Ayuda para invitar. Textos en los 11 idiomas.
|
||||
- Votaciones: al crear se elige Si/No, Semaforo (verde, amarillo, rojo) o
|
||||
Termometro (1 a 5). Resultados en barras. Aviso a quien la crea.
|
||||
- Flujo activistas con tribus, parlamento, votaciones y eventos.
|
||||
- Abrir una seccion apagada dice cual es y ofrece "Activar" de un toque.
|
||||
- Modulos: agrupados como el lanzador, interruptores grandes, Guardar fijo.
|
||||
- Tribus en filas compactas (12 tribus: de 10 pantallas a unas 3). Dentro de
|
||||
una tribu, las 25 secciones en una tira de fichas + "Mas".
|
||||
- Iconos del lanzador y de la barra: dibujos claros en vez de letras vai.
|
||||
La barra de abajo marca la seccion abierta y se esconde con el teclado.
|
||||
- Ajustes empieza por idioma y aspecto; los idiomas se eligen de un toque.
|
||||
- Aviso del avatar solo en la portada y en una linea. "Ahora no" al final de
|
||||
la bienvenida.
|
||||
- Pie de una linea: "Aun no sincronizas con nadie" / "Sincronizando con N".
|
||||
- Fuera la jerga: tasa ECO, HcT/HcH, "server relay".
|
||||
- Espacio: un solo margen de 14px (las fichas perdian 74px por lado).
|
||||
Letra: Anton solo en titulos y en el boton principal.
|
||||
|
||||
=================================
|
||||
Sigue pendiente
|
||||
=================================
|
||||
|
||||
- Invitar a una sala o mandar un privado eligiendo de los contactos, no
|
||||
pegando el @id.
|
||||
- Turnos de palabra con tiempo en la sala de voz.
|
||||
- Boton "Entrar a la sala de voz" (Mumble) en Parlamento y en cada tribu.
|
||||
- Quien crea una votacion sigue sin poder votar (regla de upstream: cambiarla
|
||||
haria que los nodos contaran distinto).
|
||||
- Probar un primer arranque en limpio y dos moviles en la misma sala.
|
||||
|
||||
=================================
|
||||
Segunda ronda (19/10/2026, tarde)
|
||||
=================================
|
||||
|
||||
Desde ahora cada arreglo respeta docs/FRONTERA_UPSTREAM.md: primero CSS propio;
|
||||
si hace falta HTML, una funcion propia y una linea en la vista del dev; nada en
|
||||
backend, middleware ni modelos del dev.
|
||||
|
||||
- Boton principal de cada modulo (Crear chat, foro, pad, feed, evento...): el
|
||||
mismo en todos. Antes unos a todo lo ancho, otros estrechos, otros con otra
|
||||
letra. [CSS]
|
||||
- Listas vacias: icono, texto centrado y recuadro discontinuo, en vez de una
|
||||
linea suelta ("No se encontraron chats."). [CSS]
|
||||
- El pie se queda abajo aunque la pagina sea corta. [CSS]
|
||||
- Buzon: cada mensaje en pocas lineas (remitente y fecha arriba, el aviso, el
|
||||
titulo y tres lineas de texto). Fuera Para (eres tu), Tasa ECO y Cifrado, que
|
||||
eran iguales en todos. Antes un aviso ocupaba una pantalla. [CSS]
|
||||
- Busqueda: primero el campo de buscar; las fechas y el ID despues. [CSS]
|
||||
- Nodos: Refrescar, Quitar inactivos y Exportar en rejilla. [CSS]
|
||||
- Workflow propio "Acampada" (chats, Karvan, pads, foros, wiki, tribus,
|
||||
parlamento, votaciones, eventos...) en Ajustes y en la bienvenida, sin tocar
|
||||
el "Activismo" del dev. [fichero propio]
|
||||
|
||||
Visto y pendiente de esta ronda:
|
||||
- Buzon: Marcar como leido / Archivar / Borrar siguen uno debajo de otro.
|
||||
- Buzon: los avisos del PoliticalBot de las tribus propias siguen llegando
|
||||
(es upstream; se puede agrupar en la vista, no quitar).
|
||||
- Los ID @...ed25519 siguen a la vista en perfil, buzon y nodos: falta mostrar
|
||||
el nombre y dejar el ID para "copiar".
|
||||
|
||||
=================================
|
||||
Tercera ronda: emulador limpio (19/10/2026, noche)
|
||||
=================================
|
||||
|
||||
Emulador nuevo (Pixel 9, Android 16, 12 GB) para probar una instalacion desde
|
||||
cero con la APK sin Karvan (codigo 102).
|
||||
|
||||
- Primer arranque: 18 segundos hasta la portada. Bien.
|
||||
- Quien entra por primera vez no ve por donde empezar: el lanzador ocupa toda la
|
||||
pantalla y el aviso "pon tu nombre y tu foto" quedaba a 1.300 px, abajo del
|
||||
todo. Arreglado: el aviso va encima del lanzador. [CSS]
|
||||
- El boton No molestar del Telefono ocupaba una fila entera arriba de cada
|
||||
pagina. Arreglado: va en la barra de arriba, junto al avatar. [CSS]
|
||||
- Ajustes marcaba English aunque se hubiera elegido otro idioma. Arreglado. [vista propia]
|
||||
- LLAMADAS: en esta APK el Telefono dice "las llamadas no estan disponibles". La
|
||||
APK oficial del dev trae un puente nativo de audio y camara (.audio_bridge) y
|
||||
la nuestra no. Hay que escribirlo en la app Android o construir sobre la APK
|
||||
oficial. Mientras tanto, en la rama no hay llamadas.
|
||||
- El avatar por defecto (una calavera con gafas) asusta un poco para quien
|
||||
empieza. Es del dev; se puede cambiar solo en el skin.
|
||||
|
||||
Con la APK montada sobre la oficial (1.3.3-hacklab, mismo emulador):
|
||||
- Llamadas: ahora SI. El Telefono ofrece "Nueva llamada" y en una sala el micro
|
||||
graba de verdad (comprobado en Android: VOICE_COMMUNICATION activo) y se corta
|
||||
al salir.
|
||||
- Turnos de palabra: las Salas del dev ya tienen "Levantar la mano". Es la base de
|
||||
lo que pedia el pad; falta el cronometro.
|
||||
- Dentro de una sala, los controles (silenciar, camara, levantar la mano, salir)
|
||||
quedan muy abajo, debajo de la ficha de la sala. Habria que tenerlos a mano
|
||||
mientras se esta dentro. [pendiente, CSS]
|
||||
- La app del dev pide el permiso de camara nada mas abrirse, antes de ensenar
|
||||
nada. Es su app; no se toca.
|
||||
- El boton de crear sala se tapa con el teclado al escribir el titulo: hay que
|
||||
cerrar el teclado para verlo.
|
||||
|
||||
=================================
|
||||
Cuarta ronda: tamaños y sistemas (19/10/2026)
|
||||
=================================
|
||||
|
||||
Con la navegación nueva (portada = actividad, pestaña Menú abajo).
|
||||
|
||||
- Android 16, Pixel 9 (APK real): menú, atrás con gesto y con tres botones, ir a un
|
||||
módulo desde la hoja y volver. Bien.
|
||||
- Fallaba: volver atrás desde un módulo abierto desde la hoja dejaba la portada con
|
||||
la hoja abierta. Arreglado. [script propio]
|
||||
- Android 9 (WebView 69, el que trae sin actualizar): fichas pegadas, hoja del menú
|
||||
sin pintar, grupos montados y No molestar encima de la hoja. Arreglado. [CSS]
|
||||
- Android 11 a 360 dp (motor 83): nueve pantallas sin desbordar, sin textos de
|
||||
menos de 12 px ni botones de menos de 40 px. La barra de cinco cabe (70 dp cada una).
|
||||
- En emulador, Android 9 y 11 no arrancan la APK (ni la nuestra ni la oficial): el
|
||||
node arm64 no corre en el traductor ARM de x86. En un móvil de verdad no pasa; falta
|
||||
probarlo en uno físico con Android 9-11.
|
||||
- Tableta y letra al 130 %: bien.
|
||||
- Bienvenida, paso Tipo: había que elegir una tarjeta y bajar seis tarjetas grandes
|
||||
hasta Guardar. Ahora cada opción es un botón: un toque y queda guardado, el paso se
|
||||
marca y se abre el siguiente. Una fila por opción con miniatura (en rejilla de tres,
|
||||
"Conversaciones" se partía a 360 px). En Ajustes, lo mismo, y la sección va justo
|
||||
después del idioma para encontrarla fácil. [vista propia + una línea en welcome_view
|
||||
y settings_view]
|
||||
- Bienvenida: faltaba elegir para qué se usa Oasis (el backend del dev quita ese paso
|
||||
en el móvil). Vuelve como paso "Perfil", el segundo, justo después de la identidad:
|
||||
Activismo primero, luego Acampada y el resto, cada uno con una línea de para qué
|
||||
sirve. Un toque y guardado. En Ajustes, la misma lista. [vista propia + una línea
|
||||
en welcome_view y settings_view]
|
||||
- Con el perfil Activismo (45 módulos) los botones Personal/Social de la barra de
|
||||
arriba no cabían a 360 px y tapaban el logo. Ya no hacían falta (filtraban el
|
||||
lanzador, que ahora está en la hoja del menú): quitados. [vista propia]
|
||||
|
Before Width: | Height: | Size: 305 KiB After Width: | Height: | Size: 242 KiB |
|
Before Width: | Height: | Size: 134 KiB After Width: | Height: | Size: 50 KiB |
BIN
docs/capturas/02-chats.png
Normal file
|
After Width: | Height: | Size: 135 KiB |
|
Before Width: | Height: | Size: 160 KiB |
BIN
docs/capturas/03-karvan.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 94 KiB |
BIN
docs/capturas/04-foros.png
Normal file
|
After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 133 KiB |
|
Before Width: | Height: | Size: 110 KiB |
BIN
docs/capturas/05-feed.png
Normal file
|
After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 36 KiB |
BIN
docs/capturas/07-karvan-salas.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 54 KiB |
|
|
@ -1,208 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Portada y esquemas del README, con la paleta del tema Acampada.
|
||||
|
||||
Las capturas de movil (01..08) se hacen aparte, con la app arrancada en un nodo de
|
||||
pruebas a 412x915 y escala 2; este script compone 00-portada.png a partir de ellas y
|
||||
dibuja esquema-interfaz.png, esquema-rama.png y esquema-llamadas.png.
|
||||
El texto vive aqui: se cambia y se relanza, nunca se retocan los PNG a mano.
|
||||
|
||||
python3 docs/capturas/generar_portada.py
|
||||
"""
|
||||
import math
|
||||
from pathlib import Path
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ANTON = HERE.parents[1] / "src/client/assets/fonts/Anton-Regular.ttf"
|
||||
SANS = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"
|
||||
|
||||
BG, FG, NEON, MUTED = "#ffffff", "#000000", "#00e5ff", "#555555"
|
||||
|
||||
|
||||
def font(path, size):
|
||||
return ImageFont.truetype(str(path), size)
|
||||
|
||||
|
||||
def phone(shot, width):
|
||||
"""Una captura dentro de un marco negro grueso con la sombra cian de los botones."""
|
||||
im = Image.open(HERE / shot).convert("RGB")
|
||||
h = round(im.height * width / im.width)
|
||||
im = im.resize((width, h), Image.LANCZOS)
|
||||
pad, radius, shadow = 10, 34, 10
|
||||
frame = Image.new("RGBA", (width + 2 * pad, h + 2 * pad + shadow), (0, 0, 0, 0))
|
||||
d = ImageDraw.Draw(frame)
|
||||
d.rounded_rectangle((0, shadow, width + 2 * pad - 1, h + 2 * pad + shadow - 1), radius, fill=NEON)
|
||||
d.rounded_rectangle((0, 0, width + 2 * pad - 1, h + 2 * pad - 1), radius, fill=FG)
|
||||
mask = Image.new("L", im.size, 0)
|
||||
ImageDraw.Draw(mask).rounded_rectangle((0, 0, width - 1, h - 1), radius - pad, fill=255)
|
||||
frame.paste(im, (pad, pad), mask)
|
||||
return frame
|
||||
|
||||
|
||||
def portada():
|
||||
W, H = 1600, 1100
|
||||
out = Image.new("RGB", (W, H), BG)
|
||||
d = ImageDraw.Draw(out)
|
||||
d.text((80, 40), "OASIS MOBILE", font=font(ANTON, 92), fill=FG)
|
||||
bottom = d.textbbox((80, 40), "OASIS MOBILE", font=font(ANTON, 92))[3]
|
||||
d.rectangle((80, bottom + 12, 420, bottom + 20), fill=FG)
|
||||
d.text((80, 196), "Rama PRUEBAS del hacklab · base Oasis 1.3.3 · tema Acampada, pensado para el pulgar",
|
||||
font=font(SANS, 27), fill=MUTED)
|
||||
shots = [("01-portada.png", "Actividad al abrir"), ("02-tribus.png", "Tribus en filas"),
|
||||
("03-votacion.png", "Votar en semáforo"), ("07-oasismobile.png", "El menú, tema del dev")]
|
||||
pw, gap, top = 320, 50, 270
|
||||
x = (W - (4 * (pw + 20) + 3 * gap)) // 2
|
||||
cap = font(ANTON, 34)
|
||||
for shot, label in shots:
|
||||
fr = phone(shot, pw)
|
||||
out.paste(fr, (x, top), fr)
|
||||
tw = d.textlength(label.upper(), font=cap)
|
||||
d.text((x + (fr.width - tw) / 2, top + fr.height + 18), label.upper(), font=cap, fill=FG)
|
||||
x += fr.width + gap
|
||||
out.save(HERE / "00-portada.png", optimize=True)
|
||||
|
||||
|
||||
def caja(d, box, titulo, lineas, relleno=BG):
|
||||
x0, y0, x1, y1 = box
|
||||
d.rounded_rectangle((x0, y0 + 6, x1, y1 + 6), 18, fill=FG)
|
||||
d.rounded_rectangle(box, 18, fill=relleno, outline=FG, width=4)
|
||||
t = font(ANTON, 30)
|
||||
d.text(((x0 + x1 - d.textlength(titulo, font=t)) / 2, y0 + 18), titulo, font=t, fill=FG)
|
||||
s = font(SANS, 18)
|
||||
for i, l in enumerate(lineas):
|
||||
d.text(((x0 + x1 - d.textlength(l, font=s)) / 2, y0 + 70 + i * 26), l, font=s, fill=FG)
|
||||
|
||||
|
||||
def esquema():
|
||||
W, H = 1400, 840
|
||||
out = Image.new("RGB", (W, H), BG)
|
||||
d = ImageDraw.Draw(out)
|
||||
d.text((46, 26), "LA INTERFAZ DE MÓVIL, POR DENTRO", font=font(ANTON, 46), fill=FG)
|
||||
bottom = d.textbbox((46, 26), "LA INTERFAZ DE MÓVIL, POR DENTRO", font=font(ANTON, 46))[3]
|
||||
d.rectangle((46, bottom + 10, 260, bottom + 16), fill=FG)
|
||||
d.text((46, bottom + 30), "Casi sesenta módulos no caben en una pantalla de teléfono. Lo primero es lo que pasa; el resto, en el menú.",
|
||||
font=font(SANS, 18), fill=MUTED)
|
||||
|
||||
# el movil esquematico
|
||||
px0, py0, px1, py1 = 110, 170, 470, 790
|
||||
d.rounded_rectangle((px0, py0, px1, py1), 30, outline=FG, width=5)
|
||||
d.rounded_rectangle((px0 + 16, py0 + 20, px1 - 16, py0 + 84), 14, fill=BG, outline=FG, width=3)
|
||||
s = font(SANS, 15)
|
||||
d.text((px0 + 46, py0 + 42), "logo · no molestar · avatar", font=s, fill=FG)
|
||||
# en medio, la actividad: lo que ha pasado en la red
|
||||
y = py0 + 110
|
||||
d.text((px0 + 40, y), "ACTIVIDAD", font=font(ANTON, 22), fill=FG)
|
||||
d.line((px0 + 40, y + 32, px1 - 40, y + 32), fill=FG, width=3)
|
||||
y += 52
|
||||
for i in range(4):
|
||||
d.rounded_rectangle((px0 + 40, y, px1 - 40, y + 86), 12, fill=BG, outline=FG, width=3)
|
||||
d.rectangle((px0 + 56, y + 18, px0 + 150, y + 30), fill=FG)
|
||||
d.rectangle((px0 + 56, y + 44, px1 - 70, y + 52), fill=MUTED)
|
||||
d.rectangle((px0 + 56, y + 62, px1 - 120, y + 70), fill=MUTED)
|
||||
y += 104
|
||||
d.rounded_rectangle((px0 + 16, py1 - 84, px1 - 16, py1 - 20), 14, fill=NEON, outline=FG, width=3)
|
||||
d.text((px0 + 62, py1 - 62), "4 atajos que eliges tú · MENÚ", font=s, fill=FG)
|
||||
|
||||
bx0, bx1 = 560, 1320
|
||||
cajas = [
|
||||
((bx0, 190, bx1, 340), "ARRIBA — LA BARRA SUPERIOR",
|
||||
["El logo vuelve al inicio y el avatar abre tu perfil.",
|
||||
"En medio, el botón de No molestar del Teléfono.",
|
||||
"Con muchos módulos activos salen también los filtros Personal y Social."], BG),
|
||||
((bx0, 375, bx1, 565), "EN MEDIO — LA ACTIVIDAD",
|
||||
["Al abrir la app se ve lo que ha pasado en la red, no un menú.",
|
||||
"Dentro de cada módulo, los filtros van en fichas pequeñas",
|
||||
"y el botón principal (crear tribu, crear sala…) siempre igual."], BG),
|
||||
((bx0, 600, bx1, 750), "ABAJO — LOS ATAJOS Y EL MENÚ",
|
||||
["Cuatro atajos a tu elección y una quinta pestaña fija, Menú,",
|
||||
"que abre desde abajo una hoja con todos los módulos por secciones",
|
||||
"(Comunidad, Biblioteca, Gobernanza…). Está en todas las pantallas."], NEON),
|
||||
]
|
||||
flechas = [((px1 + 10, py0 + 52), (bx0 - 12, 262)), ((px1 + 10, 440), (bx0 - 12, 470)),
|
||||
((px1 + 10, py1 - 52), (bx0 - 12, 675))]
|
||||
for (a, b) in flechas:
|
||||
ang = math.atan2(b[1] - a[1], b[0] - a[0])
|
||||
tip = lambda da, r: (b[0] - r * math.cos(ang + da), b[1] - r * math.sin(ang + da))
|
||||
d.line((a, tip(0, 12)), fill=FG, width=4)
|
||||
d.polygon([b, tip(0.45, 20), tip(-0.45, 20)], fill=FG)
|
||||
for box, t, l, r in cajas:
|
||||
caja(d, box, t, l, r)
|
||||
out.save(HERE / "esquema-interfaz.png", optimize=True)
|
||||
|
||||
|
||||
def flecha(d, a, b, ancho=4):
|
||||
ang = math.atan2(b[1] - a[1], b[0] - a[0])
|
||||
tip = lambda da, r: (b[0] - r * math.cos(ang + da), b[1] - r * math.sin(ang + da))
|
||||
d.line((a, tip(0, 12)), fill=FG, width=ancho)
|
||||
d.polygon([b, tip(0.45, 20), tip(-0.45, 20)], fill=FG)
|
||||
|
||||
|
||||
def titulo(d, texto, sub):
|
||||
d.text((46, 26), texto, font=font(ANTON, 46), fill=FG)
|
||||
bottom = d.textbbox((46, 26), texto, font=font(ANTON, 46))[3]
|
||||
d.rectangle((46, bottom + 10, 260, bottom + 16), fill=FG)
|
||||
d.text((46, bottom + 30), sub, font=font(SANS, 18), fill=MUTED)
|
||||
|
||||
|
||||
def esquema_rama():
|
||||
"""Como va la rama encima de Oasis: lo del dev abajo, lo nuestro encima."""
|
||||
W, H = 1400, 760
|
||||
out = Image.new("RGB", (W, H), BG)
|
||||
d = ImageDraw.Draw(out)
|
||||
titulo(d, "CÓMO VA LA RAMA ENCIMA DE OASIS",
|
||||
"Oasis lo cambia el dev, casi una versión al día. La rama solo cambia cómo se ve y se usa en el móvil.")
|
||||
capa = [
|
||||
((60, 210, 380, 400), "ASPECTO",
|
||||
["mobile_base.css: la maqueta", "mobile_ux.css: márgenes, letra,", "botones y piezas nuevas",
|
||||
"Acampada.css: el skin"], NEON),
|
||||
((400, 210, 720, 400), "PANTALLAS",
|
||||
["mobile_ui.js y las vistas", "mobile_*.js. En una vista", "del dev, como mucho una", "línea que las llama"], NEON),
|
||||
((740, 210, 1060, 400), "RUTAS",
|
||||
["fork_routes.js, delante", "del router del dev: lo", "que no atiende pasa al", "suyo"], NEON),
|
||||
((1080, 210, 1340, 400), "ANDROID",
|
||||
["la app del dev tal cual,", "con nuestro backend", "dentro y paquete", "propio"], NEON),
|
||||
]
|
||||
for box, t, l, r in capa:
|
||||
caja(d, box, t, l, r)
|
||||
d.text((60, 166), "LO DE LA RAMA", font=font(ANTON, 26), fill=FG)
|
||||
for x in (220, 560, 900, 1210):
|
||||
flecha(d, (x, 418), (x, 478))
|
||||
caja(d, (60, 500, 1340, 690), "OASIS DEL DEV: NO SE TOCA",
|
||||
["backend.js y su middleware · los modelos (lo que se publica y cómo se cuenta) · SSB y su arranque",
|
||||
"las traducciones · style.css y mobile.css · los temas, también OasisMobile.css",
|
||||
"Comparado con su APK oficial, la rama cambia 17 ficheros suyos y 561 líneas. Ver GUIA_DE_DESARROLLO.md"], BG)
|
||||
out.save(HERE / "esquema-rama.png", optimize=True)
|
||||
|
||||
|
||||
def esquema_llamadas():
|
||||
"""Las llamadas son las del dev; en Android pasan por un puente nativo de la app."""
|
||||
W, H = 1400, 700
|
||||
out = Image.new("RGB", (W, H), BG)
|
||||
d = ImageDraw.Draw(out)
|
||||
titulo(d, "LLAMADAS: LAS DEL DEV",
|
||||
"Teléfono y Salas son de Oasis. En Android el sonido y la cámara pasan por un puente nativo de la app.")
|
||||
cajas = [
|
||||
((50, 200, 360, 430), "TELÉFONO Y SALAS",
|
||||
["De Oasis, tal cual.", "Llamadas de uno a uno", "con buzón de voz; salas", "de hasta 50, con vídeo,", "grabación y emisión."], BG),
|
||||
((400, 200, 700, 430), "LOS ENCHUFES",
|
||||
["phone_*_android.js", "(del dev): se conectan", "a un puerto local que", "deja la app en", ".audio_bridge"], BG),
|
||||
((740, 200, 1040, 430), "EL PUENTE NATIVO",
|
||||
["Dentro de la app:", "R graba el micro", "P reproduce", "V saca la cámara", "S emite un fichero"], NEON),
|
||||
((1080, 200, 1350, 430), "EL MÓVIL",
|
||||
["Micrófono, cámara", "y altavoz, con los", "permisos que se piden", "la primera vez."], BG),
|
||||
]
|
||||
for box, t, l, r in cajas:
|
||||
caja(d, box, t, l, r)
|
||||
for a, b in (((362, 315), (398, 315)), ((702, 315), (738, 315)), ((1042, 315), (1078, 315))):
|
||||
flecha(d, a, b)
|
||||
caja(d, (50, 480, 1350, 630), "DÓNDE ESTÁ CADA COSA",
|
||||
["El puente está en la app Android del dev, que no tiene el código publicado. Por eso la APK de la",
|
||||
"rama se monta sobre la oficial: su app tal cual, nuestro backend dentro y paquete propio."], BG)
|
||||
out.save(HERE / "esquema-llamadas.png", optimize=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
portada()
|
||||
esquema()
|
||||
esquema_rama()
|
||||
esquema_llamadas()
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
# PDF de la guia para el dev
|
||||
|
||||
Fuente de Oasis_Mobile_Guia_de_desarrollo.pdf (5 paginas A4). Para sacarlo:
|
||||
|
||||
chromium --headless=new --no-pdf-header-footer --print-to-pdf=guia.pdf guia.html
|
||||
|
||||
Comprobar con `pdfinfo guia.pdf` que siguen siendo 5 paginas.
|
||||
|
|
@ -1,340 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Oasis Mobile · Guía de desarrollo</title>
|
||||
<style>
|
||||
@font-face { font-family: "Anton"; src: url("file:///home/sito/COFRE/CODERS/OASIS_MOBILE/src/client/assets/fonts/Anton-Regular.ttf"); }
|
||||
@page { size: A4; margin: 0; }
|
||||
:root { --fg: #0b0b0b; --muted: #5a5a5a; --line: #0b0b0b; --cyan: #00d7f0; --soft: #f1f4f5; --red: #c4003b; --amber: #c98a00; --green: #0a8f3c; }
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; background: #fff; color: var(--fg); font-family: "Inter", "DejaVu Sans", sans-serif; font-size: 10.5pt; line-height: 1.45; }
|
||||
.page { width: 210mm; height: 297mm; padding: 16mm 17mm 14mm; position: relative; page-break-after: always; overflow: hidden; }
|
||||
.page:last-child { page-break-after: auto; }
|
||||
h1, h2, h3 { font-family: "Anton", Impact, sans-serif; font-weight: 400; text-transform: uppercase; margin: 0; letter-spacing: .01em; line-height: 1; }
|
||||
h1 { font-size: 54pt; }
|
||||
h2 { font-size: 30pt; margin-bottom: 4mm; }
|
||||
h2 + .rule { width: 34mm; height: 2.2mm; background: var(--fg); margin: 0 0 6mm; }
|
||||
h3 { font-size: 14pt; margin: 0 0 2.5mm; }
|
||||
p { margin: 0 0 3mm; }
|
||||
.lead { font-size: 12pt; color: var(--muted); margin-bottom: 6mm; }
|
||||
ul { margin: 0 0 3mm; padding-left: 5mm; }
|
||||
li { margin: 0 0 1.6mm; }
|
||||
li::marker { color: var(--fg); }
|
||||
code { font-family: "DejaVu Sans Mono", monospace; font-size: 8.8pt; background: var(--soft); padding: .2mm 1.2mm; border-radius: 1mm; }
|
||||
.foot { position: absolute; left: 17mm; right: 17mm; bottom: 8mm; display: flex; justify-content: space-between; font-size: 8pt; color: var(--muted); border-top: .4mm solid #ddd; padding-top: 2mm; }
|
||||
.cols { display: grid; grid-template-columns: 1fr 1fr; gap: 7mm; }
|
||||
.cols3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 5mm; }
|
||||
.card { border: .6mm solid var(--fg); border-radius: 3mm; padding: 4.5mm 5mm; }
|
||||
.card.cyan { background: #e2fbff; }
|
||||
.tag { display: inline-block; font-family: "Anton"; text-transform: uppercase; font-size: 11pt; padding: .6mm 3mm; border-radius: 1.5mm; color: #fff; margin-bottom: 3mm; }
|
||||
.tag.red { background: var(--red); } .tag.amber { background: var(--amber); } .tag.green { background: var(--green); }
|
||||
.num { font-family: "Anton"; font-size: 34pt; line-height: 1; }
|
||||
.kpi { display: grid; grid-template-columns: repeat(4, 1fr); gap: 4mm; margin: 6mm 0; }
|
||||
.kpi div { border-top: 1.2mm solid var(--fg); padding-top: 2mm; }
|
||||
.kpi span { display: block; font-size: 8.5pt; color: var(--muted); }
|
||||
figure { margin: 0 0 5mm; }
|
||||
figcaption { font-size: 8.5pt; color: var(--muted); margin-top: 1.5mm; }
|
||||
svg text { font-family: "Inter", sans-serif; }
|
||||
.cover { background: #fff; }
|
||||
.cover .band { position: absolute; left: 0; right: 0; top: 0; height: 9mm; background: var(--cyan); }
|
||||
.cover h1 { margin-top: 30mm; font-size: 64pt; }
|
||||
.cover .sub { font-family: "Anton"; text-transform: uppercase; font-size: 24pt; margin-top: 4mm; }
|
||||
.cover .meta { margin-top: 8mm; font-size: 11pt; color: var(--muted); }
|
||||
.ask { border-left: 2.2mm solid var(--cyan); background: var(--soft); padding: 4mm 5mm; border-radius: 0 3mm 3mm 0; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 9.5pt; }
|
||||
th { text-align: left; font-family: "Anton"; font-weight: 400; text-transform: uppercase; font-size: 10pt; border-bottom: .6mm solid var(--fg); padding: 1.5mm 2mm; }
|
||||
td { border-bottom: .3mm solid #ddd; padding: 1.6mm 2mm; vertical-align: top; }
|
||||
table.tight td { padding: 1.1mm 2mm; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- 1. PORTADA -->
|
||||
<section class="page cover">
|
||||
<div class="band"></div>
|
||||
<h1>Oasis Mobile</h1>
|
||||
<div class="sub">Guía de desarrollo de la rama del hacklab</div>
|
||||
<div class="meta">Propuesta para epsylon · base Oasis 1.3.3 · rama PRUEBAS · 19 de octubre de 2026</div>
|
||||
|
||||
<div class="kpi">
|
||||
<div><div class="num">39</div><span>versiones de Oasis entre la 1.0.1 y la 1.3.3</span></div>
|
||||
<div><div class="num">17</div><span>ficheros tuyos que la rama cambia, frente a tu APK oficial</span></div>
|
||||
<div><div class="num">2</div><span>líneas nuestras en <code>backend.js</code></span></div>
|
||||
<div><div class="num">0</div><span>cambios en modelos, middleware o traducciones</span></div>
|
||||
</div>
|
||||
|
||||
<h3>Qué es esta rama</h3>
|
||||
<ul>
|
||||
<li>Una forma de usar Oasis en el móvil pensada para una acampada: tribus como comisiones, votaciones de asamblea, salas de voz.</li>
|
||||
<li>No es otro Oasis. Es tu APK oficial con una capa encima: skins, unas pocas vistas, un router propio y textos.</li>
|
||||
<li>Se instala junto a la oficial con otro paquete (<code>net.laenre.oasis</code>) y habla con la misma red.</li>
|
||||
</ul>
|
||||
<h3>Qué te pedimos</h3>
|
||||
<ul>
|
||||
<li>Que revises si la frontera que proponemos (página 3) te parece bien: qué es tuyo y no tocamos, y qué es nuestro.</li>
|
||||
<li>Que nos digas si alguno de los puntos de la página 5 te interesa para Oasis, para dejar de llevarlo nosotros.</li>
|
||||
</ul>
|
||||
|
||||
<figure style="margin-top:6mm">
|
||||
<svg viewBox="0 0 700 150" width="100%">
|
||||
<defs><marker id="ar0" markerWidth="10" markerHeight="10" refX="9" refY="5" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#0b0b0b"/></marker></defs>
|
||||
<rect x="2" y="20" width="200" height="110" rx="10" fill="#fff" stroke="#0b0b0b" stroke-width="2.5"/>
|
||||
<text x="102" y="58" text-anchor="middle" font-size="17" font-weight="700">Tu APK oficial</text>
|
||||
<text x="102" y="82" text-anchor="middle" font-size="12" fill="#5a5a5a">app Android, puente de</text>
|
||||
<text x="102" y="98" text-anchor="middle" font-size="12" fill="#5a5a5a">audio, node_modules</text>
|
||||
<rect x="250" y="20" width="200" height="110" rx="10" fill="#e2fbff" stroke="#0b0b0b" stroke-width="2.5"/>
|
||||
<text x="350" y="58" text-anchor="middle" font-size="17" font-weight="700">+ capa de la rama</text>
|
||||
<text x="350" y="82" text-anchor="middle" font-size="12" fill="#5a5a5a">skins, vistas mobile_*,</text>
|
||||
<text x="350" y="98" text-anchor="middle" font-size="12" fill="#5a5a5a">router propio, textos</text>
|
||||
<rect x="498" y="20" width="200" height="110" rx="10" fill="#0b0b0b" stroke="#0b0b0b" stroke-width="2.5"/>
|
||||
<text x="598" y="58" text-anchor="middle" font-size="17" font-weight="700" fill="#fff">Oasis hacklab</text>
|
||||
<text x="598" y="82" text-anchor="middle" font-size="12" fill="#ddd">otro paquete, misma red,</text>
|
||||
<text x="598" y="98" text-anchor="middle" font-size="12" fill="#ddd">convive con la oficial</text>
|
||||
<line x1="204" y1="75" x2="244" y2="75" stroke="#0b0b0b" stroke-width="2.5" marker-end="url(#ar0)"/>
|
||||
<line x1="452" y1="75" x2="492" y2="75" stroke="#0b0b0b" stroke-width="2.5" marker-end="url(#ar0)"/>
|
||||
</svg>
|
||||
</figure>
|
||||
<div class="foot"><span>Oasis Mobile · Guía de desarrollo</span><span>1 / 5</span></div>
|
||||
</section>
|
||||
|
||||
<!-- 2. LA REGLA -->
|
||||
<section class="page">
|
||||
<h2>La regla</h2><div class="rule"></div>
|
||||
<p class="lead">Oasis lo cambias tú. Nosotros cambiamos cómo se ve y se usa en el móvil, y lo hacemos en ficheros nuestros.</p>
|
||||
|
||||
<figure>
|
||||
<svg viewBox="0 0 700 330" width="100%">
|
||||
<defs><marker id="ar1" markerWidth="10" markerHeight="10" refX="9" refY="5" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#0b0b0b"/></marker></defs>
|
||||
<text x="0" y="16" font-size="13" font-weight="700" letter-spacing="1">LA CAPA DE LA RAMA</text>
|
||||
<g font-size="12">
|
||||
<rect x="0" y="28" width="166" height="96" rx="9" fill="#e2fbff" stroke="#0b0b0b" stroke-width="2"/>
|
||||
<text x="83" y="54" text-anchor="middle" font-size="15" font-weight="700">Aspecto</text>
|
||||
<text x="83" y="76" text-anchor="middle">mobile_base.css</text><text x="83" y="92" text-anchor="middle">mobile_ux.css</text><text x="83" y="108" text-anchor="middle">Acampada.css</text>
|
||||
<rect x="178" y="28" width="166" height="96" rx="9" fill="#e2fbff" stroke="#0b0b0b" stroke-width="2"/>
|
||||
<text x="261" y="54" text-anchor="middle" font-size="15" font-weight="700">Pantallas</text>
|
||||
<text x="261" y="76" text-anchor="middle">mobile_ui.js</text><text x="261" y="92" text-anchor="middle">mobile_*_view.js</text><text x="261" y="108" text-anchor="middle">hive_views.js</text>
|
||||
<rect x="356" y="28" width="166" height="96" rx="9" fill="#e2fbff" stroke="#0b0b0b" stroke-width="2"/>
|
||||
<text x="439" y="54" text-anchor="middle" font-size="15" font-weight="700">Rutas</text>
|
||||
<text x="439" y="76" text-anchor="middle">fork_routes.js</text><text x="439" y="92" text-anchor="middle">mobile_layer.js</text><text x="439" y="108" text-anchor="middle">fork_workflows.js</text>
|
||||
<rect x="534" y="28" width="166" height="96" rx="9" fill="#e2fbff" stroke="#0b0b0b" stroke-width="2"/>
|
||||
<text x="617" y="54" text-anchor="middle" font-size="15" font-weight="700">Textos y APK</text>
|
||||
<text x="617" y="76" text-anchor="middle">i18n_fork.js</text><text x="617" y="92" text-anchor="middle">(11 idiomas)</text><text x="617" y="108" text-anchor="middle">sobre_oficial.sh</text>
|
||||
</g>
|
||||
<g stroke="#0b0b0b" stroke-width="2" marker-end="url(#ar1)">
|
||||
<line x1="83" y1="130" x2="83" y2="176"/><line x1="261" y1="130" x2="261" y2="176"/><line x1="439" y1="130" x2="439" y2="176"/><line x1="617" y1="130" x2="617" y2="176"/>
|
||||
</g>
|
||||
<text x="270" y="158" font-size="11" fill="#5a5a5a">una línea de enganche, solo con OASIS_MOBILE=1</text>
|
||||
<rect x="0" y="184" width="700" height="140" rx="10" fill="#fff" stroke="#0b0b0b" stroke-width="3"/>
|
||||
<text x="350" y="216" text-anchor="middle" font-size="18" font-weight="700">OASIS: TU CÓDIGO, TAL CUAL</text>
|
||||
<g font-size="12" text-anchor="middle">
|
||||
<rect x="18" y="234" width="152" height="70" rx="7" fill="#f1f4f5"/><text x="94" y="262" font-weight="700">backend.js</text><text x="94" y="280" fill="#5a5a5a">y su middleware</text>
|
||||
<rect x="188" y="234" width="152" height="70" rx="7" fill="#f1f4f5"/><text x="264" y="262" font-weight="700">modelos</text><text x="264" y="280" fill="#5a5a5a">qué se publica y se cuenta</text>
|
||||
<rect x="358" y="234" width="152" height="70" rx="7" fill="#f1f4f5"/><text x="434" y="262" font-weight="700">SSB y servidor</text><text x="434" y="280" fill="#5a5a5a">phone_module, puente</text>
|
||||
<rect x="528" y="234" width="154" height="70" rx="7" fill="#f1f4f5"/><text x="605" y="262" font-weight="700">traducciones y temas</text><text x="605" y="280" fill="#5a5a5a">oasis_*.js, style.css…</text>
|
||||
</g>
|
||||
</svg>
|
||||
<figcaption>La rama son cuatro piezas propias encima de tu código. Lo único que entra en tus ficheros son líneas de enganche.</figcaption>
|
||||
</figure>
|
||||
|
||||
<div class="cols">
|
||||
<div>
|
||||
<h3>Por qué así</h3>
|
||||
<ul>
|
||||
<li>Sacas versión casi cada día y cada una llega en un solo commit: no se pueden coger cambios sueltos.</li>
|
||||
<li><code>backend.js</code> cambió en 34 de 39 versiones, <code>main_views.js</code> en 31, las traducciones en 31.</li>
|
||||
<li>Cada línea nuestra en esos ficheros sería un conflicto en la siguiente integración.</li>
|
||||
<li>Si tocáramos un modelo, nuestro móvil contaría distinto que tu escritorio (votos, tribus, gobierno).</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3>Cómo se mide</h3>
|
||||
<ul>
|
||||
<li>La referencia para el móvil es el <code>nodejs-project</code> de tu APK oficial, no tu git: ahí van tus cambios de móvil.</li>
|
||||
<li>Hoy la rama cambia 17 ficheros tuyos y 561 líneas; en <code>backend.js</code>, 2.</li>
|
||||
<li>En cada versión tuya se repite la comparación y se revisa si ha desaparecido alguna clase CSS de la que tiramos.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="foot"><span>Oasis Mobile · Guía de desarrollo</span><span>2 / 5</span></div>
|
||||
</section>
|
||||
|
||||
<!-- 3. LA FRONTERA -->
|
||||
<section class="page">
|
||||
<h2>Qué se toca y qué no</h2><div class="rule"></div>
|
||||
<p class="lead">Tres zonas. La de en medio es la única que entra en tus ficheros, y solo con una línea.</p>
|
||||
<div class="cols3">
|
||||
<div class="card">
|
||||
<span class="tag red">No se toca</span>
|
||||
<ul>
|
||||
<li><code>backend.js</code>, rutas y cadena de middleware</li>
|
||||
<li>Modelos de <code>src/models</code>: lo que se publica en el log y cómo se cuenta</li>
|
||||
<li><code>src/server</code>: arranque de SSB, dependencias, <code>phone_module</code></li>
|
||||
<li><code>request_guards.js</code> y la CSP: nada en línea</li>
|
||||
<li>Traducciones <code>oasis_*.js</code></li>
|
||||
<li><code>style.css</code>, <code>mobile.css</code> y los temas, también <code>OasisMobile.css</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card">
|
||||
<span class="tag amber">Una línea</span>
|
||||
<ul>
|
||||
<li>Tus vistas: <code>main_views.js</code>, <code>tribes_view.js</code>, <code>vote_view.js</code>, <code>settings_view.js</code>, <code>welcome_view.js</code></li>
|
||||
<li>La lógica va en <code>mobile_ui.js</code>; en tu vista, una llamada</li>
|
||||
<li>Siempre detrás de <code>OASIS_MOBILE === '1'</code>: en escritorio todo queda como lo dejas</li>
|
||||
<li>Si reescribes la vista, se vuelve a poner la línea</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card cyan">
|
||||
<span class="tag green">Nuestro</span>
|
||||
<ul>
|
||||
<li>Hojas: <code>mobile_base.css</code>, <code>mobile_ux.css</code>, <code>Acampada.css</code></li>
|
||||
<li>Vistas: <code>mobile_ui.js</code>, <code>mobile_modules_view.js</code>, <code>hive_views.js</code>, <code>mobile_icons.js</code></li>
|
||||
<li>Router: <code>fork_routes.js</code> y <code>mobile_layer.js</code></li>
|
||||
<li>Workflows: <code>fork_workflows.js</code></li>
|
||||
<li>Textos: <code>i18n_fork.js</code></li>
|
||||
<li>APK: <code>sobre_oficial.sh</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 style="margin-top:4mm">Ejemplos concretos</h3>
|
||||
<table class="tight">
|
||||
<tr><th>Lo que hacía falta</th><th>Cómo se ha hecho</th><th>Zona</th></tr>
|
||||
<tr><td>Votar en semáforo o termómetro, como en asamblea</td><td>Tu servidor ya acepta <code>options</code>; solo se añade el selector en el formulario</td><td>Una línea</td></tr>
|
||||
<tr><td>Quien crea una votación no puede votar</td><td>Es una regla de tu modelo: no se cambia, se explica en pantalla</td><td>Nuestro</td></tr>
|
||||
<tr><td>Avisar de que una sección está apagada y encenderla</td><td>Nuestro router mira tu redirección a <code>/modules</code>, sin tocar <code>checkMod</code></td><td>Nuestro</td></tr>
|
||||
<tr><td>Workflow para acampadas</td><td>Se registra <code>acampada</code> en tus arrays; <code>activists</code> queda como el tuyo</td><td>Nuestro</td></tr>
|
||||
<tr><td>Llamadas y salas</td><td>Las tuyas, con tu puente de audio: la APK se monta sobre la oficial</td><td>No se toca</td></tr>
|
||||
<tr><td>Borrar con confirmación</td><td>Función en <code>mobile_ui.js</code>, enganchada en <code>renderContentActions</code></td><td>Una línea</td></tr>
|
||||
</table>
|
||||
<div class="foot"><span>Oasis Mobile · Guía de desarrollo</span><span>3 / 5</span></div>
|
||||
</section>
|
||||
|
||||
<!-- 4. CÓMO FUNCIONA -->
|
||||
<section class="page">
|
||||
<h2>Cómo funciona por dentro</h2><div class="rule"></div>
|
||||
|
||||
<h3>Una petición en el móvil</h3>
|
||||
<figure>
|
||||
<svg viewBox="0 0 700 175" width="100%">
|
||||
<defs><marker id="ar2" markerWidth="10" markerHeight="10" refX="9" refY="5" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#0b0b0b"/></marker></defs>
|
||||
<g font-size="12" text-anchor="middle">
|
||||
<rect x="0" y="40" width="110" height="80" rx="9" fill="#fff" stroke="#0b0b0b" stroke-width="2"/><text x="55" y="76" font-weight="700">WebView</text><text x="55" y="94" fill="#5a5a5a">de tu app</text>
|
||||
<rect x="140" y="40" width="130" height="80" rx="9" fill="#f1f4f5" stroke="#0b0b0b" stroke-width="2"/><text x="205" y="70" font-weight="700">Tu middleware</text><text x="205" y="88" fill="#5a5a5a">contexto, idioma,</text><text x="205" y="102" fill="#5a5a5a">CSP</text>
|
||||
<rect x="300" y="40" width="130" height="80" rx="9" fill="#e2fbff" stroke="#0b0b0b" stroke-width="2"/><text x="365" y="70" font-weight="700">fork_routes</text><text x="365" y="88" fill="#5a5a5a">rutas propias o</text><text x="365" y="102" fill="#5a5a5a">next() al tuyo</text>
|
||||
<rect x="460" y="40" width="110" height="80" rx="9" fill="#f1f4f5" stroke="#0b0b0b" stroke-width="2"/><text x="515" y="76" font-weight="700">Tus rutas</text><text x="515" y="94" fill="#5a5a5a">y vistas</text>
|
||||
<rect x="600" y="40" width="100" height="80" rx="9" fill="#e2fbff" stroke="#0b0b0b" stroke-width="2"/><text x="650" y="70" font-weight="700">mobile_ui</text><text x="650" y="88" fill="#5a5a5a">si la vista</text><text x="650" y="102" fill="#5a5a5a">lo llama</text>
|
||||
</g>
|
||||
<g stroke="#0b0b0b" stroke-width="2" marker-end="url(#ar2)">
|
||||
<line x1="112" y1="80" x2="134" y2="80"/><line x1="272" y1="80" x2="294" y2="80"/><line x1="432" y1="80" x2="454" y2="80"/><line x1="572" y1="80" x2="594" y2="80"/>
|
||||
</g>
|
||||
<path d="M365 124 C365 160, 515 160, 515 124" fill="none" stroke="#0b0b0b" stroke-width="1.6" stroke-dasharray="5 4"/>
|
||||
<text x="440" y="168" text-anchor="middle" font-size="11" fill="#5a5a5a">wrapUpstream mira tu respuesta (redirecciones) sin cambiarla</text>
|
||||
<text x="0" y="22" font-size="11" fill="#5a5a5a">Nuestro router va justo delante del tuyo: una sola línea en backend.js lo monta.</text>
|
||||
</svg>
|
||||
</figure>
|
||||
|
||||
<div class="cols">
|
||||
<div>
|
||||
<h3>Hojas de estilo, en este orden</h3>
|
||||
<figure>
|
||||
<svg viewBox="0 0 330 200" width="100%">
|
||||
<g font-size="12">
|
||||
<rect x="0" y="0" width="330" height="34" rx="6" fill="#f1f4f5" stroke="#0b0b0b"/><text x="12" y="22">1 · style.css y mobile.css (tuyas)</text>
|
||||
<rect x="0" y="40" width="330" height="34" rx="6" fill="#e2fbff" stroke="#0b0b0b"/><text x="12" y="62">2 · mobile_base.css: maqueta de la rama</text>
|
||||
<rect x="0" y="80" width="330" height="34" rx="6" fill="#fff" stroke="#0b0b0b"/><text x="12" y="102">3 · skin: Acampada.css o tu OasisMobile.css</text>
|
||||
<rect x="0" y="120" width="330" height="34" rx="6" fill="#e2fbff" stroke="#0b0b0b"/><text x="12" y="142">4 · hive_acampada.css (solo con Acampada)</text>
|
||||
<rect x="0" y="160" width="330" height="34" rx="6" fill="#0b0b0b"/><text x="12" y="182" fill="#fff">5 · mobile_ux.css: piezas comunes, la última</text>
|
||||
</g>
|
||||
</svg>
|
||||
</figure>
|
||||
<ul>
|
||||
<li>Con tu skin OasisMobile se ve tu tema tal cual, encima de nuestra maqueta.</li>
|
||||
<li>Colores por variables <code>--ux-*</code>: valen para los dos skins.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3>La APK, montada sobre la tuya</h3>
|
||||
<figure>
|
||||
<svg viewBox="0 0 330 200" width="100%">
|
||||
<defs><marker id="ar3" markerWidth="10" markerHeight="10" refX="9" refY="5" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#0b0b0b"/></marker></defs>
|
||||
<g font-size="12">
|
||||
<rect x="0" y="0" width="330" height="30" rx="6" fill="#fff" stroke="#0b0b0b"/><text x="12" y="20">tu APK oficial de la misma versión</text>
|
||||
<rect x="0" y="42" width="330" height="30" rx="6" fill="#f1f4f5" stroke="#0b0b0b"/><text x="12" y="62">se desmonta (apktool, versión fija)</text>
|
||||
<rect x="0" y="84" width="330" height="30" rx="6" fill="#e2fbff" stroke="#0b0b0b"/><text x="12" y="104">backend de la rama + tu node_modules</text>
|
||||
<rect x="0" y="126" width="330" height="30" rx="6" fill="#e2fbff" stroke="#0b0b0b"/><text x="12" y="146">paquete net.laenre.oasis</text>
|
||||
<rect x="0" y="168" width="330" height="30" rx="6" fill="#0b0b0b"/><text x="12" y="188" fill="#fff">se monta, se alinea y se firma</text>
|
||||
</g>
|
||||
<g stroke="#0b0b0b" stroke-width="1.6" marker-end="url(#ar3)"><line x1="300" y1="31" x2="300" y2="40"/><line x1="300" y1="73" x2="300" y2="82"/><line x1="300" y1="115" x2="300" y2="124"/><line x1="300" y1="157" x2="300" y2="166"/></g>
|
||||
</svg>
|
||||
</figure>
|
||||
<ul>
|
||||
<li>Tu app tal cual: puente de audio, notificaciones, permisos.</li>
|
||||
<li>Se cambian también los authorities, el permiso propio y las acciones de llamada, para no cruzarse con la tuya.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="foot"><span>Oasis Mobile · Guía de desarrollo</span><span>4 / 5</span></div>
|
||||
</section>
|
||||
|
||||
<!-- 5. TRABAJAR JUNTOS -->
|
||||
<section class="page">
|
||||
<h2>Cómo seguimos</h2><div class="rule"></div>
|
||||
|
||||
<div class="cols">
|
||||
<div>
|
||||
<h3>Antes de cada commit</h3>
|
||||
<ul>
|
||||
<li>Nada de la zona roja en el diff; en tus vistas, solo líneas de enganche.</li>
|
||||
<li><code>node --check</code> y el servidor local responde en las pantallas principales.</li>
|
||||
<li>Visto a 360 y 412 px, en tableta, con la letra al 130 % y en un WebView viejo.</li>
|
||||
<li>Textos nuevos en los 11 idiomas, en <code>i18n_fork.js</code>.</li>
|
||||
<li>Nada en línea: la CSP lo bloquea.</li>
|
||||
</ul>
|
||||
<h3>Cuando sacas versión</h3>
|
||||
<ul>
|
||||
<li>Se coge la tuya tal cual; para el móvil, la de tu APK.</li>
|
||||
<li>Se vuelven a poner los enganches en tus vistas.</li>
|
||||
<li>Se repite la medición y se revisan las clases CSS.</li>
|
||||
<li>Se monta la APK sobre tu oficial de esa versión.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3>Navegación en el móvil</h3>
|
||||
<ul>
|
||||
<li>Al abrir se ve la actividad, no un menú.</li>
|
||||
<li>Abajo, cuatro atajos que elige cada cual y una pestaña Menú que abre todos los módulos desde abajo.</li>
|
||||
<li>Probado en el emulador: dos toques a cualquier módulo desde cualquier pantalla.</li>
|
||||
</ul>
|
||||
<h3>Probado</h3>
|
||||
<ul>
|
||||
<li>Android 16 (APK real): 412 y 360 dp, tableta de 800 dp, letra al 130 %, gestos y tres botones.</li>
|
||||
<li>Motores viejos: WebView 69 (Android 9) y 83 (Android 11), con alternativas donde falta <code>:has</code>, <code>gap</code> o <code>inset</code>.</li>
|
||||
<li>Llamadas: en una sala el micrófono graba y se corta al salir.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 style="margin-top:2mm">Lo que podría pasar a Oasis</h3>
|
||||
<p>Cosas que hoy llevamos en tus ficheros y que nos gustaría que fueran tuyas, para dejar de mantenerlas aparte:</p>
|
||||
<table>
|
||||
<tr><th>Qué</th><th>Dónde está ahora</th><th>Por qué te puede interesar</th></tr>
|
||||
<tr><td>Limpiar metadatos de las fotos también en Android</td><td><code>blobHandler.js</code> (190 líneas)</td><td>Sin <code>sharp</code> en el móvil, hoy las fotos suben con el GPS dentro</td></tr>
|
||||
<tr><td>Varias identidades en el mismo móvil</td><td><code>ssb_config.js</code> (30 líneas)</td><td>Una persona, varias cuentas, con aviso si dos comparten clave</td></tr>
|
||||
<tr><td>DNS del sistema primero</td><td><code>main.js</code> (45 líneas)</td><td>Respeta la VPN y el DNS privado de Android</td></tr>
|
||||
<tr><td>Selector de idioma con el idioma actual</td><td>nuestro, en <code>mobile_ui.js</code></td><td><code>settings_view</code> lee <code>selectedLanguage</code> al cargar y siempre marca English</td></tr>
|
||||
</table>
|
||||
|
||||
<div class="ask" style="margin-top:3mm">
|
||||
<h3>Para aprobar</h3>
|
||||
<ul style="margin:0">
|
||||
<li>¿Te parece bien la frontera de la página 3?</li>
|
||||
<li>¿Te parece bien que la APK de la rama se monte sobre tu oficial, con otro paquete?</li>
|
||||
<li>¿Quieres alguno de los cuatro puntos de arriba en Oasis?</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="foot"><span>Oasis Mobile · Guía de desarrollo · hacklab · gitea.laenre.net/hacklab/OASIS_MOBILE</span><span>5 / 5</span></div>
|
||||
</section>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
40
main.js
|
|
@ -26,8 +26,6 @@ try {
|
|||
const dns = require('dns');
|
||||
const net = require('net');
|
||||
const servers = ['8.8.8.8', '1.1.1.1', '8.8.4.4'];
|
||||
const systemLookup = dns.lookup;
|
||||
const SYSTEM_LOOKUP_MS = 5000;
|
||||
try { dns.setServers(servers); } catch (e) {}
|
||||
try { if (dns.promises && dns.promises.setServers) dns.promises.setServers(servers); } catch (e) {}
|
||||
|
||||
|
|
@ -50,43 +48,11 @@ try {
|
|||
? callback(null, [direct])
|
||||
: callback(null, direct.address, direct.family));
|
||||
}
|
||||
// rama movil: primero el resolutor del sistema (getaddrinfo), que es el que
|
||||
// respeta la VPN y el DNS privado de Android. Upstream iba siempre a
|
||||
// 8.8.8.8/1.1.1.1, y las VPN que evitan fugas de DNS (Mullvad, por ejemplo)
|
||||
// o las redes que bloquean DNS externos los rechazan: no se encontraba
|
||||
// ningun pub por nombre y unirse a uno "no hacia nada". Los publicos quedan
|
||||
// de respaldo, para los moviles donde getaddrinfo no contesta.
|
||||
// Los dos caminos compiten: el del sistema sale ya y, si en SYSTEM_LOOKUP_MS
|
||||
// no ha contestado o falla, se pregunta tambien a los publicos. Gana la primera
|
||||
// respuesta buena; si fallan los dos, se devuelve el error del sistema, que
|
||||
// dice mas ("no existe") que el de unos servidores quiza bloqueados.
|
||||
let done = false, sysErr = null, pubErr = null, sysDone = false, pubDone = false, pubStarted = false;
|
||||
const finish = (err, address, fam) => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
clearTimeout(timer);
|
||||
if (err) return callback(err);
|
||||
if (options.all) return callback(null, Array.isArray(address) ? address : [{ address, family: fam }]);
|
||||
if (Array.isArray(address)) return callback(null, address[0].address, address[0].family);
|
||||
return callback(null, address, fam);
|
||||
};
|
||||
const bothFailed = () => { if (sysDone && pubDone) finish(sysErr || pubErr || new Error('ENOTFOUND ' + hostname)); };
|
||||
const viaPublic = () => {
|
||||
if (pubStarted) return;
|
||||
pubStarted = true;
|
||||
const resolver = family === 6 ? dns.resolve6 : dns.resolve4;
|
||||
resolver.call(dns, hostname, (err, addrs) => {
|
||||
pubDone = true;
|
||||
if (err || !addrs || !addrs.length) { pubErr = err || new Error('ENOTFOUND ' + hostname); return bothFailed(); }
|
||||
finish(null, options.all ? addrs.map((x) => ({ address: x, family })) : addrs[0], family);
|
||||
});
|
||||
};
|
||||
const timer = setTimeout(viaPublic, SYSTEM_LOOKUP_MS);
|
||||
systemLookup.call(dns, hostname, { family, all: !!options.all }, (err, address, fam) => {
|
||||
sysDone = true;
|
||||
if (!err) return finish(null, address, fam);
|
||||
sysErr = err;
|
||||
if (pubStarted) bothFailed(); else viaPublic();
|
||||
if (err || !addrs || !addrs.length) return callback(err || new Error('ENOTFOUND ' + hostname));
|
||||
if (options.all) return callback(null, addrs.map((a) => ({ address: a, family })));
|
||||
return callback(null, addrs[0], family);
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -4675,7 +4675,7 @@ router
|
|||
ctx.body = statsView(stats, filter);
|
||||
})
|
||||
.get("/modules", async (ctx) => {
|
||||
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 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 cfg = getConfig().modules;
|
||||
ctx.body = modulesView(modules.reduce((acc, m) => { acc[`${m}Mod`] = cfg[`${m}Mod`]; return acc; }, {}));
|
||||
})
|
||||
|
|
@ -8486,7 +8486,7 @@ router
|
|||
const available = {
|
||||
profile: true,
|
||||
ux: true,
|
||||
workflow: process.env.OASIS_MOBILE !== '1' && isLoopbackRequest(ctx),
|
||||
workflow: isLoopbackRequest(ctx),
|
||||
theme: process.env.OASIS_MOBILE !== '1',
|
||||
community: checkMod(ctx, 'invitesMod'),
|
||||
greeting: checkMod(ctx, 'feedMod')
|
||||
|
|
@ -15345,6 +15345,19 @@ const folderBytes = async (dir) => {
|
|||
return bytes;
|
||||
};
|
||||
const refreshAll = async (now) => {
|
||||
try {
|
||||
// avatar propio para el circulo de la topbar movil (lo lee renderMobileTopbar)
|
||||
const meId = getViewerId();
|
||||
const selfImg = meId ? await about.image(meId) : null;
|
||||
const NULL_IMG = '&' + '0'.repeat(43) + '=.sha256';
|
||||
if (typeof selfImg === 'string' && selfImg[0] === '&' && selfImg !== NULL_IMG) {
|
||||
global.__OASIS_SELF_AVATAR__ = `/image/256/${encodeURIComponent(selfImg)}`;
|
||||
} else if (typeof selfImg === 'string' && selfImg[0] === '/') {
|
||||
global.__OASIS_SELF_AVATAR__ = selfImg;
|
||||
} else {
|
||||
global.__OASIS_SELF_AVATAR__ = null;
|
||||
}
|
||||
} catch (_) {}
|
||||
try { await bankingModel.ensureSelfAddressPublished(); } catch (_) {}
|
||||
try { await bankingModel.getUserEngagementScore(getViewerId()); } catch (_) {}
|
||||
try {
|
||||
|
|
@ -15571,12 +15584,18 @@ const middleware = [
|
|||
},
|
||||
async (ctx, next) => {
|
||||
if (!ctx.path.startsWith('/assets/') && !ctx.path.startsWith('/image/') && !ctx.path.startsWith('/blob/') && !ctx.path.startsWith('/qr') && !ctx.path.startsWith('/c/')) {
|
||||
// filtro de la home (Personal/Community); lo leen la topbar y el lanzador
|
||||
global.__OASIS_HIVE_FILTER__ = (ctx.path === '/activity' && (ctx.query.hive === 'personal' || ctx.query.hive === 'community')) ? ctx.query.hive : '';
|
||||
// el lanzador de modulos SOLO en la home; en el resto de paginas el top queda limpio
|
||||
global.__OASIS_SHOW_HIVE__ = (ctx.path === '/activity' || ctx.path === '/');
|
||||
if (Date.now() - sharedState.getLastRefresh() > 60000) kickRefresh();
|
||||
}
|
||||
await next();
|
||||
},
|
||||
// la rama movil: su router y su capa de backend (ver GUIA_DE_DESARROLLO.md)
|
||||
require('./fork_routes')({ checkMod, getViewerId, getConfig, saveConfig, isLoopbackRequest, sendErrorPage, about }),
|
||||
// rutas propias de la rama (Karvan, barra inferior, exportacion): fuera de la cadena de upstream
|
||||
require('./karvan_routes')({ cooler, pull, pmModel, checkMod, getViewerId,
|
||||
getConfig, saveConfig,
|
||||
isLoopbackRequest, sendErrorPage }),
|
||||
routes,
|
||||
async (ctx) => {
|
||||
if (ctx.body != null || (ctx.method !== 'GET' && ctx.method !== 'HEAD')) return;
|
||||
|
|
|
|||
|
|
@ -85,12 +85,11 @@ const stripJpeg = (buf) => {
|
|||
const out = [buf.subarray(0, 2)];
|
||||
let orientation = 0;
|
||||
let i = 2;
|
||||
let scan = false;
|
||||
while (i + 4 <= buf.length) {
|
||||
if (buf[i] !== 0xFF) return null; // no es un JPEG sano: no tocarlo
|
||||
const marker = buf[i + 1];
|
||||
if (marker === 0xD8 || (marker >= 0xD0 && marker <= 0xD9)) { i += 2; continue; }
|
||||
if (marker === 0xDA) { out.push(buf.subarray(i)); scan = true; break; } // datos comprimidos: hasta el final
|
||||
if (marker === 0xDA) { out.push(buf.subarray(i)); break; } // datos comprimidos: hasta el final
|
||||
const len = buf.readUInt16BE(i + 2);
|
||||
if (len < 2 || i + 2 + len > buf.length) return null;
|
||||
const seg = buf.subarray(i + 4, i + 2 + len);
|
||||
|
|
@ -98,8 +97,6 @@ const stripJpeg = (buf) => {
|
|||
if (!JPEG_DROP.has(marker)) out.push(buf.subarray(i, i + 2 + len));
|
||||
i += 2 + len;
|
||||
}
|
||||
// sin datos de imagen (SOS) es un JPEG cortado: devolver la cabecera sola subiria una foto vacia
|
||||
if (!scan) return null;
|
||||
if (orientation > 1) {
|
||||
const exif = exifOnlyOrientation(orientation);
|
||||
const head = Buffer.alloc(4);
|
||||
|
|
@ -153,40 +150,14 @@ const stripWebp = (buf) => {
|
|||
return Buffer.concat([head, body]);
|
||||
};
|
||||
|
||||
// Formatos que pueden llevar EXIF (GPS incluido) y que aqui no se saben limpiar:
|
||||
// HEIC/HEIF/AVIF (caja ftyp) y TIFF. Antes subian tal cual, con la ubicacion.
|
||||
const isUncleanable = (buf) => {
|
||||
if (buf.length >= 12 && buf.toString("latin1", 4, 8) === "ftyp") {
|
||||
return /^(heic|heix|hevc|hevx|heim|heis|mif1|msf1|avif|avis)$/.test(buf.toString("latin1", 8, 12));
|
||||
}
|
||||
return buf.length >= 4 && (buf.toString("latin1", 0, 4) === "II*\0" || buf.toString("latin1", 0, 4) === "MM\0*");
|
||||
};
|
||||
const looksCleanable = (buf) =>
|
||||
(buf.length >= 2 && buf[0] === 0xFF && buf[1] === 0xD8) ||
|
||||
(buf.length >= 8 && buf.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]))) ||
|
||||
(buf.length >= 12 && buf.toString("latin1", 0, 4) === "RIFF" && buf.toString("latin1", 8, 12) === "WEBP");
|
||||
|
||||
const unsafe = () => {
|
||||
let msg = "This image could not be cleaned of its metadata, so it has not been uploaded.";
|
||||
try { msg = require("../views/main_views").i18n.imageMetadataUnsafe || msg; } catch (_) {}
|
||||
const err = new Error(msg);
|
||||
err.status = 400;
|
||||
return err;
|
||||
};
|
||||
|
||||
/**
|
||||
* Limpia segun el formato. Si es JPEG/PNG/WebP y no se puede limpiar, o es un
|
||||
* formato con EXIF que no sabemos limpiar, LANZA: es mejor no subir la foto que
|
||||
* subirla con la ubicacion. Formatos sin metadatos de ese tipo (BMP, SVG, ICO...)
|
||||
* pasan tal cual.
|
||||
*/
|
||||
/** Limpia segun el formato; si algo no cuadra, devuelve la imagen tal cual. */
|
||||
const stripMetadataPure = (buffer) => {
|
||||
if (isUncleanable(buffer)) throw unsafe();
|
||||
if (!looksCleanable(buffer)) return buffer;
|
||||
let out = null;
|
||||
try { out = stripJpeg(buffer) || stripPng(buffer) || stripWebp(buffer); } catch (_) { out = null; }
|
||||
if (!out || !out.length) throw unsafe();
|
||||
return out;
|
||||
try {
|
||||
const out = stripJpeg(buffer) || stripPng(buffer) || stripWebp(buffer);
|
||||
return out && out.length ? out : buffer;
|
||||
} catch {
|
||||
return buffer;
|
||||
}
|
||||
};
|
||||
|
||||
const stripImageMetadata = async (buffer) => {
|
||||
|
|
|
|||
|
|
@ -1,152 +0,0 @@
|
|||
"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: lanzador, filtro y avatar de la barra de arriba
|
||||
// - 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 }) => {
|
||||
const mobileLayer = require('./mobile_layer')({ getViewerId, about });
|
||||
|
||||
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()]);
|
||||
};
|
||||
237
src/backend/karvan_routes.js
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
"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");
|
||||
|
||||
module.exports = ({ cooler, pull, pmModel, checkMod, getViewerId,
|
||||
getConfig, saveConfig, 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
|
||||
// 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 forkRouter.routes();
|
||||
};
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
"use strict";
|
||||
// Lo que la interfaz de la rama necesita del backend, fuera de backend.js.
|
||||
//
|
||||
// Prepara lo que leen la barra de arriba y el lanzador de hive_views.js: el filtro
|
||||
// Personal/Social, que el lanzador no va en la portada y el avatar propio. Lo
|
||||
// monta el router propio (fork_routes.js) delante de las rutas del dev. Nada de
|
||||
// esto corre en escritorio. Ver GUIA_DE_DESARROLLO.md.
|
||||
//
|
||||
// Lo demas que el movil necesita del backend (modulos que no van, rutas que se
|
||||
// cortan, retoques del HTML, descargas de la copia de seguridad) lo hace el propio
|
||||
// dev en la version de movil de su backend.js, la que va dentro de su APK.
|
||||
|
||||
const NULL_IMG = '&' + '0'.repeat(43) + '=.sha256';
|
||||
const isMobile = () => process.env.OASIS_MOBILE === '1';
|
||||
const isPage = (p) => !/^\/(assets|image|blob|qr|c)(\/|$)/.test(p);
|
||||
|
||||
module.exports = ({ getViewerId, about }) => {
|
||||
let avatarAt = 0;
|
||||
const refreshAvatar = async () => {
|
||||
avatarAt = Date.now();
|
||||
try {
|
||||
const me = getViewerId();
|
||||
const img = me ? await about.image(me) : null;
|
||||
if (typeof img === 'string' && img[0] === '&' && img !== NULL_IMG) global.__OASIS_SELF_AVATAR__ = `/image/256/${encodeURIComponent(img)}`;
|
||||
else if (typeof img === 'string' && img[0] === '/') global.__OASIS_SELF_AVATAR__ = img;
|
||||
else global.__OASIS_SELF_AVATAR__ = null;
|
||||
} catch (_) {}
|
||||
};
|
||||
|
||||
return async (ctx, next) => {
|
||||
if (!isMobile() || !isPage(ctx.path)) return next();
|
||||
global.__OASIS_HIVE_FILTER__ = (ctx.path === '/activity' && (ctx.query.hive === 'personal' || ctx.query.hive === 'community')) ? ctx.query.hive : '';
|
||||
// el lanzador ya no va en la portada: la portada es la actividad y los modulos
|
||||
// estan en la hoja de la pestaña Menu (docs/ESTUDIO_NAVEGACION.md)
|
||||
global.__OASIS_SHOW_HIVE__ = false;
|
||||
if (!avatarAt) await refreshAvatar();
|
||||
else if (Date.now() - avatarAt > 60000) refreshAvatar();
|
||||
return next();
|
||||
};
|
||||
};
|
||||
50
src/backend/turnCredentials.js
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
"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 };
|
||||
|
|
@ -1,93 +0,0 @@
|
|||
Copyright 2020 The Anton Project Authors (https://github.com/googlefonts/AntonFont.git)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
|
Before Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 45 KiB |
|
|
@ -1 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M3 13l3-8h12l3 8v6a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1z"/><path d="M3 13h5l1 3h6l1-3h5"/></svg>
|
||||
|
Before Width: | Height: | Size: 251 B |
|
|
@ -1,507 +0,0 @@
|
|||
/* Lanzador, barras, tarjetas e identidades con el tema Acampada (rama movil).
|
||||
*
|
||||
* OasisMobile.css (cargado antes que themes/Acampada.css) esta pensado para fondo
|
||||
* oscuro; esta hoja va detras del tema, solo con Acampada, y cambia lo que alli
|
||||
* es amarillo sobre negro por negro sobre blanco con el cian neon de
|
||||
* acampada26s.net. Las variables --a26-* las define themes/Acampada.css. */
|
||||
|
||||
body { background: var(--a26-bg) !important; }
|
||||
|
||||
/* ---------- barra de arriba ---------- */
|
||||
.oasis-mobile-topbar {
|
||||
background: var(--a26-bg) !important;
|
||||
border: 0 !important;
|
||||
border-bottom: var(--a26-bd) !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
.oasis-mobile-topbar .omt-menus { background: transparent !important; border: 0 !important; box-shadow: none !important; gap: 10px; }
|
||||
.oasis-mobile-topbar .omt-menus .oasis-menu-btn {
|
||||
font-family: var(--a26-tit) !important;
|
||||
font-weight: 400 !important;
|
||||
text-transform: uppercase;
|
||||
font-size: 20px !important;
|
||||
letter-spacing: .02em;
|
||||
min-height: 44px !important;
|
||||
padding: 4px 16px !important;
|
||||
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;
|
||||
}
|
||||
.oasis-mobile-topbar .omt-menus .oasis-menu-btn span { color: var(--a26-fg) !important; }
|
||||
.oasis-mobile-topbar .omt-menus .oasis-menu-btn.active { background: var(--a26-neon) !important; }
|
||||
.oasis-mobile-topbar .omt-menus .oasis-menu-btn:active { transform: translateY(3px); box-shadow: 0 1px 0 var(--a26-fg) !important; }
|
||||
.oasis-mobile-topbar .omt-logo img, .oasis-mobile-topbar .omt-avatar img {
|
||||
border: var(--a26-bd) !important;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* la cabecera de escritorio de upstream no pinta nada en el movil */
|
||||
.header { background: transparent !important; border: 0 !important; box-shadow: none !important; padding: 0 !important; }
|
||||
.header .top-bar-left, .header .top-bar-right { display: none !important; }
|
||||
|
||||
/* ---------- lanzador de modulos (views/hive_views.js) ---------- */
|
||||
.launcher, .ln-group, .ln-grid { background: transparent !important; border: 0 !important; box-shadow: none !important; }
|
||||
.header .launcher { margin: 18px auto 10px !important; }
|
||||
.ln-group + .ln-group { margin-top: 22px !important; }
|
||||
.ln-title {
|
||||
font-family: var(--a26-tit) !important;
|
||||
font-weight: 400 !important;
|
||||
font-size: 17px !important;
|
||||
letter-spacing: .03em !important;
|
||||
color: var(--a26-fg) !important;
|
||||
border-bottom: var(--a26-bd) !important;
|
||||
padding: 0 0 4px !important;
|
||||
margin: 0 2px 14px !important;
|
||||
}
|
||||
.ln-grid { gap: 16px 10px !important; }
|
||||
.ln-ico {
|
||||
width: 58px !important; height: 58px !important;
|
||||
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-size: 24px !important;
|
||||
transition: transform .12s ease-out, box-shadow .12s ease-out;
|
||||
}
|
||||
.ln-app:active .ln-ico, .ln-app:focus-visible .ln-ico {
|
||||
transform: translateY(3px);
|
||||
box-shadow: 0 1px 0 var(--a26-fg) !important;
|
||||
background: var(--a26-neon) !important;
|
||||
border-color: var(--a26-fg) !important;
|
||||
}
|
||||
.ln-app:hover, .ln-app:focus-visible { background: transparent !important; }
|
||||
.ln-lbl { color: var(--a26-fg) !important; font-size: 12px !important; font-weight: 600 !important; }
|
||||
|
||||
/* ---------- barra de abajo ---------- */
|
||||
.oasis-bottombar {
|
||||
background: var(--a26-bg) !important;
|
||||
border: 0 !important;
|
||||
border-top: var(--a26-bd) !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
.oasis-bottombar { padding: 6px 4px calc(6px + env(safe-area-inset-bottom, 0px)) !important; gap: 2px !important; }
|
||||
.oasis-bottombar .bb-item { gap: 2px !important; }
|
||||
.oasis-bottombar .bb-ico { color: var(--a26-fg) !important; font-size: 21px !important; }
|
||||
.oasis-bottombar .bb-lbl {
|
||||
color: var(--a26-fg) !important;
|
||||
font-size: 10.5px !important;
|
||||
font-weight: 600;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.oasis-bottombar a.bb-item.current .bb-ico { background: var(--a26-neon) !important; color: var(--a26-fg) !important; }
|
||||
.oasis-bottombar a.bb-item.current .bb-lbl { color: var(--a26-fg) !important; }
|
||||
.oasis-bottombar .bb-badge { background: var(--a26-neon) !important; color: var(--a26-fg) !important; border: 2px solid var(--a26-fg) !important; }
|
||||
/* Nodos e Invitaciones: juntos y estrechos {
|
||||
background: var(--a26-bg) !important;
|
||||
border: var(--a26-bd-fino) !important;
|
||||
border-radius: var(--a26-radius) !important;
|
||||
padding: 2px !important;
|
||||
gap: 0 !important;
|
||||
}
|
||||
|
||||
/* ---------- personalizar la barra ---------- */
|
||||
.bb-manage-btn, .bb-pick-btn {
|
||||
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-txt) !important;
|
||||
text-transform: none;
|
||||
}
|
||||
.bb-manage-ico, .bb-manage-x, .bb-pick-ico { color: var(--a26-fg) !important; }
|
||||
.bb-manage-lbl, .bb-pick-lbl { color: var(--a26-fg) !important; font-weight: 600; }
|
||||
.bb-manage h3, .bb-manage-add h3 { color: var(--a26-fg) !important; }
|
||||
.bb-manage-empty, .bb-manage-full { color: var(--a26-muted) !important; }
|
||||
|
||||
/* ---------- menu desplegable de upstream (si se abre) ---------- */
|
||||
.oasis-nav-header { background: var(--a26-bg) !important; color: var(--a26-fg) !important; border: var(--a26-bd-fino) !important; }
|
||||
.oasis-nav-list li a, .oasis-nav-list li a .emoji { color: var(--a26-fg) !important; }
|
||||
.oasis-nav-list li a.current { background: var(--a26-neon) !important; }
|
||||
|
||||
/* ---------- foro y chat: los colores de hive.css pensados para fondo negro ---------- */
|
||||
.forum-comment.level-1, .forum-comment.level-2, .forum-comment.level-3, .forum-comment.level-4 { border-left: 3px solid var(--a26-fg) !important; }
|
||||
.forum-comment.highlighted-reply { background: #e5fcff !important; border-left: 5px solid var(--a26-neon) !important; }
|
||||
.chat-message-form .filter-btn::before, .forum-send-btn::before { color: var(--a26-fg) !important; }
|
||||
|
||||
/* ---------- lo que mobile.css de upstream pinta en amarillo con !important ---------- */
|
||||
/* mobile.css va detras del tema, asi que estas reglas tienen que ir aqui */
|
||||
.create-button, button.create-button, a.create-button {
|
||||
background-color: var(--a26-fg) !important;
|
||||
border-color: var(--a26-fg) !important;
|
||||
color: var(--a26-bg) !important;
|
||||
}
|
||||
.filter-btn.active { background-color: var(--a26-neon) !important; border-color: var(--a26-fg) !important; color: var(--a26-fg) !important; }
|
||||
.peers-conn-actions button, .filter-box__button { background-color: var(--a26-bg) !important; color: var(--a26-fg) !important; }
|
||||
.oasis-id-copy { background: var(--a26-soft) !important; }
|
||||
.gov-overview tbody tr { border: var(--a26-bd-fino) !important; }
|
||||
button, input[type="submit"], input[type="button"], .filter-btn, .create-button { min-height: 48px !important; font-size: 18px !important; }
|
||||
|
||||
/* aviso de bienvenida: el boton de empezar en negro y la X sin caja */
|
||||
.welcome-banner .welcome-banner-start {
|
||||
background: var(--a26-fg) !important;
|
||||
color: var(--a26-bg) !important;
|
||||
box-shadow: 0 4px 0 var(--a26-bg) !important;
|
||||
border: var(--a26-bd) !important;
|
||||
}
|
||||
.welcome-banner .welcome-banner-close-btn {
|
||||
background: transparent !important;
|
||||
border: 0 !important;
|
||||
box-shadow: none !important;
|
||||
color: var(--a26-fg) !important;
|
||||
font-size: 22px !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
/* pie: gris y subrayado, sin amarillos */
|
||||
.oasis-footer .oasis-footer-center a, .oasis-footer .oasis-footer-center span,
|
||||
.oasis-footer .oasis-footer-center a span { color: var(--a26-muted) !important; }
|
||||
|
||||
/* la columna de contenido sin marco: el marco lo llevan las tarjetas */
|
||||
.main-column { border: 0 !important; box-shadow: none !important; border-radius: 0 !important; }
|
||||
|
||||
/* la sombra de Personal/Social no se corta contra la raya de la barra */
|
||||
.oasis-mobile-topbar { padding-bottom: 12px !important; }
|
||||
|
||||
/* ---------- asistente de bienvenida: botones grandes de un toque ---------- */
|
||||
.welcome-step { border: var(--a26-bd) !important; border-radius: var(--a26-radius) !important; }
|
||||
.welcome-banner .welcome-banner-start { font-size: 20px !important; min-height: 48px !important; padding: 8px 22px !important; }
|
||||
|
||||
/* mobile.css pinta en naranja los botones sin clase y todos los .filter-btn */
|
||||
button:not([class]), input[type="submit"]:not([class]), input[type="button"]:not([class]),
|
||||
.filter-btn, a.filter-btn, .conn-actions button, .peers-conn-actions button, .filter-box__button {
|
||||
background-color: var(--a26-bg) !important;
|
||||
color: var(--a26-fg) !important;
|
||||
border: var(--a26-bd) !important;
|
||||
border-radius: var(--a26-radius) !important;
|
||||
font-weight: 400 !important;
|
||||
font-size: 18px !important;
|
||||
height: auto !important;
|
||||
min-height: 48px !important;
|
||||
}
|
||||
.filter-btn.active, a.filter-btn.active { background-color: var(--a26-neon) !important; }
|
||||
|
||||
/* pantallas estrechas (360px): Nodos e Invitaciones solo con icono, para que
|
||||
los cuatro atajos no se corten */
|
||||
@media (max-width: 380px) {
|
||||
.oasis-bottombar .bb-pair .bb-lbl { display: none !important; }
|
||||
.oasis-bottombar .bb-pair .bb-ico { font-size: 22px !important; }
|
||||
.oasis-bottombar .bb-pair .bb-item { padding: 2px 6px !important; }
|
||||
.oasis-bottombar .bb-lbl { font-size: 10px !important; }
|
||||
.welcome-banner .update-banner-icon { display: none !important; }
|
||||
}
|
||||
|
||||
/* ---------- identidades (views/identities_view.js): una tarjeta por identidad ---------- */
|
||||
.identities .identity-list { list-style: none; margin: 0 0 16px; padding: 0; display: flex; flex-direction: column; gap: 10px; }
|
||||
.identities .identity-row { display: flex; flex-direction: column; align-items: stretch; gap: 10px; padding: 12px 14px; }
|
||||
.identities .identity-info { display: flex; flex-direction: column; gap: 2px; min-width: 0; border: 0; background: transparent; padding: 0; margin: 0; box-shadow: none; }
|
||||
.identities .identity-feed { font-family: monospace; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.identities .identity-warn { font-size: 12px; }
|
||||
.identities .identity-row form { margin: 0; }
|
||||
.identities .identity-row button { width: 100%; margin: 0; }
|
||||
.identities .identity-active { text-align: center; font-weight: bold; padding: 10px 12px; border-radius: var(--a26-radius); }
|
||||
.identities .identity-create { display: flex; flex-direction: column; gap: 10px; margin: 0 0 16px; }
|
||||
.identities .identity-create input { width: 100%; box-sizing: border-box; margin: 0; }
|
||||
.identities .identity-create button { width: 100%; margin: 0; }
|
||||
.identities .identity-actions { display: flex; flex-direction: column; gap: 12px; align-items: stretch; border: 0; background: transparent; padding: 0; }
|
||||
.identities .identity-actions form { margin: 0; }
|
||||
.identities .identity-actions button { width: 100%; }
|
||||
.identities .identity-legacy { text-align: center; text-decoration: underline; }
|
||||
.identities .identity-row { border: var(--a26-bd) !important; border-radius: var(--a26-radius) !important; background: var(--a26-bg) !important; }
|
||||
.identities .identity-name { font-family: var(--a26-tit) !important; font-weight: 400 !important; text-transform: uppercase; font-size: 22px !important; }
|
||||
.identities .identity-feed { color: var(--a26-muted) !important; }
|
||||
.identities .identity-active { background: var(--a26-neon) !important; border: var(--a26-bd-fino) !important; color: var(--a26-fg) !important; }
|
||||
.identities .identity-warn { color: #c4003b !important; }
|
||||
|
||||
/* las barras de filtros de cada modulo: upstream las encoge (.75em) y les pone
|
||||
borde amarillo con selectores compuestos; aqui, del mismo tamaño que el resto */
|
||||
.tribe-mode-buttons .create-button, .ui-toolbar--filters .create-button, .mode-buttons .create-button,
|
||||
.mode-buttons-row .create-button, .filters .create-button, .filter-group .create-button,
|
||||
.activity-sub-filter .create-button, .activity-filter-grid .create-button {
|
||||
border: var(--a26-bd) !important;
|
||||
border-color: var(--a26-fg) !important;
|
||||
background: var(--a26-fg) !important;
|
||||
color: var(--a26-bg) !important;
|
||||
font-size: 18px !important;
|
||||
padding: 8px 16px !important;
|
||||
min-height: 48px !important;
|
||||
border-radius: var(--a26-radius) !important;
|
||||
line-height: 1.2 !important;
|
||||
}
|
||||
.tribe-mode-buttons .filter-btn, .ui-toolbar--filters .filter-btn, .mode-buttons .filter-btn,
|
||||
.mode-buttons-row .filter-btn, .mode-buttons-cols .filter-btn, .filters .filter-btn,
|
||||
.filter-group .filter-btn, .activity-sub-filter .filter-btn, .activity-filter-grid .filter-btn {
|
||||
border: var(--a26-bd) !important;
|
||||
font-size: 16px !important;
|
||||
padding: 6px 14px !important;
|
||||
min-height: 44px !important;
|
||||
border-radius: var(--a26-radius) !important;
|
||||
line-height: 1.2 !important;
|
||||
}
|
||||
|
||||
/* ---------- lo que tiene que seguir siendo pequeño ---------- */
|
||||
/* los iconos de accion de cada tarjeta (borrar, fijar, enlace...): en fila y redondos */
|
||||
.content-actions {
|
||||
display: flex !important; flex-direction: row !important; flex-wrap: wrap !important;
|
||||
justify-content: flex-end !important; align-items: center !important; gap: 6px !important;
|
||||
}
|
||||
.main-column .content-actions form, .content-actions .content-action-form { display: inline-flex !important; margin: 0 !important; width: auto !important; }
|
||||
.btn-singleview, button.btn-singleview, a.btn-singleview {
|
||||
width: 34px !important; height: 34px !important; min-width: 34px !important; min-height: 34px !important;
|
||||
padding: 0 !important; margin: 0 !important;
|
||||
display: inline-flex !important; align-items: center !important; justify-content: center !important;
|
||||
font-family: var(--a26-txt) !important; font-size: 15px !important; text-transform: none !important;
|
||||
background: var(--a26-bg) !important; color: var(--a26-fg) !important;
|
||||
border: var(--a26-bd-fino) !important; border-radius: 50% !important; box-shadow: none !important;
|
||||
}
|
||||
.btn-singleview.btn-delete { color: #c4003b !important; border-color: #c4003b !important; }
|
||||
|
||||
/* barras de filtros: chips en fila que se reparten el ancho, no un boton por linea */
|
||||
.mode-buttons, .mode-buttons-row, .mode-buttons-cols, .tribe-mode-buttons, .ui-toolbar--filters, .filters {
|
||||
display: flex !important; flex-direction: row !important; flex-wrap: wrap !important;
|
||||
justify-content: center !important; gap: 8px !important; margin: 0 0 14px !important;
|
||||
}
|
||||
.mode-buttons form, .mode-buttons-row form, .mode-buttons-cols form, .tribe-mode-buttons form, .filters > form:not(.filter-box) {
|
||||
display: flex !important; flex-direction: row !important; flex-wrap: wrap !important;
|
||||
justify-content: center !important; gap: 8px !important; width: auto !important; max-width: 100% !important; margin: 0 !important;
|
||||
}
|
||||
.mode-buttons .filter-btn, .mode-buttons-row .filter-btn, .mode-buttons-cols .filter-btn, .tribe-mode-buttons .filter-btn,
|
||||
.ui-toolbar--filters .filter-btn, .filters .filter-btn, .activity-filter-grid .filter-btn, .activity-sub-filter .filter-btn {
|
||||
width: auto !important; flex: 0 0 auto !important;
|
||||
min-height: 38px !important; height: auto !important;
|
||||
padding: 4px 12px !important; margin: 0 !important;
|
||||
font-size: 15px !important; box-shadow: 0 3px 0 var(--a26-fg) !important;
|
||||
border: var(--a26-bd-fino) !important;
|
||||
}
|
||||
.mode-buttons .create-button, .mode-buttons-row .create-button, .tribe-mode-buttons .create-button, .filters .create-button {
|
||||
flex: 1 1 100% !important; width: 100% !important; margin-top: 4px !important;
|
||||
}
|
||||
/* chips de la actividad (activity-filter-*) */
|
||||
.activity-filter-chips button, .activity-filter-grid button, .activity-sub-filter button, .activity-filter-col button {
|
||||
background: var(--a26-bg) !important; color: var(--a26-fg) !important; border: var(--a26-bd-fino) !important;
|
||||
}
|
||||
.activity-filter-chips button.active, .activity-filter-grid button.active, .activity-sub-filter button.active, .activity-filter-col button.active {
|
||||
background: var(--a26-neon) !important;
|
||||
}
|
||||
/* fechas y autores al pie de cada tarjeta: texto, no botones */
|
||||
.card-footer .date-link, .card-footer a.user-link, a.user-link, .date-link {
|
||||
font-family: var(--a26-txt) !important; text-transform: none !important; letter-spacing: 0 !important;
|
||||
font-size: 13px !important; box-shadow: none !important; border: 0 !important;
|
||||
background: transparent !important; padding: 0 !important; min-height: 0 !important;
|
||||
}
|
||||
a.user-link { font-weight: 700 !important; text-decoration: underline !important; }
|
||||
.card-footer { display: flex !important; flex-wrap: wrap; gap: 10px; align-items: center; color: var(--a26-muted) !important; }
|
||||
/* buscador de cada modulo */
|
||||
.filter-box { display: flex !important; gap: 8px !important; align-items: stretch !important; }
|
||||
.filter-box__input::placeholder, input::placeholder, textarea::placeholder { color: #777 !important; }
|
||||
.filter-box__button { font-size: 15px !important; padding: 4px 14px !important; box-shadow: none !important; }
|
||||
/* el pie de cada pagina y el id del perfil, en gris */
|
||||
.main-column [class*="feed-id"], .profile .feed-id, code { color: var(--a26-muted) !important; }
|
||||
|
||||
/* una sola caja por tarjeta: lo de dentro (cabecera, titulo, texto) sin marco */
|
||||
.main-column section, .main-column [class*="-card-"], .main-column .card-section, .main-column .card-header,
|
||||
.main-column .card-meta-row, .main-column .shop-title-row, .main-column [class*="-grid"],
|
||||
.main-column .feed-container, .main-column [class$="-card"] [class$="-card"] {
|
||||
border: 0 !important; box-shadow: none !important; background: transparent !important; border-radius: 0 !important;
|
||||
}
|
||||
.main-column [class$="-card"], .main-column [class*="-card "] {
|
||||
border: var(--a26-bd) !important; border-radius: var(--a26-radius) !important;
|
||||
background: var(--a26-bg) !important; padding: 12px !important; margin: 0 0 14px !important;
|
||||
}
|
||||
.main-column .card-header { padding: 0 0 8px !important; border-bottom: var(--a26-bd-fino) !important; margin-bottom: 8px !important; }
|
||||
.main-column [class$="-card"] .card-footer { border-top: var(--a26-bd-fino) !important; padding-top: 8px !important; margin-top: 8px !important; }
|
||||
.main-column hr { border: 0 !important; border-top: var(--a26-bd-fino) !important; }
|
||||
|
||||
/* titulos de las tarjetas: enlaces en negro */
|
||||
.main-column h1 a, .main-column h2 a, .main-column h3 a, .main-column [class*="title"] a {
|
||||
color: var(--a26-fg) !important; text-decoration: none !important;
|
||||
}
|
||||
.main-column [class*="-card"] .tribe-card-title, .main-column [class*="-card"] h2 { font-size: 22px !important; margin: 4px 0 8px !important; }
|
||||
|
||||
/* la etiqueta del tipo (IMAGENES, FOROS...) y los chips de estado */
|
||||
.pm-exposition-chip, .card-chips-row > span {
|
||||
background: var(--a26-fg) !important; color: var(--a26-bg) !important;
|
||||
border: 0 !important; border-radius: 8px !important; padding: 2px 8px !important;
|
||||
}
|
||||
.pm-exposition-chip *, .card-chips-row > span * { color: var(--a26-bg) !important; }
|
||||
.pm-exposition-text { font-family: var(--a26-tit) !important; letter-spacing: .04em; font-size: 13px !important; }
|
||||
.content-actions .btn-singleview.btn-content, .content-actions a.btn-content { border-color: var(--a26-fg) !important; color: var(--a26-fg) !important; }
|
||||
|
||||
/* opiniones y votos */
|
||||
.opinions-summary, .vote-btn, .score-btn {
|
||||
background: var(--a26-bg) !important; color: var(--a26-fg) !important;
|
||||
border: var(--a26-bd-fino) !important; border-radius: 10px !important; box-shadow: none !important;
|
||||
font-family: var(--a26-txt) !important; text-transform: none !important; font-size: 14px !important;
|
||||
}
|
||||
.post-image { border: var(--a26-bd-fino) !important; border-radius: 10px; }
|
||||
|
||||
/* tablas de detalle (fecha, estado, lugar...): filas claras, la etiqueta en gris */
|
||||
.main-column table, .main-column table tr, .main-column table td, .main-column table th, .main-column table tbody {
|
||||
background: var(--a26-bg) !important; color: var(--a26-fg) !important; border-color: var(--a26-fg) !important;
|
||||
}
|
||||
.main-column table { border: var(--a26-bd-fino) !important; border-radius: 10px !important; }
|
||||
.main-column table tr + tr td { border-top: 1px solid #ccc !important; }
|
||||
.main-column table td:first-child, .main-column table th, .main-column [class*="-info-label"] {
|
||||
color: var(--a26-muted) !important; font-weight: 700 !important; font-size: 13px !important;
|
||||
text-transform: uppercase; letter-spacing: .03em;
|
||||
}
|
||||
|
||||
/* perfil: los datos del habitante como lineas claras, sin cajas negras */
|
||||
.profile-sensors-box, .profile-sensors-box > span, .karma-line, .inhabitant-last-activity, .gpg-line,
|
||||
.inhabitant-stats-box, .inhabitant-stat, .profile-side-relationship .status {
|
||||
background: transparent !important; color: var(--a26-fg) !important; border: 0 !important; box-shadow: none !important;
|
||||
}
|
||||
.profile-sensors-box { display: flex !important; flex-direction: column !important; gap: 4px !important; align-items: center !important; }
|
||||
.profile-sensors-box strong, .profile-sensors-box a, .device-mobile { color: var(--a26-fg) !important; }
|
||||
.inhabitant-stat { border: var(--a26-bd-fino) !important; border-radius: 10px !important; padding: 4px 10px !important; }
|
||||
.profile-side-relationship .status { background: var(--a26-neon) !important; border: var(--a26-bd-fino) !important; border-radius: 8px !important; padding: 2px 10px !important; }
|
||||
.main-column [class*="feed-id"], .main-column a[href^="/author/"]:not(.user-link) { color: var(--a26-muted) !important; }
|
||||
.profile img.avatar, .inhabitant-photo { border: var(--a26-bd) !important; }
|
||||
|
||||
/* etiquetas (#asamblea) y la tasa ECO: chips claros */
|
||||
.card-tags { background: transparent !important; border: 0 !important; padding: 0 !important; display: flex !important; flex-wrap: wrap; gap: 6px; }
|
||||
.card-tags .tag-link, a.tag-link {
|
||||
background: var(--a26-bg) !important; color: var(--a26-fg) !important;
|
||||
border: var(--a26-bd-fino) !important; border-radius: 8px !important; padding: 2px 8px !important;
|
||||
text-decoration: none !important; font-weight: 700;
|
||||
}
|
||||
.eco-tax-chip, [class*="eco-tax-chip-"] {
|
||||
background: var(--a26-soft) !important; color: var(--a26-muted) !important;
|
||||
border: 1px solid #bbb !important; border-radius: 8px !important;
|
||||
}
|
||||
|
||||
/* los filtros de la actividad son enlaces .activity-chip */
|
||||
.activity-chip {
|
||||
background: var(--a26-bg) !important; color: var(--a26-fg) !important;
|
||||
border: var(--a26-bd-fino) !important; border-radius: 10px !important;
|
||||
padding: 4px 10px !important; font-weight: 700 !important; text-decoration: none !important;
|
||||
}
|
||||
.activity-chip.active { background: var(--a26-neon) !important; }
|
||||
.activity-chip:hover { color: var(--a26-fg) !important; border-color: var(--a26-fg) !important; }
|
||||
|
||||
/* ---------- fichas de filtro de 1.3.3 (OasisMobile.css las pinta en amarillo y gris) ---------- */
|
||||
/* mismo selector que alli: gana por ir detras */
|
||||
.main-column :is(.tribe-mode-buttons, .mode-buttons, .mode-buttons-row, .filter-group) {
|
||||
justify-content: center !important;
|
||||
gap: 8px !important;
|
||||
padding: 4px 0 !important;
|
||||
}
|
||||
.main-column :is(.tribe-mode-buttons, .mode-buttons, .mode-buttons-row, .filter-group) .filter-btn:not(.create-button) {
|
||||
font-family: var(--a26-tit) !important;
|
||||
font-weight: 400 !important;
|
||||
font-size: 15px !important;
|
||||
letter-spacing: .02em !important;
|
||||
padding: 4px 12px !important;
|
||||
min-height: 38px !important;
|
||||
border: var(--a26-bd-fino) !important;
|
||||
border-radius: var(--a26-radius) !important;
|
||||
background: var(--a26-bg) !important;
|
||||
color: var(--a26-fg) !important;
|
||||
box-shadow: 0 3px 0 var(--a26-fg) !important;
|
||||
}
|
||||
.main-column :is(.tribe-mode-buttons, .mode-buttons, .mode-buttons-row, .filter-group) .filter-btn.active:not(.create-button) {
|
||||
background: var(--a26-neon) !important;
|
||||
border-color: var(--a26-fg) !important;
|
||||
color: var(--a26-fg) !important;
|
||||
}
|
||||
.main-column :is(.tribe-mode-buttons, .mode-buttons, .mode-buttons-row, .filter-group) .create-button { min-height: 48px !important; }
|
||||
|
||||
/* acciones de la tarjeta: iconos SVG en negro dentro del circulo */
|
||||
.main-column .content-actions .btn-singleview { width: 36px !important; height: 36px !important; }
|
||||
.btn-singleview .btn-icon { width: 18px !important; height: 18px !important; }
|
||||
.btn-singleview.btn-pin-on { background: var(--a26-neon) !important; }
|
||||
|
||||
/* hilos del foro (lista tipo archivo de 1.3.3): tarjeta negra sin la raya naranja */
|
||||
.main-column .mailing-archive-item {
|
||||
border: var(--a26-bd-fino) !important;
|
||||
border-radius: var(--a26-radius) !important;
|
||||
background: var(--a26-bg) !important;
|
||||
padding: 12px !important;
|
||||
margin: 0 0 14px !important;
|
||||
}
|
||||
.main-column .mailing-archive-item a { color: var(--a26-fg) !important; }
|
||||
.main-column a.forum-category {
|
||||
background: var(--a26-fg) !important; color: var(--a26-bg) !important;
|
||||
border: 0 !important; border-radius: 8px !important; padding: 2px 8px !important;
|
||||
font-family: var(--a26-tit) !important; text-decoration: none !important; letter-spacing: .03em;
|
||||
}
|
||||
|
||||
/* restos de naranja de style.css y mobile.css */
|
||||
.oasis-id-copy, input.oasis-id-copy, input.oasis-id-copy[readonly] {
|
||||
background: var(--a26-soft) !important; border: var(--a26-bd-fino) !important; color: var(--a26-muted) !important;
|
||||
}
|
||||
.oasis-menu-btn.oasis-menu-open, .oasis-menu-btn.oasis-menu-close {
|
||||
background: var(--a26-bg) !important; border: var(--a26-bd-fino) !important; color: var(--a26-fg) !important;
|
||||
}
|
||||
img.inhabitant-photo-details { border: var(--a26-bd) !important; }
|
||||
.welcome-action { color: var(--a26-fg) !important; }
|
||||
label.welcome-ux-option {
|
||||
background: var(--a26-bg) !important; border: var(--a26-bd-fino) !important;
|
||||
border-radius: var(--a26-radius) !important; box-shadow: 0 4px 0 var(--a26-fg) !important;
|
||||
}
|
||||
label.welcome-ux-option:has(input:checked) { background: var(--a26-neon) !important; }
|
||||
|
||||
/* chat: burbujas con marco negro y las reacciones como chips pequeños
|
||||
(el estilo general de botones de Acampada las hacia de 48px a ancho completo) */
|
||||
.chat-bubble-row .chat-message {
|
||||
background: var(--a26-bg) !important; color: var(--a26-fg) !important;
|
||||
border: var(--a26-bd-fino) !important; border-radius: 14px 14px 14px 4px !important;
|
||||
}
|
||||
.chat-bubble-row-self .chat-message { background: #e5fcff !important; border-radius: 14px 14px 4px 14px !important; }
|
||||
.chat-bubble-sender, .chat-bubble-sender-owner { color: var(--a26-fg) !important; }
|
||||
.chat-bubble-time { color: var(--a26-muted) !important; }
|
||||
.main-column .chat-msg-actions { display: flex !important; flex-wrap: wrap !important; gap: 4px !important; }
|
||||
.main-column .chat-msg-actions form, .main-column form.chat-react-form { display: inline-flex !important; width: auto !important; margin: 0 !important; }
|
||||
.main-column button.chat-react-btn, button.chat-react-btn {
|
||||
width: auto !important; min-height: 0 !important; height: auto !important;
|
||||
padding: 1px 8px !important; margin: 0 !important;
|
||||
font-size: 13px !important; line-height: 1.4 !important;
|
||||
font-family: "Noto Color Emoji", "Apple Color Emoji", "Segoe UI Emoji", sans-serif !important;
|
||||
text-transform: none !important; letter-spacing: 0 !important;
|
||||
background: var(--a26-bg) !important; color: var(--a26-fg) !important;
|
||||
border: 1px solid var(--a26-fg) !important; border-radius: 10px !important; box-shadow: none !important;
|
||||
}
|
||||
/* el 📌 va en la esquina: el texto deja su hueco (el padding general de la burbuja lo pisaba) */
|
||||
.chat-bubble-row .chat-message.chat-has-pin-toggle { position: relative !important; padding-right: 44px !important; }
|
||||
.chat-day-chip { background: var(--a26-fg) !important; color: var(--a26-bg) !important; border: 0 !important; }
|
||||
.chat-reply-cancel,
|
||||
.chat-message-meta .chat-reply-btn { color: var(--a26-fg) !important; }
|
||||
|
||||
/* ---------- skins en Ajustes ---------- */
|
||||
.skin-card {
|
||||
background: var(--a26-bg) !important; color: var(--a26-fg) !important;
|
||||
border: var(--a26-bd) !important; border-radius: var(--a26-radius) !important;
|
||||
box-shadow: 0 5px 0 var(--a26-fg) !important;
|
||||
}
|
||||
.skin-card .skin-shot { border: var(--a26-bd-fino); }
|
||||
.skin-card .skin-name {
|
||||
font-family: var(--a26-tit) !important; font-weight: 400 !important;
|
||||
text-transform: uppercase; letter-spacing: .02em; font-size: 21px !important;
|
||||
}
|
||||
.skin-card.is-current { background: var(--a26-neon) !important; color: var(--a26-fg) !important; border: var(--a26-bd) !important; }
|
||||
.skin-card:active { transform: translateY(4px); box-shadow: 0 1px 0 var(--a26-fg) !important; background: var(--a26-neon) !important; }
|
||||
|
||||
/* ---------- letra y botones mas grandes, para leer sin gafas ---------- */
|
||||
body { font-size: 18px; }
|
||||
.ln-ico { width: 64px !important; height: 64px !important; font-size: 28px !important; }
|
||||
.ln-lbl { font-size: 13px !important; font-weight: 700 !important; white-space: normal !important; overflow-wrap: normal !important; word-break: normal !important; line-height: 1.15 !important; letter-spacing: -.02em; overflow: visible !important; text-overflow: clip !important; max-width: calc(100% + 10px) !important; }
|
||||
.ln-title { font-size: 20px !important; }
|
||||
.ln-grid { gap: 20px 8px !important; }
|
||||
.oasis-bottombar .bb-ico { font-size: 25px !important; }
|
||||
.oasis-bottombar .bb-lbl { font-size: 12.5px !important; font-weight: 700 !important; }
|
||||
.main-column :is(.tribe-mode-buttons, .mode-buttons, .mode-buttons-row, .filter-group) .filter-btn:not(.create-button) {
|
||||
font-size: 18px !important; min-height: 46px !important; padding: 6px 16px !important;
|
||||
}
|
||||
.activity-chip { font-size: 16px !important; padding: 6px 12px !important; }
|
||||
.main-column .content-actions .btn-singleview { width: 44px !important; height: 44px !important; min-width: 44px !important; }
|
||||
.btn-singleview .btn-icon { width: 22px !important; height: 22px !important; }
|
||||
.main-column button.chat-react-btn, button.chat-react-btn { font-size: 17px !important; padding: 3px 10px !important; }
|
||||
.oasis-mobile-topbar .omt-menus .oasis-menu-btn { font-size: 22px !important; min-height: 48px !important; }
|
||||
.tags-header p { font-size: 16px !important; }
|
||||
.card-footer .date-link, .card-footer a.user-link, a.user-link, .date-link { font-size: 15px !important; }
|
||||
input, textarea, select { font-size: 18px !important; min-height: 50px; }
|
||||
103
src/client/assets/styles/karvan.css
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
/* 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; }
|
||||
|
|
@ -1,648 +0,0 @@
|
|||
/* Piezas de usabilidad de la rama movil, para los dos skins (Acampada y OasisMobile).
|
||||
*
|
||||
* Va la ultima, detras del skin. Los colores salen de las variables de
|
||||
* Acampada (--a26-*) si el tema las define y, si no, de la paleta oscura con oro
|
||||
* de OasisMobile; asi cada pieza nueva se ve bien en los dos sin duplicarla.
|
||||
*
|
||||
* Letra: Anton (--ux-title) solo para titulos y para el boton principal de cada
|
||||
* pantalla; todo lo demas (fichas, botones secundarios, etiquetas) en la letra del
|
||||
* sistema en negrita. Antes habia Anton en botones, fichas, cabeceras de tabla y
|
||||
* pestañas, y la pantalla parecia un cartel.
|
||||
*
|
||||
* Espacios: rejilla de 4/8. Zonas tactiles de 44px como minimo. */
|
||||
|
||||
:root {
|
||||
--ux-bg: var(--a26-bg, #121212);
|
||||
--ux-fg: var(--a26-fg, #f2f2f2);
|
||||
--ux-accent: var(--a26-neon, #ffd700);
|
||||
--ux-on-accent: #000;
|
||||
--ux-soft: var(--a26-soft, #1e1e1e);
|
||||
--ux-muted: var(--a26-muted, #a8a8a8);
|
||||
--ux-line: var(--a26-fg, #3a3a3a);
|
||||
--ux-radius: var(--a26-radius, 12px);
|
||||
--ux-title: var(--a26-tit, inherit);
|
||||
--ux-text: var(--a26-txt, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif);
|
||||
--ux-danger: #c4003b;
|
||||
--ux-ok: #0a8f3c;
|
||||
--ux-warn: #c98a00;
|
||||
}
|
||||
|
||||
/* ---------- letra unificada ---------- */
|
||||
/* fichas de filtro, pestañas y botones secundarios: letra del sistema */
|
||||
.main-column :is(.tribe-mode-buttons, .mode-buttons, .mode-buttons-row, .filter-group, .filters) .filter-btn:not(.create-button),
|
||||
.main-column .filter-box__button, .main-column .doc-export-actions .filter-btn,
|
||||
.main-column .update-btn, .main-column .tribe-action-btn, .lang-chip,
|
||||
.main-column table th, .card-chips-row > span, .pm-exposition-text {
|
||||
font-family: var(--ux-text) !important;
|
||||
font-weight: 700 !important;
|
||||
letter-spacing: .02em !important;
|
||||
}
|
||||
.main-column :is(.tribe-mode-buttons, .mode-buttons, .mode-buttons-row, .filter-group, .filters) .filter-btn:not(.create-button) {
|
||||
font-size: 15px !important;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
/* los formularios de cada modulo (crear tribu, votacion, sala...): etiquetas como
|
||||
texto, no como titulos; el h2 de la pregunta de una votacion era Anton de 28px */
|
||||
.main-column .vote-form h2, .main-column form > label:not(.module-switch):not(.vote-kind-option), .main-column form label[for]:not(.module-switch) {
|
||||
font-family: var(--ux-text) !important;
|
||||
font-size: 15px !important;
|
||||
font-weight: 700 !important;
|
||||
text-transform: none !important;
|
||||
letter-spacing: 0 !important;
|
||||
color: var(--ux-fg) !important;
|
||||
}
|
||||
.form-hint {
|
||||
margin: 6px 0 14px !important;
|
||||
font-size: 14px !important;
|
||||
line-height: 1.4;
|
||||
color: var(--ux-muted) !important;
|
||||
}
|
||||
|
||||
/* el boton de enviar de los formularios: a todo lo ancho, abajo, que se vea que es
|
||||
el final. Antes unos centrados,
|
||||
.main-column form[action$="/tribes/create"][method="post"] > button[type="submit"],
|
||||
.main-column form[action*="/tribes/update/"] > button[type="submit"] {
|
||||
display: block !important;
|
||||
width: 100% !important;
|
||||
margin: 8px 0 0 !important;
|
||||
}
|
||||
|
||||
/* ---------- iconos SVG (lanzador, barra, modulos) ---------- */
|
||||
.has-svg { display: inline-flex !important; align-items: center; justify-content: center; }
|
||||
.has-svg svg { width: 100%; height: 100%; display: block; }
|
||||
.ln-ico.has-svg { padding: 15px !important; }
|
||||
.ln-ico.has-svg svg { width: 30px; height: 30px; }
|
||||
.oasis-bottombar .bb-ico.has-svg {
|
||||
width: 56px !important; height: 30px !important; padding: 3px 0 !important;
|
||||
border-radius: 16px !important; font-size: 0 !important;
|
||||
}
|
||||
.oasis-bottombar .bb-ico.has-svg svg { width: 24px; height: 24px; }
|
||||
.bb-manage-ico.has-svg, .bb-pick-ico.has-svg { width: 24px; height: 24px; }
|
||||
|
||||
/* ---------- barra de abajo: la pestaña actual con pastilla, como en Android ---------- */
|
||||
.oasis-bottombar a.bb-item.current .bb-ico.has-svg { background: var(--ux-accent) !important; color: var(--ux-on-accent) !important; }
|
||||
.oasis-bottombar .bb-lbl { font-family: var(--ux-text) !important; }
|
||||
/* con el teclado abierto la ventana encoge: la barra se esconde y el formulario
|
||||
recupera ese tercio de pantalla */
|
||||
@media (max-height: 520px) {
|
||||
.oasis-bottombar { display: none !important; }
|
||||
body { padding-bottom: 0 !important; }
|
||||
}
|
||||
|
||||
/* ---------- aviso de bienvenida: una linea, no un bloque ---------- */
|
||||
.welcome-banner {
|
||||
display: flex !important; align-items: center !important; gap: 10px !important;
|
||||
padding: 8px 8px 8px 14px !important; min-height: 0 !important;
|
||||
}
|
||||
.welcome-banner .update-banner-icon { display: none !important; }
|
||||
.welcome-banner .update-banner-text { flex: 1 1 auto; font-size: 15px !important; line-height: 1.3 !important; margin: 0 !important; }
|
||||
.welcome-banner .welcome-banner-start {
|
||||
flex: 0 0 auto; font-size: 16px !important; min-height: 40px !important;
|
||||
padding: 6px 14px !important; margin: 0 !important; width: auto !important;
|
||||
}
|
||||
.welcome-banner .welcome-banner-close { margin: 0 !important; flex: 0 0 auto; }
|
||||
.welcome-banner .welcome-banner-close-btn { min-height: 40px !important; width: 36px !important; margin: 0 !important; }
|
||||
|
||||
/* ---------- pie de una linea ---------- */
|
||||
.oasis-footer.oasis-footer-mobile {
|
||||
display: flex !important; flex-direction: column; align-items: center; gap: 4px;
|
||||
padding: 14px 16px 18px !important; margin-top: 24px !important; text-align: center;
|
||||
font-size: 13px !important;
|
||||
}
|
||||
.oasis-footer-mobile .footer-sync {
|
||||
display: inline-flex; align-items: center; gap: 8px; min-height: 36px;
|
||||
font-weight: 700; text-decoration: none !important; color: var(--ux-fg) !important;
|
||||
}
|
||||
.oasis-footer-mobile .footer-sync-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--ux-muted); }
|
||||
.oasis-footer-mobile .footer-sync.is-on .footer-sync-dot { background: var(--ux-ok); }
|
||||
.oasis-footer-mobile .footer-version { color: var(--ux-muted) !important; }
|
||||
|
||||
/* ---------- acciones de tarjeta: borrar con confirmacion ---------- */
|
||||
.content-action-confirm { display: inline-flex; flex-wrap: wrap; justify-content: flex-end; }
|
||||
.content-action-confirm > summary { list-style: none; cursor: pointer; }
|
||||
.content-action-confirm > summary::-webkit-details-marker { display: none; }
|
||||
.content-action-confirm[open] { flex-basis: 100%; order: 99; }
|
||||
.content-action-confirm[open] > summary { background: var(--ux-danger) !important; color: #fff !important; border-color: var(--ux-danger) !important; }
|
||||
.content-action-confirm .confirm-bar {
|
||||
flex-basis: 100%; display: flex; align-items: center; justify-content: flex-end; gap: 10px;
|
||||
margin-top: 8px; padding: 8px 8px 8px 12px; border: 2px solid var(--ux-danger); border-radius: var(--ux-radius);
|
||||
}
|
||||
.content-action-confirm .confirm-question { flex: 1 1 auto; font-weight: 700; font-size: 15px; text-align: left; }
|
||||
.content-action-confirm form { margin: 0 !important; width: auto !important; }
|
||||
.main-column .content-action-confirm .confirm-yes, .content-action-confirm .confirm-yes {
|
||||
background: var(--ux-danger) !important; color: #fff !important; border-color: var(--ux-danger) !important;
|
||||
box-shadow: none !important; min-height: 44px !important; margin: 0 !important; width: auto !important;
|
||||
font-family: var(--ux-text) !important; font-weight: 700 !important; font-size: 15px !important;
|
||||
}
|
||||
|
||||
/* ---------- lista de tribus: una fila por tribu ---------- */
|
||||
.main-column .tribe-list { display: flex !important; flex-direction: column; gap: 10px; padding: 0 !important; }
|
||||
.main-column a.tribe-row {
|
||||
display: flex !important; flex-wrap: nowrap !important; align-items: center; gap: 12px; width: auto !important;
|
||||
padding: 10px 12px !important; min-height: 72px;
|
||||
border: 2px solid var(--ux-line) !important; border-radius: var(--ux-radius) !important;
|
||||
background: var(--ux-bg) !important; color: var(--ux-fg) !important; text-decoration: none !important;
|
||||
}
|
||||
.main-column a.tribe-row:active { background: var(--ux-accent) !important; color: var(--ux-on-accent) !important; }
|
||||
.tribe-row .tribe-row-thumb {
|
||||
flex: 0 0 52px; width: 52px !important; height: 52px !important; border-radius: 10px !important;
|
||||
object-fit: cover; border: 2px solid var(--ux-line) !important; margin: 0 !important;
|
||||
}
|
||||
.tribe-row .tribe-row-icon { padding: 11px; background: var(--ux-soft); }
|
||||
.tribe-row .tribe-row-text { flex: 1 1 0; min-width: 0; overflow: hidden; display: flex; flex-direction: column; gap: 2px; }
|
||||
.tribe-row .tribe-row-text > span { display: block; max-width: 100%; margin: 0 !important; padding: 0 !important; line-height: 1.3; }
|
||||
.tribe-row .tribe-row-title {
|
||||
font-family: var(--ux-title) !important; font-size: 21px; line-height: 1.1; text-transform: uppercase;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.tribe-row .tribe-row-facts { font-size: 13px; font-weight: 700; color: var(--ux-muted); }
|
||||
.tribe-row .tribe-row-desc { font-size: 14px; color: var(--ux-fg); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.tribe-row .tribe-row-go { flex: 0 0 auto; font-size: 28px; line-height: 1; color: var(--ux-muted); }
|
||||
|
||||
/* ---------- votaciones ---------- */
|
||||
.vote-bars { display: flex; flex-direction: column; gap: 10px; margin: 8px 0 4px; }
|
||||
.vote-bar { display: grid; grid-template-columns: 1fr auto; align-items: center; gap: 4px 10px; }
|
||||
.vote-bar-label { font-weight: 700; font-size: 15px; }
|
||||
.vote-bar-count { font-weight: 700; font-size: 15px; min-width: 2ch; text-align: right; }
|
||||
.vote-bar-meter {
|
||||
grid-column: 1 / -1; width: 100%; height: 12px; appearance: none; -webkit-appearance: none;
|
||||
border: 2px solid var(--ux-line); border-radius: 8px; overflow: hidden; background: var(--ux-soft);
|
||||
}
|
||||
.vote-bar-meter::-webkit-progress-bar { background: var(--ux-soft); }
|
||||
.vote-bar-meter::-webkit-progress-value { background: var(--ux-fg); }
|
||||
.vote-bar-meter::-moz-progress-bar { background: var(--ux-fg); }
|
||||
.vote-bar-yes .vote-bar-meter::-webkit-progress-value, .vote-bar-green .vote-bar-meter::-webkit-progress-value { background: var(--ux-ok); }
|
||||
.vote-bar-no .vote-bar-meter::-webkit-progress-value, .vote-bar-red .vote-bar-meter::-webkit-progress-value { background: var(--ux-danger); }
|
||||
.vote-bar-yellow .vote-bar-meter::-webkit-progress-value { background: var(--ux-warn); }
|
||||
.vote-own-note {
|
||||
padding: 12px 14px !important; border-left: 4px solid var(--ux-accent) !important;
|
||||
background: var(--ux-soft) !important; border-radius: 0 10px 10px 0; font-size: 15px !important;
|
||||
}
|
||||
/* botones de votar: rejilla de dos columnas, cada opcion un boton grande */
|
||||
.main-column .vote-buttons-row-single { display: grid !important; grid-template-columns: 1fr 1fr; gap: 10px !important; }
|
||||
.main-column .vote-buttons-row-single form { margin: 0 !important; width: 100% !important; }
|
||||
.main-column .vote-buttons-row-single button {
|
||||
width: 100% !important; min-height: 52px !important; margin: 0 !important;
|
||||
font-family: var(--ux-text) !important; font-weight: 700 !important; font-size: 16px !important;
|
||||
}
|
||||
.main-column .vote-buttons-row-single button[value="YES"], .main-column .vote-buttons-row-single button[value="GREEN"] { border-color: var(--ux-ok) !important; box-shadow: 0 4px 0 var(--ux-ok) !important; }
|
||||
.main-column .vote-buttons-row-single button[value="NO"], .main-column .vote-buttons-row-single button[value="RED"] { border-color: var(--ux-danger) !important; box-shadow: 0 4px 0 var(--ux-danger) !important; }
|
||||
.main-column .vote-buttons-row-single button[value="YELLOW"] { border-color: var(--ux-warn) !important; box-shadow: 0 4px 0 var(--ux-warn) !important; }
|
||||
/* tipo de votacion: tarjetas de radio */
|
||||
.vote-kind { display: flex; flex-direction: column; gap: 10px; margin: 4px 0 18px; }
|
||||
.vote-kind-title { font-weight: 700; font-size: 15px; }
|
||||
.vote-kind-option {
|
||||
display: grid !important; grid-template-columns: auto 1fr; gap: 2px 12px; align-items: center;
|
||||
padding: 12px 14px; border: 2px solid var(--ux-line); border-radius: var(--ux-radius); cursor: pointer;
|
||||
}
|
||||
.main-column .vote-kind-option input[type="radio"] { grid-row: 1 / span 2; width: 22px !important; height: 22px !important; min-height: 0 !important; margin: 0 !important; accent-color: var(--ux-fg); }
|
||||
.vote-kind-option:has(input:checked) { background: var(--ux-accent); color: var(--ux-on-accent); }
|
||||
.vote-kind-option:has(input:checked) .vote-kind-hint { color: var(--ux-on-accent); }
|
||||
.vote-kind-name { font-weight: 700; font-size: 17px; }
|
||||
.vote-kind-hint { font-size: 14px; color: var(--ux-muted); }
|
||||
|
||||
/* ---------- modulos: interruptores agrupados ---------- */
|
||||
.module-switches { display: flex; flex-direction: column; gap: 22px; padding-bottom: 8px; }
|
||||
.module-group { display: flex; flex-direction: column; gap: 8px; }
|
||||
.module-group-title {
|
||||
font-family: var(--ux-title) !important; font-weight: 400 !important; font-size: 20px !important;
|
||||
text-transform: uppercase; margin: 0 0 4px !important; padding-bottom: 4px; border-bottom: 3px solid var(--ux-line);
|
||||
}
|
||||
.main-column label.module-switch {
|
||||
display: grid !important; grid-template-columns: 40px minmax(0, 1fr) 52px; align-items: center; gap: 12px;
|
||||
padding: 10px 12px; min-height: 64px; cursor: pointer;
|
||||
border: 2px solid var(--ux-line); border-radius: var(--ux-radius);
|
||||
font-family: var(--ux-text) !important; text-transform: none !important; letter-spacing: 0 !important;
|
||||
}
|
||||
.module-switch-ico { width: 40px; height: 40px; padding: 8px; border-radius: 10px; background: var(--ux-soft); color: var(--ux-fg); }
|
||||
.module-switch-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||
.module-switch-name { font-weight: 700; font-size: 17px; }
|
||||
.module-switch-desc { font-size: 13px; color: var(--ux-muted); line-height: 1.3; }
|
||||
.module-switch:has(.switch-input:checked) { border-color: var(--ux-fg); }
|
||||
/* el interruptor: la casilla de siempre con forma de pastilla */
|
||||
.main-column label.module-switch input.switch-input[type="checkbox"] {
|
||||
appearance: none; -webkit-appearance: none; flex: 0 0 auto;
|
||||
width: 52px !important; height: 30px !important; min-height: 0 !important; margin: 0 !important; padding: 0 !important;
|
||||
border: 2px solid var(--ux-line) !important; border-radius: 16px !important; background: var(--ux-soft) !important;
|
||||
position: relative; cursor: pointer; transition: background .15s ease-out;
|
||||
}
|
||||
.main-column label.module-switch input.switch-input[type="checkbox"]::after {
|
||||
content: ""; position: absolute; top: 3px; left: 3px; width: 20px; height: 20px; border-radius: 50%;
|
||||
background: var(--ux-muted); transition: transform .15s ease-out, background .15s ease-out;
|
||||
}
|
||||
.main-column label.module-switch input.switch-input[type="checkbox"]:checked { background: var(--ux-accent) !important; }
|
||||
.main-column label.module-switch input.switch-input[type="checkbox"]:checked::after { transform: translateX(22px); background: var(--ux-on-accent); }
|
||||
/* Guardar siempre a la vista, encima de la barra de abajo */
|
||||
.module-save-bar {
|
||||
position: sticky; bottom: calc(76px + env(safe-area-inset-bottom, 0px)); z-index: 5;
|
||||
padding: 10px 0; background: var(--ux-bg);
|
||||
}
|
||||
.module-save-bar .create-button { width: 100% !important; margin: 0 !important; }
|
||||
.module-saved {
|
||||
padding: 10px 14px !important; margin: 0 0 14px !important; font-weight: 700;
|
||||
border-left: 4px solid var(--ux-ok) !important; background: var(--ux-soft) !important; border-radius: 0 10px 10px 0;
|
||||
}
|
||||
/* la seccion apagada a la que se queria entrar */
|
||||
.main-column div.module-blocked {
|
||||
display: grid; grid-template-columns: 48px 1fr; gap: 12px; align-items: center;
|
||||
padding: 14px; margin: 0 0 20px; border: 3px solid var(--ux-line); border-radius: var(--ux-radius); background: var(--ux-soft);
|
||||
}
|
||||
.module-blocked-ico { width: 48px; height: 48px; padding: 10px; border-radius: 12px; background: var(--ux-bg); }
|
||||
.module-blocked-text { margin: 0 !important; font-weight: 700; font-size: 17px; }
|
||||
.module-blocked form { grid-column: 1 / -1; margin: 0 !important; }
|
||||
.module-blocked .create-button { width: 100% !important; margin: 0 !important; }
|
||||
|
||||
/* ---------- Ajustes: idiomas como fichas de un toque ---------- */
|
||||
.lang-pick { display: grid !important; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 12px; }
|
||||
.main-column .lang-pick .lang-chip, .lang-pick .lang-chip {
|
||||
width: 100% !important; min-height: 48px !important; margin: 0 !important; padding: 6px 10px !important;
|
||||
font-size: 16px !important; text-transform: none !important; box-shadow: none !important;
|
||||
border: 2px solid var(--ux-line) !important; background: var(--ux-bg) !important; color: var(--ux-fg) !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; }
|
||||
|
||||
/* ---------- foco visible para teclado y lectores ---------- */
|
||||
a:focus-visible, button:focus-visible, summary:focus-visible, label.module-switch:focus-within {
|
||||
outline: 3px solid var(--ux-accent) !important; outline-offset: 2px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.main-column label.module-switch input.switch-input[type="checkbox"], .main-column label.module-switch input.switch-input[type="checkbox"]::after { transition: none; }
|
||||
}
|
||||
|
||||
/* ---------- buscador de cada modulo: campo y lupa en la misma linea ---------- */
|
||||
/* solo cuando el unico control es el boton: los de Transferencias llevan rangos y
|
||||
desplegables y siguen en columna */
|
||||
.main-column form.filter-box:not(:has(.filter-box__select, .filter-box__number, .transfer-range)) {
|
||||
display: flex !important; flex-direction: row !important; flex-wrap: nowrap !important;
|
||||
align-items: stretch !important; gap: 8px !important; width: 100% !important; margin: 0 0 12px !important;
|
||||
}
|
||||
.main-column form.filter-box:not(:has(.filter-box__select, .filter-box__number, .transfer-range)) .filter-box__input {
|
||||
flex: 1 1 auto !important; min-width: 0 !important; width: 0 !important; height: 48px !important; margin: 0 !important;
|
||||
}
|
||||
.main-column form.filter-box:not(:has(.filter-box__select, .filter-box__number, .transfer-range)) .filter-box__controls {
|
||||
flex: 0 0 auto !important; width: auto !important; margin: 0 !important;
|
||||
}
|
||||
.main-column form.filter-box .filter-box__button { min-height: 48px !important; margin: 0 !important; }
|
||||
/* el recuento (TOTAL: 12 · PUBLICA: 12) como una linea discreta, no un bloque */
|
||||
.main-column .activity-toolbar-row { display: flex !important; flex-direction: column !important; align-items: stretch !important; gap: 6px !important; }
|
||||
.main-column [class*="module-stats"] { font-size: 13px !important; color: var(--ux-muted) !important; text-align: left !important; margin: 0 !important; }
|
||||
|
||||
/* el boton de crear de cada modulo a todo lo ancho, tambien cuando va solo en su
|
||||
formulario (en Votaciones salia estrecho y en Tribus entero) */
|
||||
.main-column .filters > form:has(.create-button), .main-column .mode-buttons > form:has(.create-button) { width: 100% !important; }
|
||||
|
||||
/* jerga fuera del movil: la tasa ECO (microgramos de CO2) no se entendia y su ficha
|
||||
salia ilegible; sigue en Estadisticas */
|
||||
.main-column .eco-tax-chip, .main-column [class*="eco-tax-chip"] { display: none !important; }
|
||||
/* etiquetas (#asamblea) sin caja negra detras */
|
||||
.main-column .card-tags, .main-column .tribe-side .card-tags { background: transparent !important; padding: 0 !important; margin: 8px 0 !important; }
|
||||
|
||||
/* acciones secundarias de una ficha (actualizar, PDF, compartir): una rejilla de
|
||||
dos columnas, sin los huecos de 80px que dejaban los formularios sueltos */
|
||||
.main-column :is(.tribe-side-actions, .doc-export-actions) {
|
||||
display: grid !important; grid-template-columns: 1fr 1fr; gap: 8px !important; margin: 12px 0 !important; padding: 0 !important;
|
||||
}
|
||||
.main-column :is(.tribe-side-actions, .doc-export-actions) form { margin: 0 !important; width: 100% !important; padding: 0 !important; }
|
||||
.main-column :is(.tribe-side-actions, .doc-export-actions) :is(button, .filter-btn, a) {
|
||||
width: 100% !important; min-height: 44px !important; margin: 0 !important; padding: 6px 8px !important;
|
||||
font-family: var(--ux-text) !important; font-weight: 700 !important; font-size: 14px !important; box-shadow: none !important;
|
||||
}
|
||||
.main-column .tribe-side-actions + .doc-export-actions { margin-top: 0 !important; }
|
||||
/* resultado de la votacion: ficha de color legible en los dos skins */
|
||||
.main-column .vote-outcome {
|
||||
display: inline-block; padding: 2px 10px !important; border-radius: 8px;
|
||||
font-family: var(--ux-text) !important; font-size: 15px !important; font-weight: 700 !important;
|
||||
background: var(--ux-soft) !important; color: var(--ux-fg) !important; vertical-align: middle;
|
||||
}
|
||||
.main-column .vote-outcome-yes { background: var(--ux-ok) !important; color: #fff !important; }
|
||||
.main-column .vote-outcome-no { background: var(--ux-danger) !important; color: #fff !important; }
|
||||
.main-column .job-section-title:has(.vote-outcome) { display: flex !important; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
|
||||
/* ---------- margenes: uno solo, de 14px ---------- */
|
||||
/* se sumaban capa a capa (contenido 20 + columna 10 + ficha 24 + lateral 20 = 74px
|
||||
por lado) y el texto de una votacion o una tribu se quedaba en 264 de 412px */
|
||||
body .main-content { padding-left: 14px !important; padding-right: 14px !important; }
|
||||
body .main-content > .main-column { padding-left: 0 !important; padding-right: 0 !important; }
|
||||
.main-column :is(.tribe-details, .tribe-side, .tribe-main, .job-section, .vote-form, .tribe-content) {
|
||||
padding-left: 0 !important; padding-right: 0 !important; margin-left: 0 !important; margin-right: 0 !important;
|
||||
width: 100% !important; max-width: 100% !important; box-sizing: border-box;
|
||||
}
|
||||
.main-column .tribe-details { display: flex !important; flex-direction: column !important; gap: 18px; }
|
||||
/* dentro de una ficha, un solo ritmo: 10px entre bloques (antes hueco de 16 + los
|
||||
margenes de cada bloque + 20 de relleno, unos 70px entre secciones) */
|
||||
.main-column .tribe-side { gap: 10px !important; padding-top: 0 !important; padding-bottom: 0 !important; }
|
||||
.main-column .tribe-side > *, .main-column .tribe-main > * { margin-top: 0 !important; margin-bottom: 0 !important; }
|
||||
.main-column .tribe-main { display: flex !important; flex-direction: column; gap: 14px !important; }
|
||||
.main-column .tribe-card-members { text-align: left !important; padding: 0 !important; font-weight: 700; }
|
||||
.main-column :is(.tribe-side-actions, .doc-export-actions) > :only-child { grid-column: 1 / -1; }
|
||||
/* la imagen generica de tribu no aporta nada y ocupaba 300px: solo se ve la propia */
|
||||
.main-column img[src*="default-tribe"] { display: none !important; }
|
||||
/* etiquetas del lanzador largas (Configuracion): se parten con guion */
|
||||
.ln-lbl { hyphens: auto; -webkit-hyphens: auto; font-size: 12.5px !important; }
|
||||
|
||||
/* ---------- secciones de una tribu: tira de fichas deslizable + "Mas" ---------- */
|
||||
.main-column .tribe-tabs { margin: 4px 0 12px !important; }
|
||||
.tribe-tabs-strip {
|
||||
display: flex; gap: 8px; overflow-x: auto; scrollbar-width: none; -webkit-overflow-scrolling: touch;
|
||||
margin: 0; padding: 4px 0 8px; scroll-snap-type: x proximity;
|
||||
}
|
||||
.tribe-tabs-strip::-webkit-scrollbar { display: none; }
|
||||
.main-column a.tribe-tab, .tribe-tabs-more > summary {
|
||||
flex: 0 0 auto; scroll-snap-align: start; display: inline-flex; align-items: center; justify-content: center;
|
||||
min-height: 40px; padding: 4px 14px; border: 2px solid var(--ux-line) !important; border-radius: 20px !important;
|
||||
background: var(--ux-bg) !important; color: var(--ux-fg) !important; text-decoration: none !important;
|
||||
font-family: var(--ux-text) !important; font-weight: 700 !important; font-size: 14px !important; white-space: nowrap;
|
||||
text-transform: uppercase; letter-spacing: .02em;
|
||||
}
|
||||
.main-column a.tribe-tab.is-current { background: var(--ux-accent) !important; color: var(--ux-on-accent) !important; }
|
||||
.tribe-tabs-more > summary { list-style: none; cursor: pointer; margin-top: 2px; }
|
||||
.tribe-tabs-more > summary::-webkit-details-marker { display: none; }
|
||||
.tribe-tabs-more > summary::after { content: " ▾"; }
|
||||
.tribe-tabs-more[open] > summary::after { content: " ▴"; }
|
||||
.tribe-tabs-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 10px; }
|
||||
.tribe-tabs-grid .tribe-tab { width: 100%; }
|
||||
/* filas etiqueta / valor de las fichas (plazo, estado, creado el): en dos columnas */
|
||||
.main-column table.tribe-info-table tr { display: grid !important; grid-template-columns: minmax(96px, 38%) 1fr; align-items: center; }
|
||||
.main-column table.tribe-info-table td { display: block !important; border: 0 !important; padding: 8px 10px !important; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
||||
.main-column table.tribe-info-table tr + tr { border-top: 1px solid #ccc !important; }
|
||||
|
||||
/* bienvenida: "Ahora no" al final, como enlace discreto */
|
||||
.main-column .welcome-skip-end { text-align: center; margin: 8px 0 0 !important; }
|
||||
.main-column .welcome-skip-end .welcome-skip-btn {
|
||||
background: transparent !important; border: 0 !important; box-shadow: none !important;
|
||||
font-family: var(--ux-text) !important; font-weight: 700 !important; font-size: 15px !important;
|
||||
text-transform: none !important; text-decoration: underline; color: var(--ux-muted) !important; width: auto !important;
|
||||
}
|
||||
/* buzon y otros: los botones de filtro sueltos (Mas recientes, Marcar como leido)
|
||||
en la letra de texto, como el resto de fichas */
|
||||
.main-column [class*="inbox"] .filter-btn:not(.create-button),
|
||||
.main-column form[action^="/inbox"] button:not(.create-button) {
|
||||
font-family: var(--ux-text) !important; font-weight: 700 !important; font-size: 15px !important; letter-spacing: .02em !important;
|
||||
}
|
||||
|
||||
/* ---------- regla general de letra para botones ---------- */
|
||||
/* todo boton en letra del sistema; Anton solo en el principal de cada pantalla
|
||||
(el negro, .create-button) y en las tarjetas de aspecto */
|
||||
.main-column :is(button, input[type="submit"], input[type="button"], .filter-btn, a.filter-btn):not(.create-button):not(.welcome-banner-start):not(.skin-card):not(.ux-pick-card):not(.btn-singleview) {
|
||||
font-family: var(--ux-text) !important;
|
||||
font-weight: 700 !important;
|
||||
letter-spacing: .02em !important;
|
||||
}
|
||||
.main-column form.welcome-skip-end { display: flex !important; justify-content: center !important; }
|
||||
/* la barra de confirmar borrado con su marco rojo (Acampada quita el borde a los div) */
|
||||
.main-column div.confirm-bar { border: 2px solid var(--ux-danger) !important; }
|
||||
.main-column .content-action-confirm[open] > summary { opacity: 1 !important; }
|
||||
|
||||
/* ---------- boton principal de cada modulo: el mismo en todos ---------- */
|
||||
/* en Chats y Pads salia entero, en Foros estrecho y en Feed con otra letra (lleva
|
||||
tambien .filter-btn y le tocaban las reglas de las fichas) */
|
||||
.main-column :is(.tribe-mode-buttons, .mode-buttons, .mode-buttons-row, .filters, .forum-create-col) :is(form, div):has(> .create-button),
|
||||
.main-column .forum-create-col { width: 100% !important; flex: 1 1 100% !important; }
|
||||
.main-column :is(.tribe-mode-buttons, .mode-buttons, .mode-buttons-row, .filters, .forum-create-col) .create-button.create-button {
|
||||
width: 100% !important; min-height: 52px !important; margin: 4px 0 0 !important; padding: 8px 16px !important;
|
||||
font-family: var(--ux-title) !important; font-weight: 400 !important; font-size: 20px !important;
|
||||
letter-spacing: .02em !important; text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* ---------- estados vacios: icono, texto centrado, sin cajas ---------- */
|
||||
.main-column :is(.no-content, .no-results, .no-content-box),
|
||||
.main-column .tribe-grid > p:only-child {
|
||||
display: flex !important; flex-direction: column; align-items: center; gap: 12px;
|
||||
margin: 24px 0 !important; padding: 28px 16px !important; text-align: center !important;
|
||||
border: 2px dashed var(--ux-muted) !important; border-radius: var(--ux-radius) !important;
|
||||
color: var(--ux-muted) !important; font-size: 16px !important; font-weight: 700; background: transparent !important;
|
||||
}
|
||||
.main-column :is(.no-content, .no-results, .no-content-box)::before,
|
||||
.main-column .tribe-grid > p:only-child::before {
|
||||
content: ""; width: 44px; height: 44px; background: var(--ux-muted);
|
||||
-webkit-mask: url("../images/ux-empty.svg") center / contain no-repeat; mask: url("../images/ux-empty.svg") center / contain no-repeat;
|
||||
}
|
||||
.main-column .no-content-box p { margin: 0 !important; color: inherit !important; }
|
||||
|
||||
/* ---------- el pie abajo del todo aunque la pagina sea corta ---------- */
|
||||
body .main-content { min-height: calc(100dvh - 300px); }
|
||||
.main-column .tribe-grid:has(> p:only-child), .main-column .filters, .main-column .tribe-mode-buttons { padding-left: 0 !important; padding-right: 0 !important; margin-left: 0 !important; margin-right: 0 !important; }
|
||||
.main-column :is(.tribe-mode-buttons, .mode-buttons, .mode-buttons-row, .filters, .forum-create-col) .create-button.create-button { box-shadow: 0 5px 0 var(--ux-accent) !important; }
|
||||
|
||||
/* ---------- buzon: cada mensaje en pocas lineas ---------- */
|
||||
/* era una tabla de seis filas (De, Fecha, Para, Asunto, Tasa ECO, Cifrado) dentro de
|
||||
dos cajas: un aviso ocupaba una pantalla. Para es siempre una misma, el asunto ya
|
||||
es el titulo, la tasa ECO no se entiende y el cifrado es igual en todos */
|
||||
.main-column .pm-thread { border: 0 !important; padding: 0 !important; margin: 0 0 10px !important; background: transparent !important; }
|
||||
.main-column div.pm-card { padding: 12px !important; margin: 0 !important; border: 2px solid var(--ux-line) !important; border-radius: var(--ux-radius) !important; }
|
||||
.main-column .pm-card .pm-info-table { display: flex !important; flex-wrap: wrap; align-items: center; gap: 4px 10px; border: 0 !important; margin: 0 0 6px !important; width: 100%; }
|
||||
.main-column .pm-card .pm-info-table tbody { display: contents !important; }
|
||||
.main-column .pm-card .pm-info-table tr { display: none !important; }
|
||||
.main-column .pm-card .pm-info-table tr:nth-child(1), .main-column .pm-card .pm-info-table tr:nth-child(2),
|
||||
.main-column .pm-card .pm-info-table tr:has(.pm-unread-chip) { display: flex !important; align-items: center; border: 0 !important; }
|
||||
.main-column .pm-card .pm-info-table td { display: block !important; padding: 0 !important; border: 0 !important; background: transparent !important; }
|
||||
.main-column .pm-card .pm-info-table td.card-label { display: none !important; }
|
||||
.main-column .pm-card .pm-info-table tr:nth-child(1) { flex: 1 1 60%; min-width: 0; }
|
||||
.main-column .pm-card .pm-info-table tr:nth-child(1) a { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px !important; }
|
||||
.main-column .pm-card .pm-info-table tr:nth-child(2) td { font-size: 13px !important; color: var(--ux-muted) !important; }
|
||||
.main-column .pm-card .pm-subject-cell > span:not(.pm-unread-chip) { display: none !important; }
|
||||
.main-column .pm-card h2.pm-title { font-family: var(--ux-text) !important; font-weight: 700 !important; font-size: 17px !important; text-transform: none !important; line-height: 1.3 !important; margin: 0 0 6px !important; }
|
||||
.main-column .pm-card .message-text { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; font-size: 15px; }
|
||||
|
||||
/* ---------- busqueda: primero el campo de buscar, los filtros despues ---------- */
|
||||
.main-column .search-filters-row { display: flex !important; flex-direction: column-reverse !important; gap: 12px; }
|
||||
.main-column table.search-filters-table, .main-column table.search-filters-table tbody { display: block !important; border: 0 !important; width: 100% !important; }
|
||||
.main-column table.search-filters-table tr { display: block !important; border: 0 !important; margin: 0 0 10px; }
|
||||
.main-column table.search-filters-table td { display: block !important; padding: 0 !important; border: 0 !important; background: transparent !important; }
|
||||
.main-column table.search-filters-table td.card-label { margin-bottom: 4px; }
|
||||
.main-column .search-submit-row button { width: 100% !important; }
|
||||
|
||||
/* ---------- nodos: acciones en rejilla ---------- */
|
||||
.main-column .peers-conn-actions.filter-box { display: grid !important; grid-template-columns: 1fr 1fr; gap: 8px !important; }
|
||||
.main-column .peers-conn-actions form { margin: 0 !important; width: 100% !important; }
|
||||
.main-column .peers-conn-actions button { width: 100% !important; margin: 0 !important; font-size: 15px !important; }
|
||||
.main-column .message-list { padding: 0 !important; }
|
||||
.main-column .pm-card .pm-info-table tr { width: auto !important; flex: 0 1 auto; }
|
||||
.main-column .pm-card .pm-info-table tr:nth-child(1) { flex: 1 1 0 !important; }
|
||||
.main-column .pm-card .pm-info-table tr:has(.pm-unread-chip) { order: 3; flex-basis: 100% !important; }
|
||||
.main-column .pm-card .pm-actions { display: inline-flex !important; width: auto !important; margin: 8px 8px 0 0 !important; }
|
||||
.main-column .pm-card .pm-actions button { min-height: 40px !important; padding: 4px 14px !important; font-size: 14px !important; box-shadow: none !important; width: auto !important; }
|
||||
.main-column table.search-filters-table { height: auto !important; min-height: 0 !important; margin: 0 !important; }
|
||||
.main-column .peers-conn-actions > form:last-child:nth-child(odd) { grid-column: 1 / -1; }
|
||||
.main-column .search-filters-row { height: auto !important; }
|
||||
.main-column .search-filters-row > table.search-filters-table { flex: 0 0 auto !important; }
|
||||
.main-column .pm-card div.pm-actions.pm-actions { width: auto !important; max-width: max-content; display: inline-flex !important; vertical-align: top; }
|
||||
.main-column .pm-card .pm-info-table tr:nth-child(1) td, .main-column .pm-card .pm-info-table tr:nth-child(1) a { white-space: nowrap !important; overflow: hidden; text-overflow: ellipsis; min-width: 0; max-width: 100%; text-align: left !important; }
|
||||
|
||||
/* ---------- el interruptor Abierto / No molestar del Telefono del dev ---------- */
|
||||
/* salia centrado en una fila propia arriba de cada pagina (unos 60px); va en la
|
||||
barra de arriba, junto al avatar */
|
||||
form.oasis-phone-state {
|
||||
position: fixed !important; top: calc(env(safe-area-inset-top, 0px) + 14px); right: 62px; z-index: 101; /* encima de la barra de arriba (100), debajo de la hoja del menu (1000) */
|
||||
margin: 0 !important; padding: 0 !important; width: auto !important;
|
||||
}
|
||||
form.oasis-phone-state .oasis-phone-state-btn {
|
||||
width: 40px !important; height: 40px !important; min-height: 40px !important; padding: 0 !important; margin: 0 !important;
|
||||
display: inline-flex !important; align-items: center; justify-content: center;
|
||||
border-radius: 50% !important; box-shadow: none !important; font-size: 20px !important;
|
||||
}
|
||||
.oasis-mobile-topbar .omt-menus { padding-right: 46px; }
|
||||
|
||||
/* barras de la votacion: juntas, y las etiquetas del dev (CONFUNDIDO, SEGUIR
|
||||
MAYORIA) en el mismo tipo que las nuestras (Si, No) */
|
||||
.main-column div.vote-bar { margin: 0 !important; padding: 0 !important; min-height: 0 !important; gap: 4px 10px !important; }
|
||||
.main-column .vote-bar > * { margin: 0 !important; }
|
||||
.main-column .vote-bar-meter { height: 12px !important; min-height: 0 !important; }
|
||||
.main-column .vote-bar-label { text-transform: lowercase; }
|
||||
.main-column .vote-bar-label::first-letter { text-transform: uppercase; }
|
||||
|
||||
/* personalizar la barra de abajo: una fila por atajo, como el resto de listas */
|
||||
.main-column .bb-manage-list, .main-column .bb-pick-grid { display: flex !important; flex-direction: column !important; gap: 8px !important; align-items: stretch !important; }
|
||||
.main-column .bb-manage-list form, .main-column .bb-pick-grid form { margin: 0 !important; width: 100% !important; }
|
||||
.main-column button.bb-manage-btn, .main-column button.bb-pick-btn {
|
||||
width: 100% !important; min-height: 52px !important; margin: 0 !important; padding: 8px 14px !important;
|
||||
display: flex !important; align-items: center; gap: 12px; justify-content: flex-start !important;
|
||||
font-family: var(--ux-text) !important; font-weight: 700 !important; font-size: 16px !important;
|
||||
text-transform: none !important; letter-spacing: 0 !important; box-shadow: none !important;
|
||||
}
|
||||
.main-column .bb-manage-btn .bb-manage-lbl, .main-column .bb-pick-btn .bb-pick-lbl { flex: 1 1 auto; text-align: left; }
|
||||
.main-column .bb-manage-btn .bb-manage-x { color: var(--ux-danger) !important; font-size: 18px; }
|
||||
.main-column .bb-manage h3, .main-column .bb-manage-add h3 { font-size: 20px !important; margin: 18px 0 10px !important; }
|
||||
.main-column a.bb-manage-done { display: block !important; width: 100% !important; margin-top: 18px !important; text-align: center; }
|
||||
.main-column div.bb-manage, .main-column div.bb-manage-add, .main-column div.bb-manage-list, .main-column div.bb-pick-grid { padding: 0 !important; margin: 0 !important; border: 0 !important; }
|
||||
|
||||
/* el aviso de bienvenida, encima del lanzador: debajo quedaba a 1.300px y quien
|
||||
abria la app por primera vez no lo veia */
|
||||
body > .welcome-banner { order: -1; margin-top: 10px !important; }
|
||||
|
||||
/* ---------- dentro de una sala: los controles a mano ---------- */
|
||||
/* silenciar, camara, levantar la mano, salir... quedaban debajo de toda la ficha de
|
||||
la sala (a 1.100px). Estando dentro, el panel en directo va primero y los
|
||||
controles en rejilla de dos */
|
||||
.main-column .tribe-details:has(.room-live-panel) > .tribe-main { order: -1; }
|
||||
.main-column div.room-live-controls { display: grid !important; grid-template-columns: 1fr 1fr; gap: 8px !important; }
|
||||
.main-column .room-live-controls form { margin: 0 !important; width: 100% !important; }
|
||||
.main-column .room-live-controls button {
|
||||
width: 100% !important; min-height: 48px !important; margin: 0 !important; padding: 6px 8px !important;
|
||||
font-size: 15px !important; box-shadow: none !important;
|
||||
}
|
||||
.main-column .room-live-controls form[action="/rooms/leave"] { grid-column: 1 / -1; }
|
||||
.main-column .room-live-controls form[action="/rooms/leave"] button { background: var(--ux-danger) !important; color: #fff !important; border-color: var(--ux-danger) !important; }
|
||||
.main-column .room-live-controls :is(label, a.tribe-action-btn, .room-stream-form label) {
|
||||
display: flex !important; align-items: center; justify-content: center; width: 100% !important; min-height: 48px !important;
|
||||
margin: 0 !important; padding: 6px 8px !important; box-sizing: border-box;
|
||||
border: var(--a26-bd-fino, 2px solid var(--ux-line)) !important; border-radius: var(--ux-radius) !important;
|
||||
background: var(--ux-bg) !important; color: var(--ux-fg) !important; box-shadow: none !important;
|
||||
font-family: var(--ux-text) !important; font-weight: 700 !important; font-size: 15px !important; text-transform: uppercase; text-decoration: none !important;
|
||||
}
|
||||
|
||||
/* ---------- pestaña Menu y su hoja con todos los modulos ---------- */
|
||||
/* la portada es la actividad; los modulos se abren desde abajo, como el "Mas" de
|
||||
Android (docs/ESTUDIO_NAVEGACION.md). Una casilla oculta abre y cierra la hoja */
|
||||
.oasis-bottombar input.menu-toggle { position: absolute; opacity: 0; width: 0 !important; height: 0 !important; min-height: 0 !important; pointer-events: none; }
|
||||
.oasis-bottombar label.bb-menu { cursor: pointer; }
|
||||
.oasis-bottombar .menu-toggle:checked ~ label.bb-menu .bb-ico.has-svg { background: var(--ux-accent) !important; color: var(--ux-on-accent) !important; }
|
||||
.oasis-bottombar .menu-sheet, .oasis-bottombar .menu-sheet-backdrop { display: none; }
|
||||
.oasis-bottombar .menu-toggle:checked ~ .menu-sheet-backdrop {
|
||||
display: block; position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 1002; background: rgba(0, 0, 0, .45);
|
||||
}
|
||||
.oasis-bottombar .menu-toggle:checked ~ .menu-sheet {
|
||||
display: block; position: fixed; left: 0; right: 0; z-index: 1003;
|
||||
bottom: 0; max-height: 82vh; max-height: 82dvh; overflow-y: auto; overscroll-behavior: contain;
|
||||
padding: 8px 14px calc(18px + env(safe-area-inset-bottom, 0px));
|
||||
background: var(--ux-bg); color: var(--ux-fg);
|
||||
border-top: 3px solid var(--ux-line); border-radius: 22px 22px 0 0;
|
||||
box-shadow: 0 -6px 24px rgba(0, 0, 0, .25);
|
||||
}
|
||||
.menu-sheet-head {
|
||||
position: sticky; top: -8px; z-index: 1; display: flex; align-items: center; justify-content: space-between;
|
||||
margin: 0 -14px 8px; padding: 12px 14px 10px; background: var(--ux-bg); border-bottom: 2px solid var(--ux-line);
|
||||
}
|
||||
.menu-sheet-head::before {
|
||||
content: ""; position: absolute; top: 4px; left: 50%; width: 44px; height: 5px; margin-left: -22px;
|
||||
border-radius: 3px; background: var(--ux-muted);
|
||||
}
|
||||
.menu-sheet-title { font-family: var(--ux-title); font-size: 26px; text-transform: uppercase; line-height: 1; }
|
||||
.menu-sheet-close {
|
||||
display: inline-flex; align-items: center; justify-content: center; width: 44px; height: 44px; cursor: pointer;
|
||||
border: 2px solid var(--ux-line); border-radius: 50%; font-size: 20px; background: var(--ux-bg);
|
||||
}
|
||||
.menu-sheet .launcher { margin: 0 !important; padding: 0 !important; }
|
||||
/* cinco pestañas: que quepan a 360px */
|
||||
.oasis-bottombar .bb-item { flex: 1 1 0 !important; min-width: 0 !important; }
|
||||
@media (max-width: 380px) { .oasis-bottombar .bb-lbl { font-size: 11px !important; } }
|
||||
.oasis-bottombar .menu-toggle:checked ~ div.menu-sheet { background: var(--ux-bg) !important; bottom: calc(66px + env(safe-area-inset-bottom, 0px)); border-radius: 22px 22px 0 0 !important; border: 0 !important; border-top: 3px solid var(--ux-line) !important; }
|
||||
.oasis-bottombar div.menu-sheet-head { background: var(--ux-bg) !important; border: 0 !important; border-bottom: 2px solid var(--ux-line) !important; }
|
||||
.oasis-bottombar .menu-toggle:checked ~ .menu-sheet-backdrop { bottom: calc(66px + env(safe-area-inset-bottom, 0px)); }
|
||||
/* en la hoja todo tiene que caber sin deslizar: cinco iconos por fila y mas pequeños
|
||||
(con 20 modulos la rejilla del lanzador medía 1.200px para 690 de hoja) */
|
||||
.menu-sheet .launcher .ln-grid { display: grid !important; grid-template-columns: repeat(5, 1fr) !important; gap: 8px 2px !important; }
|
||||
.menu-sheet .launcher .ln-app { min-width: 0 !important; padding: 0 !important; gap: 4px !important; }
|
||||
.menu-sheet .launcher .ln-ico.has-svg { width: 48px !important; height: 48px !important; padding: 11px !important; box-shadow: 0 3px 0 var(--ux-line) !important; }
|
||||
.menu-sheet .launcher .ln-lbl { font-size: 11px !important; line-height: 1.1 !important; }
|
||||
.menu-sheet .launcher .ln-title { font-size: 15px !important; margin: 0 0 6px !important; padding: 0 0 2px !important; border-bottom-width: 2px !important; }
|
||||
.menu-sheet .launcher .ln-group + .ln-group { margin-top: 10px !important; }
|
||||
/* con el menu abierto, solo Menu lleva la pastilla */
|
||||
.oasis-bottombar .menu-toggle:checked ~ a.bb-item.current .bb-ico.has-svg { background: transparent !important; color: var(--ux-fg) !important; }
|
||||
/* los grupos de uno o dos modulos (Biblioteca, Oficina, Gobernanza...) van de dos en
|
||||
dos, uno al lado del otro; los grandes ocupan el ancho. Hace falta :has(): en los
|
||||
WebView viejos (Android 9-11 sin actualizar) cada grupo va a lo ancho */
|
||||
.menu-sheet .launcher .ln-group, .menu-sheet .launcher .ln-group + .ln-group { margin: 0 0 10px !important; min-width: 0; }
|
||||
@supports selector(:has(a)) {
|
||||
.menu-sheet nav.launcher { display: grid !important; grid-template-columns: 1fr 1fr; column-gap: 14px; row-gap: 10px; }
|
||||
.menu-sheet .launcher .ln-group, .menu-sheet .launcher .ln-group + .ln-group { margin: 0 !important; }
|
||||
.menu-sheet .launcher .ln-group:has(.ln-app:nth-child(3)) { grid-column: 1 / -1; }
|
||||
.menu-sheet .launcher .ln-group:not(:has(.ln-app:nth-child(3))) .ln-grid { grid-template-columns: 1fr 1fr !important; }
|
||||
}
|
||||
/* con el menu abierto, el boton de No molestar no se monta encima de la hoja */
|
||||
body:has(#oasis-menu-toggle:checked) form.oasis-phone-state { display: none !important; }
|
||||
|
||||
/* ---------- tipo de portada: un toque y guardado ---------- */
|
||||
/* bienvenida y Ajustes: cada opcion es un boton. En el movil, una fila por opcion con
|
||||
la miniatura a la izquierda (en rejilla de tres, "Conversaciones" se partia a 360px);
|
||||
en tableta, rejilla de tres */
|
||||
.main-column form.ux-pick { margin: 0 !important; padding: 0 !important; border: 0 !important; background: transparent !important; box-shadow: none !important; }
|
||||
.main-column .ux-pick-hint { margin: 0 0 10px !important; color: var(--ux-muted); font-size: 14px; }
|
||||
.main-column .ux-pick-grid { display: block !important; border: 0 !important; background: transparent !important; padding: 0 !important; }
|
||||
.main-column button.ux-pick-card {
|
||||
display: flex !important; flex-direction: row; align-items: center; text-align: left;
|
||||
width: 100% !important; min-width: 0 !important; min-height: 64px !important; height: auto !important; margin: 0 0 10px !important; padding: 8px 12px 8px 8px !important;
|
||||
background: var(--ux-bg) !important; color: var(--ux-fg) !important; border: 2px solid var(--ux-line) !important; border-radius: 14px !important;
|
||||
box-shadow: 0 3px 0 var(--ux-line) !important; text-transform: none !important; font-family: inherit !important; cursor: pointer;
|
||||
}
|
||||
.main-column button.ux-pick-card.is-current { background: var(--ux-accent) !important; color: var(--ux-on-accent) !important; }
|
||||
.main-column .ux-pick-shot { display: block; flex: 0 0 76px; width: 76px; height: auto; border-radius: 8px; margin: 0 12px 0 0; border: 1px solid rgba(0, 0, 0, .2); }
|
||||
.main-column .ux-pick-label { flex: 1 1 auto; min-width: 0; text-align: left; font-size: 16px; font-weight: 700; line-height: 1.2; }
|
||||
.main-column .ux-pick-now { flex: 0 0 auto; margin-left: 8px; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; }
|
||||
/* perfil de uso: sin miniatura, nombre y una linea de para que sirve */
|
||||
.main-column .ux-pick-text { flex: 1 1 auto; min-width: 0; display: block; text-align: left; }
|
||||
.main-column .ux-pick-text .ux-pick-label { display: block; }
|
||||
.main-column .ux-pick-sub { display: block; margin-top: 2px; font-size: 13px; font-weight: 400; line-height: 1.3; opacity: .8; }
|
||||
.main-column .wf-pick button.ux-pick-card { padding: 10px 14px !important; }
|
||||
.main-column .wf-pick .ux-pick-grid { display: block !important; }
|
||||
@media (min-width: 700px) {
|
||||
.main-column .ux-pick-grid { display: grid !important; grid-template-columns: repeat(3, minmax(0, 1fr)); grid-gap: 12px; }
|
||||
.main-column button.ux-pick-card { flex-direction: column; text-align: center; margin: 0 !important; padding: 10px !important; }
|
||||
.main-column .ux-pick-shot { width: 100%; flex: 0 0 auto; margin: 0 0 8px; }
|
||||
.main-column .ux-pick-now { margin: 4px 0 0; }
|
||||
}
|
||||
@media (min-width: 700px) { .main-column .wf-pick button.ux-pick-card { flex-direction: row; text-align: left; margin: 0 0 10px !important; } }
|
||||
|
||||
/* ---------- tabletas y pantallas anchas ---------- */
|
||||
/* a 800dp todo se estiraba: lineas larguisimas y la hoja del menu con cinco iconos
|
||||
repartidos en 800px. Contenido a 720px como mucho, centrado; la hoja, igual */
|
||||
@media (min-width: 700px) {
|
||||
body .main-content { max-width: 720px !important; margin-left: auto !important; margin-right: auto !important; width: 100% !important; box-sizing: border-box; }
|
||||
body > .welcome-banner { width: calc(100% - 28px) !important; max-width: 692px !important; margin-left: auto !important; margin-right: auto !important; box-sizing: border-box; }
|
||||
.oasis-bottombar .menu-toggle:checked ~ div.menu-sheet { left: 50%; right: auto; width: 640px; margin-left: -320px; }
|
||||
}
|
||||
|
||||
/* ---------- WebView viejos ---------- */
|
||||
/* Android 9-11 sin actualizar traen WebView de 2018-2020: no saben gap en flex (las
|
||||
fichas salian pegadas) ni :has() (el buscador partia el boton a otra linea). inset
|
||||
llego a la vez que esas cosas, asi que sirve para reconocerlos */
|
||||
@supports not (inset: 0) {
|
||||
.main-column .filter-btn, .main-column .activity-chip, .main-column .tribe-tab, .oasis-mobile-topbar .oasis-menu-btn { margin: 0 6px 8px 0 !important; }
|
||||
.main-column form.filter-box { display: flex !important; flex-direction: row !important; flex-wrap: wrap !important; }
|
||||
.main-column form.filter-box .filter-box__input { flex: 1 1 60% !important; width: auto !important; margin: 0 8px 8px 0 !important; }
|
||||
.main-column form.filter-box .filter-box__controls { flex: 0 0 auto !important; width: auto !important; }
|
||||
.main-column .lang-pick .lang-chip, .main-column .vote-buttons-row-single form { margin-bottom: 8px !important; }
|
||||
/* sticky dentro de un fixed se pintaba en blanco */
|
||||
.menu-sheet-head { position: relative !important; top: 0 !important; }
|
||||
}
|
||||
|
|
@ -1,195 +0,0 @@
|
|||
/* Tema Acampada (rama movil): blanco, negro y cian neon, titulos en Anton.
|
||||
*
|
||||
* Los colores y las formas salen de acampada26s.net: bordes negros gruesos,
|
||||
* botones con sombra solida que se hunden al pulsar y titulos en mayusculas.
|
||||
*
|
||||
* La maqueta de movil es styles/mobile_base.css, que main_views.js carga justo antes
|
||||
* con su propio <link> versionado (no con @import: se quedaba en la cache del
|
||||
* WebView); aqui solo se cambian colores, letra y formas encima. Por eso casi todo lleva
|
||||
* !important: la maqueta tambien lo usa. Las piezas propias de la rama
|
||||
* (panal, barras) se ajustan en styles/hive_acampada.css. */
|
||||
@font-face {
|
||||
font-family: "Anton";
|
||||
src: url("../fonts/Anton-Regular.ttf") format("truetype");
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
:root {
|
||||
--a26-bg: #fff;
|
||||
--a26-fg: #000;
|
||||
--a26-neon: #00e5ff;
|
||||
--a26-soft: #f2f2f2;
|
||||
--a26-muted: #555;
|
||||
--a26-bd: 3px solid var(--a26-fg);
|
||||
--a26-bd-fino: 2px solid var(--a26-fg);
|
||||
--a26-radius: 14px;
|
||||
--a26-tit: "Anton", Impact, "Arial Narrow", sans-serif;
|
||||
--a26-txt: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
/* --- fondo y texto --- */
|
||||
html, body {
|
||||
background: var(--a26-bg) !important;
|
||||
color: var(--a26-fg) !important;
|
||||
font-family: var(--a26-txt) !important;
|
||||
}
|
||||
body { font-size: 17px; line-height: 1.5; }
|
||||
|
||||
header, footer, .header, .main-content, .main-column, .sidebar-left, .sidebar-right,
|
||||
section, article, div, table, .profile {
|
||||
background-color: transparent !important;
|
||||
color: var(--a26-fg) !important;
|
||||
border-color: var(--a26-fg) !important;
|
||||
}
|
||||
div { border: 0 !important; box-shadow: none !important; }
|
||||
|
||||
span, p, li, td, th, label, small, strong, em, h4, h5, h6 { color: inherit !important; }
|
||||
|
||||
a { color: var(--a26-fg) !important; }
|
||||
a:hover, a:focus-visible { color: var(--a26-fg) !important; background-color: var(--a26-neon); }
|
||||
|
||||
/* --- titulos: Anton en mayusculas --- */
|
||||
h1, h2, h3, .card-label, .tags-header h2, .oasis-nav-header {
|
||||
font-family: var(--a26-tit) !important;
|
||||
font-weight: 400 !important;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .01em;
|
||||
line-height: 1.05 !important;
|
||||
color: var(--a26-fg) !important;
|
||||
}
|
||||
h1 { font-size: 34px !important; }
|
||||
h2 { font-size: 28px !important; }
|
||||
h3 { font-size: 21px !important; }
|
||||
.card-label { font-size: 15px !important; }
|
||||
|
||||
/* cabecera de cada modulo: titulo con raya gorda debajo, como los h2 de la web */
|
||||
.tags-header {
|
||||
background: transparent !important;
|
||||
border: 0 !important;
|
||||
border-bottom: var(--a26-bd) !important;
|
||||
border-radius: 0 !important;
|
||||
padding: 0 0 8px !important;
|
||||
margin: 0 0 16px !important;
|
||||
text-align: left !important;
|
||||
}
|
||||
.tags-header p { color: var(--a26-muted) !important; font-size: 15px !important; margin: 4px 0 0 !important; }
|
||||
|
||||
/* --- tarjetas: recuadro negro, sin sombras difusas --- */
|
||||
.main-column article, .main-column [class$="-card"], .main-column [class*="-card "],
|
||||
.main-column .card, .forum-comment, .feed-row {
|
||||
background: var(--a26-bg) !important;
|
||||
border: var(--a26-bd-fino) !important;
|
||||
border-radius: var(--a26-radius) !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
.main-column > section { border: 0 !important; padding: 0 !important; }
|
||||
|
||||
/* --- botones: los .bt de acampada --- */
|
||||
button, input[type="submit"], input[type="button"],
|
||||
.filter-btn, .create-button, .buy-btn, .delete-btn,
|
||||
a.user-link, button.user-link, .date-link, .welcome-banner-start, .update-download-link {
|
||||
font-family: var(--a26-tit) !important;
|
||||
font-weight: 400 !important;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .02em;
|
||||
background: var(--a26-bg) !important;
|
||||
color: var(--a26-fg) !important;
|
||||
border: var(--a26-bd) !important;
|
||||
border-radius: var(--a26-radius) !important;
|
||||
box-shadow: 0 5px 0 var(--a26-fg) !important;
|
||||
text-decoration: none !important;
|
||||
transition: transform .12s ease-out, box-shadow .12s ease-out;
|
||||
}
|
||||
button, input[type="submit"], input[type="button"], .filter-btn, .create-button {
|
||||
min-height: 48px;
|
||||
padding: 8px 16px !important;
|
||||
font-size: 18px !important;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
button:active, input[type="submit"]:active, input[type="button"]:active,
|
||||
.filter-btn:active, .create-button:active, a.user-link:active {
|
||||
transform: translateY(4px);
|
||||
box-shadow: 0 1px 0 var(--a26-fg) !important;
|
||||
background: var(--a26-neon) !important;
|
||||
}
|
||||
/* el boton elegido (filtros, pestañas): relleno cian */
|
||||
.filter-btn.active, .mode-buttons .filter-btn.active, .mode-buttons-row .filter-btn.active,
|
||||
.tribe-mode-buttons .filter-btn.active, .filters .filter-btn.active,
|
||||
.activity-filter-grid .filter-btn.active, .activity-sub-filter .filter-btn.active,
|
||||
.bank-range-tabs .filter-btn.active {
|
||||
background: var(--a26-neon) !important;
|
||||
border-color: var(--a26-fg) !important;
|
||||
color: var(--a26-fg) !important;
|
||||
}
|
||||
/* el boton de crear: negro, para que se vea primero */
|
||||
.create-button, .mode-buttons .create-button, .mode-buttons-row .create-button,
|
||||
.tribe-mode-buttons .create-button, .filters .create-button {
|
||||
background: var(--a26-fg) !important;
|
||||
color: var(--a26-bg) !important;
|
||||
box-shadow: 0 5px 0 var(--a26-neon) !important;
|
||||
font-size: 18px !important;
|
||||
padding: 8px 16px !important;
|
||||
min-height: 48px !important;
|
||||
}
|
||||
.delete-btn { box-shadow: 0 5px 0 #e0004d !important; }
|
||||
|
||||
/* --- formularios --- */
|
||||
input, textarea, select {
|
||||
background: var(--a26-bg) !important;
|
||||
color: var(--a26-fg) !important;
|
||||
border: var(--a26-bd-fino) !important;
|
||||
border-radius: 10px !important;
|
||||
font-family: var(--a26-txt) !important;
|
||||
font-size: 16px !important;
|
||||
min-height: 44px;
|
||||
}
|
||||
input:focus, textarea:focus, select:focus {
|
||||
outline: 3px solid var(--a26-neon) !important;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
input[type="checkbox"], input[type="radio"] { min-height: 0; accent-color: var(--a26-fg); }
|
||||
|
||||
/* --- tablas --- */
|
||||
table { border: var(--a26-bd-fino) !important; border-radius: 10px; overflow: hidden; }
|
||||
table th { background: var(--a26-fg) !important; color: var(--a26-bg) !important; }
|
||||
table td { border-color: var(--a26-fg) !important; }
|
||||
|
||||
/* --- avisos de upstream (bienvenida, actualizaciones) --- */
|
||||
.update-banner, .welcome-banner {
|
||||
background: var(--a26-neon) !important;
|
||||
color: var(--a26-fg) !important;
|
||||
border: var(--a26-bd) !important;
|
||||
border-radius: var(--a26-radius) !important;
|
||||
margin: 8px 10px !important;
|
||||
}
|
||||
.update-banner-link, .update-banner-text { color: var(--a26-fg) !important; }
|
||||
.welcome-banner-close-btn {
|
||||
background: var(--a26-bg) !important;
|
||||
box-shadow: none !important;
|
||||
min-height: 40px; width: 40px; padding: 0 !important;
|
||||
}
|
||||
|
||||
/* --- colores sueltos de OasisMobile que se quedaban en amarillo o naranja --- */
|
||||
.avatar, .article img, .post-preview img { border-color: var(--a26-fg) !important; }
|
||||
.profile .name, .snh-invite-code, .stats-kpi-label, .stats-kpi-value, .carbon-bar-formula,
|
||||
a.hashtag, .chat-topic-title, .chat-topic-active .chat-topic-title,
|
||||
.chat-topic-meta, .chat-topic-last-author, .chat-workspace-details > .tribe-side .tribe-card-members {
|
||||
color: var(--a26-fg) !important;
|
||||
}
|
||||
.chat-topic-active { background: var(--a26-neon) !important; }
|
||||
a.hashtag { text-decoration: underline !important; }
|
||||
.pdf-maximized { background: var(--a26-bg) !important; }
|
||||
|
||||
/* --- pie --- */
|
||||
.oasis-footer, .oasis-footer * { color: var(--a26-muted) !important; background: transparent !important; }
|
||||
.oasis-footer { border-top: var(--a26-bd) !important; margin-top: 32px; }
|
||||
.oasis-footer a { text-decoration: underline !important; }
|
||||
|
||||
::-webkit-scrollbar-thumb { background-color: var(--a26-fg) !important; }
|
||||
::-webkit-scrollbar-track { background-color: var(--a26-soft) !important; }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
button, .filter-btn, .create-button { transition: none; }
|
||||
button:active, .filter-btn:active, .create-button:active { transform: none; }
|
||||
}
|
||||
|
|
@ -364,6 +364,19 @@ button.tribe-action-btn { background: transparent !important; border: 1px solid
|
|||
.chat-topic-active { background: #FFF3E0 !important; }
|
||||
.chat-topic-title { color: #2D2D2D !important; }
|
||||
.chat-workspace-empty { border-color: #DDD !important; }
|
||||
|
||||
/* Karvan: su hoja (karvan.css) esta pensada para fondo oscuro; aqui se traduce
|
||||
a la paleta clara, igual que este tema hace con el resto de la interfaz */
|
||||
.karvan-back, .karvan-room-title, .kr-title { color: #B25000 !important; }
|
||||
.karvan-sub, .karvan-expire, .karvan-live, .kr-meta,
|
||||
.karvan-ttl-lbl, .karvan-ttl-opt { color: #6B5300 !important; }
|
||||
.karvan-room-item, .karvan-create input[type="text"] {
|
||||
background-color: #FFFFFF !important; border: 1px solid #E0E0E0 !important; color: #2C2C2C !important;
|
||||
}
|
||||
.karvan-msg { background-color: #FFF8E1 !important; border: 1px solid #E6CE86 !important; }
|
||||
.karvan-msg-self { background-color: #FFEFC2 !important; border-color: #D9A400 !important; }
|
||||
.karvan-msg .km-from { color: #8A6D00 !important; }
|
||||
.karvan-msg .km-text { color: #2C2C2C !important; }
|
||||
.map-viewer, .map-stage div, .map-hero, .map-toolbar, .map-toolbar div, .map-place-results, .map-markers-list, .map-markers-list div, .map-detail-grid, .map-card-thumb .map-viewer { background: transparent !important; border: 0 !important; }
|
||||
.map-pick-cell, .map-pick-cell:hover { border: 0 !important; padding: 0 !important; border-radius: 0 !important; box-shadow: none !important; }
|
||||
.map-pick-cell { background: transparent !important; }
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
// ficheros oasis_*.js (upstream los reescribe entero en cada release).
|
||||
//
|
||||
// Contiene tres grupos:
|
||||
// 1. Claves que anade la rama: el selector de identidades,
|
||||
// 1. Claves que anade la rama: el modulo Karvan, el selector de identidades,
|
||||
// la barra inferior (bb*) y el filtro Personal/Social de la topbar.
|
||||
// 2. Claves que upstream tenia en 0.9.1 y elimino en 0.9.5, pero que el
|
||||
// codigo de la rama sigue usando (menuBlogs, spreadHint, chatShareUrl...).
|
||||
|
|
@ -17,58 +17,27 @@
|
|||
|
||||
module.exports = {
|
||||
en: {
|
||||
// usabilidad (19/10/2026): votaciones, borrar, modulos, pie
|
||||
bbMenu: "Menu",
|
||||
bbMenuClose: "Close",
|
||||
uxPickHint: "Tap one and it is saved. You can change it any time in Settings.",
|
||||
uxPickCurrent: "In use",
|
||||
welcomeStepUseMobile: "Profile",
|
||||
welcomeHintUseMobile: "What you use Oasis for. It turns on the modules you need.",
|
||||
wfHint_activists: "Assemblies, tribes, votes, forums and shared documents",
|
||||
wfHint_acampada: "Just what a camp needs: chats, rooms, committees and voting",
|
||||
wfHint_full: "All modules",
|
||||
wfHint_default: "All modules",
|
||||
wfHint_social: "Feed, photos, events and friends",
|
||||
wfHint_kids: "School, games and making things",
|
||||
wfHint_night: "Blogs, music, videos and talk",
|
||||
wfHint_jobs: "Work, projects, market and office",
|
||||
wfHint_business: "Economy, banking, office and documents",
|
||||
wfHint_mobile: "The basics for a phone",
|
||||
workflow_acampada: "Camp",
|
||||
tribeSectionsMore: "More",
|
||||
tribeSectionsLabel: "Sections",
|
||||
voteYes: "Yes",
|
||||
voteNo: "No",
|
||||
voteAbstention: "Abstain",
|
||||
voteGreen: "Green · agree",
|
||||
voteYellow: "Yellow · doubts",
|
||||
voteRed: "Red · against",
|
||||
voteOwnNote: "You created this vote, so you cannot vote in it: whoever proposes stays neutral.",
|
||||
voteKindLabel: "Type of vote",
|
||||
voteKind_classic: "Yes / No",
|
||||
voteKindHint_classic: "Yes, no, abstain and the usual options",
|
||||
voteKind_traffic: "Traffic light",
|
||||
voteKindHint_traffic: "Green agree · yellow doubts · red against, like in the assembly",
|
||||
voteKind_scale: "Thermometer",
|
||||
voteKindHint_scale: "From 1 (cold) to 5 (hot)",
|
||||
deleteConfirmQuestion: "Delete it for good?",
|
||||
deleteConfirmYes: "Yes, delete",
|
||||
welcomeBannerTextMobile: "Add your name and photo so people know it is you.",
|
||||
welcomeBannerActionMobile: "Set up",
|
||||
footerPeersNone: "Not syncing with anyone yet",
|
||||
footerPeersSome: "Syncing with {n}",
|
||||
tribeMapUrlLabel: "Map link (optional)",
|
||||
tribeHintPrivacy: "Private: only members see it, and it is encrypted. Public: the whole network sees it.",
|
||||
tribeHintMode: "Open: any member can invite people. Strict: only whoever created it can invite.",
|
||||
moduleBlockedText: "{name} is turned off.",
|
||||
moduleBlockedAction: "Turn on {name}",
|
||||
modulesOnCount: "{on} of {total} sections on",
|
||||
modulesSaved: "Saved.",
|
||||
// skins y workflows en el movil (settings_view.js)
|
||||
skinIntroMobile: "Pick how Oasis looks on your phone. It applies as soon as you tap it.",
|
||||
workflowsDescriptionMobile: "A workflow sets which modules are on. On the phone your skin stays as it is.",
|
||||
// fotos que no se pueden limpiar (blobHandler.js)
|
||||
imageMetadataUnsafe: "This image could not be cleaned of its metadata (location, camera…), so it has not been uploaded. Save it as JPEG or PNG and try again.",
|
||||
// Karvan
|
||||
karvanActive: "Active rooms",
|
||||
karvanBlurb: "Rooms that live only in memory and destroy themselves.",
|
||||
karvanCall: "Call",
|
||||
karvanCreate: "Create ephemeral room",
|
||||
karvanEphemeral: "Ephemeral rooms",
|
||||
karvanInvite: "Invite",
|
||||
karvanInviteBad: "✗ That is not a valid @feed-id.",
|
||||
karvanInviteFail: "✗ Could not send the invitation.",
|
||||
karvanInvitePlaceholder: "Invite a contact by @feed-id…",
|
||||
karvanInviteSent: "✓ Invitation sent to their inbox.",
|
||||
karvanNone: "No active rooms. Create one — it disappears on its own.",
|
||||
karvanRelay: "server relay",
|
||||
karvanRoomName: "Room name (optional)",
|
||||
karvanRooms: "Rooms",
|
||||
karvanSay: "Temporary message…",
|
||||
karvanSend: "Send",
|
||||
karvanTitle: "Karvan",
|
||||
karvanTtl: "Self-destruct in:",
|
||||
modulesKarvanDescription: "Ephemeral rooms: messages live only in memory and self-destruct.",
|
||||
modulesKarvanLabel: "Karvan",
|
||||
// Identidades
|
||||
identitiesActive: "In use",
|
||||
identitiesBackup: "Export or import an identity",
|
||||
|
|
@ -111,58 +80,27 @@ module.exports = {
|
|||
spreadMore: "more"
|
||||
},
|
||||
es: {
|
||||
// usabilidad (19/10/2026): votaciones, borrar, modulos, pie
|
||||
bbMenu: "Menú",
|
||||
bbMenuClose: "Cerrar",
|
||||
uxPickHint: "Toca uno y queda guardado. Puedes cambiarlo cuando quieras en Ajustes.",
|
||||
uxPickCurrent: "En uso",
|
||||
welcomeStepUseMobile: "Perfil",
|
||||
welcomeHintUseMobile: "Para qué usas Oasis. Enciende los módulos que hacen falta.",
|
||||
wfHint_activists: "Asambleas, tribus, votaciones, foros y documentos compartidos",
|
||||
wfHint_acampada: "Lo justo para una acampada: chats, salas, comisiones y votar",
|
||||
wfHint_full: "Todos los módulos",
|
||||
wfHint_default: "Todos los módulos",
|
||||
wfHint_social: "Feed, fotos, eventos y amistades",
|
||||
wfHint_kids: "Escuela, juegos y hacer cosas",
|
||||
wfHint_night: "Blogs, música, vídeos y charla",
|
||||
wfHint_jobs: "Trabajo, proyectos, mercado y oficina",
|
||||
wfHint_business: "Economía, banca, oficina y documentos",
|
||||
wfHint_mobile: "Lo básico para el móvil",
|
||||
workflow_acampada: "Acampada",
|
||||
tribeSectionsMore: "Más",
|
||||
tribeSectionsLabel: "Secciones",
|
||||
voteYes: "Sí",
|
||||
voteNo: "No",
|
||||
voteAbstention: "Abstención",
|
||||
voteGreen: "Verde · de acuerdo",
|
||||
voteYellow: "Amarillo · dudas",
|
||||
voteRed: "Rojo · en contra",
|
||||
voteOwnNote: "Has creado esta votación, así que no puedes votar en ella: quien propone se queda neutral.",
|
||||
voteKindLabel: "Tipo de votación",
|
||||
voteKind_classic: "Sí / No",
|
||||
voteKindHint_classic: "Sí, no, abstención y las opciones de siempre",
|
||||
voteKind_traffic: "Semáforo",
|
||||
voteKindHint_traffic: "Verde de acuerdo · amarillo dudas · rojo en contra, como en asamblea",
|
||||
voteKind_scale: "Termómetro",
|
||||
voteKindHint_scale: "Del 1 (frío) al 5 (caliente)",
|
||||
deleteConfirmQuestion: "¿Borrar para siempre?",
|
||||
deleteConfirmYes: "Sí, borrar",
|
||||
welcomeBannerTextMobile: "Pon tu nombre y tu foto para que te reconozcan.",
|
||||
welcomeBannerActionMobile: "Ponerlos",
|
||||
footerPeersNone: "Aún no sincronizas con nadie",
|
||||
footerPeersSome: "Sincronizando con {n}",
|
||||
tribeMapUrlLabel: "Enlace del mapa (opcional)",
|
||||
tribeHintPrivacy: "Privada: solo la ven sus miembros y va cifrada. Pública: la ve toda la red.",
|
||||
tribeHintMode: "Abierta: cualquier miembro puede invitar. Estricta: solo invita quien la creó.",
|
||||
moduleBlockedText: "{name} está desactivado.",
|
||||
moduleBlockedAction: "Activar {name}",
|
||||
modulesOnCount: "{on} de {total} secciones activas",
|
||||
modulesSaved: "Guardado.",
|
||||
// skins y workflows en el movil (settings_view.js)
|
||||
skinIntroMobile: "Elige cómo se ve Oasis en tu móvil. Se aplica al tocarlo.",
|
||||
workflowsDescriptionMobile: "Un workflow fija qué módulos están activos. En el móvil tu skin se queda como está.",
|
||||
// fotos que no se pueden limpiar (blobHandler.js)
|
||||
imageMetadataUnsafe: "No se han podido quitar los metadatos de esta imagen (ubicación, cámara…), así que no se ha subido. Guárdala como JPEG o PNG y vuelve a intentarlo.",
|
||||
// Karvan
|
||||
karvanActive: "Salas activas",
|
||||
karvanBlurb: "Salas que viven solo en memoria y se destruyen solas.",
|
||||
karvanCall: "Llamar",
|
||||
karvanCreate: "Crear sala efímera",
|
||||
karvanEphemeral: "Salas efímeras",
|
||||
karvanInvite: "Invitar",
|
||||
karvanInviteBad: "✗ Eso no es un @feed-id válido.",
|
||||
karvanInviteFail: "✗ No se ha podido enviar la invitación.",
|
||||
karvanInvitePlaceholder: "Invitar a un contacto por @feed-id…",
|
||||
karvanInviteSent: "✓ Invitación enviada a su bandeja.",
|
||||
karvanNone: "No hay salas activas. Crea una: desaparece sola.",
|
||||
karvanRelay: "a través del servidor",
|
||||
karvanRoomName: "Nombre de la sala (opcional)",
|
||||
karvanRooms: "Salas",
|
||||
karvanSay: "Mensaje temporal…",
|
||||
karvanSend: "Enviar",
|
||||
karvanTitle: "Karvan",
|
||||
karvanTtl: "Se autodestruye en:",
|
||||
modulesKarvanDescription: "Salas efímeras: los mensajes viven solo en memoria y se autodestruyen.",
|
||||
modulesKarvanLabel: "Karvan",
|
||||
// Identidades
|
||||
identitiesActive: "En uso",
|
||||
identitiesBackup: "Exportar o importar una identidad",
|
||||
|
|
@ -211,58 +149,27 @@ module.exports = {
|
|||
totalspreads: "Réplicas totales"
|
||||
},
|
||||
fr: {
|
||||
// usabilidad (19/10/2026): votaciones, borrar, modulos, pie
|
||||
bbMenu: "Menu",
|
||||
bbMenuClose: "Fermer",
|
||||
uxPickHint: "Touchez-en un et c'est enregistré. Vous pouvez le changer à tout moment dans les Paramètres.",
|
||||
uxPickCurrent: "Actuel",
|
||||
welcomeStepUseMobile: "Profil",
|
||||
welcomeHintUseMobile: "À quoi vous sert Oasis. Active les modules nécessaires.",
|
||||
wfHint_activists: "Assemblées, tribus, votes, forums et documents partagés",
|
||||
wfHint_acampada: "Le nécessaire pour un campement : chats, salons, commissions et votes",
|
||||
wfHint_full: "Tous les modules",
|
||||
wfHint_default: "Tous les modules",
|
||||
wfHint_social: "Fil, photos, événements et amis",
|
||||
wfHint_kids: "École, jeux et création",
|
||||
wfHint_night: "Blogs, musique, vidéos et discussions",
|
||||
wfHint_jobs: "Travail, projets, marché et bureau",
|
||||
wfHint_business: "Économie, banque, bureau et documents",
|
||||
wfHint_mobile: "L'essentiel pour le téléphone",
|
||||
workflow_acampada: "Campement",
|
||||
tribeSectionsMore: "Plus",
|
||||
tribeSectionsLabel: "Sections",
|
||||
voteYes: "Oui",
|
||||
voteNo: "Non",
|
||||
voteAbstention: "Abstention",
|
||||
voteGreen: "Vert · d'accord",
|
||||
voteYellow: "Jaune · doutes",
|
||||
voteRed: "Rouge · contre",
|
||||
voteOwnNote: "Vous avez créé ce vote, vous ne pouvez donc pas y voter : qui propose reste neutre.",
|
||||
voteKindLabel: "Type de vote",
|
||||
voteKind_classic: "Oui / Non",
|
||||
voteKindHint_classic: "Oui, non, abstention et les options habituelles",
|
||||
voteKind_traffic: "Feu tricolore",
|
||||
voteKindHint_traffic: "Vert d'accord · jaune doutes · rouge contre, comme en assemblée",
|
||||
voteKind_scale: "Thermomètre",
|
||||
voteKindHint_scale: "De 1 (froid) à 5 (chaud)",
|
||||
deleteConfirmQuestion: "Supprimer définitivement ?",
|
||||
deleteConfirmYes: "Oui, supprimer",
|
||||
welcomeBannerTextMobile: "Ajoutez votre nom et votre photo pour qu'on vous reconnaisse.",
|
||||
welcomeBannerActionMobile: "Ajouter",
|
||||
footerPeersNone: "Aucune synchronisation pour l'instant",
|
||||
footerPeersSome: "Synchronisé avec {n}",
|
||||
tribeMapUrlLabel: "Lien de la carte (facultatif)",
|
||||
tribeHintPrivacy: "Privée : seuls ses membres la voient, et elle est chiffrée. Publique : tout le réseau la voit.",
|
||||
tribeHintMode: "Ouverte : tout membre peut inviter. Stricte : seule la personne qui l'a créée invite.",
|
||||
moduleBlockedText: "{name} est désactivé.",
|
||||
moduleBlockedAction: "Activer {name}",
|
||||
modulesOnCount: "{on} sections actives sur {total}",
|
||||
modulesSaved: "Enregistré.",
|
||||
// skins y workflows en el movil (settings_view.js)
|
||||
skinIntroMobile: "Choisis l'apparence d'Oasis sur ton téléphone. Elle s'applique dès que tu touches.",
|
||||
workflowsDescriptionMobile: "Un workflow fixe les modules actifs. Sur le téléphone, ton thème ne change pas.",
|
||||
// fotos que no se pueden limpiar (blobHandler.js)
|
||||
imageMetadataUnsafe: "Impossible de retirer les métadonnées de cette image (position, appareil…), elle n'a donc pas été envoyée. Enregistrez-la en JPEG ou PNG et réessayez.",
|
||||
// Karvan
|
||||
karvanActive: "Salons actifs",
|
||||
karvanBlurb: "Des salons qui ne vivent qu'en mémoire et se détruisent d'eux-mêmes.",
|
||||
karvanCall: "Appeler",
|
||||
karvanCreate: "Créer un salon éphémère",
|
||||
karvanEphemeral: "Salons éphémères",
|
||||
karvanInvite: "Inviter",
|
||||
karvanInviteBad: "✗ Ce n'est pas un @feed-id valide.",
|
||||
karvanInviteFail: "✗ Impossible d'envoyer l'invitation.",
|
||||
karvanInvitePlaceholder: "Inviter un contact par @feed-id…",
|
||||
karvanInviteSent: "✓ Invitation envoyée dans sa boîte de réception.",
|
||||
karvanNone: "Aucun salon actif. Créez-en un : il disparaîtra tout seul.",
|
||||
karvanRelay: "relais par le serveur",
|
||||
karvanRoomName: "Nom du salon (facultatif)",
|
||||
karvanRooms: "Salons",
|
||||
karvanSay: "Message temporaire…",
|
||||
karvanSend: "Envoyer",
|
||||
karvanTitle: "Karvan",
|
||||
karvanTtl: "Autodestruction dans :",
|
||||
modulesKarvanDescription: "Salons éphémères : les messages ne vivent qu'en mémoire et s'autodétruisent.",
|
||||
modulesKarvanLabel: "Karvan",
|
||||
// Identidades
|
||||
identitiesActive: "En cours d'utilisation",
|
||||
identitiesBackup: "Exporter ou importer une identité",
|
||||
|
|
@ -305,58 +212,27 @@ module.exports = {
|
|||
spreadMore: "plus"
|
||||
},
|
||||
eu: {
|
||||
// usabilidad (19/10/2026): votaciones, borrar, modulos, pie
|
||||
bbMenu: "Menua",
|
||||
bbMenuClose: "Itxi",
|
||||
uxPickHint: "Bat ukitu eta gordeta geratzen da. Nahi duzunean alda dezakezu Ezarpenetan.",
|
||||
uxPickCurrent: "Erabiltzen",
|
||||
welcomeStepUseMobile: "Profila",
|
||||
welcomeHintUseMobile: "Oasis zertarako erabiltzen duzun. Behar diren moduluak pizten ditu.",
|
||||
wfHint_activists: "Batzarrak, tribuak, bozketak, foroak eta dokumentu partekatuak",
|
||||
wfHint_acampada: "Kanpaldi baterako behar dena: txatak, gelak, batzordeak eta bozkatzea",
|
||||
wfHint_full: "Modulu guztiak",
|
||||
wfHint_default: "Modulu guztiak",
|
||||
wfHint_social: "Jarioa, argazkiak, ekitaldiak eta lagunak",
|
||||
wfHint_kids: "Eskola, jolasak eta sortzea",
|
||||
wfHint_night: "Blogak, musika, bideoak eta solasa",
|
||||
wfHint_jobs: "Lana, proiektuak, merkatua eta bulegoa",
|
||||
wfHint_business: "Ekonomia, banka, bulegoa eta dokumentuak",
|
||||
wfHint_mobile: "Mugikorrerako oinarrizkoa",
|
||||
workflow_acampada: "Kanpaldia",
|
||||
tribeSectionsMore: "Gehiago",
|
||||
tribeSectionsLabel: "Atalak",
|
||||
voteYes: "Bai",
|
||||
voteNo: "Ez",
|
||||
voteAbstention: "Abstentzioa",
|
||||
voteGreen: "Berdea · ados",
|
||||
voteYellow: "Horia · zalantzak",
|
||||
voteRed: "Gorria · aurka",
|
||||
voteOwnNote: "Bozketa hau zuk sortu duzu, beraz ezin duzu bozkatu: proposatzen duena neutral geratzen da.",
|
||||
voteKindLabel: "Bozketa mota",
|
||||
voteKind_classic: "Bai / Ez",
|
||||
voteKindHint_classic: "Bai, ez, abstentzioa eta ohiko aukerak",
|
||||
voteKind_traffic: "Semaforoa",
|
||||
voteKindHint_traffic: "Berdea ados · horia zalantzak · gorria aurka, batzarrean bezala",
|
||||
voteKind_scale: "Termometroa",
|
||||
voteKindHint_scale: "1etik (hotza) 5era (beroa)",
|
||||
deleteConfirmQuestion: "Betiko ezabatu?",
|
||||
deleteConfirmYes: "Bai, ezabatu",
|
||||
welcomeBannerTextMobile: "Jarri zure izena eta argazkia ezagutu zaitzaten.",
|
||||
welcomeBannerActionMobile: "Jarri",
|
||||
footerPeersNone: "Oraindik ez zaude inorekin sinkronizatzen",
|
||||
footerPeersSome: "{n}-rekin sinkronizatzen",
|
||||
tribeMapUrlLabel: "Maparen esteka (aukerakoa)",
|
||||
tribeHintPrivacy: "Pribatua: kideek bakarrik ikusten dute, eta zifratuta dago. Publikoa: sare osoak ikusten du.",
|
||||
tribeHintMode: "Irekia: edozein kidek gonbida dezake. Zorrotza: sortu zuenak bakarrik gonbidatzen du.",
|
||||
moduleBlockedText: "{name} desaktibatuta dago.",
|
||||
moduleBlockedAction: "Aktibatu {name}",
|
||||
modulesOnCount: "{total} ataletatik {on} aktibo",
|
||||
modulesSaved: "Gordeta.",
|
||||
// skins y workflows en el movil (settings_view.js)
|
||||
skinIntroMobile: "Aukeratu nolakoa den Oasis zure mugikorrean. Ukitu bezain laster aplikatzen da.",
|
||||
workflowsDescriptionMobile: "Workflow batek zein modulu dauden aktibo finkatzen du. Mugikorrean zure itxura ez da aldatzen.",
|
||||
// fotos que no se pueden limpiar (blobHandler.js)
|
||||
imageMetadataUnsafe: "Ezin izan dira irudi honen metadatuak kendu (kokapena, kamera…), beraz ez da igo. Gorde JPEG edo PNG gisa eta saiatu berriro.",
|
||||
// Karvan
|
||||
karvanActive: "Gela aktiboak",
|
||||
karvanBlurb: "Memorian bakarrik bizi diren eta beren kabuz suntsitzen diren gelak.",
|
||||
karvanCall: "Deitu",
|
||||
karvanCreate: "Sortu gela iragankorra",
|
||||
karvanEphemeral: "Gela iragankorrak",
|
||||
karvanInvite: "Gonbidatu",
|
||||
karvanInviteBad: "✗ Hori ez da @feed-id baliozkoa.",
|
||||
karvanInviteFail: "✗ Ezin izan da gonbidapena bidali.",
|
||||
karvanInvitePlaceholder: "Gonbidatu kontaktu bat @feed-id bidez…",
|
||||
karvanInviteSent: "✓ Gonbidapena bere sarrera-ontzira bidali da.",
|
||||
karvanNone: "Ez dago gela aktiborik. Sortu bat: bere kabuz desagertuko da.",
|
||||
karvanRelay: "zerbitzariaren bidez",
|
||||
karvanRoomName: "Gelaren izena (aukerakoa)",
|
||||
karvanRooms: "Gelak",
|
||||
karvanSay: "Aldi baterako mezua…",
|
||||
karvanSend: "Bidali",
|
||||
karvanTitle: "Karvan",
|
||||
karvanTtl: "Bere burua suntsituko du:",
|
||||
modulesKarvanDescription: "Gela iragankorrak: mezuak memorian bakarrik bizi dira eta beren burua suntsitzen dute.",
|
||||
modulesKarvanLabel: "Karvan",
|
||||
// Identidades
|
||||
identitiesActive: "Erabiltzen",
|
||||
identitiesBackup: "Esportatu edo inportatu nortasun bat",
|
||||
|
|
@ -399,58 +275,27 @@ module.exports = {
|
|||
spreadMore: "gehiago"
|
||||
},
|
||||
de: {
|
||||
// usabilidad (19/10/2026): votaciones, borrar, modulos, pie
|
||||
bbMenu: "Menü",
|
||||
bbMenuClose: "Schließen",
|
||||
uxPickHint: "Tippe eins an und es ist gespeichert. Du kannst es jederzeit in den Einstellungen ändern.",
|
||||
uxPickCurrent: "Aktiv",
|
||||
welcomeStepUseMobile: "Profil",
|
||||
welcomeHintUseMobile: "Wofür du Oasis nutzt. Schaltet die nötigen Module ein.",
|
||||
wfHint_activists: "Versammlungen, Stämme, Abstimmungen, Foren und gemeinsame Dokumente",
|
||||
wfHint_acampada: "Was ein Camp braucht: Chats, Räume, Arbeitsgruppen und Abstimmen",
|
||||
wfHint_full: "Alle Module",
|
||||
wfHint_default: "Alle Module",
|
||||
wfHint_social: "Feed, Fotos, Termine und Freunde",
|
||||
wfHint_kids: "Schule, Spiele und Selbermachen",
|
||||
wfHint_night: "Blogs, Musik, Videos und Gespräche",
|
||||
wfHint_jobs: "Arbeit, Projekte, Markt und Büro",
|
||||
wfHint_business: "Wirtschaft, Bank, Büro und Dokumente",
|
||||
wfHint_mobile: "Das Wichtigste fürs Handy",
|
||||
workflow_acampada: "Camp",
|
||||
tribeSectionsMore: "Mehr",
|
||||
tribeSectionsLabel: "Bereiche",
|
||||
voteYes: "Ja",
|
||||
voteNo: "Nein",
|
||||
voteAbstention: "Enthaltung",
|
||||
voteGreen: "Grün · einverstanden",
|
||||
voteYellow: "Gelb · Zweifel",
|
||||
voteRed: "Rot · dagegen",
|
||||
voteOwnNote: "Du hast diese Abstimmung erstellt und kannst daher nicht abstimmen: wer vorschlägt, bleibt neutral.",
|
||||
voteKindLabel: "Art der Abstimmung",
|
||||
voteKind_classic: "Ja / Nein",
|
||||
voteKindHint_classic: "Ja, nein, Enthaltung und die üblichen Optionen",
|
||||
voteKind_traffic: "Ampel",
|
||||
voteKindHint_traffic: "Grün einverstanden · gelb Zweifel · rot dagegen, wie im Plenum",
|
||||
voteKind_scale: "Thermometer",
|
||||
voteKindHint_scale: "Von 1 (kalt) bis 5 (heiß)",
|
||||
deleteConfirmQuestion: "Endgültig löschen?",
|
||||
deleteConfirmYes: "Ja, löschen",
|
||||
welcomeBannerTextMobile: "Füge deinen Namen und dein Foto hinzu, damit man dich erkennt.",
|
||||
welcomeBannerActionMobile: "Einrichten",
|
||||
footerPeersNone: "Noch mit niemandem synchronisiert",
|
||||
footerPeersSome: "Synchronisiert mit {n}",
|
||||
tribeMapUrlLabel: "Kartenlink (optional)",
|
||||
tribeHintPrivacy: "Privat: nur Mitglieder sehen sie, und sie ist verschlüsselt. Öffentlich: das ganze Netz sieht sie.",
|
||||
tribeHintMode: "Offen: jedes Mitglied kann einladen. Streng: nur wer sie erstellt hat, lädt ein.",
|
||||
moduleBlockedText: "{name} ist ausgeschaltet.",
|
||||
moduleBlockedAction: "{name} einschalten",
|
||||
modulesOnCount: "{on} von {total} Bereichen aktiv",
|
||||
modulesSaved: "Gespeichert.",
|
||||
// skins y workflows en el movil (settings_view.js)
|
||||
skinIntroMobile: "Wähle, wie Oasis auf deinem Handy aussieht. Es wird beim Antippen übernommen.",
|
||||
workflowsDescriptionMobile: "Ein Workflow legt fest, welche Module aktiv sind. Auf dem Handy bleibt dein Skin, wie er ist.",
|
||||
// fotos que no se pueden limpiar (blobHandler.js)
|
||||
imageMetadataUnsafe: "Die Metadaten dieses Bildes (Standort, Kamera…) konnten nicht entfernt werden, daher wurde es nicht hochgeladen. Speichere es als JPEG oder PNG und versuche es erneut.",
|
||||
// Karvan
|
||||
karvanActive: "Aktive Räume",
|
||||
karvanBlurb: "Räume, die nur im Speicher leben und sich von selbst zerstören.",
|
||||
karvanCall: "Anrufen",
|
||||
karvanCreate: "Flüchtigen Raum erstellen",
|
||||
karvanEphemeral: "Flüchtige Räume",
|
||||
karvanInvite: "Einladen",
|
||||
karvanInviteBad: "✗ Das ist keine gültige @feed-id.",
|
||||
karvanInviteFail: "✗ Einladung konnte nicht gesendet werden.",
|
||||
karvanInvitePlaceholder: "Kontakt per @feed-id einladen…",
|
||||
karvanInviteSent: "✓ Einladung an den Posteingang gesendet.",
|
||||
karvanNone: "Keine aktiven Räume. Erstelle einen — er verschwindet von selbst.",
|
||||
karvanRelay: "über den Server",
|
||||
karvanRoomName: "Raumname (optional)",
|
||||
karvanRooms: "Räume",
|
||||
karvanSay: "Temporäre Nachricht…",
|
||||
karvanSend: "Senden",
|
||||
karvanTitle: "Karvan",
|
||||
karvanTtl: "Selbstzerstörung in:",
|
||||
modulesKarvanDescription: "Flüchtige Räume: Nachrichten leben nur im Speicher und zerstören sich selbst.",
|
||||
modulesKarvanLabel: "Karvan",
|
||||
// Identidades
|
||||
identitiesActive: "In Benutzung",
|
||||
identitiesBackup: "Identität exportieren oder importieren",
|
||||
|
|
@ -493,58 +338,27 @@ module.exports = {
|
|||
spreadMore: "mehr"
|
||||
},
|
||||
it: {
|
||||
// usabilidad (19/10/2026): votaciones, borrar, modulos, pie
|
||||
bbMenu: "Menu",
|
||||
bbMenuClose: "Chiudi",
|
||||
uxPickHint: "Toccane uno ed è salvato. Puoi cambiarlo quando vuoi nelle Impostazioni.",
|
||||
uxPickCurrent: "In uso",
|
||||
welcomeStepUseMobile: "Profilo",
|
||||
welcomeHintUseMobile: "A cosa ti serve Oasis. Attiva i moduli necessari.",
|
||||
wfHint_activists: "Assemblee, tribù, votazioni, forum e documenti condivisi",
|
||||
wfHint_acampada: "Il necessario per un accampamento: chat, stanze, commissioni e voto",
|
||||
wfHint_full: "Tutti i moduli",
|
||||
wfHint_default: "Tutti i moduli",
|
||||
wfHint_social: "Feed, foto, eventi e amicizie",
|
||||
wfHint_kids: "Scuola, giochi e creatività",
|
||||
wfHint_night: "Blog, musica, video e chiacchiere",
|
||||
wfHint_jobs: "Lavoro, progetti, mercato e ufficio",
|
||||
wfHint_business: "Economia, banca, ufficio e documenti",
|
||||
wfHint_mobile: "L'essenziale per il telefono",
|
||||
workflow_acampada: "Accampata",
|
||||
tribeSectionsMore: "Altro",
|
||||
tribeSectionsLabel: "Sezioni",
|
||||
voteYes: "Sì",
|
||||
voteNo: "No",
|
||||
voteAbstention: "Astensione",
|
||||
voteGreen: "Verde · d'accordo",
|
||||
voteYellow: "Giallo · dubbi",
|
||||
voteRed: "Rosso · contrario",
|
||||
voteOwnNote: "Hai creato tu questa votazione, quindi non puoi votare: chi propone resta neutrale.",
|
||||
voteKindLabel: "Tipo di votazione",
|
||||
voteKind_classic: "Sì / No",
|
||||
voteKindHint_classic: "Sì, no, astensione e le opzioni di sempre",
|
||||
voteKind_traffic: "Semaforo",
|
||||
voteKindHint_traffic: "Verde d'accordo · giallo dubbi · rosso contrario, come in assemblea",
|
||||
voteKind_scale: "Termometro",
|
||||
voteKindHint_scale: "Da 1 (freddo) a 5 (caldo)",
|
||||
deleteConfirmQuestion: "Eliminare per sempre?",
|
||||
deleteConfirmYes: "Sì, elimina",
|
||||
welcomeBannerTextMobile: "Aggiungi nome e foto perché ti riconoscano.",
|
||||
welcomeBannerActionMobile: "Aggiungi",
|
||||
footerPeersNone: "Non sincronizzi ancora con nessuno",
|
||||
footerPeersSome: "In sincronia con {n}",
|
||||
tribeMapUrlLabel: "Link della mappa (facoltativo)",
|
||||
tribeHintPrivacy: "Privata: la vedono solo i membri ed è cifrata. Pubblica: la vede tutta la rete.",
|
||||
tribeHintMode: "Aperta: qualsiasi membro può invitare. Rigida: invita solo chi l'ha creata.",
|
||||
moduleBlockedText: "{name} è disattivato.",
|
||||
moduleBlockedAction: "Attiva {name}",
|
||||
modulesOnCount: "{on} sezioni attive su {total}",
|
||||
modulesSaved: "Salvato.",
|
||||
// skins y workflows en el movil (settings_view.js)
|
||||
skinIntroMobile: "Scegli come appare Oasis sul tuo telefono. Si applica appena lo tocchi.",
|
||||
workflowsDescriptionMobile: "Un workflow stabilisce quali moduli sono attivi. Sul telefono la tua skin resta com'è.",
|
||||
// fotos que no se pueden limpiar (blobHandler.js)
|
||||
imageMetadataUnsafe: "Non è stato possibile rimuovere i metadati di questa immagine (posizione, fotocamera…), quindi non è stata caricata. Salvala come JPEG o PNG e riprova.",
|
||||
// Karvan
|
||||
karvanActive: "Stanze attive",
|
||||
karvanBlurb: "Stanze che vivono solo in memoria e si distruggono da sole.",
|
||||
karvanCall: "Chiama",
|
||||
karvanCreate: "Crea stanza effimera",
|
||||
karvanEphemeral: "Stanze effimere",
|
||||
karvanInvite: "Invita",
|
||||
karvanInviteBad: "✗ Non è un @feed-id valido.",
|
||||
karvanInviteFail: "✗ Impossibile inviare l'invito.",
|
||||
karvanInvitePlaceholder: "Invita un contatto con @feed-id…",
|
||||
karvanInviteSent: "✓ Invito inviato alla sua casella.",
|
||||
karvanNone: "Nessuna stanza attiva. Creane una: sparisce da sola.",
|
||||
karvanRelay: "tramite il server",
|
||||
karvanRoomName: "Nome della stanza (facoltativo)",
|
||||
karvanRooms: "Stanze",
|
||||
karvanSay: "Messaggio temporaneo…",
|
||||
karvanSend: "Invia",
|
||||
karvanTitle: "Karvan",
|
||||
karvanTtl: "Autodistruzione tra:",
|
||||
modulesKarvanDescription: "Stanze effimere: i messaggi vivono solo in memoria e si autodistruggono.",
|
||||
modulesKarvanLabel: "Karvan",
|
||||
// Identidades
|
||||
identitiesActive: "In uso",
|
||||
identitiesBackup: "Esporta o importa un'identità",
|
||||
|
|
@ -587,58 +401,27 @@ module.exports = {
|
|||
spreadMore: "altro"
|
||||
},
|
||||
pt: {
|
||||
// usabilidad (19/10/2026): votaciones, borrar, modulos, pie
|
||||
bbMenu: "Menu",
|
||||
bbMenuClose: "Fechar",
|
||||
uxPickHint: "Toque num e fica guardado. Pode mudá-lo quando quiser nas Definições.",
|
||||
uxPickCurrent: "Em uso",
|
||||
welcomeStepUseMobile: "Perfil",
|
||||
welcomeHintUseMobile: "Para que usa o Oasis. Liga os módulos necessários.",
|
||||
wfHint_activists: "Assembleias, tribos, votações, fóruns e documentos partilhados",
|
||||
wfHint_acampada: "O necessário para um acampamento: chats, salas, comissões e votar",
|
||||
wfHint_full: "Todos os módulos",
|
||||
wfHint_default: "Todos os módulos",
|
||||
wfHint_social: "Feed, fotos, eventos e amizades",
|
||||
wfHint_kids: "Escola, jogos e criar coisas",
|
||||
wfHint_night: "Blogues, música, vídeos e conversa",
|
||||
wfHint_jobs: "Trabalho, projetos, mercado e escritório",
|
||||
wfHint_business: "Economia, banca, escritório e documentos",
|
||||
wfHint_mobile: "O básico para o telemóvel",
|
||||
workflow_acampada: "Acampada",
|
||||
tribeSectionsMore: "Mais",
|
||||
tribeSectionsLabel: "Secções",
|
||||
voteYes: "Sim",
|
||||
voteNo: "Não",
|
||||
voteAbstention: "Abstenção",
|
||||
voteGreen: "Verde · de acordo",
|
||||
voteYellow: "Amarelo · dúvidas",
|
||||
voteRed: "Vermelho · contra",
|
||||
voteOwnNote: "Criaste esta votação, por isso não podes votar nela: quem propõe fica neutro.",
|
||||
voteKindLabel: "Tipo de votação",
|
||||
voteKind_classic: "Sim / Não",
|
||||
voteKindHint_classic: "Sim, não, abstenção e as opções de sempre",
|
||||
voteKind_traffic: "Semáforo",
|
||||
voteKindHint_traffic: "Verde de acordo · amarelo dúvidas · vermelho contra, como em assembleia",
|
||||
voteKind_scale: "Termómetro",
|
||||
voteKindHint_scale: "De 1 (frio) a 5 (quente)",
|
||||
deleteConfirmQuestion: "Apagar para sempre?",
|
||||
deleteConfirmYes: "Sim, apagar",
|
||||
welcomeBannerTextMobile: "Põe o teu nome e a tua foto para que te reconheçam.",
|
||||
welcomeBannerActionMobile: "Pôr",
|
||||
footerPeersNone: "Ainda não sincronizas com ninguém",
|
||||
footerPeersSome: "A sincronizar com {n}",
|
||||
tribeMapUrlLabel: "Ligação do mapa (opcional)",
|
||||
tribeHintPrivacy: "Privada: só os membros a veem e vai cifrada. Pública: toda a rede a vê.",
|
||||
tribeHintMode: "Aberta: qualquer membro pode convidar. Estrita: só convida quem a criou.",
|
||||
moduleBlockedText: "{name} está desativado.",
|
||||
moduleBlockedAction: "Ativar {name}",
|
||||
modulesOnCount: "{on} de {total} secções ativas",
|
||||
modulesSaved: "Guardado.",
|
||||
// skins y workflows en el movil (settings_view.js)
|
||||
skinIntroMobile: "Escolhe como o Oasis aparece no teu telemóvel. Aplica-se ao tocar.",
|
||||
workflowsDescriptionMobile: "Um workflow define que módulos estão ativos. No telemóvel o teu tema fica como está.",
|
||||
// fotos que no se pueden limpiar (blobHandler.js)
|
||||
imageMetadataUnsafe: "Não foi possível remover os metadados desta imagem (localização, câmara…), por isso não foi enviada. Guarda-a como JPEG ou PNG e tenta de novo.",
|
||||
// Karvan
|
||||
karvanActive: "Salas ativas",
|
||||
karvanBlurb: "Salas que vivem apenas em memória e destroem-se sozinhas.",
|
||||
karvanCall: "Chamar",
|
||||
karvanCreate: "Criar sala efémera",
|
||||
karvanEphemeral: "Salas efémeras",
|
||||
karvanInvite: "Convidar",
|
||||
karvanInviteBad: "✗ Isso não é um @feed-id válido.",
|
||||
karvanInviteFail: "✗ Não foi possível enviar o convite.",
|
||||
karvanInvitePlaceholder: "Convidar um contacto por @feed-id…",
|
||||
karvanInviteSent: "✓ Convite enviado para a caixa de entrada.",
|
||||
karvanNone: "Não há salas ativas. Cria uma: desaparece sozinha.",
|
||||
karvanRelay: "através do servidor",
|
||||
karvanRoomName: "Nome da sala (opcional)",
|
||||
karvanRooms: "Salas",
|
||||
karvanSay: "Mensagem temporária…",
|
||||
karvanSend: "Enviar",
|
||||
karvanTitle: "Karvan",
|
||||
karvanTtl: "Autodestrói-se em:",
|
||||
modulesKarvanDescription: "Salas efémeras: as mensagens vivem apenas em memória e autodestroem-se.",
|
||||
modulesKarvanLabel: "Karvan",
|
||||
// Identidades
|
||||
identitiesActive: "Em uso",
|
||||
identitiesBackup: "Exportar ou importar uma identidade",
|
||||
|
|
@ -681,58 +464,27 @@ module.exports = {
|
|||
spreadMore: "mais"
|
||||
},
|
||||
zh: {
|
||||
// usabilidad (19/10/2026): votaciones, borrar, modulos, pie
|
||||
bbMenu: "菜单",
|
||||
bbMenuClose: "关闭",
|
||||
uxPickHint: "点一下即保存。随时可以在设置中更改。",
|
||||
uxPickCurrent: "使用中",
|
||||
welcomeStepUseMobile: "用途",
|
||||
welcomeHintUseMobile: "你用 Oasis 做什么。会打开需要的模块。",
|
||||
wfHint_activists: "集会、部落、投票、论坛和共享文档",
|
||||
wfHint_acampada: "营地所需:聊天、房间、小组和投票",
|
||||
wfHint_full: "全部模块",
|
||||
wfHint_default: "全部模块",
|
||||
wfHint_social: "动态、照片、活动和朋友",
|
||||
wfHint_kids: "学校、游戏和创作",
|
||||
wfHint_night: "博客、音乐、视频和闲聊",
|
||||
wfHint_jobs: "工作、项目、市场和办公",
|
||||
wfHint_business: "经济、银行、办公和文档",
|
||||
wfHint_mobile: "手机基本功能",
|
||||
workflow_acampada: "营地",
|
||||
tribeSectionsMore: "更多",
|
||||
tribeSectionsLabel: "板块",
|
||||
voteYes: "赞成",
|
||||
voteNo: "反对",
|
||||
voteAbstention: "弃权",
|
||||
voteGreen: "绿 · 同意",
|
||||
voteYellow: "黄 · 有疑问",
|
||||
voteRed: "红 · 反对",
|
||||
voteOwnNote: "这是你发起的投票,所以你不能投票:发起人保持中立。",
|
||||
voteKindLabel: "投票类型",
|
||||
voteKind_classic: "是 / 否",
|
||||
voteKindHint_classic: "赞成、反对、弃权及常用选项",
|
||||
voteKind_traffic: "红绿灯",
|
||||
voteKindHint_traffic: "绿同意 · 黄有疑问 · 红反对,和集会上一样",
|
||||
voteKind_scale: "温度计",
|
||||
voteKindHint_scale: "从 1(冷)到 5(热)",
|
||||
deleteConfirmQuestion: "永久删除?",
|
||||
deleteConfirmYes: "是,删除",
|
||||
welcomeBannerTextMobile: "添加你的名字和照片,让大家认出你。",
|
||||
welcomeBannerActionMobile: "设置",
|
||||
footerPeersNone: "尚未与任何人同步",
|
||||
footerPeersSome: "正在与 {n} 个节点同步",
|
||||
tribeMapUrlLabel: "地图链接(可选)",
|
||||
tribeHintPrivacy: "私密:只有成员能看到,并且加密。公开:整个网络都能看到。",
|
||||
tribeHintMode: "开放:任何成员都可以邀请。严格:只有创建者可以邀请。",
|
||||
moduleBlockedText: "{name} 已关闭。",
|
||||
moduleBlockedAction: "开启 {name}",
|
||||
modulesOnCount: "已开启 {on}/{total} 个板块",
|
||||
modulesSaved: "已保存。",
|
||||
// skins y workflows en el movil (settings_view.js)
|
||||
skinIntroMobile: "选择 Oasis 在手机上的外观。点一下就会生效。",
|
||||
workflowsDescriptionMobile: "工作流决定启用哪些模块。在手机上,你的外观保持不变。",
|
||||
// fotos que no se pueden limpiar (blobHandler.js)
|
||||
imageMetadataUnsafe: "无法清除这张图片的元数据(位置、相机等),因此没有上传。请另存为 JPEG 或 PNG 后重试。",
|
||||
// Karvan
|
||||
karvanActive: "活跃房间",
|
||||
karvanBlurb: "只存在于内存中、会自行销毁的房间。",
|
||||
karvanCall: "通话",
|
||||
karvanCreate: "创建临时房间",
|
||||
karvanEphemeral: "临时房间",
|
||||
karvanInvite: "邀请",
|
||||
karvanInviteBad: "✗ 这不是有效的 @feed-id。",
|
||||
karvanInviteFail: "✗ 无法发送邀请。",
|
||||
karvanInvitePlaceholder: "用 @feed-id 邀请联系人…",
|
||||
karvanInviteSent: "✓ 邀请已发送至对方收件箱。",
|
||||
karvanNone: "没有活跃房间。创建一个吧——它会自行消失。",
|
||||
karvanRelay: "经服务器中转",
|
||||
karvanRoomName: "房间名称(可选)",
|
||||
karvanRooms: "房间",
|
||||
karvanSay: "临时消息…",
|
||||
karvanSend: "发送",
|
||||
karvanTitle: "Karvan",
|
||||
karvanTtl: "自动销毁于:",
|
||||
modulesKarvanDescription: "临时房间:消息只存在于内存中,并会自动销毁。",
|
||||
modulesKarvanLabel: "Karvan",
|
||||
// Identidades
|
||||
identitiesActive: "使用中",
|
||||
identitiesBackup: "导出或导入身份",
|
||||
|
|
@ -775,58 +527,27 @@ module.exports = {
|
|||
spreadMore: "更多"
|
||||
},
|
||||
ar: {
|
||||
// usabilidad (19/10/2026): votaciones, borrar, modulos, pie
|
||||
bbMenu: "القائمة",
|
||||
bbMenuClose: "إغلاق",
|
||||
uxPickHint: "المس واحدًا ويُحفظ. يمكنك تغييره في أي وقت من الإعدادات.",
|
||||
uxPickCurrent: "قيد الاستخدام",
|
||||
welcomeStepUseMobile: "الملف",
|
||||
welcomeHintUseMobile: "لماذا تستخدم Oasis. يفعّل الوحدات التي تحتاجها.",
|
||||
wfHint_activists: "جمعيات وقبائل وتصويت ومنتديات ووثائق مشتركة",
|
||||
wfHint_acampada: "ما يلزم للمخيم: دردشات وغرف ولجان وتصويت",
|
||||
wfHint_full: "كل الوحدات",
|
||||
wfHint_default: "كل الوحدات",
|
||||
wfHint_social: "الموجز والصور والفعاليات والأصدقاء",
|
||||
wfHint_kids: "المدرسة والألعاب والإبداع",
|
||||
wfHint_night: "مدونات وموسيقى وفيديو ودردشة",
|
||||
wfHint_jobs: "عمل ومشاريع وسوق ومكتب",
|
||||
wfHint_business: "اقتصاد ومصرف ومكتب ووثائق",
|
||||
wfHint_mobile: "الأساسيات للهاتف",
|
||||
workflow_acampada: "مخيم",
|
||||
tribeSectionsMore: "المزيد",
|
||||
tribeSectionsLabel: "الأقسام",
|
||||
voteYes: "نعم",
|
||||
voteNo: "لا",
|
||||
voteAbstention: "امتناع",
|
||||
voteGreen: "أخضر · موافق",
|
||||
voteYellow: "أصفر · شكوك",
|
||||
voteRed: "أحمر · ضد",
|
||||
voteOwnNote: "أنت من أنشأ هذا التصويت، لذا لا يمكنك التصويت فيه: من يقترح يبقى محايدًا.",
|
||||
voteKindLabel: "نوع التصويت",
|
||||
voteKind_classic: "نعم / لا",
|
||||
voteKindHint_classic: "نعم، لا، امتناع والخيارات المعتادة",
|
||||
voteKind_traffic: "إشارة المرور",
|
||||
voteKindHint_traffic: "أخضر موافق · أصفر شكوك · أحمر ضد، كما في الجمعية",
|
||||
voteKind_scale: "مقياس الحرارة",
|
||||
voteKindHint_scale: "من 1 (بارد) إلى 5 (ساخن)",
|
||||
deleteConfirmQuestion: "حذف نهائيًا؟",
|
||||
deleteConfirmYes: "نعم، احذف",
|
||||
welcomeBannerTextMobile: "أضف اسمك وصورتك ليتعرّف عليك الآخرون.",
|
||||
welcomeBannerActionMobile: "إضافة",
|
||||
footerPeersNone: "لا مزامنة مع أحد بعد",
|
||||
footerPeersSome: "مزامنة مع {n}",
|
||||
tribeMapUrlLabel: "رابط الخريطة (اختياري)",
|
||||
tribeHintPrivacy: "خاصة: يراها أعضاؤها فقط وهي مشفّرة. عامة: تراها الشبكة كلها.",
|
||||
tribeHintMode: "مفتوحة: يمكن لأي عضو أن يدعو. صارمة: لا يدعو إلا من أنشأها.",
|
||||
moduleBlockedText: "{name} مُعطّل.",
|
||||
moduleBlockedAction: "تفعيل {name}",
|
||||
modulesOnCount: "{on} من {total} أقسام مفعّلة",
|
||||
modulesSaved: "تم الحفظ.",
|
||||
// skins y workflows en el movil (settings_view.js)
|
||||
skinIntroMobile: "اختر مظهر Oasis على هاتفك. يُطبَّق بمجرد اللمس.",
|
||||
workflowsDescriptionMobile: "يحدد سير العمل الوحدات المفعّلة. على الهاتف يبقى مظهرك كما هو.",
|
||||
// fotos que no se pueden limpiar (blobHandler.js)
|
||||
imageMetadataUnsafe: "تعذّر إزالة البيانات الوصفية من هذه الصورة (الموقع، الكاميرا…)، لذلك لم تُرفع. احفظها بصيغة JPEG أو PNG وحاول مجددًا.",
|
||||
// Karvan
|
||||
karvanActive: "الغرف النشطة",
|
||||
karvanBlurb: "غرف تعيش في الذاكرة فقط وتدمّر نفسها بنفسها.",
|
||||
karvanCall: "اتصال",
|
||||
karvanCreate: "إنشاء غرفة مؤقتة",
|
||||
karvanEphemeral: "غرف مؤقتة",
|
||||
karvanInvite: "دعوة",
|
||||
karvanInviteBad: "✗ هذا ليس @feed-id صالحًا.",
|
||||
karvanInviteFail: "✗ تعذّر إرسال الدعوة.",
|
||||
karvanInvitePlaceholder: "ادعُ جهة اتصال عبر @feed-id…",
|
||||
karvanInviteSent: "✓ تم إرسال الدعوة إلى صندوق الوارد.",
|
||||
karvanNone: "لا توجد غرف نشطة. أنشئ واحدة، وستختفي وحدها.",
|
||||
karvanRelay: "عبر الخادم",
|
||||
karvanRoomName: "اسم الغرفة (اختياري)",
|
||||
karvanRooms: "الغرف",
|
||||
karvanSay: "رسالة مؤقتة…",
|
||||
karvanSend: "إرسال",
|
||||
karvanTitle: "Karvan",
|
||||
karvanTtl: "التدمير الذاتي خلال:",
|
||||
modulesKarvanDescription: "غرف مؤقتة: الرسائل تعيش في الذاكرة فقط وتدمّر نفسها.",
|
||||
modulesKarvanLabel: "Karvan",
|
||||
// Identidades
|
||||
identitiesActive: "قيد الاستخدام",
|
||||
identitiesBackup: "تصدير هوية أو استيرادها",
|
||||
|
|
@ -869,58 +590,27 @@ module.exports = {
|
|||
spreadMore: "المزيد"
|
||||
},
|
||||
hi: {
|
||||
// usabilidad (19/10/2026): votaciones, borrar, modulos, pie
|
||||
bbMenu: "मेनू",
|
||||
bbMenuClose: "बंद करें",
|
||||
uxPickHint: "एक पर टैप करें और वह सेव हो जाएगा। सेटिंग्स में इसे कभी भी बदल सकते हैं।",
|
||||
uxPickCurrent: "चालू",
|
||||
welcomeStepUseMobile: "प्रोफ़ाइल",
|
||||
welcomeHintUseMobile: "आप Oasis किस लिए इस्तेमाल करते हैं। ज़रूरी मॉड्यूल चालू करता है।",
|
||||
wfHint_activists: "सभाएँ, ट्राइब, मतदान, फ़ोरम और साझा दस्तावेज़",
|
||||
wfHint_acampada: "शिविर के लिए ज़रूरी: चैट, कमरे, समितियाँ और मतदान",
|
||||
wfHint_full: "सभी मॉड्यूल",
|
||||
wfHint_default: "सभी मॉड्यूल",
|
||||
wfHint_social: "फ़ीड, फ़ोटो, कार्यक्रम और दोस्त",
|
||||
wfHint_kids: "स्कूल, खेल और रचना",
|
||||
wfHint_night: "ब्लॉग, संगीत, वीडियो और बातचीत",
|
||||
wfHint_jobs: "काम, प्रोजेक्ट, बाज़ार और दफ़्तर",
|
||||
wfHint_business: "अर्थव्यवस्था, बैंकिंग, दफ़्तर और दस्तावेज़",
|
||||
wfHint_mobile: "फ़ोन के लिए बुनियादी",
|
||||
workflow_acampada: "शिविर",
|
||||
tribeSectionsMore: "और",
|
||||
tribeSectionsLabel: "खंड",
|
||||
voteYes: "हाँ",
|
||||
voteNo: "नहीं",
|
||||
voteAbstention: "तटस्थ",
|
||||
voteGreen: "हरा · सहमत",
|
||||
voteYellow: "पीला · संदेह",
|
||||
voteRed: "लाल · विरोध",
|
||||
voteOwnNote: "यह मतदान आपने बनाया है, इसलिए आप इसमें वोट नहीं दे सकते: प्रस्ताव रखने वाला तटस्थ रहता है।",
|
||||
voteKindLabel: "मतदान का प्रकार",
|
||||
voteKind_classic: "हाँ / नहीं",
|
||||
voteKindHint_classic: "हाँ, नहीं, तटस्थ और सामान्य विकल्प",
|
||||
voteKind_traffic: "ट्रैफ़िक लाइट",
|
||||
voteKindHint_traffic: "हरा सहमत · पीला संदेह · लाल विरोध, सभा की तरह",
|
||||
voteKind_scale: "थर्मामीटर",
|
||||
voteKindHint_scale: "1 (ठंडा) से 5 (गरम) तक",
|
||||
deleteConfirmQuestion: "हमेशा के लिए हटाएँ?",
|
||||
deleteConfirmYes: "हाँ, हटाएँ",
|
||||
welcomeBannerTextMobile: "अपना नाम और फ़ोटो जोड़ें ताकि लोग आपको पहचानें।",
|
||||
welcomeBannerActionMobile: "जोड़ें",
|
||||
footerPeersNone: "अभी किसी से सिंक नहीं",
|
||||
footerPeersSome: "{n} से सिंक हो रहा है",
|
||||
tribeMapUrlLabel: "नक्शे का लिंक (वैकल्पिक)",
|
||||
tribeHintPrivacy: "निजी: केवल सदस्य देखते हैं, और यह एन्क्रिप्टेड है। सार्वजनिक: पूरा नेटवर्क देखता है।",
|
||||
tribeHintMode: "खुला: कोई भी सदस्य आमंत्रित कर सकता है। सख़्त: केवल बनाने वाला आमंत्रित करता है।",
|
||||
moduleBlockedText: "{name} बंद है।",
|
||||
moduleBlockedAction: "{name} चालू करें",
|
||||
modulesOnCount: "{total} में से {on} खंड चालू",
|
||||
modulesSaved: "सहेजा गया।",
|
||||
// skins y workflows en el movil (settings_view.js)
|
||||
skinIntroMobile: "चुनें कि आपके फ़ोन पर Oasis कैसा दिखे। छूते ही लागू हो जाता है।",
|
||||
workflowsDescriptionMobile: "वर्कफ़्लो तय करता है कि कौन-से मॉड्यूल चालू हैं। फ़ोन पर आपकी स्किन वैसी ही रहती है।",
|
||||
// fotos que no se pueden limpiar (blobHandler.js)
|
||||
imageMetadataUnsafe: "इस छवि का मेटाडेटा (स्थान, कैमरा…) हटाया नहीं जा सका, इसलिए इसे अपलोड नहीं किया गया। इसे JPEG या PNG के रूप में सहेजें और फिर कोशिश करें।",
|
||||
// Karvan
|
||||
karvanActive: "सक्रिय कक्ष",
|
||||
karvanBlurb: "ऐसे कक्ष जो केवल मेमोरी में रहते हैं और स्वयं नष्ट हो जाते हैं।",
|
||||
karvanCall: "कॉल",
|
||||
karvanCreate: "अस्थायी कक्ष बनाएँ",
|
||||
karvanEphemeral: "अस्थायी कक्ष",
|
||||
karvanInvite: "आमंत्रित करें",
|
||||
karvanInviteBad: "✗ यह मान्य @feed-id नहीं है।",
|
||||
karvanInviteFail: "✗ आमंत्रण नहीं भेजा जा सका।",
|
||||
karvanInvitePlaceholder: "@feed-id से संपर्क आमंत्रित करें…",
|
||||
karvanInviteSent: "✓ आमंत्रण उनके इनबॉक्स में भेजा गया।",
|
||||
karvanNone: "कोई सक्रिय कक्ष नहीं। एक बनाएँ — यह स्वयं गायब हो जाएगा।",
|
||||
karvanRelay: "सर्वर के माध्यम से",
|
||||
karvanRoomName: "कक्ष का नाम (वैकल्पिक)",
|
||||
karvanRooms: "कक्ष",
|
||||
karvanSay: "अस्थायी संदेश…",
|
||||
karvanSend: "भेजें",
|
||||
karvanTitle: "Karvan",
|
||||
karvanTtl: "स्वतः नष्ट होगा:",
|
||||
modulesKarvanDescription: "अस्थायी कक्ष: संदेश केवल मेमोरी में रहते हैं और स्वयं नष्ट हो जाते हैं।",
|
||||
modulesKarvanLabel: "Karvan",
|
||||
// Identidades
|
||||
identitiesActive: "उपयोग में",
|
||||
identitiesBackup: "पहचान निर्यात या आयात करें",
|
||||
|
|
@ -963,58 +653,27 @@ module.exports = {
|
|||
spreadMore: "और"
|
||||
},
|
||||
ru: {
|
||||
// usabilidad (19/10/2026): votaciones, borrar, modulos, pie
|
||||
bbMenu: "Меню",
|
||||
bbMenuClose: "Закрыть",
|
||||
uxPickHint: "Нажмите на вариант, и он сохранится. Изменить можно в любой момент в Настройках.",
|
||||
uxPickCurrent: "Сейчас",
|
||||
welcomeStepUseMobile: "Профиль",
|
||||
welcomeHintUseMobile: "Для чего вам Oasis. Включает нужные модули.",
|
||||
wfHint_activists: "Собрания, племена, голосования, форумы и общие документы",
|
||||
wfHint_acampada: "Всё для лагеря: чаты, комнаты, комиссии и голосование",
|
||||
wfHint_full: "Все модули",
|
||||
wfHint_default: "Все модули",
|
||||
wfHint_social: "Лента, фото, события и друзья",
|
||||
wfHint_kids: "Школа, игры и творчество",
|
||||
wfHint_night: "Блоги, музыка, видео и общение",
|
||||
wfHint_jobs: "Работа, проекты, рынок и офис",
|
||||
wfHint_business: "Экономика, банк, офис и документы",
|
||||
wfHint_mobile: "Основное для телефона",
|
||||
workflow_acampada: "Лагерь",
|
||||
tribeSectionsMore: "Ещё",
|
||||
tribeSectionsLabel: "Разделы",
|
||||
voteYes: "Да",
|
||||
voteNo: "Нет",
|
||||
voteAbstention: "Воздержаться",
|
||||
voteGreen: "Зелёный · за",
|
||||
voteYellow: "Жёлтый · сомнения",
|
||||
voteRed: "Красный · против",
|
||||
voteOwnNote: "Вы создали это голосование, поэтому не можете в нём голосовать: кто предлагает, остаётся нейтральным.",
|
||||
voteKindLabel: "Тип голосования",
|
||||
voteKind_classic: "Да / Нет",
|
||||
voteKindHint_classic: "Да, нет, воздержаться и обычные варианты",
|
||||
voteKind_traffic: "Светофор",
|
||||
voteKindHint_traffic: "Зелёный за · жёлтый сомнения · красный против, как на собрании",
|
||||
voteKind_scale: "Термометр",
|
||||
voteKindHint_scale: "От 1 (холодно) до 5 (горячо)",
|
||||
deleteConfirmQuestion: "Удалить навсегда?",
|
||||
deleteConfirmYes: "Да, удалить",
|
||||
welcomeBannerTextMobile: "Добавьте имя и фото, чтобы вас узнавали.",
|
||||
welcomeBannerActionMobile: "Добавить",
|
||||
footerPeersNone: "Пока ни с кем не синхронизировано",
|
||||
footerPeersSome: "Синхронизация с {n}",
|
||||
tribeMapUrlLabel: "Ссылка на карту (необязательно)",
|
||||
tribeHintPrivacy: "Закрытое: видят только участники, и оно зашифровано. Открытое: видит вся сеть.",
|
||||
tribeHintMode: "Открытое: приглашать может любой участник. Строгое: приглашает только создатель.",
|
||||
moduleBlockedText: "{name} выключен.",
|
||||
moduleBlockedAction: "Включить {name}",
|
||||
modulesOnCount: "Включено {on} из {total} разделов",
|
||||
modulesSaved: "Сохранено.",
|
||||
// skins y workflows en el movil (settings_view.js)
|
||||
skinIntroMobile: "Выберите, как Oasis выглядит на телефоне. Применяется сразу по нажатию.",
|
||||
workflowsDescriptionMobile: "Рабочий процесс задаёт, какие модули включены. На телефоне ваше оформление не меняется.",
|
||||
// fotos que no se pueden limpiar (blobHandler.js)
|
||||
imageMetadataUnsafe: "Не удалось удалить метаданные этого изображения (местоположение, камера…), поэтому оно не загружено. Сохраните его как JPEG или PNG и попробуйте снова.",
|
||||
// Karvan
|
||||
karvanActive: "Активные комнаты",
|
||||
karvanBlurb: "Комнаты, которые живут только в памяти и уничтожаются сами.",
|
||||
karvanCall: "Позвонить",
|
||||
karvanCreate: "Создать эфемерную комнату",
|
||||
karvanEphemeral: "Эфемерные комнаты",
|
||||
karvanInvite: "Пригласить",
|
||||
karvanInviteBad: "✗ Это неверный @feed-id.",
|
||||
karvanInviteFail: "✗ Не удалось отправить приглашение.",
|
||||
karvanInvitePlaceholder: "Пригласить контакт по @feed-id…",
|
||||
karvanInviteSent: "✓ Приглашение отправлено во входящие.",
|
||||
karvanNone: "Активных комнат нет. Создайте — она исчезнет сама.",
|
||||
karvanRelay: "через сервер",
|
||||
karvanRoomName: "Название комнаты (необязательно)",
|
||||
karvanRooms: "Комнаты",
|
||||
karvanSay: "Временное сообщение…",
|
||||
karvanSend: "Отправить",
|
||||
karvanTitle: "Karvan",
|
||||
karvanTtl: "Самоуничтожение через:",
|
||||
modulesKarvanDescription: "Эфемерные комнаты: сообщения живут только в памяти и самоуничтожаются.",
|
||||
modulesKarvanLabel: "Karvan",
|
||||
// Identidades
|
||||
identitiesActive: "Используется",
|
||||
identitiesBackup: "Экспортировать или импортировать личность",
|
||||
|
|
|
|||
316
src/client/public/js/karvan.js
Normal file
|
|
@ -0,0 +1,316 @@
|
|||
/* 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 ? "🔒 P2P (WebRTC)" : "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" : ""); }
|
||||
function setStatus(msg) { if (statusEl) statusEl.textContent = msg || ""; }
|
||||
function statusText() { var p = []; if (micActive) p.push("mic ON"); if (camActive) p.push("cam ON"); return p.length ? p.join(" · ") : ""; }
|
||||
function updateBtns() {
|
||||
if (btnMic) btnMic.className = "kc-btn" + (micActive ? " on" : "");
|
||||
if (btnCam) btnCam.className = "kc-btn" + (camActive ? " on" : "");
|
||||
}
|
||||
|
||||
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("⛔ Cámara/micro denegados (en el móvil, pendiente del wrapper). El chat sigue.");
|
||||
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); });
|
||||
}
|
||||
function startCall() {
|
||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { setStatus("⚠ Cámara/micro no disponibles aquí."); return; }
|
||||
setStatus("📞 pidiendo cámara y micro…");
|
||||
navigator.mediaDevices.getUserMedia({ audio: true, video: true }).then(function (s) {
|
||||
s.getTracks().forEach(addLocalTrack);
|
||||
micActive = true; camActive = true; setCallActive(true); updateBtns(); setStatus(statusText());
|
||||
}).catch(function (x) {
|
||||
if (x && x.name === "NotFoundError") { getMedia("audio"); return; } // sin cámara → intenta solo audio
|
||||
onMediaErr("call", x);
|
||||
});
|
||||
}
|
||||
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);
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
// Hoja del menu (pestaña Menu de la barra de abajo). Abre y cierra con una casilla
|
||||
// y CSS; esto solo hace que el boton atras de Android cierre la hoja en vez de
|
||||
// cambiar de pagina. Ver docs/ESTUDIO_NAVEGACION.md.
|
||||
(function () {
|
||||
var box = document.getElementById('oasis-menu-toggle');
|
||||
if (!box || !window.history || !history.pushState) return;
|
||||
var sheet = document.querySelector('.menu-sheet');
|
||||
var pending = null;
|
||||
if (history.state && history.state.oasisMenu) box.checked = true;
|
||||
box.addEventListener('change', function () {
|
||||
if (box.checked) { history.pushState({ oasisMenu: true }, ''); }
|
||||
else if (history.state && history.state.oasisMenu) { history.back(); }
|
||||
});
|
||||
window.addEventListener('popstate', function () {
|
||||
if (box.checked) box.checked = false;
|
||||
// se navega despues de que la vuelta atras termine (en WebView 69, si no, se
|
||||
// perdia la entrada nueva)
|
||||
if (pending) setTimeout(follow, 50);
|
||||
});
|
||||
function follow() { if (pending) { var go = pending; pending = null; location.href = go; } }
|
||||
// al ir a un modulo desde la hoja, primero se quita la entrada de la hoja: si no,
|
||||
// al volver atras desde el modulo la portada aparecia con la hoja abierta
|
||||
if (sheet) sheet.addEventListener('click', function (e) {
|
||||
var a = e.target.closest ? e.target.closest('a[href]') : null;
|
||||
if (!a || !(history.state && history.state.oasisMenu)) return;
|
||||
e.preventDefault();
|
||||
pending = a.href;
|
||||
history.back();
|
||||
setTimeout(follow, 1000);
|
||||
});
|
||||
})();
|
||||
|
|
@ -65,6 +65,7 @@ if (!fs.existsSync(configFilePath)) {
|
|||
"modules": {
|
||||
"blogsMod": "on",
|
||||
"housingMod": "off",
|
||||
"karvanMod": "on",
|
||||
"popularMod": "on",
|
||||
"topicsMod": "on",
|
||||
"summariesMod": "on",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"themes": {
|
||||
"current": "Acampada"
|
||||
"current": "OasisMobile"
|
||||
},
|
||||
"ux": {
|
||||
"current": "blocks"
|
||||
|
|
@ -20,14 +20,15 @@
|
|||
"tagsMod": "off",
|
||||
"imagesMod": "on",
|
||||
"trendingMod": "off",
|
||||
"eventsMod": "on",
|
||||
"eventsMod": "off",
|
||||
"tasksMod": "off",
|
||||
"marketMod": "off",
|
||||
"votesMod": "on",
|
||||
"tribesMod": "on",
|
||||
"votesMod": "off",
|
||||
"tribesMod": "off",
|
||||
"reportsMod": "off",
|
||||
"opinionsMod": "off",
|
||||
"padsMod": "on",
|
||||
"karvanMod": "on",
|
||||
"calendarsMod": "off",
|
||||
"transfersMod": "off",
|
||||
"feedMod": "on",
|
||||
|
|
@ -45,7 +46,7 @@
|
|||
"industryMod": "off",
|
||||
"bankingMod": "off",
|
||||
"schoolMod": "off",
|
||||
"parliamentMod": "on",
|
||||
"parliamentMod": "off",
|
||||
"courtsMod": "off",
|
||||
"favoritesMod": "off",
|
||||
"logsMod": "off",
|
||||
|
|
@ -53,8 +54,8 @@
|
|||
"chatsMod": "on",
|
||||
"torrentsMod": "off",
|
||||
"filesMod": "off",
|
||||
"phoneMod": "on",
|
||||
"roomsMod": "on",
|
||||
"phoneMod": "off",
|
||||
"roomsMod": "off",
|
||||
"graphosMod": "off",
|
||||
"larpMod": "off",
|
||||
"backupMod": "on",
|
||||
|
|
|
|||
|
|
@ -1,39 +0,0 @@
|
|||
"use strict";
|
||||
// Workflows propios de la rama, registrados sin editar workflows_model.js.
|
||||
//
|
||||
// workflows_model.js es del dev (lo toca en 8 de cada 39 releases) y ya trae un
|
||||
// workflow "activists" con unos 50 modulos. En vez de redefinirlo, la rama anade
|
||||
// los suyos empujandolos a los arrays que ese fichero exporta; getWorkflow,
|
||||
// currentWorkflow y el asistente de bienvenida los leen de ahi.
|
||||
// Ver docs/FRONTERA_UPSTREAM.md.
|
||||
|
||||
const wf = require('./workflows_model');
|
||||
|
||||
const FORK_WORKFLOWS = [
|
||||
{
|
||||
// una acampada: hablar (chats, telefono y salas de voz del dev), escribir juntos,
|
||||
// compartir lo que pasa y decidir (tribus = comisiones, parlamento y votaciones)
|
||||
key: 'acampada',
|
||||
theme: 'Acampada',
|
||||
homePage: 'activity',
|
||||
modules: ['chats', 'phone', 'rooms', 'pads', 'forum', 'images', 'videos', 'wiki', 'blogs', 'feed', 'invites', 'backup',
|
||||
'tribes', 'parliament', 'votes', 'events']
|
||||
}
|
||||
];
|
||||
|
||||
let done = false;
|
||||
const register = () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
for (const w of FORK_WORKFLOWS) {
|
||||
if (wf.WORKFLOWS.some(x => x.key === w.key)) continue;
|
||||
wf.WORKFLOWS.push(w);
|
||||
wf.WORKFLOW_KEYS.push(w.key);
|
||||
// en el asistente, detras de activistas (en el movil mobile_ui.workflowPick pone
|
||||
// Activismo y Acampada las primeras)
|
||||
const at = wf.WIZARD_WORKFLOWS.indexOf('activists');
|
||||
wf.WIZARD_WORKFLOWS.splice(at === -1 ? wf.WIZARD_WORKFLOWS.length : at + 1, 0, w.key);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { FORK_WORKFLOWS, register };
|
||||
198
src/models/karvan_model.js
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
"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,
|
||||
};
|
||||
};
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
const ALL_MODULES = [
|
||||
'agenda', 'ai', 'aiNav', 'emergencies', 'audios', 'backup', 'banking', 'blogs', 'bookmarks', 'calendars', 'campaigns', 'chats', 'cipher',
|
||||
'courts', 'dev', 'docs', 'events', 'favorites', 'fediverse', 'feed', 'files', 'forum', 'games', 'graphos',
|
||||
'housing', 'images', 'industry', 'invites', 'jobs', 'larp', 'logistics', 'logs', 'mailing', 'maps', 'market',
|
||||
'housing', 'images', 'industry', 'invites', 'jobs', 'karvan', 'larp', 'logistics', 'logs', 'mailing', 'maps', 'market',
|
||||
'melody', 'opinions', 'pads', 'wiki', 'parliament', 'phone', 'pixelia', 'podcasts', 'polls', 'projects', 'reports', 'rooms', 'school', 'shops',
|
||||
'tags', 'tasks', 'torrents', 'transfers', 'trending', 'tribes', 'videos', 'votes', 'wallet'
|
||||
];
|
||||
|
|
@ -26,7 +26,7 @@ const PRESETS = {
|
|||
const MOBILE_MODULES = [
|
||||
'agenda', 'favorites', 'wallet', 'tribes', 'larp', 'votes', 'polls', 'events', 'calendars', 'tasks',
|
||||
'reports', 'banking', 'market', 'housing', 'jobs', 'shops', 'school', 'courts', 'transfers', 'cipher', 'invites',
|
||||
'games', 'audios', 'bookmarks', 'docs', 'files', 'images', 'torrents', 'emergencies', 'mailing', 'logistics', 'podcasts', 'campaigns', ...NETWORK.filter(m => m !== 'rooms')
|
||||
'games', 'audios', 'bookmarks', 'docs', 'files', 'images', 'torrents', 'emergencies', 'mailing', 'logistics', 'podcasts', 'campaigns', 'karvan', ...NETWORK.filter(m => m !== 'rooms')
|
||||
];
|
||||
|
||||
const WORKFLOWS = [
|
||||
|
|
@ -72,17 +72,15 @@ const WORKFLOWS = [
|
|||
'tags', 'trending', 'tribes', 'larp', 'polls', 'opinions', 'invites']
|
||||
},
|
||||
{
|
||||
// lo justo para organizarse: hablar, escribir juntos y compartir lo que pasa
|
||||
key: 'activists',
|
||||
theme: 'Purple-SNH',
|
||||
homePage: 'activity',
|
||||
modules: [...GOVERNANCE, ...OFFICE,
|
||||
'forum', 'chats', 'rooms', 'feed', 'blogs', 'opinions', 'pads', 'wiki', 'docs', 'images', 'videos', 'audios', 'podcasts',
|
||||
'files', 'torrents', 'maps', 'bookmarks', 'graphos', 'melody', 'cipher', 'fediverse', 'projects', 'logistics',
|
||||
'invites', 'tags', 'trending', 'backup']
|
||||
modules: ['chats', 'karvan', 'pads', 'forum', 'images', 'videos', 'wiki', 'blogs', 'feed', 'invites', 'backup']
|
||||
},
|
||||
{
|
||||
key: 'mobile',
|
||||
theme: 'Acampada',
|
||||
theme: 'OasisMobile',
|
||||
homePage: 'activity',
|
||||
modules: MOBILE_MODULES
|
||||
}
|
||||
|
|
@ -91,15 +89,7 @@ const WORKFLOWS = [
|
|||
const WORKFLOW_KEYS = WORKFLOWS.map(w => w.key);
|
||||
|
||||
const isMobile = () => process.env.OASIS_MOBILE === '1';
|
||||
// en el movil el workflow no toca el skin: se queda el que haya elegido cada cual
|
||||
// (Acampada u OasisMobile); si no hay ninguno de movil, Acampada
|
||||
const MOBILE_THEMES = ['Acampada', 'OasisMobile'];
|
||||
const mobileSkin = (cfg) => {
|
||||
const cur = cfg && cfg.themes && cfg.themes.current;
|
||||
return MOBILE_THEMES.includes(cur) ? cur : 'Acampada';
|
||||
};
|
||||
const themeOf = (workflow, cfg) => (isMobile() ? mobileSkin(cfg) : workflow.theme);
|
||||
const sameTheme = (theme, workflow) => (isMobile() ? MOBILE_THEMES.includes(theme) : theme === workflow.theme);
|
||||
const themeOf = (workflow) => (isMobile() ? 'OasisMobile' : workflow.theme);
|
||||
|
||||
const getWorkflow = (key) => WORKFLOWS.find(w => w.key === String(key || '')) || null;
|
||||
|
||||
|
|
@ -113,7 +103,7 @@ const currentWorkflow = (config) => {
|
|||
const theme = (config && config.themes && config.themes.current) || '';
|
||||
return WORKFLOW_KEYS.find(key => {
|
||||
const w = getWorkflow(key);
|
||||
if (!sameTheme(theme, w)) return false;
|
||||
if (theme !== themeOf(w)) return false;
|
||||
const on = new Set(modulesOf(w));
|
||||
return ALL_MODULES.every(m => (modules[`${m}Mod`] === 'on') === on.has(m));
|
||||
}) || null;
|
||||
|
|
@ -123,14 +113,14 @@ const applyWorkflow = (cfg, workflow) => {
|
|||
const enabled = new Set(modulesOf(workflow));
|
||||
cfg.modules = cfg.modules || {};
|
||||
ALL_MODULES.forEach(mod => { cfg.modules[`${mod}Mod`] = enabled.has(mod) ? 'on' : 'off'; });
|
||||
if (workflow.theme) cfg.themes = { ...(cfg.themes || {}), current: themeOf(workflow, cfg) };
|
||||
if (workflow.theme) cfg.themes = { ...(cfg.themes || {}), current: themeOf(workflow) };
|
||||
if (workflow.homePage) cfg.homePage = workflow.homePage;
|
||||
return cfg;
|
||||
};
|
||||
|
||||
const FULL_WORKFLOW = { key: 'full', modules: ALL_MODULES };
|
||||
|
||||
const WIZARD_WORKFLOWS = ['full', 'social', 'activists', 'kids', 'night', 'jobs', 'business', 'mobile'];
|
||||
const WIZARD_WORKFLOWS = ['activists', 'full', 'social', 'kids', 'night', 'jobs', 'business', 'mobile'];
|
||||
|
||||
const wizardWorkflow = (key) => {
|
||||
const k = String(key || '');
|
||||
|
|
|
|||
|
|
@ -7,10 +7,9 @@
|
|||
// cada integracion. Aqui solo depende de i18n, que se inyecta porque main_views
|
||||
// lo muta en cada peticion sin reasignarlo.
|
||||
|
||||
const { a, button, div, form, h2, h3, img, input, label, nav, p, script, section, span } = require("../server/node_modules/hyperaxe");
|
||||
const { a, button, div, form, h2, h3, img, input, label, nav, p, section, span } = require("../server/node_modules/hyperaxe");
|
||||
const { getConfig } = require("../configs/config-manager.js");
|
||||
const sharedState = require("../configs/shared-state");
|
||||
const { iconNode, iconKeyOf } = require("./mobile_icons");
|
||||
|
||||
// con pocos modulos activos (el workflow activistas) todo cabe en una pantalla:
|
||||
// sobran el filtro Personal/Social y los accesos de usuario avanzado
|
||||
|
|
@ -22,13 +21,16 @@ module.exports = ({ i18n }) => {
|
|||
const renderMobileTopbar = () => {
|
||||
// solo en movil: en escritorio manda la cabecera de upstream
|
||||
if (process.env.OASIS_MOBILE !== '1') return "";
|
||||
// Personal/Social ya no van aqui: filtraban el lanzador, que ahora esta en la hoja
|
||||
// del menu, y con muchos modulos (workflow activistas) no cabian a 360px y tapaban
|
||||
// el logo. Queda el hueco para el boton de No molestar del telefono
|
||||
// FORK_IA_UX v3: Personal/Community VUELVEN al topbar (ya funcionan: la hoja cerrada es
|
||||
// visibility:hidden y no bloquea los taps). Llena el top y da el filtro a un toque.
|
||||
const hiveFilter = (typeof global !== "undefined" && global.__OASIS_HIVE_FILTER__) || "";
|
||||
return div(
|
||||
{ class: "oasis-mobile-topbar" },
|
||||
a({ class: "omt-logo", href: "/" }, img({ src: "/assets/images/snh-oasis.jpg", alt: "Oasis" })),
|
||||
div({ class: "omt-menus" }),
|
||||
isCompact() ? div({ class: "omt-menus" }) : div({ class: "omt-menus" },
|
||||
a({ class: hiveFilter === "personal" ? "oasis-menu-btn active" : "oasis-menu-btn", href: hiveFilter === "personal" ? "/activity" : "/activity?hive=personal" }, span(i18n.menuPersonal || "Personal")),
|
||||
a({ class: hiveFilter === "community" ? "oasis-menu-btn active" : "oasis-menu-btn", href: hiveFilter === "community" ? "/activity" : "/activity?hive=community" }, span(i18n.menuSocial || "Social"))
|
||||
),
|
||||
a({ class: "omt-avatar", href: "/profile" }, img({ src: (typeof global !== "undefined" && global.__OASIS_SELF_AVATAR__) || "/assets/images/default-avatar.png", alt: i18n.profile || "Profile" }))
|
||||
);
|
||||
};
|
||||
|
|
@ -40,6 +42,7 @@ module.exports = ({ i18n }) => {
|
|||
{ key: 'activity', href: '/activity', glyph: 'ꔙ', labelKey: 'activityTitle', mod: null },
|
||||
{ key: 'tribes', href: '/tribes', glyph: 'ꖥ', labelKey: 'tribesTitle', mod: 'tribesMod' },
|
||||
{ key: 'chats', href: '/chats', glyph: 'ꖒ', labelKey: 'chatsTitle', mod: 'chatsMod' },
|
||||
{ key: 'karvan', href: '/karvan', glyph: '⧗', labelKey: 'karvanTitle', mod: 'karvanMod' },
|
||||
{ key: 'rooms', href: '/rooms', glyph: 'ꕢ', labelKey: 'roomsTitle', mod: 'roomsMod' },
|
||||
{ key: 'wiki', href: '/wiki', glyph: 'ꖿ', labelKey: 'wikiTitle', mod: 'wikiMod' },
|
||||
{ key: 'polls', href: '/polls', glyph: 'ꖙ', labelKey: 'pollsTitle', mod: 'pollsMod' },
|
||||
|
|
@ -91,13 +94,9 @@ module.exports = ({ i18n }) => {
|
|||
const inboxN = sharedState.getInboxCount();
|
||||
const cfg = getConfig();
|
||||
const modOn = (m) => !m || cfg.modules[m] === 'on' || cfg.modules[m] === undefined;
|
||||
// la pestaña de la seccion abierta se marca (pastilla de color tras el icono)
|
||||
let here = '';
|
||||
try { here = (require('../models/typed_log').requestScope.getStore() || {}).path || ''; } catch (_) {}
|
||||
const isHere = (href) => here === href || (href !== '/' && here.startsWith(href + '/')) || (href === '/activity' && here === '/');
|
||||
const bbLink = (href, ico, label, badge) => a(
|
||||
{ class: isHere(href) ? "bb-item current" : "bb-item", href, ...(isHere(href) ? { 'aria-current': 'page' } : {}) },
|
||||
iconNode("bb-ico", iconKeyOf(href), ico),
|
||||
{ class: "bb-item", href },
|
||||
span({ class: "bb-ico" }, ico),
|
||||
(badge && badge > 0) ? span({ class: "bb-badge" }, String(badge)) : "",
|
||||
span({ class: "bb-lbl" }, label)
|
||||
);
|
||||
|
|
@ -111,28 +110,9 @@ module.exports = ({ i18n }) => {
|
|||
const slots = pins.map(m => bbLink(
|
||||
m.href, m.glyph, i18n[m.labelKey] || m.key, m.key === 'inbox' ? inboxN : 0
|
||||
));
|
||||
// quinta pestaña fija: Menu abre desde abajo una hoja con todos los modulos (el
|
||||
// lanzador). Sin JS: una casilla y CSS. autocomplete=off para que al volver atras
|
||||
// el navegador no la deje abierta. Ver docs/ESTUDIO_NAVEGACION.md
|
||||
const menuLabel = i18n.bbMenu || 'Menu';
|
||||
return nav(
|
||||
{ class: "oasis-bottombar", attrs: { "aria-label": i18n.bbQuickActions || "Quick actions" } },
|
||||
input({ type: 'checkbox', id: 'oasis-menu-toggle', class: 'menu-toggle', autocomplete: 'off', 'aria-label': menuLabel }),
|
||||
...slots,
|
||||
label({ class: 'bb-item bb-menu', for: 'oasis-menu-toggle' },
|
||||
iconNode('bb-ico', 'modules', '▦'),
|
||||
span({ class: 'bb-lbl' }, menuLabel)
|
||||
),
|
||||
label({ class: 'menu-sheet-backdrop', for: 'oasis-menu-toggle', 'aria-hidden': 'true' }),
|
||||
div({ class: 'menu-sheet', role: 'dialog', 'aria-label': menuLabel },
|
||||
div({ class: 'menu-sheet-head' },
|
||||
span({ class: 'menu-sheet-title' }, menuLabel),
|
||||
label({ class: 'menu-sheet-close', for: 'oasis-menu-toggle', 'aria-label': i18n.bbMenuClose || 'Close' }, '✕')
|
||||
),
|
||||
renderHiveNav()
|
||||
),
|
||||
// atras cierra la hoja (sin este script el menu funciona igual)
|
||||
script({ src: '/js/mobile_menu.js', defer: 'defer' })
|
||||
...slots
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -144,6 +124,7 @@ module.exports = ({ i18n }) => {
|
|||
{ href: '/activity', glyph: 'ꔙ', labelKey: 'activityTitle', mod: null },
|
||||
{ href: '/feed', glyph: 'ꕿ', labelKey: 'feedTitle', mod: 'feedMod' },
|
||||
{ href: '/chats', glyph: 'ꖒ', labelKey: 'chatsTitle', mod: 'chatsMod' },
|
||||
{ href: '/karvan', glyph: '⧗', labelKey: 'karvanTitle', mod: 'karvanMod' },
|
||||
{ href: '/rooms', glyph: 'ꕢ', labelKey: 'roomsTitle', mod: 'roomsMod' },
|
||||
{ href: '/forum', glyph: 'ꕒ', labelKey: 'forumTitle', mod: 'forumMod' },
|
||||
{ href: '/pads', glyph: 'ꔗ', labelKey: 'padsTitle', mod: 'padsMod' },
|
||||
|
|
@ -230,7 +211,7 @@ module.exports = ({ i18n }) => {
|
|||
// Personal/Social de la topbar (lo pone el middleware desde ?hive=) parte el lanzador en dos
|
||||
const hiveFilter = compact ? "" : ((typeof global !== "undefined" && global.__OASIS_HIVE_FILTER__) || "");
|
||||
const app = (m) => a({ class: 'ln-app', href: m.href },
|
||||
iconNode('ln-ico', iconKeyOf(m.href), m.glyph),
|
||||
span({ class: 'ln-ico', 'aria-hidden': 'true' }, m.glyph),
|
||||
span({ class: 'ln-lbl' }, i18n[m.labelKey] || m.href.slice(1)));
|
||||
const groups = GROUPS
|
||||
.filter(g => !hiveFilter || g.side === hiveFilter)
|
||||
|
|
@ -262,14 +243,14 @@ module.exports = ({ i18n }) => {
|
|||
);
|
||||
const pinnedRow = (m) => actionForm('remove', m.key, 'bb-manage-form',
|
||||
button({ type: 'submit', class: 'bb-manage-btn', 'aria-label': i18n.bbRemove || 'Remove' },
|
||||
iconNode('bb-manage-ico', iconKeyOf(m.href), m.glyph),
|
||||
span({ class: 'bb-manage-ico' }, m.glyph),
|
||||
span({ class: 'bb-manage-lbl' }, i18n[m.labelKey] || m.key),
|
||||
span({ class: 'bb-manage-x' }, '✕')
|
||||
)
|
||||
);
|
||||
const addBtn = (m) => actionForm('add', m.key, 'bb-pick-form',
|
||||
button({ type: 'submit', class: 'bb-pick-btn' },
|
||||
iconNode('bb-pick-ico', iconKeyOf(m.href), m.glyph),
|
||||
span({ class: 'bb-pick-ico' }, m.glyph),
|
||||
span({ class: 'bb-pick-lbl' }, i18n[m.labelKey] || m.key)
|
||||
)
|
||||
);
|
||||
|
|
@ -298,5 +279,5 @@ module.exports = ({ i18n }) => {
|
|||
);
|
||||
};
|
||||
|
||||
return { renderMobileTopbar, renderBottomBar, renderHiveNav, PINNABLE_MODULES, bottomBarPickerView, GROUPS };
|
||||
return { renderMobileTopbar, renderBottomBar, renderHiveNav, PINNABLE_MODULES, bottomBarPickerView };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -50,22 +50,20 @@ module.exports = ({ i18n, html }) => {
|
|||
button({ type: "submit" }, i18n.identitiesRestart))
|
||||
: null;
|
||||
|
||||
return section({ class: "identities" },
|
||||
return section(
|
||||
div({ class: "tags-header" },
|
||||
h2(i18n.identitiesTitle),
|
||||
p(i18n.identitiesDescription)
|
||||
),
|
||||
p(i18n.identitiesDescription),
|
||||
ul({ class: "identity-list" }, names.map(row)),
|
||||
form({ method: "POST", action: "/settings/identity/create", class: "identity-create" },
|
||||
form({ method: "POST", action: "/settings/identity/create" },
|
||||
input({ type: "text", name: "account", placeholder: i18n.identitiesNewName,
|
||||
maxlength: "32", autocomplete: "off" }),
|
||||
button({ type: "submit" }, i18n.identitiesCreate)
|
||||
),
|
||||
div({ class: "identity-actions" },
|
||||
restart,
|
||||
// sacar o meter una identidad ya existente es cosa del modulo legacy, que
|
||||
// es el que sabe cifrar el secret con una contraseña
|
||||
a({ href: "/legacy", class: "identity-legacy" }, i18n.identitiesBackup)
|
||||
p(a({ href: "/legacy", class: "identity-legacy" }, i18n.identitiesBackup)),
|
||||
restart
|
||||
)
|
||||
);
|
||||
};
|
||||
|
|
|
|||
123
src/views/karvan_view.js
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
"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 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" },
|
||||
span({ class: "kl-dot" }),
|
||||
span({ class: "kl-txt", id: "karvan-live-txt" }, 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" },
|
||||
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" },
|
||||
button({ type: "button", class: "kc-btn kc-btn-start", id: "kc-start" }, "📞 " + (i18n.karvanCall || "Call")),
|
||||
button({ type: "button", class: "kc-btn", id: "kc-mic", "aria-label": "mic" }, "🎤"),
|
||||
button({ type: "button", class: "kc-btn", id: "kc-cam", "aria-label": "camera" }, "🎥"),
|
||||
button({ type: "button", class: "kc-btn kc-btn-stop", id: "kc-stop", "aria-label": "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" },
|
||||
input({ type: "text", name: "to", placeholder: i18n.karvanInvitePlaceholder || "Invite a contact by @feed-id…", autocomplete: "off", spellcheck: "false" }),
|
||||
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>');
|
||||
|
|
@ -386,9 +386,7 @@ const renderContentActions = (msgId, viewHref, opts = {}) => {
|
|||
? a({ href: `/phone?filter=create&to=${encodeURIComponent(o.author)}`, class: 'btn-singleview btn-call', title: i18n.phoneCallTooltip }, renderIcon('call'))
|
||||
: null;
|
||||
|
||||
const deleteBtn = o.deleteAction && process.env.OASIS_MOBILE === '1'
|
||||
? require('./mobile_ui').deleteConfirm(o.deleteAction, o.returnTo && o.returnTo !== viewHref ? input({ type: 'hidden', name: 'returnTo', value: o.returnTo }) : null)
|
||||
: o.deleteAction
|
||||
const deleteBtn = o.deleteAction
|
||||
? form({ method: 'POST', action: o.deleteAction, class: 'content-action-form content-action-delete' },
|
||||
o.returnTo && o.returnTo !== viewHref ? input({ type: 'hidden', name: 'returnTo', value: o.returnTo }) : null,
|
||||
button({ type: 'submit', class: 'btn-singleview btn-delete', title: i18n.chatDelete }, renderIcon('close'))
|
||||
|
|
@ -405,8 +403,6 @@ const renderContentActions = (msgId, viewHref, opts = {}) => {
|
|||
: null;
|
||||
|
||||
if (!pinBtn && !spreadBtn && !chainBtn && !contentBtn && !reportBtn && !pmBtn && !callBtn && !deleteBtn && !donateBtn && !torrentBtn && !hideBtn) return null;
|
||||
// movil: borrar al final, lejos del pulgar
|
||||
if (process.env.OASIS_MOBILE === '1') return div({ class: 'content-actions' }, contentBtn, linkBtn, pinBtn, spreadBtn, pmBtn, callBtn, donateBtn, reportBtn, hideBtn, deleteBtn);
|
||||
return div({ class: 'content-actions' }, deleteBtn, spreadBtn, pinBtn, reportBtn, hideBtn, donateBtn, pmBtn, callBtn, torrentBtn, linkBtn, chainBtn, contentBtn);
|
||||
};
|
||||
exports.renderContentActions = renderContentActions;
|
||||
|
|
@ -441,17 +437,8 @@ exports.CONTENT_FAV_KIND = CONTENT_FAV_KIND;
|
|||
exports.CONTENT_SPREADABLE = CONTENT_SPREADABLE;
|
||||
exports.CONTENT_TORRENTABLE = CONTENT_TORRENTABLE;
|
||||
exports.contentDeleteAction = contentDeleteAction;
|
||||
exports.THEMES = [['Acampada', 'Acampada'], ['Dark-SNH', 'Dark-SNH'], ['Clear-SNH', 'Clear-SNH'], ['Purple-SNH', 'Purple-SNH'], ['Matrix-SNH', 'Matrix-SNH'], ['OasisMobile', 'Oasis-Mobile']];
|
||||
// los dos skins de movil; los de escritorio no estan pensados para la maqueta del movil
|
||||
exports.MOBILE_THEMES = [['Acampada', 'Acampada'], ['OasisMobile', 'Oasis Mobile']];
|
||||
exports.WIZARD_THEMES = process.env.OASIS_MOBILE === '1'
|
||||
? exports.MOBILE_THEMES
|
||||
: exports.THEMES.filter(([value]) => value !== 'OasisMobile' && value !== 'Acampada');
|
||||
// en el movil la maqueta de la rama (styles/mobile_base.css) va antes de cualquier skin;
|
||||
// encima, Acampada.css o el OasisMobile.css del dev. hive_acampada.css va detras del
|
||||
// tema Acampada: pinta lanzador, barras y tarjetas.
|
||||
const acampadaBase = () => process.env.OASIS_MOBILE === '1' ? link({ rel: "stylesheet", href: `/assets/styles/mobile_base.css?v=${assetVersion()}` }) : null;
|
||||
const acampadaLink = (theme) => theme === 'Acampada' ? link({ rel: "stylesheet", href: `/assets/styles/hive_acampada.css?v=${assetVersion()}` }) : null;
|
||||
exports.THEMES = [['Dark-SNH', 'Dark-SNH'], ['Clear-SNH', 'Clear-SNH'], ['Purple-SNH', 'Purple-SNH'], ['Matrix-SNH', 'Matrix-SNH'], ['OasisMobile', 'Oasis-Mobile']];
|
||||
exports.WIZARD_THEMES = exports.THEMES.filter(([value]) => value !== 'OasisMobile');
|
||||
|
||||
const renderDocumentActions = (kind, id, leadingNodes = []) => {
|
||||
const base = id ? `/${kind}/${encodeURIComponent(id)}` : `/${kind}`;
|
||||
|
|
@ -974,7 +961,6 @@ const renderFooter = () => {
|
|||
const lastSyncLabel = lastSyncTs ? moment(lastSyncTs).fromNow() : '–';
|
||||
const lastActivity = sharedState.getLastActivity ? sharedState.getLastActivity() : null;
|
||||
|
||||
if (process.env.OASIS_MOBILE === '1') return require('./mobile_ui').footerLine(syncedPeers, pkgVersion);
|
||||
return div(
|
||||
{ class: "oasis-footer" },
|
||||
div(
|
||||
|
|
@ -1101,6 +1087,17 @@ const navGroup = ({ id, emoji, title, defaultOpen = false }, ...items) => {
|
|||
|
||||
|
||||
|
||||
const renderKarvanLink = () => {
|
||||
const karvanMod = getConfig().modules.karvanMod === "on";
|
||||
return karvanMod
|
||||
? navLink({
|
||||
href: "/karvan",
|
||||
emoji: "⧗",
|
||||
text: i18n.karvanTitle,
|
||||
class: "karvan-link enabled"
|
||||
})
|
||||
: "";
|
||||
};
|
||||
|
||||
const renderPollsLink = () => {
|
||||
const pollsMod = getConfig().modules.pollsMod === "on";
|
||||
|
|
@ -1872,7 +1869,6 @@ const renderMobileTopbar = forkNav.renderMobileTopbar;
|
|||
const renderBottomBar = forkNav.renderBottomBar;
|
||||
const renderHiveNav = forkNav.renderHiveNav;
|
||||
exports.PINNABLE_MODULES = forkNav.PINNABLE_MODULES;
|
||||
exports.LAUNCHER_GROUPS = forkNav.GROUPS;
|
||||
exports.renderHiveNav = renderHiveNav;
|
||||
// se resuelve al llamarse, cuando template ya esta definido mas abajo
|
||||
exports.bottomBarPickerView = () => forkNav.bottomBarPickerView(template);
|
||||
|
|
@ -2007,9 +2003,11 @@ const template = (titlePrefix, ...elements) => {
|
|||
// movil: el tema va DESPUES de mobile.css para ganar por cascada sin !important;
|
||||
// escritorio: orden original de epsylon
|
||||
...(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()}` }), themeLink ]
|
||||
: [ themeLink, link({ rel: "stylesheet", href: `/assets/styles/mobile.css?v=${assetVersion()}`, media: "(max-width: 768px)" }) ]),
|
||||
process.env.OASIS_MOBILE === '1' ? link({ rel: "stylesheet", href: `/assets/styles/mobile_ux.css?v=${assetVersion()}` }) : null,
|
||||
// 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()}` }),
|
||||
link({ rel: "icon", href: "/assets/images/favicon.svg" }),
|
||||
meta({ charset: "utf-8" }),
|
||||
meta({ name: "description", content: i18n.oasisDescription }),
|
||||
|
|
@ -2179,13 +2177,11 @@ const template = (titlePrefix, ...elements) => {
|
|||
let here = '';
|
||||
try { here = (require('../models/typed_log').requestScope.getStore() || {}).path || ''; } catch (_) {}
|
||||
if (here === '/welcome' || here.startsWith('/welcome/')) return null;
|
||||
const mobileUi = require('./mobile_ui');
|
||||
if (mobileUi.isMobile() && !mobileUi.welcomeBannerHere(here)) return null;
|
||||
return div(
|
||||
{ class: "update-banner welcome-banner" },
|
||||
span({ class: "update-banner-icon" }, "🌴"),
|
||||
span({ class: "update-banner-text" }, mobileUi.isMobile() ? mobileUi.welcomeBannerText() : i18n.welcomeBannerText),
|
||||
a({ href: "/welcome", class: "create-button welcome-banner-start" }, mobileUi.isMobile() ? mobileUi.welcomeBannerAction() : i18n.welcomeBannerAction),
|
||||
span({ class: "update-banner-text" }, i18n.welcomeBannerText),
|
||||
a({ href: "/welcome", class: "create-button welcome-banner-start" }, i18n.welcomeBannerAction),
|
||||
form(
|
||||
{ method: "POST", action: "/welcome/dismiss", class: "welcome-banner-close" },
|
||||
button({ type: "submit", class: "welcome-banner-close-btn" }, "✕")
|
||||
|
|
@ -2334,7 +2330,8 @@ const template = (titlePrefix, ...elements) => {
|
|||
renderWikiLink(),
|
||||
renderMapsLink(),
|
||||
renderRoomsLink(),
|
||||
renderChatsLink()
|
||||
renderChatsLink(),
|
||||
renderKarvanLink()
|
||||
),
|
||||
navGroup(
|
||||
{
|
||||
|
|
@ -2395,9 +2392,11 @@ exports.ainavHomeView = ({ recentTags = [] } = {}) => {
|
|||
// movil: el tema va DESPUES de mobile.css para ganar por cascada sin !important;
|
||||
// escritorio: orden original de epsylon
|
||||
...(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()}` }), link({ rel: "stylesheet", href: `/assets/themes/${theme}.css?v=${assetVersion()}` }) ]
|
||||
: [ link({ rel: "stylesheet", href: `/assets/themes/${theme}.css?v=${assetVersion()}` }), link({ rel: "stylesheet", href: `/assets/styles/mobile.css?v=${assetVersion()}`, media: "(max-width: 768px)" }) ]),
|
||||
process.env.OASIS_MOBILE === '1' ? link({ rel: "stylesheet", href: `/assets/styles/mobile_ux.css?v=${assetVersion()}` }) : null,
|
||||
// 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()}` }),
|
||||
link({ rel: "icon", href: "/assets/images/favicon.svg" }),
|
||||
meta({ charset: "utf-8" }),
|
||||
meta({ name: "description", content: i18n.oasisDescription }),
|
||||
|
|
|
|||
|
|
@ -1,106 +0,0 @@
|
|||
"use strict";
|
||||
// Iconos del lanzador y de la barra de abajo (rama movil).
|
||||
//
|
||||
// Antes eran letras del alfabeto vai (ꔙ ꕿ ꖒ...): no decian nada de lo que abren,
|
||||
// dependian de que el movil trajera la fuente y Karvan y Nodos llevaban casi el
|
||||
// mismo reloj de arena. Estos son SVG de trazo en rejilla de 24, el mismo estilo
|
||||
// que los ACTION_ICONS de las tarjetas (main_views.js), y se pintan con
|
||||
// currentColor, asi que cada tema les pone su color.
|
||||
|
||||
const RAW_HTML = Symbol.for('oasis.rawHtml');
|
||||
const { span } = require("../server/node_modules/hyperaxe");
|
||||
|
||||
const ICONS = {
|
||||
activity: '<polyline points="3 12 7 12 10 5 14 19 17 12 21 12"/>',
|
||||
feed: '<path d="M4 6h16M4 12h16M4 18h10"/>',
|
||||
chats: '<path d="M21 11.5a8.5 8.5 0 0 1-12.3 7.6L3 20.5l1.4-5A8.5 8.5 0 1 1 21 11.5z"/>',
|
||||
rooms: '<path d="M6 21V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v17M3 21h18"/><circle cx="14.5" cy="12" r="1"/>',
|
||||
forum: '<path d="M14 9a2 2 0 0 1-2 2H6l-3 3V4a2 2 0 0 1 2-2h7a2 2 0 0 1 2 2z"/><path d="M18 9h1a2 2 0 0 1 2 2v10l-3-3h-6a2 2 0 0 1-2-2v-1"/>',
|
||||
pads: '<path d="M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><path d="M14 3v6h6M8 13h8M8 17h5"/>',
|
||||
wiki: '<path d="M2 4h6a4 4 0 0 1 4 4v13a3 3 0 0 0-3-3H2z"/><path d="M22 4h-6a4 4 0 0 0-4 4v13a3 3 0 0 1 3-3h7z"/>',
|
||||
blogs: '<path d="M16.5 3.5l4 4L8 20H4v-4z"/><path d="M13.5 6.5l4 4"/>',
|
||||
opinions: '<polygon points="12 3 14.8 8.8 21 9.6 16.5 14 17.6 20.3 12 17.3 6.4 20.3 7.5 14 3 9.6 9.2 8.8"/>',
|
||||
mentions: '<circle cx="12" cy="12" r="4"/><path d="M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-4 8"/>',
|
||||
tags: '<path d="M4 9h16M4 15h16M10 3L8 21M16 3l-2 18"/>',
|
||||
trending: '<polyline points="3 17 9 11 13 15 21 7"/><polyline points="15 7 21 7 21 13"/>',
|
||||
maps: '<path d="M12 21s-7-6.2-7-11a7 7 0 0 1 14 0c0 4.8-7 11-7 11z"/><circle cx="12" cy="10" r="2.5"/>',
|
||||
graphos: '<circle cx="5" cy="6" r="2"/><circle cx="19" cy="6" r="2"/><circle cx="12" cy="18" r="2"/><path d="M7 6h10M6 8l5 8M18 8l-5 8"/>',
|
||||
inhabitants: '<circle cx="9" cy="8" r="3.5"/><path d="M2 21a7 7 0 0 1 14 0M16 4.5a3.5 3.5 0 0 1 0 7M22 21a7 7 0 0 0-4-6.3"/>',
|
||||
fediverse: '<circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18"/>',
|
||||
images: '<rect x="3" y="4" width="18" height="16" rx="2"/><circle cx="9" cy="10" r="2"/><path d="M21 16l-5-5-9 9"/>',
|
||||
videos: '<rect x="2" y="6" width="14" height="12" rx="2"/><path d="M16 10l6-3v10l-6-3z"/>',
|
||||
audios: '<path d="M9 18V5l11-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="17" cy="16" r="3"/>',
|
||||
podcasts: '<rect x="9" y="2" width="6" height="12" rx="3"/><path d="M5 11a7 7 0 0 0 14 0M12 18v4M8 22h8"/>',
|
||||
documents: '<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 3v6h6"/>',
|
||||
files: '<path d="M3 6a2 2 0 0 1 2-2h4l2 3h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>',
|
||||
bookmarks: '<path d="M6 3h12v18l-6-4-6 4z"/>',
|
||||
torrents: '<line x1="7" y1="20" x2="7" y2="4"/><polyline points="3 8 7 4 11 8"/><line x1="17" y1="4" x2="17" y2="20"/><polyline points="13 16 17 20 21 16"/>',
|
||||
games: '<rect x="2" y="7" width="20" height="11" rx="5"/><path d="M7 10.5v4M5 12.5h4"/><circle cx="15.5" cy="11.5" r="1"/><circle cx="18" cy="14" r="1"/>',
|
||||
pixelia: '<rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18M3 15h18M9 3v18M15 3v18"/>',
|
||||
melody: '<circle cx="7" cy="17" r="3"/><path d="M10 17V4l8 3"/>',
|
||||
events: '<rect x="3" y="5" width="18" height="16" rx="2"/><path d="M3 10h18M8 3v4M16 3v4"/><circle cx="12" cy="15.5" r="1.5"/>',
|
||||
calendars: '<rect x="3" y="5" width="18" height="16" rx="2"/><path d="M3 10h18M8 3v4M16 3v4M7.5 14h2M14.5 14h2M7.5 17.5h2"/>',
|
||||
tasks: '<path d="M3.5 6l2 2 3.5-3.5M3.5 14l2 2 3.5-3.5M12 7h9M12 15h9"/>',
|
||||
campaigns: '<path d="M3 10v4h4l8 5V5L7 10z"/><path d="M19 9a4 4 0 0 1 0 6"/>',
|
||||
mailing: '<rect x="3" y="5" width="18" height="14" rx="2"/><polyline points="3 7 12 13 21 7"/>',
|
||||
votes: '<path d="M4 12h16v8H4z"/><path d="M8 12V4h8v8"/><path d="M10 8l1.5 1.5L14 7"/>',
|
||||
polls: '<path d="M4 20V10M10 20V4M16 20v-7M2 20h20"/>',
|
||||
reports: '<line x1="5" y1="21" x2="5" y2="3"/><path d="M5 4h12l-2.5 4.5L17 13H5"/>',
|
||||
tribes: '<path d="M2 20h20M5 20l7-14 7 14M10 20l2-5 2 5"/>',
|
||||
emergencies: '<path d="M12 3l10 18H2z"/><path d="M12 10v4M12 17.5v.5"/>',
|
||||
school: '<path d="M2 9l10-5 10 5-10 5z"/><path d="M6 11v5c3 2.5 9 2.5 12 0v-5M22 9v6"/>',
|
||||
larp: '<path d="M12 3l2 7 7 2-7 2-2 7-2-7-7-2 7-2z"/>',
|
||||
parliament: '<path d="M3 21h18M4 10h16M12 3l9 5H3z"/><path d="M6 10v8M10 10v8M14 10v8M18 10v8"/>',
|
||||
courts: '<path d="M12 3v18M7 21h10M5 7h14M5 7l-3 7a3 3 0 0 0 6 0zM19 7l-3 7a3 3 0 0 0 6 0z"/>',
|
||||
market: '<path d="M6 7h12l1 14H5z"/><path d="M9 10V6a3 3 0 0 1 6 0v4"/>',
|
||||
shops: '<path d="M3 9l2-5h14l2 5"/><path d="M3 9a3 3 0 0 0 6 0 3 3 0 0 0 6 0 3 3 0 0 0 6 0"/><path d="M5 12v9h14v-9M10 21v-5h4v5"/>',
|
||||
jobs: '<rect x="3" y="7" width="18" height="13" rx="2"/><path d="M9 7V5a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2M3 13h18"/>',
|
||||
projects: '<rect x="3" y="4" width="5" height="16" rx="1"/><rect x="10" y="4" width="5" height="10" rx="1"/><rect x="17" y="4" width="4" height="13" rx="1"/>',
|
||||
industry: '<path d="M2 21h20M4 21V11l5 3V11l5 3V7l6 4v10"/><path d="M8 17h1M13 17h1M18 17h1"/>',
|
||||
housing: '<path d="M3 11l9-7 9 7"/><path d="M5 10v10h14V10M10 20v-6h4v6"/>',
|
||||
logistics: '<path d="M2 6h12v10H2zM14 10h4l3 3v3h-7"/><circle cx="6" cy="18" r="2"/><circle cx="17" cy="18" r="2"/>',
|
||||
banking: '<rect x="2" y="5" width="20" height="14" rx="2"/><path d="M2 10h20M6 15h4"/>',
|
||||
transfers: '<path d="M4 8h16M16 4l4 4-4 4M20 16H4M8 12l-4 4 4 4"/>',
|
||||
profile: '<circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/>',
|
||||
agenda: '<rect x="5" y="4" width="14" height="17" rx="2"/><path d="M9 4V3h6v1M9 11h6M9 15h4"/>',
|
||||
favorites: '<path d="M12 20s-8-5-8-11a4.5 4.5 0 0 1 8-2.8A4.5 4.5 0 0 1 20 9c0 6-8 11-8 11z"/>',
|
||||
phone: '<path d="M5 3h4l2 5-2.5 1.5a11 11 0 0 0 6 6L16 13l5 2v4a2 2 0 0 1-2 2A17 17 0 0 1 3 5a2 2 0 0 1 2-2z"/>',
|
||||
cv: '<rect x="3" y="5" width="18" height="14" rx="2"/><circle cx="9" cy="11" r="2"/><path d="M6 16a3 3 0 0 1 6 0M14 10h4M14 14h4"/>',
|
||||
wallet: '<path d="M3 7a2 2 0 0 1 2-2h13v4"/><rect x="3" y="7" width="18" height="13" rx="2"/><path d="M16 13.5h2"/>',
|
||||
logs: '<path d="M8 6h13M8 12h13M8 18h13M3.5 6h.01M3.5 12h.01M3.5 18h.01"/>',
|
||||
invites: '<circle cx="9" cy="8" r="4"/><path d="M2 21a7 7 0 0 1 14 0M19 8v6M16 11h6"/>',
|
||||
peers: '<circle cx="12" cy="12" r="2"/><path d="M8.5 8.5a5 5 0 0 0 0 7M15.5 8.5a5 5 0 0 1 0 7M5.5 5.5a9.2 9.2 0 0 0 0 13M18.5 5.5a9.2 9.2 0 0 1 0 13"/>',
|
||||
bar: '<path d="M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3M1 14h6M9 8h6M17 16h6"/>',
|
||||
backup: '<path d="M12 3v12M7 10l5 5 5-5"/><path d="M4 17v3h16v-3"/>',
|
||||
cipher: '<rect x="4" y="11" width="16" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/>',
|
||||
ai: '<rect x="6" y="6" width="12" height="12" rx="2"/><path d="M9 2v4M15 2v4M9 18v4M15 18v4M2 9h4M2 15h4M18 9h4M18 15h4"/>',
|
||||
stats: '<path d="M21 12A9 9 0 1 1 12 3v9z"/><path d="M15 3.5A9 9 0 0 1 20.5 9H15z"/>',
|
||||
blockexplorer: '<path d="M12 2.5l8.5 4.75v9.5L12 21.5l-8.5-4.75v-9.5z"/><polyline points="3.5 7.25 12 12 20.5 7.25"/><line x1="12" y1="12" x2="12" y2="21.5"/>',
|
||||
modules: '<rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/>',
|
||||
settings: '<circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3M4.9 4.9L7 7M17 17l2.1 2.1M2 12h3M19 12h3M4.9 19.1L7 17M17 7l2.1-2.1"/>',
|
||||
search: '<circle cx="11" cy="11" r="7"/><path d="M21 21l-5-5"/>',
|
||||
inbox: '<path d="M3 13l3-8h12l3 8v6a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1z"/><path d="M3 13h5l1 3h6l1-3h5"/>',
|
||||
publish: '<path d="M12 5v14M5 12h14"/>',
|
||||
data: '<ellipse cx="12" cy="5" rx="8" ry="3"/><path d="M4 5v14c0 1.7 3.6 3 8 3s8-1.3 8-3V5M4 12c0 1.7 3.6 3 8 3s8-1.3 8-3"/>',
|
||||
mic: '<rect x="9" y="2" width="6" height="12" rx="3"/><path d="M5 11a7 7 0 0 0 14 0M12 18v4"/>',
|
||||
camera: '<rect x="2" y="6" width="14" height="12" rx="2"/><path d="M16 10l6-3v10l-6-3z"/>',
|
||||
hangup: '<path d="M3 15.5c5-4.7 13-4.7 18 0l-2.5 2.5-3-1.5v-2.5a10 10 0 0 0-7 0v2.5l-3 1.5z"/>'
|
||||
};
|
||||
|
||||
// la clave sale de la ruta: /settings/bottombar es "bar"
|
||||
const iconKeyOf = (href) => {
|
||||
const h = String(href || '').replace(/^\//, '').split(/[?#]/)[0];
|
||||
if (h === 'settings/bottombar') return 'bar';
|
||||
return h.split('/')[0];
|
||||
};
|
||||
|
||||
const svg = (key) => ICONS[key]
|
||||
? `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">${ICONS[key]}</svg>`
|
||||
: '';
|
||||
|
||||
// span con el SVG dentro; si no hay icono para esa clave, el caracter de antes
|
||||
const iconNode = (cls, key, fallback) => ICONS[key]
|
||||
? span({ class: cls + ' has-svg', 'aria-hidden': 'true', [RAW_HTML]: svg(key) })
|
||||
: span({ class: cls, 'aria-hidden': 'true' }, fallback || '');
|
||||
|
||||
module.exports = { ICONS, iconKeyOf, svg, iconNode };
|
||||
|
|
@ -1,103 +0,0 @@
|
|||
"use strict";
|
||||
// Pantalla de Modulos del movil (la sirve el router propio, fork_routes.js).
|
||||
//
|
||||
// Los modulos agrupados como en el lanzador, cada uno una fila que se toca entera con
|
||||
// un interruptor, y Guardar siempre a la vista. La tabla de upstream eran 52 filas
|
||||
// con casillas de 16px, unas 11 pantallas. Va en un fichero propio para no tocar
|
||||
// modules_view.js, que es del dev (docs/FRONTERA_UPSTREAM.md).
|
||||
//
|
||||
// El formulario envia a la misma ruta de upstream (/save-modules) con los mismos
|
||||
// nombres de campo (<modulo>Form), asi que guardar sigue siendo cosa suya.
|
||||
|
||||
const { button, div, form, h2, h3, input, label, p, section, span } = require("../server/node_modules/hyperaxe");
|
||||
const { getConfig } = require('../configs/config-manager.js');
|
||||
const { iconNode } = require('./mobile_icons');
|
||||
|
||||
// clave de i18n de cada modulo (modules<Stem>Label / modules<Stem>Description)
|
||||
const STEMS = {
|
||||
agenda: 'Agenda', ai: 'AI', aiNav: 'AINav', emergencies: 'Emergencies', mailing: 'Mailing', rooms: 'Rooms',
|
||||
phone: 'Phone', logistics: 'Logistics', podcasts: 'Podcasts', campaigns: 'Campaigns', audios: 'Audios', banking: 'Banking',
|
||||
bookmarks: 'Bookmarks', calendars: 'Calendars', chats: 'Chats', cipher: 'Cipher', courts: 'Courts', dev: 'Dev',
|
||||
docs: 'Docs', events: 'Events', favorites: 'Favorites', blogs: 'Blogs', fediverse: 'Fediverse', feed: 'Feed',
|
||||
files: 'Files', forum: 'Forum', games: 'Games', graphos: 'Graphos', housing: 'Housing', images: 'Images',
|
||||
industry: 'Industry', invites: 'Invites', jobs: 'Jobs', larp: 'Larp', backup: 'Backup',
|
||||
logs: 'Logs', maps: 'Map', market: 'Market', melody: 'Melody', opinions: 'Opinions', pads: 'Pads',
|
||||
parliament: 'Parliament', pixelia: 'Pixelia', polls: 'Polls', projects: 'Projects', reports: 'Reports', school: 'School',
|
||||
shops: 'Shops', tags: 'Tags', tasks: 'Tasks', torrents: 'Torrents', transfers: 'Transfers', trending: 'Trending',
|
||||
tribes: 'Tribes', videos: 'Videos', votes: 'Votations', wallet: 'Wallet', wiki: 'Wiki'
|
||||
};
|
||||
// los que no van en el movil (los apaga tambien /save-modules)
|
||||
const MOBILE_UNAVAILABLE = ['ai', 'aiNav', 'dev', 'games', 'logs', 'maps', 'pixelia'];
|
||||
|
||||
const moduleLabel = (i18n, name) => i18n[`modules${STEMS[name]}Label`] || name;
|
||||
|
||||
const mobileModulesView = (opts = {}) => {
|
||||
const { template, i18n, LAUNCHER_GROUPS } = require('./main_views');
|
||||
const config = getConfig().modules || {};
|
||||
const visible = Object.keys(STEMS)
|
||||
.filter(name => !MOBILE_UNAVAILABLE.includes(name))
|
||||
.map(name => ({ name, label: moduleLabel(i18n, name), description: i18n[`modules${STEMS[name]}Description`] || '' }))
|
||||
.sort((x, y) => String(x.label).localeCompare(String(y.label), undefined, { sensitivity: 'base' }));
|
||||
const isOn = (m) => config[`${m.name}Mod`] === 'on';
|
||||
|
||||
// grupos del lanzador; Industria va con la economia y lo demas, al final
|
||||
const groupOf = {};
|
||||
(LAUNCHER_GROUPS || []).forEach(g => g.mods.forEach(m => { if (m.mod) groupOf[m.mod.replace(/Mod$/, '')] = g; }));
|
||||
const economy = (LAUNCHER_GROUPS || []).find(g => g.id === 'economy');
|
||||
if (economy && !groupOf.industry) groupOf.industry = economy;
|
||||
const groups = [];
|
||||
const byId = {};
|
||||
visible.forEach(mod => {
|
||||
const g = groupOf[mod.name] || { id: 'other', titleKey: 'menuTools' };
|
||||
if (!byId[g.id]) { byId[g.id] = { g, mods: [] }; groups.push(byId[g.id]); }
|
||||
byId[g.id].mods.push(mod);
|
||||
});
|
||||
const order = (LAUNCHER_GROUPS || []).map(g => g.id);
|
||||
const rank = (id) => { const i = order.indexOf(id); return i === -1 ? order.length : i; };
|
||||
groups.sort((x, y) => rank(x.g.id) - rank(y.g.id));
|
||||
|
||||
const blocked = opts.blocked && visible.find(m => m.name === opts.blocked.mod);
|
||||
const blockedBox = blocked ? div({ class: 'module-blocked' },
|
||||
iconNode('module-blocked-ico', blocked.name),
|
||||
p({ class: 'module-blocked-text' }, String(i18n.moduleBlockedText || '{name} is turned off.').replace('{name}', blocked.label)),
|
||||
form({ method: 'POST', action: '/modules/enable' },
|
||||
input({ type: 'hidden', name: 'mod', value: blocked.name }),
|
||||
input({ type: 'hidden', name: 'returnTo', value: opts.blocked.path || `/${blocked.name}` }),
|
||||
button({ type: 'submit', class: 'create-button' }, String(i18n.moduleBlockedAction || 'Turn on {name}').replace('{name}', blocked.label))
|
||||
)
|
||||
) : null;
|
||||
|
||||
return template(
|
||||
i18n.modules,
|
||||
section(div({ class: 'tags-header module-header-line' },
|
||||
h2(i18n.modulesViewTitle),
|
||||
p(String(i18n.modulesOnCount || '{on} of {total} sections on')
|
||||
.replace('{on}', visible.filter(isOn).length).replace('{total}', visible.length))
|
||||
)),
|
||||
blockedBox,
|
||||
opts.saved ? p({ class: 'module-saved', role: 'status' }, i18n.modulesSaved || 'Saved.') : null,
|
||||
section(
|
||||
form({ action: '/save-modules', method: 'post', class: 'module-switches' },
|
||||
...groups.map(({ g, mods }) => div({ class: 'module-group' },
|
||||
h3({ class: 'module-group-title' }, i18n[g.titleKey] || g.id),
|
||||
...mods.map(mod => label({ class: 'module-switch', for: `${mod.name}Mod` },
|
||||
iconNode('module-switch-ico', mod.name),
|
||||
span({ class: 'module-switch-text' },
|
||||
span({ class: 'module-switch-name' }, mod.label),
|
||||
span({ class: 'module-switch-desc' }, mod.description)
|
||||
),
|
||||
input({
|
||||
type: 'checkbox', role: 'switch', id: `${mod.name}Mod`, name: `${mod.name}Form`, value: 'on', class: 'switch-input',
|
||||
checked: isOn(mod) ? true : undefined
|
||||
})
|
||||
))
|
||||
)),
|
||||
div({ class: 'module-save-bar' },
|
||||
button({ type: 'submit', class: 'create-button' }, i18n.saveSettings)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
module.exports = { mobileModulesView, MOBILE_UNAVAILABLE, STEMS };
|
||||
|
|
@ -1,235 +0,0 @@
|
|||
"use strict";
|
||||
// Piezas de interfaz de la rama movil que se enchufan en vistas de upstream.
|
||||
//
|
||||
// Las vistas (vote_view, tribes_view, settings_view, main_views, welcome_view) son del
|
||||
// dev y se reescriben a menudo (tribes_view en 23 de 39 releases, main_views en 31).
|
||||
// Aqui vive la logica; en cada vista queda una llamada con la bandera de movil, que es
|
||||
// lo unico que hay que volver a poner si el dev la reescribe.
|
||||
// Ver docs/FRONTERA_UPSTREAM.md.
|
||||
//
|
||||
// i18n se lee al llamar (main_views lo cambia en cada peticion y este fichero se carga
|
||||
// antes que el).
|
||||
|
||||
const { a, button, details, div, form, img, input, label, nav, p, progress, span, summary } = require("../server/node_modules/hyperaxe");
|
||||
const { iconNode } = require("./mobile_icons");
|
||||
|
||||
const isMobile = () => process.env.OASIS_MOBILE === '1';
|
||||
const t = () => require('./main_views').i18n;
|
||||
|
||||
// ---------- votaciones ----------
|
||||
|
||||
// tipos de votacion: el servidor ya guarda las opciones que se le pasen (campo
|
||||
// options de /votes/create); aqui solo se ofrecen las que usan las asambleas
|
||||
const VOTE_KINDS = [
|
||||
{ key: 'classic', options: null },
|
||||
{ key: 'traffic', options: ['GREEN', 'YELLOW', 'RED'] },
|
||||
{ key: 'scale', options: ['1', '2', '3', '4', '5'] }
|
||||
];
|
||||
|
||||
const voteKindPicker = (current) => {
|
||||
const i18n = t();
|
||||
const cur = String(current || '');
|
||||
return div({ class: 'vote-kind' },
|
||||
label({ class: 'vote-kind-title' }, i18n.voteKindLabel || 'Type of vote'),
|
||||
...VOTE_KINDS.map((k, i) => {
|
||||
const value = k.options ? k.options.join(',') : '';
|
||||
const checked = cur ? cur === value : i === 0;
|
||||
return label({ class: 'vote-kind-option' },
|
||||
input(Object.assign({ type: 'radio', name: 'options', value }, checked ? { checked: 'checked' } : {})),
|
||||
span({ class: 'vote-kind-name' }, i18n['voteKind_' + k.key] || k.key),
|
||||
span({ class: 'vote-kind-hint' }, i18n['voteKindHint_' + k.key] || '')
|
||||
);
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
// resultados como barras, una por opcion: la tabla de 6 columnas no cabia y partia
|
||||
// las palabras por silabas
|
||||
const voteBars = (voteOptions, baseCounts, voteLabel) => {
|
||||
const total = voteOptions.reduce((s, o) => s + (Number(baseCounts[o]) || 0), 0);
|
||||
return div({ class: 'vote-bars' },
|
||||
...voteOptions.map((opt) => {
|
||||
const n = Number(baseCounts[opt]) || 0;
|
||||
return div({ class: `vote-bar vote-bar-${String(opt).toLowerCase()}` },
|
||||
span({ class: 'vote-bar-label' }, voteLabel(opt)),
|
||||
span({ class: 'vote-bar-count' }, String(n)),
|
||||
progress({ class: 'vote-bar-meter', max: String(Math.max(total, 1)), value: String(n) })
|
||||
);
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
// quien crea una votacion no vota (regla de votes_model.js, no se toca): se explica
|
||||
const voteOwnNote = () => p({ class: 'vote-own-note' }, t().voteOwnNote || 'You created this vote, so you cannot vote in it.');
|
||||
|
||||
// ---------- tribus ----------
|
||||
|
||||
// una fila por tribu: con 12 comisiones la lista de tarjetas con la imagen generica
|
||||
// eran 10 pantallas. La imagen solo sale si la tribu tiene una propia
|
||||
const tribeRow = (tr, { own, imageNode }) => {
|
||||
const i18n = t();
|
||||
const href = `/tribe/${encodeURIComponent(tr.id)}`;
|
||||
const members = tr.memberCount != null ? tr.memberCount : (tr.members || []).length;
|
||||
const facts = [
|
||||
tr.isAnonymous ? i18n.tribePrivate : i18n.tribePublic,
|
||||
tr.inviteMode === 'open' ? i18n.tribeOpen : i18n.tribeStrict,
|
||||
`${members} ${String(i18n.tribeMembersCount || '').toLowerCase()}`
|
||||
].filter(Boolean).join(' · ');
|
||||
return a({ href, class: 'tribe-row' + (own ? ' own-content' : '') },
|
||||
tr.image && imageNode ? imageNode : iconNode('tribe-row-thumb tribe-row-icon', 'tribes'),
|
||||
span({ class: 'tribe-row-text' },
|
||||
span({ class: 'tribe-row-title' }, tr.title),
|
||||
span({ class: 'tribe-row-facts' }, facts),
|
||||
tr.description ? span({ class: 'tribe-row-desc' }, String(tr.description).slice(0, 90)) : null
|
||||
),
|
||||
span({ class: 'tribe-row-go', 'aria-hidden': 'true' }, '›')
|
||||
);
|
||||
};
|
||||
|
||||
// secciones de una tribu: 25 botones a todo lo ancho eran varias pantallas antes del
|
||||
// contenido. Tira de fichas con lo que mas usa una comision y el resto en "Mas"
|
||||
const TRIBE_MAIN_TABS = ['activity', 'inhabitants', 'governance', 'votations', 'forum', 'chats', 'events', 'pads'];
|
||||
const tribeTabs = (tribe, section, sections) => {
|
||||
const i18n = t();
|
||||
const all = sections.flatMap(g => g.items);
|
||||
const tab = (s) => a({ href: `/tribe/${encodeURIComponent(tribe.id)}?section=${encodeURIComponent(s.key)}`, class: section === s.key ? 'tribe-tab is-current' : 'tribe-tab', ...(section === s.key ? { 'aria-current': 'page' } : {}) }, s.label);
|
||||
const main = all.filter(s => TRIBE_MAIN_TABS.includes(s.key));
|
||||
const rest = all.filter(s => !TRIBE_MAIN_TABS.includes(s.key));
|
||||
const currentRest = rest.find(s => s.key === section);
|
||||
return nav({ class: 'tribe-tabs', 'aria-label': i18n.tribeSectionsLabel || 'Sections' },
|
||||
div({ class: 'tribe-tabs-strip' }, ...(currentRest ? [tab(currentRest)] : []), ...main.map(tab)),
|
||||
details({ class: 'tribe-tabs-more' },
|
||||
summary(i18n.tribeSectionsMore || 'More'),
|
||||
div({ class: 'tribe-tabs-grid' }, ...rest.filter(s => s !== currentRest).map(tab))
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
// ---------- ajustes ----------
|
||||
|
||||
// primero lo que se toca (idioma, aspecto, tipo de uso, portada) y despues lo tecnico;
|
||||
// se reordena por id de seccion, sin tocar las secciones
|
||||
const SETTINGS_FIRST = ['update', 'language', 'ux', 'theme', 'workflows', 'home-page'];
|
||||
const settingsOrder = (parts) => {
|
||||
const list = parts.filter(Boolean);
|
||||
const rank = (el) => { const i = SETTINGS_FIRST.indexOf(el && el.id); return i === -1 ? SETTINGS_FIRST.length : i; };
|
||||
return list.map((el, i) => ({ el, i })).sort((x, y) => rank(x.el) - rank(y.el) || x.i - y.i).map(x => x.el);
|
||||
};
|
||||
|
||||
// cada idioma un boton que se aplica al tocarlo (antes desplegable y otro boton)
|
||||
const LANGS = [['Español', 'es'], ['Català', 'ca'], ['Euskara', 'eu'], ['Galego', 'gl'], ['English', 'en'], ['Français', 'fr'],
|
||||
['Português', 'pt'], ['Italiano', 'it'], ['Deutsch', 'de'], ['Русский', 'ru'], ['العربية', 'ar'], ['हिन्दी', 'hi'], ['中文', 'zh']];
|
||||
// el idioma se lee al pintar: settings_view desestructura selectedLanguage al cargar y
|
||||
// se queda con el valor de entonces (siempre "en")
|
||||
const languagePick = () => {
|
||||
const selected = require('./main_views').getLanguage();
|
||||
return form({ action: '/language', method: 'post', class: 'lang-pick' },
|
||||
...LANGS.map(([name, code]) => button({ type: 'submit', name: 'language', value: code, class: code === selected ? 'lang-chip is-current' : 'lang-chip', lang: code }, name))
|
||||
);
|
||||
};
|
||||
|
||||
// tipo de portada (ux): cada tarjeta es un boton que guarda al tocarlo. Antes eran
|
||||
// radios y un boton de guardar debajo de seis tarjetas grandes, fuera de pantalla.
|
||||
// Mismos modos y mismas condiciones que settings_view y welcome_view; las rutas
|
||||
// (/settings/ux, /welcome/ux) son las del dev
|
||||
const uxPick = (action, cfg, withHint) => {
|
||||
const i18n = t();
|
||||
const mods = (cfg && cfg.modules) || {};
|
||||
const ux = cfg && cfg.ux && cfg.ux.current;
|
||||
const modes = [
|
||||
['blocks', i18n.uxModeMenus || 'Blocks', true],
|
||||
['ainav', i18n.uxModeAINav || 'AI', mods.aiNavMod === 'on'],
|
||||
['chats', i18n.uxModeChats || 'Conversations', mods.chatsMod === 'on'],
|
||||
['feed', i18n.uxModeFeed || 'Microblogging', true],
|
||||
['phone', i18n.phoneTitle || 'Phone', mods.phoneMod !== 'off'],
|
||||
['rooms', i18n.roomsTitle || 'Rooms', mods.roomsMod !== 'off']
|
||||
].filter(m => m[2]);
|
||||
const cur = modes.some(m => m[0] === ux) ? ux : 'blocks';
|
||||
return form({ method: 'POST', action, class: 'ux-pick' },
|
||||
withHint ? p({ class: 'ux-pick-hint' }, i18n.uxPickHint || 'Tap one and it is saved. You can change it any time in Settings.') : null,
|
||||
div({ class: 'ux-pick-grid' },
|
||||
...modes.map(([value, title]) => button(Object.assign({ type: 'submit', name: 'ux', value, class: value === cur ? 'ux-pick-card is-current' : 'ux-pick-card' }, value === cur ? { 'aria-current': 'true' } : {}),
|
||||
img({ src: `/assets/images/ux-${value}.png`, class: 'ux-pick-shot', alt: '' }),
|
||||
span({ class: 'ux-pick-label' }, title),
|
||||
value === cur ? span({ class: 'ux-pick-now' }, i18n.uxPickCurrent || 'In use') : null
|
||||
))
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
// perfil de uso (workflow): igual, un boton por opcion con una linea de para que
|
||||
// sirve. Activismo y Acampada primero; el resto en el orden del dev
|
||||
const WORKFLOW_FIRST = ['activists', 'acampada'];
|
||||
const workflowPick = (action, current, keys, withHint) => {
|
||||
const i18n = t();
|
||||
const list = WORKFLOW_FIRST.filter(k => keys.includes(k)).concat(keys.filter(k => !WORKFLOW_FIRST.includes(k)));
|
||||
const name = (k) => k === 'full' ? (i18n.welcomeWorkflow_full || k) : (i18n[`workflow_${k}`] || k);
|
||||
return form({ method: 'POST', action, class: 'ux-pick wf-pick' },
|
||||
withHint ? p({ class: 'ux-pick-hint' }, i18n.uxPickHint || 'Tap one and it is saved. You can change it any time in Settings.') : null,
|
||||
div({ class: 'ux-pick-grid' },
|
||||
...list.map(k => button(Object.assign({ type: 'submit', name: 'workflow', value: k, class: k === current ? 'ux-pick-card is-current' : 'ux-pick-card' }, k === current ? { 'aria-current': 'true' } : {}),
|
||||
span({ class: 'ux-pick-text' },
|
||||
span({ class: 'ux-pick-label' }, name(k)),
|
||||
i18n[`wfHint_${k}`] ? span({ class: 'ux-pick-sub' }, i18n[`wfHint_${k}`]) : null),
|
||||
k === current ? span({ class: 'ux-pick-now' }, i18n.uxPickCurrent || 'In use') : null
|
||||
))
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
// pasos de la bienvenida en el movil: el backend del dev quita el de uso en el movil;
|
||||
// aqui vuelve, el segundo, justo despues de la identidad (/welcome/workflow es suyo)
|
||||
const welcomeSteps = (usable) => {
|
||||
if (usable.includes('workflow')) return usable;
|
||||
const out = usable.slice();
|
||||
const at = out.indexOf('profile');
|
||||
out.splice(at === -1 ? 0 : at + 1, 0, 'workflow');
|
||||
return out;
|
||||
};
|
||||
|
||||
// ---------- acciones de tarjeta ----------
|
||||
|
||||
// borrar pide un segundo toque: la X abre "¿Borrar? Si, borrar" y solo ese boton
|
||||
// envia. Sin JS, con <details>. hidden: los <input type=hidden> que lleve el formulario
|
||||
const deleteConfirm = (action, hidden) => {
|
||||
const i18n = t();
|
||||
return details({ class: 'content-action-confirm' },
|
||||
summary({ class: 'btn-singleview btn-delete', title: i18n.chatDelete, 'aria-label': i18n.chatDelete }, require('./main_views').renderIcon('close')),
|
||||
div({ class: 'confirm-bar' },
|
||||
span({ class: 'confirm-question' }, i18n.deleteConfirmQuestion || 'Delete it for good?'),
|
||||
form({ method: 'POST', action, class: 'content-action-form' }, hidden || null,
|
||||
button({ type: 'submit', class: 'confirm-yes' }, i18n.deleteConfirmYes || 'Yes, delete'))
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
// ---------- pie y aviso de bienvenida ----------
|
||||
|
||||
// una linea: con cuanta gente estas sincronizando, dicho en claro, y la version. La
|
||||
// clave, HcT/HcH y la licencia estan en Perfil, Estadisticas y Ajustes
|
||||
const footerLine = (syncedPeers, version) => {
|
||||
const i18n = t();
|
||||
const n = Number(syncedPeers) || 0;
|
||||
const text = n > 0
|
||||
? String(i18n.footerPeersSome || '{n} peers in sync').replace('{n}', String(n))
|
||||
: (i18n.footerPeersNone || 'Not syncing with anyone yet');
|
||||
return div({ class: 'oasis-footer oasis-footer-mobile' },
|
||||
a({ href: '/peers', class: n > 0 ? 'footer-sync is-on' : 'footer-sync' },
|
||||
span({ class: 'footer-sync-dot', 'aria-hidden': 'true' }),
|
||||
span(text)
|
||||
),
|
||||
span({ class: 'footer-version' }, `Oasis ${version} · GPLv3`)
|
||||
);
|
||||
};
|
||||
|
||||
// el aviso de "pon tu avatar" solo en la portada (en cada pagina se comia una quinta
|
||||
// parte de la pantalla) y diciendo para que es
|
||||
const welcomeBannerHere = (here) => ['', '/', '/activity'].includes(here);
|
||||
const welcomeBannerText = () => t().welcomeBannerTextMobile || t().welcomeBannerText;
|
||||
const welcomeBannerAction = () => t().welcomeBannerActionMobile || t().welcomeBannerAction;
|
||||
|
||||
module.exports = {
|
||||
isMobile, VOTE_KINDS, voteKindPicker, voteBars, voteOwnNote, tribeRow, tribeTabs,
|
||||
settingsOrder, languagePick, uxPick, workflowPick, welcomeSteps, deleteConfirm, footerLine,
|
||||
welcomeBannerHere, welcomeBannerText, welcomeBannerAction
|
||||
};
|
||||
|
|
@ -39,6 +39,7 @@ const modulesView = () => {
|
|||
{ name: 'industry', label: i18n.modulesIndustryLabel, description: i18n.modulesIndustryDescription },
|
||||
{ name: 'invites', label: i18n.modulesInvitesLabel, description: i18n.modulesInvitesDescription },
|
||||
{ name: 'jobs', label: i18n.modulesJobsLabel, description: i18n.modulesJobsDescription },
|
||||
{ name: 'karvan', label: i18n.modulesKarvanLabel, description: i18n.modulesKarvanDescription },
|
||||
{ name: 'larp', label: i18n.modulesLarpLabel, description: i18n.modulesLarpDescription },
|
||||
{ name: 'backup', label: i18n.modulesBackupLabel, description: i18n.modulesBackupDescription },
|
||||
{ name: 'logs', label: i18n.modulesLogsLabel, description: i18n.modulesLogsDescription },
|
||||
|
|
|
|||
|
|
@ -2,11 +2,10 @@ const { form, button, div, h2, h3, p, section, select, option, input, br, a, lab
|
|||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { getConfig } = require('../configs/config-manager.js');
|
||||
const { template, selectedLanguage, i18n, setLanguage, INBOX_BOT_ORDER, inboxBotLabel, THEMES, MOBILE_THEMES, userLink } = require('./main_views');
|
||||
const { template, selectedLanguage, i18n, setLanguage, INBOX_BOT_ORDER, inboxBotLabel, THEMES, userLink } = require('./main_views');
|
||||
const i18nBase = require("../client/assets/translations/i18n");
|
||||
const moment = require("../server/node_modules/moment");
|
||||
const { WORKFLOWS, currentWorkflow } = require('../models/workflows_model');
|
||||
const mobileUi = require('./mobile_ui');
|
||||
const html = require("../server/node_modules/hyperaxe");
|
||||
// selector de identidades: la implementacion vive en ./identities_view.js
|
||||
const { renderIdentities } = require('./identities_view')({ i18n, html });
|
||||
|
|
@ -59,23 +58,7 @@ const settingsView = (params) => {
|
|||
const modOn = (name) => (currentConfig.modules || {})[`${name}Mod`] === 'on';
|
||||
const isMobile = process.env.OASIS_MOBILE === '1';
|
||||
|
||||
// movil: skins y workflows como tarjetas que se aplican al tocarlas (un boton
|
||||
// de envio por opcion); el desplegable de escritorio quedaba enterrado y dejaba
|
||||
// poner temas de escritorio, que en el movil se quedan sin maqueta
|
||||
const skinPicker = () => section({ id: "theme" },
|
||||
div({ class: "tags-header" }, h2(i18n.theme), p(i18n.skinIntroMobile)),
|
||||
form({ action: "/settings/theme", method: "post", class: "skin-pick" },
|
||||
...MOBILE_THEMES.map(([value, name]) => button({ type: "submit", name: "theme", value, class: theme === value ? "skin-card is-current" : "skin-card" },
|
||||
img({ src: `/assets/images/theme-${value.toLowerCase()}.png`, alt: "", class: "skin-shot" }),
|
||||
span({ class: "skin-name" }, name)))
|
||||
)
|
||||
);
|
||||
const workflowPicker = () => section({ id: "workflows" },
|
||||
div({ class: "tags-header" }, h2(i18n.workflowsTitle), p(i18n.workflowsDescriptionMobile)),
|
||||
mobileUi.workflowPick("/settings/workflow", activeWorkflow, WORKFLOWS.map(w => w.key))
|
||||
);
|
||||
|
||||
const workflowSection = isMobile ? workflowPicker() : section({ id: "workflows" },
|
||||
const workflowSection = section({ id: "workflows" },
|
||||
div({ class: "tags-header" },
|
||||
h2(i18n.workflowsTitle),
|
||||
p(i18n.workflowsDescription),
|
||||
|
|
@ -145,7 +128,7 @@ const settingsView = (params) => {
|
|||
div({ class: "tags-header" },
|
||||
h2(i18n.language),
|
||||
p(i18n.languageDescription),
|
||||
isMobile ? mobileUi.languagePick() : form(
|
||||
form(
|
||||
{ action: "/language", method: "post" },
|
||||
select({ name: "language" }, [
|
||||
languageOption("English", "en"),
|
||||
|
|
@ -172,7 +155,7 @@ const settingsView = (params) => {
|
|||
div({ class: "tags-header" },
|
||||
h2(i18n.uxModeTitle || "UX"),
|
||||
p(i18n.uxModeDescription || "Select which UX navigation mode you want for your GUI."),
|
||||
isMobile ? mobileUi.uxPick("/settings/ux", currentConfig) : form(
|
||||
form(
|
||||
{ action: "/settings/ux", method: "POST" },
|
||||
(() => {
|
||||
const aiNavEnabled = currentConfig.modules && currentConfig.modules.aiNavMod === 'on';
|
||||
|
|
@ -198,7 +181,7 @@ const settingsView = (params) => {
|
|||
)
|
||||
)
|
||||
),
|
||||
isMobile ? skinPicker() : section({ id: "theme" },
|
||||
section({ id: "theme" },
|
||||
div({ class: "tags-header" },
|
||||
h2(i18n.theme),
|
||||
p(i18n.themeIntro),
|
||||
|
|
@ -672,7 +655,7 @@ const settingsView = (params) => {
|
|||
modOn('fediverse') ? a({ class: filter === 'multiverse' ? 'filter-btn active' : 'filter-btn', href: '/settings?filter=multiverse' }, String(i18n.multiverse).toUpperCase()) : null
|
||||
)
|
||||
),
|
||||
...(filter === 'multiverse' ? multiverseParts : filter === 'content' ? contentParts : (isMobile ? mobileUi.settingsOrder(oasisParts) : oasisParts))
|
||||
...(filter === 'multiverse' ? multiverseParts : filter === 'content' ? contentParts : oasisParts)
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ const { TEXT_CAP } = require('../backend/long_text');
|
|||
const moment = require("../server/node_modules/moment");
|
||||
const { template, i18n, userLink, renderStateChip, renderPrivacyChip, renderLifespanChip, renderModeChip, renderInviteQrCard, renderContentActions, renderSubscriptionBox, renderModuleStats, renderModuleStatsBy, moduleIsEmpty, renderTorrentDownload, torrentDownloadHref, contentDeleteAction, clearnetSlugFor, renderEngagement, renderOpinionsVoting, renderVotesSummary, paged, CLEARNET_HUB_CSS } = require('./main_views');
|
||||
const { renderTribeWikiSection } = require('./wiki_view');
|
||||
const mobileUi = require('./mobile_ui');
|
||||
const { renderEncryptedChip: renderTribeEncryptedChip, renderLicenseSelect, renderReachChip, renderClearnetPage, escapeHtml, renderRichText, blobUrl: cnBlobUrl, paginateClearnet, renderClearnetPager, CLEARNET_PAGER_CSS } = require('./clearnet_view');
|
||||
const { renderPollCard } = require('./polls_view');
|
||||
const { renderFeedCard } = require('./feed_view');
|
||||
|
|
@ -287,7 +286,7 @@ exports.tribesView = async (tribes, filter, tribeId, query = {}, allTribes = nul
|
|||
br,
|
||||
input({ type: 'text', name: 'location', id: 'location', placeholder: i18n.tribeLocationPlaceholder, value: tribeToEdit.location || '' }),
|
||||
br,
|
||||
label(process.env.OASIS_MOBILE === '1' ? (i18n.tribeMapUrlLabel || 'Map link (optional)') : (i18n.mapLocationTitle || 'Map Location')),
|
||||
label(i18n.mapLocationTitle || 'Map Location'),
|
||||
br,
|
||||
input({ type: 'text', name: 'mapUrl', placeholder: i18n.mapUrlPlaceholder || '/maps/MAP_ID', value: tribeToEdit.mapUrl || '' }),
|
||||
br,
|
||||
|
|
@ -305,7 +304,6 @@ exports.tribesView = async (tribes, filter, tribeId, query = {}, allTribes = nul
|
|||
option({ value: 'true', selected: tribeToEdit.isAnonymous === true ? 'selected' : undefined }, i18n.tribePrivate),
|
||||
option({ value: 'false', selected: tribeToEdit.isAnonymous === false ? 'selected' : undefined }, i18n.tribePublic)
|
||||
),
|
||||
!isSubEdit && mobileUi.isMobile() ? p({ class: 'form-hint' }, i18n.tribeHintPrivacy) : null,
|
||||
isSubEdit ? null : br(),
|
||||
isSubEdit ? null : br(),
|
||||
label({ for: 'inviteMode' }, i18n.tribeModeLabel),
|
||||
|
|
@ -314,7 +312,6 @@ exports.tribesView = async (tribes, filter, tribeId, query = {}, allTribes = nul
|
|||
option({ value: 'strict', selected: tribeToEdit.inviteMode === 'strict' ? 'selected' : undefined }, i18n.tribeStrict),
|
||||
option({ value: 'open', selected: tribeToEdit.inviteMode === 'open' ? 'selected' : undefined }, i18n.tribeOpen)
|
||||
),
|
||||
mobileUi.isMobile() ? p({ class: 'form-hint' }, i18n.tribeHintMode) : null,
|
||||
br(), br(),
|
||||
button({ type: 'submit' }, isEdit ? i18n.tribeUpdateButton : i18n.tribeCreateButton)
|
||||
)
|
||||
|
|
@ -325,7 +322,6 @@ exports.tribesView = async (tribes, filter, tribeId, query = {}, allTribes = nul
|
|||
const shownTribes = filter === 'create' || filter === 'edit' ? [] : paged(sorted);
|
||||
|
||||
const tribeCards = shownTribes.map(t => {
|
||||
if (mobileUi.isMobile()) return mobileUi.tribeRow(t, { own: String(t.author) === String(userId), imageNode: t.image ? renderMediaBlob(t.image, null, { class: 'tribe-row-thumb' }) : null });
|
||||
const parentTribe = t.parentTribeId ? allT.find(p => p.id === t.parentTribeId) : null;
|
||||
|
||||
const isOwn = String(t.author) === String(userId);
|
||||
|
|
@ -380,7 +376,7 @@ exports.tribesView = async (tribes, filter, tribeId, query = {}, allTribes = nul
|
|||
: filter === 'gallery'
|
||||
? renderGallery(shownTribes)
|
||||
: tribeCards.length > 0
|
||||
? div({ class: mobileUi.isMobile() ? 'tribe-list' : 'tribe-grid' }, tribeCards)
|
||||
? div({ class: 'tribe-grid' }, tribeCards)
|
||||
: div({ class: "no-content-box" }, p(i18n.noTribes))
|
||||
),
|
||||
...renderLightbox(shownTribes)
|
||||
|
|
@ -413,7 +409,6 @@ const renderSectionNav = (tribe, section) => {
|
|||
{ items: [{ key: 'images', label: i18n.tribeSectionImages || 'IMAGES' }, { key: 'audios', label: i18n.tribeSectionAudios || 'AUDIOS' }, { key: 'videos', label: i18n.tribeSectionVideos || 'VIDEOS' }, { key: 'documents', label: i18n.tribeSectionDocuments || 'DOCUMENTS' }, { key: 'files', label: i18n.tribeSectionFiles || 'FILES' }, { key: 'bookmarks', label: i18n.tribeSectionBookmarks || 'BOOKMARKS' }] },
|
||||
{ items: [{ key: 'tags', label: i18n.tribeSectionTags || 'TAGS' }, { key: 'search', label: i18n.tribeSectionSearch }] },
|
||||
];
|
||||
if (mobileUi.isMobile()) return mobileUi.tribeTabs(tribe, section, sections);
|
||||
return div({ class: 'tribe-section-nav no-border' },
|
||||
sections.map(g =>
|
||||
div({ class: 'tribe-section-group no-border' },
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ const { template, i18n, renderOpinionsVoting, renderEngagement, renderOpenClosed
|
|||
const moment = require("../server/node_modules/moment");
|
||||
const { config } = require("../server/SSB_server.js");
|
||||
const { renderTribeOriginChip } = require("./clearnet_view");
|
||||
const mobileUi = require("./mobile_ui");
|
||||
|
||||
const userId = config.keys.id;
|
||||
const VOTE_QUORUM = 2;
|
||||
|
|
@ -22,7 +21,7 @@ const computeVoteOutcome = (baseCounts, voteOptions, totalVotesNum) => {
|
|||
const topOpts = opts.filter((o) => (baseCounts[o] || 0) === maxCount);
|
||||
if (maxCount === 0 || topOpts.length > 1) return noResult;
|
||||
const winner = topOpts[0];
|
||||
const variant = (winner === "YES" || winner === "GREEN") ? "yes" : (winner === "NO" || winner === "RED") ? "no" : "pending";
|
||||
const variant = winner === "YES" ? "yes" : winner === "NO" ? "no" : "pending";
|
||||
return { text: voteLabel(winner), variant };
|
||||
};
|
||||
|
||||
|
|
@ -214,7 +213,6 @@ const renderVoteDetail = (v, voteOptionsDefault, firstRow, secondRow, mode, acti
|
|||
const voteButtonsNode = renderVoteButtons(v, voteOptions, firstRow, secondRow, returnTo);
|
||||
|
||||
const voteMain = div({ class: "tribe-main" },
|
||||
mobileUi.isMobile() && !voteButtonsNode && isVoteAuthor(v) && normalizeStatus(v.status) === "OPEN" ? mobileUi.voteOwnNote() : null,
|
||||
voteButtonsNode
|
||||
? div({ class: "job-section" },
|
||||
h2({ class: "job-section-title" }, i18n.voteCastTitle || "Cast Vote"),
|
||||
|
|
@ -226,7 +224,7 @@ const renderVoteDetail = (v, voteOptionsDefault, firstRow, secondRow, mode, acti
|
|||
`${i18n.voteResults || "Results"}: `,
|
||||
span({ class: `vote-outcome vote-outcome-${outcome.variant}` }, outcome.text)
|
||||
),
|
||||
mobileUi.isMobile() ? mobileUi.voteBars(voteOptions, baseCounts, voteLabel) : div({ class: "vote-table" },
|
||||
div({ class: "vote-table" },
|
||||
table(
|
||||
tr(...voteOptions.map((opt) => th(voteLabel(opt)))),
|
||||
tr(...voteOptions.map((opt) => td(baseCounts[opt])))
|
||||
|
|
@ -353,7 +351,6 @@ exports.voteView = async (votes, mode, voteId, comments = [], activeFilterParam,
|
|||
}), br(), br(),
|
||||
label(i18n.voteTagsLabel), br(),
|
||||
input({ type: "text", name: "tags", id: "tags", value: tagsValue }), br(), br(),
|
||||
mobileUi.isMobile() && mode === "create" ? mobileUi.voteKindPicker(draft.options) : null,
|
||||
button({ type: "submit" }, mode === "edit" ? i18n.voteUpdateButton : i18n.voteCreateButton)
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ const { template, i18n, WIZARD_THEMES } = require("./main_views")
|
|||
const { renderRestoreStatus } = require("./backup_view")
|
||||
const { renderSuggestedPub } = require("./invites_view")
|
||||
const { config } = require("../server/SSB_server.js")
|
||||
const mobileUi = require("./mobile_ui")
|
||||
const { WIZARD_WORKFLOWS } = require("../models/workflows_model")
|
||||
|
||||
const FEED_TEXT_MIN = Number(config?.feed?.minLength ?? 1)
|
||||
|
|
@ -17,7 +16,6 @@ const SECTIONS = {
|
|||
community: ["welcomeStepCommunity", "welcomeHintCommunity"],
|
||||
greeting: ["welcomeStepIntro", "welcomeHintIntro"]
|
||||
}
|
||||
if (mobileUi.isMobile()) SECTIONS.workflow = ["welcomeStepUseMobile", "welcomeHintUseMobile"]
|
||||
|
||||
const submitRow = (text) => div({ class: "welcome-action" }, button({ type: "submit" }, text))
|
||||
|
||||
|
|
@ -40,7 +38,6 @@ const stepAction = (key, profile, status) => {
|
|||
let cfg = {}
|
||||
try { cfg = require("../configs/config-manager.js").getConfig() || {} } catch (_) {}
|
||||
const cur = ["ainav", "chats", "feed", "phone", "rooms"].includes(cfg.ux?.current) ? cfg.ux.current : "blocks"
|
||||
if (mobileUi.isMobile()) return mobileUi.uxPick("/welcome/ux", cfg, true)
|
||||
return form({ method: "POST", action: "/welcome/ux", class: "welcome-ux-form" },
|
||||
div({ class: "welcome-ux-grid" },
|
||||
pickCard("ux", "blocks", cur, i18n.uxModeMenus, "/assets/images/ux-blocks.png"),
|
||||
|
|
@ -55,7 +52,6 @@ const stepAction = (key, profile, status) => {
|
|||
}
|
||||
if (key === "workflow") {
|
||||
const selected = WIZARD_WORKFLOWS.includes(status.workflow) ? status.workflow : "full"
|
||||
if (mobileUi.isMobile()) return mobileUi.workflowPick("/welcome/workflow", WIZARD_WORKFLOWS.includes(status.workflow) ? status.workflow : null, WIZARD_WORKFLOWS, true)
|
||||
return form({ method: "POST", action: "/welcome/workflow", class: "welcome-select-form" },
|
||||
select({ name: "workflow" },
|
||||
WIZARD_WORKFLOWS.map(k => option({ value: k, ...(k === selected ? { selected: true } : {}) }, k === "full" ? i18n.welcomeWorkflow_full : (i18n[`workflow_${k}`] || k)))
|
||||
|
|
@ -106,7 +102,7 @@ const stepAction = (key, profile, status) => {
|
|||
|
||||
exports.welcomeView = async (status, profile = {}) => {
|
||||
const steps = status.steps || {}
|
||||
const usable = mobileUi.isMobile() ? mobileUi.welcomeSteps(status.usable || []) : (status.usable || [])
|
||||
const usable = status.usable || []
|
||||
const opened = usable.find(key => !steps[key])
|
||||
const html = template(
|
||||
i18n.welcomeTitle,
|
||||
|
|
@ -115,7 +111,7 @@ exports.welcomeView = async (status, profile = {}) => {
|
|||
h2(i18n.welcomeTitle),
|
||||
p(i18n.welcomeDescription)
|
||||
),
|
||||
status.pending && !mobileUi.isMobile()
|
||||
status.pending
|
||||
? form({ method: "POST", action: "/welcome/dismiss", class: "welcome-action" },
|
||||
input({ type: "hidden", name: "returnTo", value: "/" }),
|
||||
button({ type: "submit" }, i18n.welcomeSkip)
|
||||
|
|
@ -139,13 +135,7 @@ exports.welcomeView = async (status, profile = {}) => {
|
|||
),
|
||||
stepAction(key, profile || {}, status)
|
||||
)
|
||||
)),
|
||||
// movil: "Ahora no" al final y discreto (arriba era lo primero que se tocaba)
|
||||
status.pending && mobileUi.isMobile()
|
||||
? section(form({ method: "POST", action: "/welcome/dismiss", class: "welcome-action welcome-skip-end" },
|
||||
input({ type: "hidden", name: "returnTo", value: "/" }),
|
||||
button({ type: "submit", class: "welcome-skip-btn" }, i18n.welcomeSkip)))
|
||||
: null
|
||||
))
|
||||
)
|
||||
return status.bootstrap && status.bootstrap.running ? html.replace('</head>', '<meta http-equiv="refresh" content="5"></head>') : html
|
||||
}
|
||||
|
|
|
|||
183
test/karvan_model.test.js
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
"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);
|
||||
})();
|
||||