Compare commits

..

44 commits

Author SHA1 Message Date
hacklab
d267d2befe bienvenida: paso Perfil (workflow) de un toque, Activismo primero; Ajustes con la misma lista; fuera Personal/Social de la barra de arriba 2026-10-19 16:00:31 +02:00
hacklab
92a8dc3853 docs: tipo de portada de un toque, en el pad 2026-10-19 15:39:58 +02:00
hacklab
3238520891 bienvenida y Ajustes: el tipo de portada se guarda al tocarlo, sin boton de guardar; en Ajustes va justo despues del idioma 2026-10-19 15:39:01 +02:00
hacklab
05027031ef docs: fuente del PDF de la guia para el dev 2026-10-19 15:06:58 +02:00
hacklab
d3e799e592 docs: pruebas en tamaños y sistemas (Android 9, 11 y 16), y WebView viejos en la guia 2026-10-19 15:06:09 +02:00
hacklab
9f8317baa3 menu: al ir a un modulo desde la hoja se quita antes su entrada del historial; volver atras ya no reabre la hoja 2026-10-19 15:04:38 +02:00
hacklab
9e2716f69b WebView viejos (Android 9-11): fichas sin gap, hoja del menu sin inset ni dvh, cabecera sin sticky y No molestar debajo de la hoja 2026-10-19 14:52:50 +02:00
hacklab
3f8839d9a8 tabletas: el limite de ancho no ganaba a otra regla; ahora si 2026-10-19 14:35:46 +02:00
hacklab
0a8935fe16 tamaños: tabletas centradas y el boton de No molestar fuera del menu
Probado en el emulador a 360x540, 412 (Pixel 9), 800dp (tableta) y con la
letra al 130%: sin desbordes. Arreglos:
- en pantallas de 700dp o mas, el contenido y la hoja del menu a 720/640px
  centrados; a 800dp todo se estiraba
- con el menu abierto se esconde el boton de No molestar, que se montaba
  encima de la X de la hoja
2026-10-19 14:34:26 +02:00
hacklab
1beb53568c menu: el boton atras de Android cierra la hoja
En el emulador, con la hoja abierta, atras cambiaba de pagina. Un script
minimo (public/js/mobile_menu.js, servido desde /js como permite la CSP)
apunta una entrada en el historial al abrirla y la quita al cerrarla; sin
el script el menu funciona igual. El estudio de navegacion recoge lo que
salio en la prueba.
2026-10-19 14:29:07 +02:00
hacklab
062d49c367 menu: todos los modulos caben en la hoja sin deslizar
En el emulador la hoja dejaba ver 690px y la rejilla medía 1.200: Tribus
quedaba fuera y hacia falta deslizar. Dentro de la hoja: cinco iconos por
fila y mas pequeños, y los grupos de uno o dos modulos de dos en dos. Ahora
caben los 20 del workflow acampada a 412 y a 360px. Con el menu abierto solo
la pestaña Menu lleva la pastilla.
2026-10-19 14:26:02 +02:00
hacklab
382d903bc5 navegacion: al abrir, la actividad; los modulos en la pestaña Menu
docs/ESTUDIO_NAVEGACION.md compara cuatro formas de moverse por la app
(lanzador en portada, boton de menu arriba, pestaña Menu abajo, hamburguesa)
y se queda con la pestaña de abajo, el patron Mas de Android y de las apps
grandes: al alcance del pulgar y en todas las pantallas.

- la portada es la actividad; el lanzador ya no se pinta alli
- quinta pestaña fija en la barra de abajo, Menu, que abre desde abajo una
  hoja con todos los modulos por secciones; se cierra con la X, tocando fuera
  o volviendo a tocar Menu. Sin JS: una casilla y CSS
- textos en los 11 idiomas; caben cinco pestañas a 360px
- capturas, portada y esquema de la interfaz al dia
2026-10-19 14:23:43 +02:00
hacklab
6eafe5ee18 coherencia con la guia: lo de movil del dev vuelve a ser suyo
Comparando con el nodejs-project de la APK oficial (la referencia buena para
el movil, no el git del dev) salia que parte de lo que se saco de backend.js
era codigo de movil del propio dev: apagar los modulos que no van, cortar
rutas, retocar el HTML, las descargas de la copia de seguridad y ocultar
tema y workflow en la bienvenida.

- backend.js vuelve a ser su version de movil mas dos lineas (montar nuestro
  router); modules_view.js, su version de movil
- mobile_layer.js se queda con lo nuestro: avatar, filtro y lanzador
- la guia lista las 17 diferencias que quedan con la APK oficial (561 lineas)
  y explica que hay que comparar con la APK, no con el git
2026-10-19 14:19:54 +02:00
hacklab
b65dde1263 salas: estando dentro, los controles arriba y en rejilla
Silenciar, camara, levantar la mano, salir... quedaban debajo de toda la
ficha de la sala, a 1.100px. Con la sala en directo el panel va primero, los
controles en rejilla de dos y Salir en rojo. Solo CSS.
2026-10-19 14:14:09 +02:00
hacklab
6bacafea64 android: la APK se monta sobre la oficial del dev
La app Android de epsylon trae desde la 1.3.x un puente nativo de audio y
camara (.audio_bridge) para el Telefono y las Salas, y no tiene el codigo
publicado. android/scripts/sobre_oficial.sh coge su APK de la misma version,
le cambia el backend por el de la rama (con su node_modules), le pone el
paquete net.laenre.oasis (y los authorities, el permiso propio y las acciones
de llamada) para que conviva con la oficial, y la monta y firma. apktool va
con version y huella fijas.

Probado en el emulador: el Telefono ofrece llamadas y en una sala el
microfono graba y se corta al salir. README, guia, android/README y los
esquemas lo cuentan; el proyecto Gradle propio queda sin uso.
2026-10-19 14:12:18 +02:00
hacklab
34265c2ed0 docs: tercera ronda de usabilidad, en un emulador limpio 2026-10-19 13:55:28 +02:00
hacklab
10e47f9bc1 llamadas: lo que hay de verdad; SSB_server.js como en la APK oficial
Revisado en el emulador: la APK oficial 1.3.3 del dev pide microfono y camara
y trae un puente nativo (.audio_bridge) que graba, reproduce, saca la camara y
emite. Nuestra app Android no lo tiene, asi que en esta APK el Telefono dice
que no hay llamadas.

- README, guia, android/README y los dos esquemas lo cuentan asi
- SSB_server.js queda identico al de la APK oficial (su version de movil
  enchufa ya phone_*_android.js); fuera el enganche duplicado de main.js
- el aviso de bienvenida va encima del lanzador: en el primer arranque
  quedaba a 1.300px y no se veia
2026-10-19 13:55:16 +02:00
hacklab
e6ea3f4b58 readme: capturas y dibujos nuevos, sin Karvan
- capturas de un nodo de prueba con contenido de acampada: lanzador, tribus,
  votacion de semaforo, modulos, ajustes, barra, salas y el tema del dev
- portada y esquema de la interfaz al dia; dos esquemas nuevos: como va la
  rama encima de Oasis y como funcionan las llamadas
- el README cuenta las novedades de esta version y explica la rama

Arreglos que salieron al hacer las capturas:
- el boton de No molestar del Telefono ocupaba una fila entera arriba de cada
  pagina; ahora va en la barra de arriba, junto al avatar
- Ajustes marcaba English aunque el idioma fuera otro (settings_view se queda
  con el idioma del arranque): el selector lo lee al pintar
- barras de la votacion juntas y con las etiquetas en el mismo tipo
- Personalizar la barra: una fila por atajo, sin sangria
2026-10-19 13:49:21 +02:00
hacklab
d4e6b6c8e8 gitignore: el enlace a node_modules y el estado de la llamada 2026-10-19 13:39:53 +02:00
hacklab
096a7d110b fuera Karvan: se usan el Telefono y las Salas del dev
El dev ya trae Telefono (llamadas uno a uno, buzon de voz) y Salas (hasta 50,
con video, grabacion y emision). La rama quita su modulo propio de salas y
llamadas: modelo, vista, JS de cliente, CSS, rutas, credenciales TURN, pruebas
y textos.

- el router propio pasa a llamarse fork_routes.js, sin Karvan
- el audio, la camara y la emision de Android se enchufan desde main.js con
  setAudioFactory/setVideoFactory/setStreamFactory, que exporta el propio dev;
  SSB_server.js vuelve a tener su linea original
- Clear-SNH.css y modules_view.js vuelven a ser identicos a los del dev
- el workflow acampada y la config de serie cambian Karvan por Telefono y Salas
- README y guia: las llamadas son las del dev; antes de hacer un modulo propio,
  mirar si el dev ya lo tiene
2026-10-19 13:39:41 +02:00
hacklab
3937ddab1e css: fuera los selectores que no apuntan a nada
153 selectores de mobile_base.css, hive_acampada.css y Acampada.css que
apuntaban a clases que ya no pinta ninguna vista (287 lineas). Comparadas 14
pantallas pixel a pixel antes y despues: identicas.
2026-10-19 13:34:26 +02:00
hacklab
ea35ca7ca1 docs: fase C en el informe de frontera 2026-10-19 13:31:39 +02:00
hacklab
1148e6a472 backend: lo del movil sale de backend.js a mobile_layer.js
De 20 trozos nuestros en backend.js quedan 9 (21 lineas). Pasan a
src/backend/mobile_layer.js, que monta el router propio delante de las rutas
del dev:
- blockexplorer, actualizar con git y presets de modulos, cortados en el movil
- los modulos que no van en el movil, apagados al arrancar y al guardar
- avatar de la barra de arriba, filtro y lanzador
- fotograma en los videos y pestaña actual marcada
- descargas de la copia de seguridad por GET
El cambio de tema del movil lo atiende karvan_routes.js.
2026-10-19 13:31:24 +02:00
hacklab
e7892c4bf9 workflows: activists vuelve a ser el del dev; karvan y acampada desde fork_workflows
- el workflow activists del dev queda como en la 1.3.3 (unos 45 modulos)
- karvan se anade a las listas del dev al arrancar, desde fork_workflows.js
- la config de serie del movil enciende tribus, parlamento, votaciones y
  eventos: coincide con acampada y sale marcado en Ajustes
2026-10-19 13:28:44 +02:00
hacklab
35fef607d7 OasisMobile.css vuelve a ser la del dev; la maqueta pasa a mobile_base.css
themes/OasisMobile.css es un tema de epsylon que cambia en 21 de cada 39
versiones y nuestra copia diferia en 4.873 lineas. Ahora es identica a la
1.3.3 y nuestra maqueta vive en styles/mobile_base.css, que el movil carga
siempre antes del skin. Con Acampada se ve igual que antes; con OasisMobile,
el tema del dev encima de la maqueta.
2026-10-19 13:27:03 +02:00
SITO
5bce7d38ed docs: la guia escrita como notas, sin adornos 2026-10-19 13:17:28 +02:00
SITO
abae8132a4 docs: guia de desarrollo con lo que se toca y lo que no
GUIA_DE_DESARROLLO.md en la raiz: la regla de oro, el semaforo (rojo:
backend, middleware, modelos, server, CSP, traducciones y hojas del dev;
ambar: una linea de enganche en las vistas del dev; verde: hojas, vistas,
router, workflows y textos propios), como hacer cada tipo de cambio, lo que
hay que comprobar antes de cada commit, como integrar una version nueva del
dev, como probar y la deuda que queda. Enlazada al principio del README.
2026-10-19 13:16:27 +02:00
SITO
087b86a184 movil: botones de crear iguales, listas vacias, buzon y busqueda compactos
Todo en mobile_ux.css (mas un SVG propio para las listas vacias), sin tocar
vistas ni backend del dev:
- el boton principal de cada modulo, igual en todos
- listas vacias con icono y recuadro
- el pie abajo en paginas cortas
- buzon: remitente y fecha en una linea; fuera Para, Tasa ECO y Cifrado
- busqueda: el campo primero, los filtros despues
- nodos: acciones en rejilla
- fuera los selectores a clases que no existen
2026-10-19 13:08:46 +02:00
SITO
d04d99ece2 frontera con upstream: lo del movil sale de los ficheros del dev
docs/FRONTERA_UPSTREAM.md mide como cambia upstream (39 releases de la 1.0.1 a
la 1.3.3; backend.js en 34, main_views en 31, OasisMobile.css en 21) y fija que
se toca: backend, middleware, modelos, server y traducciones de upstream, no;
vistas de upstream, solo una linea de gancho; hojas, vistas, router y modelos
propios, si.

Fase A, lo del 19/10:
- backend.js, workflows_model.js y modules_view.js vuelven a como estaban
- seccion apagada, /modules/enable y ?saved=1: en el router propio, que envuelve
  las redirecciones de upstream en vez de cambiar checkMod
- Modulos del movil en views/mobile_modules_view.js, servida por el router propio
- workflow propio "acampada" registrado desde models/fork_workflows.js en vez de
  redefinir "activists"
- votaciones, tribus, ajustes, borrar, pie y bienvenida: la logica en
  views/mobile_ui.js; en cada vista de upstream queda la llamada
2026-10-19 13:04:38 +02:00
SITO
9b391cfc3d docs: lo arreglado y lo pendiente en la prueba de usabilidad 2026-10-19 12:44:44 +02:00
SITO
f29540e17e movil: barra de abajo marca la seccion abierta; llamada de voz sin video vacio
- la pestaña actual de la barra de abajo lleva la pastilla de color
- en una llamada solo de voz no se ensena el video propio (era un recuadro
  gris con el icono de play) y Llamar se atenua mientras dura
- la barra de confirmar borrado con su marco rojo
2026-10-19 12:43:28 +02:00
SITO
c1f4ba75de docs: prueba de usabilidad en el emulador (para el pad) 2026-10-19 12:37:58 +02:00
SITO
4dd573662c movil: usabilidad tras la prueba en el Pixel
Lo que salio de recorrer la app como alguien de la acampada:

- borrar (tribus y todo lo que usa renderContentActions) pide un segundo
  toque; la X va la ultima de la fila
- Karvan: Llamar empieza solo con voz (si se negaba la camara no arrancaba
  nada); botones con icono y nombre; textos traducidos; ayuda para invitar
- votaciones: tipo Si/No, semaforo o termometro (el servidor ya aceptaba
  options); resultados en barras; aviso a quien la crea de que no puede votar
- workflow activistas con tribus, parlamento, votaciones y eventos
- seccion apagada: /modules dice cual era y la activa de un toque
- Modulos en el movil: agrupados como el lanzador, interruptores y Guardar fijo
- tribus en filas compactas; secciones de una tribu en tira de fichas + Mas
- lanzador y barra con iconos SVG en vez de letras vai
- Ajustes: idioma y aspecto primero; idiomas de un toque
- aviso de bienvenida solo en la portada y en una linea; Ahora no al final
- pie de una linea con el estado de sincronizacion en claro
- mobile_ux.css para los dos skins: un margen de 14px (eran 74 por lado en
  las fichas), Anton solo en titulos y boton principal, buscador en una linea,
  la barra de abajo se esconde con el teclado
2026-10-19 12:37:58 +02:00
oasis-bot
2e3ff1e79f tema Acampada: letra y botones mas grandes
Lanzador (iconos de 64px, etiquetas de 13px en negrita que solo parten entre
palabras), barra de abajo, fichas de filtro de 46px, iconos de las tarjetas
de 44px, reacciones del chat, formularios y textos de cabecera.
2026-10-19 02:17:12 +02:00
oasis-bot
0baf85faa9 skins: elegir el aspecto en el movil con tarjetas de un toque
En Ajustes del movil, Tema y Flujos de trabajo dejan de ser desplegables:
cada skin es una tarjeta con su miniatura y cada workflow un boton grande,
y se aplican al tocarlos. Solo se ofrecen los skins que tienen maqueta de
movil (Acampada y Oasis Mobile); el servidor rechaza un tema de escritorio,
que dejaba la app sin maqueta. La bienvenida tambien ofrece elegir skin.

Aplicar un workflow ya no cambia el skin: se queda el que haya elegido cada
cual.

OasisMobile.css se carga con su propio <link> versionado antes de
Acampada.css, en vez de con @import: el @import sin ?v= se quedaba en la
cache del WebView y tras actualizar se veia la maqueta vieja.
2026-10-19 02:17:12 +02:00
oasis-bot
ac921c9d56 readme: capturas con el tema Acampada
Las ocho capturas, la portada y el esquema de la interfaz rehechos sobre la
1.3.3 con el tema Acampada. generar_portada.py compone la portada a partir
de las capturas y dibuja el esquema; el texto vive en el script. Novedades:
tema, fotos que no se pueden limpiar y DNS.
2026-10-19 02:06:05 +02:00
oasis-bot
9a4ca6b2b3 tema Acampada: Karvan y respuestas del chat en blanco y negro
karvan.css trae su paleta amarilla sin !important: las salas activas salian
en negro con la letra oscura. Ahora tarjetas con marco negro, burbujas como
las del chat y los botones de llamada en blanco.
2026-10-19 02:06:05 +02:00
oasis-bot
b64dcf9b7a fotos: si no se pueden limpiar, no se suben
Si quitar los metadatos fallaba, blobHandler subia la foto original, con el
GPS. Ahora se para con un 400 y un aviso (imageMetadataUnsafe, en los 11
idiomas): HEIC/HEIF/AVIF y TIFF, que pueden llevar EXIF y aqui no se saben
limpiar, y JPEG/PNG/WebP que no se dejan recorrer. Un JPEG cortado ya no
sube como una imagen vacia. Formatos sin EXIF (BMP, SVG...) pasan igual.

Es el cambio del 10 de octubre, llevado a la copia de la limpieza que vive
ahora en blobHandler.js.
2026-10-19 02:02:16 +02:00
hacklab
ed77a13c85 dns: primero el resolutor del sistema, los publicos de respaldo
main.js preguntaba siempre a 8.8.8.8/1.1.1.1. Las VPN que evitan fugas de DNS
(Mullvad) y las redes que bloquean DNS externos los rechazan, y entonces no se
encontraba ningun pub por nombre: unirse a uno no hacia nada y no decia nada.

Ahora va primero getaddrinfo, que respeta la VPN y el DNS privado de Android;
a los 5 s, o si falla, tambien los publicos. Probado en Android 15 detras de
Mullvad: antes no resolvia hacklab.laenre.net y ahora si.
2026-10-19 02:00:56 +02:00
oasis-bot
b3172ed1c3 android: release --public, version de package.json y libnode comprimida
Lo del 10 de octubre que sigue valiendo sobre la base 1.3.3:
- con un tag v* el workflow publica la APK; a mano sigue en borrador
- el workflow trae el APK oficial de la misma version: de ahi salen
  node_modules y libnode.so (prepare.sh ya lo usaba, pero el runner no lo tenia)
- versionName sale de package.json en vez de ir escrito a mano
- libnode.so comprimida, como en la oficial (unos 35 MB menos por descarga)
- release.sh: JAVA_HOME en el PATH, no deja salir una release debuggable,
  exige CHANGE_WIFI_MULTICAST_STATE y avisa si la APK pasa de 80 MB

Se queda fuera la poda de prepare.sh y la sha256 de la APK 1.2.5: ahora el
node_modules viene ya podado del APK oficial.
2026-10-19 02:00:40 +02:00
hacklab
99e5d766e9 android: pantalla completa con margenes, teclado y descargas
El contenedor deja sitio a las barras del sistema y al teclado (Android 15
obliga con targetSdk 35). Las descargas van al gestor de Android. Fondo
blanco e iconos oscuros, como el tema Acampada.
2026-10-19 01:59:39 +02:00
hacklab
ba057f0092 android: permisos y MulticastLock para ver moviles en la misma wifi
El anuncio LAN de SSB va por UDP broadcast y Android lo filtra sin un
MulticastLock. La oficial declara ACCESS_WIFI_STATE y
CHANGE_WIFI_MULTICAST_STATE; esta no, asi que dos moviles en la misma red no
se encontraban.
2026-10-19 01:59:13 +02:00
hacklab
de5e8fba20 android: un script deja el proyecto listo para Android Studio
Clona el Oasis oficial en su commit, descarga su APK de la misma version y
lanza prepare.sh. Despues basta con abrir android/ y darle a Build APK.
2026-10-19 01:59:13 +02:00
oasis-bot
101bdfe4e8 tema Acampada: blanco, negro y cian sobre el lanzador de la 1.3.3
Trae el tema del 10 de octubre (que se quedo fuera de PRUEBAS) y lo pone
por defecto en el movil. Acampada.css importa OasisMobile.css, asi que el
lanzador, la barra de 4 atajos y el workflow de activistas siguen igual;
hive_acampada.css va detras del tema y repinta lanzador, fichas de filtro,
hilos del foro, burbujas del chat, iconos de las tarjetas e identidades.

Quien ya tenia OasisMobile conserva su workflow reconocido.
2026-10-19 01:59:04 +02:00
76 changed files with 9669 additions and 2802 deletions

View file

@ -1,4 +1,8 @@
# Construye la APK y la deja publicada como borrador en las releases.
# 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.
#
# 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.
@ -29,6 +33,13 @@ 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
@ -46,8 +57,12 @@ 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
run: rm -f /tmp/oasis.jks /tmp/oasis-base.apk

5
.gitignore vendored
View file

@ -1 +1,6 @@
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/

228
GUIA_DE_DESARROLLO.md Normal file
View file

@ -0,0 +1,228 @@
# 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
View file

@ -13,25 +13,49 @@
>
> **Los fallos de esta rama se reportan aquí, nunca a epsylon.**
Versión base: **Oasis 1.3.3** · Rama: `PRUEBAS` ·
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` ·
[Documentación completa](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki) ·
[Versión de escritorio](https://gitea.laenre.net/hacklab/OASIS_LINUX)
![Oasis Mobile: lanzador, Karvan, filtros y barra de atajos](docs/capturas/00-portada.png)
![Oasis Mobile: lanzador, filtros y barra de atajos](docs/capturas/00-portada.png)
**[⬇ Descargar la APK](https://gitea.laenre.net/hacklab/OASIS_MOBILE/releases)** ·
[⬇ 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 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.
- 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.
---
@ -39,77 +63,62 @@ Android 7 o superior, 64 bits · se instala junto al Oasis oficial sin pisarlo
| | Oasis oficial | Esta rama |
|---|---|---|
| **Salas efímeras** | — | Karvan: mensajes que solo viven en memoria y se autodestruyen |
| **Videollamadas** | — | Audio y vídeo por WebRTC, sin servidores de terceros |
| **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 |
| 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 |
Los nombres de las categorías, los módulos y el resto de la aplicación son los de
epsylon, sin tocar.
epsylon, sin tocar. Con el tema OasisMobile se ve el del propio dev.
---
## 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.
![Qué es Oasis](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/raw/img/esq/01-red.png)
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.
![Cómo llega un mensaje](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/raw/img/esq/02-gossip.png)
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.
![Qué es un pub](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/raw/img/esq/04-pub.png)
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.
![Tu registro](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/raw/img/esq/14-registro.png)
> 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).
---
## Karvan: salas que se borran solas
## Llamadas y salas
![Cómo funciona Karvan](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/raw/img/esq/11-karvan.png)
![Llamadas: las del dev, con el móvil enchufado](docs/capturas/esquema-llamadas.png)
Lo que se dice dentro **no toca el disco**, no entra en tu registro y no se replica a
nadie. Dos relojes corren a la vez —inactividad y tiempo absoluto— y la sala muere con
el que llegue antes.
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.
<p align="center">
<img src="docs/capturas/07-karvan-salas.png" width="270">
<img src="docs/capturas/03-karvan.png" width="270">
</p>
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.
---
## Videollamadas sin terceros
![Cómo funciona una llamada](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/raw/img/esq/12-llamadas.png)
El servidor solo sirve para que os encontréis: **la voz y el vídeo van directos** entre
los participantes, cifrados de extremo a extremo. Sin STUN de Google.
<p align="center">
<img src="https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/raw/img/12-permiso-camara.png" width="270">
<img src="https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/raw/img/15-llamada-controles.png" width="270">
</p>
Esto **no es posible con la APK oficial**: no declara los permisos de cámara y su
navegador interno los deniega. Por eso esta rama trae su propio envoltorio Android.
En la barra de arriba, junto al avatar, está el botón de *No molestar* del Teléfono.
---
@ -118,19 +127,43 @@ navegador interno los deniega. Por eso esta rama trae su propio envoltorio Andro
![La interfaz por dentro](docs/capturas/esquema-interfaz.png)
<p align="center">
<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">
<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">
</p>
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.
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.
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.
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
![Cómo va la rama encima de Oasis](docs/capturas/esquema-rama.png)
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).
---
@ -140,49 +173,66 @@ vídeos, wiki y blogs). En el móvil un workflow cambia los módulos, no el aspe
| | |
|---|---|
| **[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
# 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
./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
```
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.
![Qué hay dentro de la APK](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/raw/img/esq/16-arquitectura-movil.png)
---
## 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 |
| [Karvan](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/Karvan) | Salas efímeras, paso a paso |
| [Llamadas](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/Llamadas) | Cómo se llama y cómo montar un TURN |
| [Llamadas](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/Llamadas) | Teléfono y Salas en el móvil |
| [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 |
@ -194,12 +244,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

View file

@ -5,10 +5,15 @@ 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
videollamadas de Karvan no pueden funcionar por mucho que se parchee el manifest:
llamadas no pueden funcionar por mucho que se parchee el manifest:
la lista de permisos es estatica y hay que declararla al compilar.
Este wrapper resuelve las dos puertas: declara los permisos y los concede al
@ -57,6 +62,10 @@ 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)

View file

@ -14,9 +14,12 @@ android {
applicationId = "net.laenre.oasis"
minSdk = 24
targetSdk = 35
// el pipeline los pasa por entorno; a mano valen los de aqui
// 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"
versionCode = (System.getenv("OASIS_VERSION_CODE") ?: "1").toInt()
versionName = System.getenv("OASIS_VERSION_NAME") ?: "1.3.3"
versionName = System.getenv("OASIS_VERSION_NAME") ?: pkgVersion
ndk { abiFilters += listOf("arm64-v8a") }
}
@ -43,8 +46,10 @@ android {
packaging {
jniLibs {
// libnode.so son 49 MB: sin comprimir se carga por mmap y arranca mas rapido
useLegacyPackaging = false
// 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
}
}

View file

@ -3,14 +3,20 @@
<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 las videollamadas de Karvan.
<!-- Camara y microfono para el Telefono y las Salas de Oasis.
Se declaran aqui porque Android exige que la lista sea estatica, pero NO se
conceden al instalar: son permisos peligrosos y se piden en tiempo de ejecucion
la primera vez que se pulsa Llamar. Si nadie llama, no se pide nada y la app
la primera vez que se llama o se entra en una sala. Si nadie llama, no se pide nada y la app
nunca accede a la camara ni al microfono. -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
@ -46,7 +52,8 @@
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:launchMode="singleTask"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />

View file

@ -1,24 +1,33 @@
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() {
@ -57,7 +66,7 @@ class MainActivity : AppCompatActivity() {
startService(Intent(this, NodeService::class.java))
web = WebView(this)
setContentView(web)
setContentView(edgeToEdge(web))
WebView.setWebContentsDebuggingEnabled(BuildConfig.DEBUG)
web.settings.apply {
@ -143,9 +152,60 @@ 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
@ -158,9 +218,10 @@ 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:#121212;color:#FFB400;font-family:sans-serif;display:flex;
<style>body{background:#fff;color:#000;font-family:sans-serif;display:flex;
height:100vh;margin:0;align-items:center;justify-content:center;text-align:center}
div{padding:24px}p{opacity:.7;font-size:14px;line-height:1.5}</style></head>
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>
<body><div><h2>Oasis</h2>
<p>Preparando la aplicaci&oacute;n&hellip;<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>

View file

@ -6,6 +6,7 @@ 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
@ -33,16 +34,37 @@ 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)

View file

@ -1,7 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Oasis" parent="Theme.AppCompat.NoActionBar">
<item name="android:windowBackground">#121212</item>
<item name="android:statusBarColor">#121212</item>
<item name="android:windowBackground">#FFFFFF</item>
<item name="android:statusBarColor">#FFFFFF</item>
<item name="android:windowLightStatusBar">true</item>
</style>
</resources>

View file

@ -0,0 +1,76 @@
#!/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

View file

@ -7,7 +7,8 @@
# Uso:
# ./scripts/release.sh # compila debug y verifica
# ./scripts/release.sh --release # compila release firmada
# ./scripts/release.sh --release --publish
# ./scripts/release.sh --release --publish # borrador, para revisarla
# ./scripts/release.sh --release --publish --public # publicada: la descarga del README
#
# Variables:
# OASIS_KEYSTORE, OASIS_KEYSTORE_PASS, OASIS_KEY_ALIAS firma (obligatorias con --release)
@ -26,10 +27,12 @@ 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
@ -55,6 +58,9 @@ 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
@ -112,10 +118,21 @@ 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 \
|| die "la APK no esta firmada correctamente"
|| { cat /tmp/oasis-signer.txt >&2; 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
@ -139,13 +156,20 @@ 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);
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) \
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) \
|| die "no se ha podido crear la release (token sin permiso, o el tag ya existe)"
curl -sf -X POST "$API/releases/$RELEASE_ID/assets?name=oasis-$TAG.apk" \
# 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" \
-H "$AUTH" -F "attachment=@$APK" >/dev/null \
|| die "la release se ha creado pero no se ha podido subir la APK"
say "publicada como borrador: $GITEA_URL/$GITEA_REPO/releases"
say "revisala y quita el borrador a mano; no se publica sola a proposito"
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

126
android/scripts/sobre_oficial.sh Executable file
View file

@ -0,0 +1,126 @@
#!/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)"

106
docs/ESTUDIO_NAVEGACION.md Normal file
View file

@ -0,0 +1,106 @@
# 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.

163
docs/FRONTERA_UPSTREAM.md Normal file
View file

@ -0,0 +1,163 @@
# 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 |

254
docs/USABILIDAD_PAD.txt Normal file
View file

@ -0,0 +1,254 @@
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]

Binary file not shown.

Before

Width:  |  Height:  |  Size: 242 KiB

After

Width:  |  Height:  |  Size: 305 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 134 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

BIN
docs/capturas/02-tribus.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

BIN
docs/capturas/08-salas.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

View file

@ -0,0 +1,208 @@
#!/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()

7
docs/guia_pdf/README.md Normal file
View file

@ -0,0 +1,7 @@
# 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.

340
docs/guia_pdf/guia.html Normal file
View file

@ -0,0 +1,340 @@
<!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
View file

@ -26,6 +26,8 @@ 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) {}
@ -48,11 +50,43 @@ 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) => {
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);
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();
});
};

View file

@ -4675,7 +4675,7 @@ router
ctx.body = statsView(stats, filter);
})
.get("/modules", async (ctx) => {
const modules = ['blogs', 'polls', 'karvan', 'fediverse', 'invites', 'wallet', 'backup', 'dev', 'cipher', 'bookmarks', 'calendars', 'chats', 'videos', 'docs', 'audios', 'tags', 'images', 'maps', 'trending', 'events', 'tasks', 'market', 'tribes', 'larp', 'votes', 'reports', 'opinions', 'pads', 'transfers', 'feed', 'pixelia', 'melody', 'agenda', 'favorites', 'ai', 'forum', 'games', 'housing', 'jobs', 'projects', 'industry', 'shops', 'banking', 'school', 'parliament', 'courts'];
const modules = ['blogs', 'polls', 'fediverse', 'invites', 'wallet', 'backup', 'dev', 'cipher', 'bookmarks', 'calendars', 'chats', 'videos', 'docs', 'audios', 'tags', 'images', 'maps', 'trending', 'events', 'tasks', 'market', 'tribes', 'larp', 'votes', 'reports', 'opinions', 'pads', 'transfers', 'feed', 'pixelia', 'melody', 'agenda', 'favorites', 'ai', 'forum', 'games', 'housing', 'jobs', 'projects', 'industry', 'shops', 'banking', 'school', 'parliament', 'courts'];
const cfg = getConfig().modules;
ctx.body = modulesView(modules.reduce((acc, m) => { acc[`${m}Mod`] = cfg[`${m}Mod`]; return acc; }, {}));
})
@ -8486,7 +8486,7 @@ router
const available = {
profile: true,
ux: true,
workflow: isLoopbackRequest(ctx),
workflow: process.env.OASIS_MOBILE !== '1' && isLoopbackRequest(ctx),
theme: process.env.OASIS_MOBILE !== '1',
community: checkMod(ctx, 'invitesMod'),
greeting: checkMod(ctx, 'feedMod')
@ -15345,19 +15345,6 @@ 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 {
@ -15584,18 +15571,12 @@ 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();
},
// 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 }),
// 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 }),
routes,
async (ctx) => {
if (ctx.body != null || (ctx.method !== 'GET' && ctx.method !== 'HEAD')) return;

View file

@ -85,11 +85,12 @@ 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)); break; } // datos comprimidos: hasta el final
if (marker === 0xDA) { out.push(buf.subarray(i)); scan = true; 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);
@ -97,6 +98,8 @@ 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);
@ -150,14 +153,40 @@ const stripWebp = (buf) => {
return Buffer.concat([head, body]);
};
/** Limpia segun el formato; si algo no cuadra, devuelve la imagen tal cual. */
const stripMetadataPure = (buffer) => {
try {
const out = stripJpeg(buffer) || stripPng(buffer) || stripWebp(buffer);
return out && out.length ? out : buffer;
} catch {
return buffer;
// 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.
*/
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;
};
const stripImageMetadata = async (buffer) => {

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

@ -0,0 +1,152 @@
"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()]);
};

View file

@ -1,237 +0,0 @@
"use strict";
// Rutas del modulo Karvan (salas efimeras en RAM, mensajes temporales y
// senalizacion WebRTC), mas las dos pantallas que la rama anade a los ajustes.
//
// Upstream pone todas sus rutas en backend.js: 625 en una sola cadena fluida.
// Estas viven aparte porque ese fichero se reescribe entero en cada release
// (+833/-286 en la 0.9.2, +266/-31 en la 0.9.1), asi que una ruta anadida en
// medio entra en conflicto cada vez. Se montan en un router propio insertado
// antes del de upstream; lo que no casa cae a next() y sigue su curso.
//
// El nombre sigue a la familia del modulo: karvan_model.js, karvan_view.js,
// karvan.js, karvan.css.
//
// Las dependencias se inyectan en lugar de reconstruirse: cooler abre la conexion
// al sbot y duplicarla abriria un segundo cliente muxrpc.
const koaRouter = require("../server/node_modules/@koa/router");
const { koaBody } = require("../server/node_modules/koa-body");
const { stripDangerousTags } = require("./sanitizeHtml");
const { buildIceServers } = require("./turnCredentials");
const karvanModel = require('../models/karvan_model')({}); // FORK_IA_UX: salas efímeras en RAM (mensajes temporales + señalización WebRTC)
const { karvanView, karvanRoomView } = require("../views/karvan_view");
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();
};

View file

@ -0,0 +1,40 @@
"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();
};
};

View file

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

Binary file not shown.

View file

@ -0,0 +1,93 @@
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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

View file

@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 251 B

View file

@ -0,0 +1,507 @@
/* 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; }

View file

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

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,648 @@
/* 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; }
}

View file

@ -0,0 +1,195 @@
/* 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; }
}

View file

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

File diff suppressed because it is too large Load diff

View file

@ -3,7 +3,7 @@
// ficheros oasis_*.js (upstream los reescribe entero en cada release).
//
// Contiene tres grupos:
// 1. Claves que anade la rama: el modulo Karvan, el selector de identidades,
// 1. Claves que anade la rama: el selector de identidades,
// la barra inferior (bb*) y el filtro Personal/Social de la topbar.
// 2. Claves que upstream tenia en 0.9.1 y elimino en 0.9.5, pero que el
// codigo de la rama sigue usando (menuBlogs, spreadHint, chatShareUrl...).
@ -17,27 +17,58 @@
module.exports = {
en: {
// 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",
// 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.",
// Identidades
identitiesActive: "In use",
identitiesBackup: "Export or import an identity",
@ -80,27 +111,58 @@ module.exports = {
spreadMore: "more"
},
es: {
// 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",
// 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.",
// Identidades
identitiesActive: "En uso",
identitiesBackup: "Exportar o importar una identidad",
@ -149,27 +211,58 @@ module.exports = {
totalspreads: "Réplicas totales"
},
fr: {
// 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",
// 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.",
// Identidades
identitiesActive: "En cours d'utilisation",
identitiesBackup: "Exporter ou importer une identité",
@ -212,27 +305,58 @@ module.exports = {
spreadMore: "plus"
},
eu: {
// 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",
// 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.",
// Identidades
identitiesActive: "Erabiltzen",
identitiesBackup: "Esportatu edo inportatu nortasun bat",
@ -275,27 +399,58 @@ module.exports = {
spreadMore: "gehiago"
},
de: {
// 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",
// 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.",
// Identidades
identitiesActive: "In Benutzung",
identitiesBackup: "Identität exportieren oder importieren",
@ -338,27 +493,58 @@ module.exports = {
spreadMore: "mehr"
},
it: {
// 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",
// 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.",
// Identidades
identitiesActive: "In uso",
identitiesBackup: "Esporta o importa un'identità",
@ -401,27 +587,58 @@ module.exports = {
spreadMore: "altro"
},
pt: {
// 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",
// 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.",
// Identidades
identitiesActive: "Em uso",
identitiesBackup: "Exportar ou importar uma identidade",
@ -464,27 +681,58 @@ module.exports = {
spreadMore: "mais"
},
zh: {
// 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",
// 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 后重试。",
// Identidades
identitiesActive: "使用中",
identitiesBackup: "导出或导入身份",
@ -527,27 +775,58 @@ module.exports = {
spreadMore: "更多"
},
ar: {
// 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",
// 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 وحاول مجددًا.",
// Identidades
identitiesActive: "قيد الاستخدام",
identitiesBackup: "تصدير هوية أو استيرادها",
@ -590,27 +869,58 @@ module.exports = {
spreadMore: "المزيد"
},
hi: {
// 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",
// 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 के रूप में सहेजें और फिर कोशिश करें।",
// Identidades
identitiesActive: "उपयोग में",
identitiesBackup: "पहचान निर्यात या आयात करें",
@ -653,27 +963,58 @@ module.exports = {
spreadMore: "और"
},
ru: {
// 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",
// 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 и попробуйте снова.",
// Identidades
identitiesActive: "Используется",
identitiesBackup: "Экспортировать или импортировать личность",

View file

@ -1,316 +0,0 @@
/* KARVAN client — mensajes temporales + WebRTC (data-channel).
* Capa fiable: relay por servidor (RAM, autodestrucción) con polling.
* Capa turbo: data-channel WebRTC P2P (instantáneo) con "perfect negotiation".
* Todo degrada: si no hay WebRTC o no conecta, el chat sigue por el relay.
* Sin cámara/mic (solo data-channel) => no necesita permisos del wrapper. */
(function () {
"use strict";
var root = document.querySelector(".karvan-room");
if (!root) return;
var roomId = root.getAttribute("data-room");
var selfId = root.getAttribute("data-self") || "";
var peerId = "p" + Math.random().toString(16).slice(2, 12);
var fromLabel = selfId || peerId;
var listEl = document.getElementById("karvan-msgs");
var formEl = document.getElementById("karvan-form");
var textEl = document.getElementById("karvan-text");
var liveTxt = document.getElementById("karvan-live-txt");
var liveWrap = document.getElementById("karvan-live");
var seen = Object.create(null);
var lastSeq = 0;
var lastSig = 0;
var peers = Object.create(null);
// --- Fase 1: videollamada (media sobre el MISMO WebRTC del chat) ---
var callRoot = document.getElementById("karvan-call");
var localVid = document.getElementById("karvan-local");
var remoteEl = document.getElementById("karvan-remote");
var statusEl = document.getElementById("kc-status");
var btnStart = document.getElementById("kc-start");
var btnMic = document.getElementById("kc-mic");
var btnCam = document.getElementById("kc-cam");
var btnStop = document.getElementById("kc-stop");
var localStream = (window.MediaStream ? new MediaStream() : null);
if (localVid && localStream) localVid.srcObject = localStream;
var micActive = false, camActive = false;
function esc(s) { return String(s == null ? "" : s); }
function shortId(id) { id = esc(id || "?"); return id.charAt(0) === "@" ? id.slice(1, 7) : id.slice(0, 6); }
function showMsg(m, live) {
var key = m.mid ? "m" + m.mid : "s" + (m.seq || Math.random());
if (seen[key]) return;
seen[key] = 1;
var li = document.createElement("li");
li.className = "karvan-msg" + (m.from === fromLabel ? " karvan-msg-self" : "") + (live ? " karvan-msg-live" : "");
var f = document.createElement("span"); f.className = "km-from"; f.textContent = shortId(m.from);
var t = document.createElement("span"); t.className = "km-text"; t.textContent = esc(m.text);
li.appendChild(f); li.appendChild(t);
listEl.appendChild(li);
listEl.scrollTop = listEl.scrollHeight;
}
// --- relay por servidor (siempre activo) ---
function pollMsgs() {
fetch("/karvan/" + roomId + "/msgs?since=" + lastSeq, { headers: { Accept: "application/json" } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
if (!d || !d.messages) return;
d.messages.forEach(function (m) { if (m.seq > lastSeq) lastSeq = m.seq; showMsg(m, false); });
})
.catch(function () {});
}
function sendMessage(text) {
if (!text) return;
var mid = peerId + "-" + Date.now().toString(36) + Math.random().toString(16).slice(2, 6);
var m = { mid: mid, from: fromLabel, text: text };
showMsg(m, false); // optimista
broadcastDC({ kind: "chat", mid: mid, from: fromLabel, text: text }); // instantáneo a los pares
fetch("/karvan/" + roomId + "/msg", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ from: fromLabel, text: text, mid: mid })
}).catch(function () {});
}
if (formEl) {
formEl.addEventListener("submit", function (e) {
e.preventDefault();
var v = (textEl.value || "").trim();
if (!v) return;
textEl.value = "";
sendMessage(v);
});
}
// --- señalización (mailbox RAM del servidor) ---
function sendSig(to, payload) {
fetch("/karvan/" + roomId + "/signal", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ from: peerId, to: to || "", payload: payload })
}).catch(function () {});
}
function announce() { sendSig("", { kind: "hello" }); }
function pollSignals() {
fetch("/karvan/" + roomId + "/signal?for=" + peerId + "&since=" + lastSig, { headers: { Accept: "application/json" } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) {
if (!d) return;
(d.signals || []).forEach(function (s) {
if (s.seq > lastSig) lastSig = s.seq;
onSignal(s.from, s.payload);
});
(d.members || []).forEach(function (mid2) { if (mid2 && mid2 !== peerId) ensurePeer(mid2); });
})
.catch(function () {});
}
// --- WebRTC (data-channel, perfect negotiation) ---
var RTC = window.RTCPeerConnection || window.webkitRTCPeerConnection;
// Sin servidores ICE por defecto: solo candidatos host (LAN y NAT amable).
// Un STUN de terceros aprende la IP publica y el momento de cada llamada, asi que
// no se pone ninguno de fabrica. Si el nodo ofrece TURN propio, /karvan/ice lo da.
var rtcConfig = { iceServers: [] };
function setLive(on) {
if (!liveTxt) return;
liveTxt.textContent = on ? "🔒 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);
});
}
})();

View file

@ -0,0 +1,31 @@
// 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);
});
})();

View file

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

View file

@ -1,6 +1,6 @@
{
"themes": {
"current": "OasisMobile"
"current": "Acampada"
},
"ux": {
"current": "blocks"
@ -20,15 +20,14 @@
"tagsMod": "off",
"imagesMod": "on",
"trendingMod": "off",
"eventsMod": "off",
"eventsMod": "on",
"tasksMod": "off",
"marketMod": "off",
"votesMod": "off",
"tribesMod": "off",
"votesMod": "on",
"tribesMod": "on",
"reportsMod": "off",
"opinionsMod": "off",
"padsMod": "on",
"karvanMod": "on",
"calendarsMod": "off",
"transfersMod": "off",
"feedMod": "on",
@ -46,7 +45,7 @@
"industryMod": "off",
"bankingMod": "off",
"schoolMod": "off",
"parliamentMod": "off",
"parliamentMod": "on",
"courtsMod": "off",
"favoritesMod": "off",
"logsMod": "off",
@ -54,8 +53,8 @@
"chatsMod": "on",
"torrentsMod": "off",
"filesMod": "off",
"phoneMod": "off",
"roomsMod": "off",
"phoneMod": "on",
"roomsMod": "on",
"graphosMod": "off",
"larpMod": "off",
"backupMod": "on",

View file

@ -0,0 +1,39 @@
"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 };

View file

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

View file

@ -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', 'karvan', 'larp', 'logistics', 'logs', 'mailing', 'maps', 'market',
'housing', 'images', 'industry', 'invites', 'jobs', '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', 'karvan', ...NETWORK.filter(m => m !== 'rooms')
'games', 'audios', 'bookmarks', 'docs', 'files', 'images', 'torrents', 'emergencies', 'mailing', 'logistics', 'podcasts', 'campaigns', ...NETWORK.filter(m => m !== 'rooms')
];
const WORKFLOWS = [
@ -72,15 +72,17 @@ 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: ['chats', 'karvan', 'pads', 'forum', 'images', 'videos', 'wiki', 'blogs', 'feed', 'invites', 'backup']
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']
},
{
key: 'mobile',
theme: 'OasisMobile',
theme: 'Acampada',
homePage: 'activity',
modules: MOBILE_MODULES
}
@ -89,7 +91,15 @@ const WORKFLOWS = [
const WORKFLOW_KEYS = WORKFLOWS.map(w => w.key);
const isMobile = () => process.env.OASIS_MOBILE === '1';
const themeOf = (workflow) => (isMobile() ? 'OasisMobile' : workflow.theme);
// 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 getWorkflow = (key) => WORKFLOWS.find(w => w.key === String(key || '')) || null;
@ -103,7 +113,7 @@ const currentWorkflow = (config) => {
const theme = (config && config.themes && config.themes.current) || '';
return WORKFLOW_KEYS.find(key => {
const w = getWorkflow(key);
if (theme !== themeOf(w)) return false;
if (!sameTheme(theme, w)) return false;
const on = new Set(modulesOf(w));
return ALL_MODULES.every(m => (modules[`${m}Mod`] === 'on') === on.has(m));
}) || null;
@ -113,14 +123,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) };
if (workflow.theme) cfg.themes = { ...(cfg.themes || {}), current: themeOf(workflow, cfg) };
if (workflow.homePage) cfg.homePage = workflow.homePage;
return cfg;
};
const FULL_WORKFLOW = { key: 'full', modules: ALL_MODULES };
const WIZARD_WORKFLOWS = ['activists', 'full', 'social', 'kids', 'night', 'jobs', 'business', 'mobile'];
const WIZARD_WORKFLOWS = ['full', 'social', 'activists', 'kids', 'night', 'jobs', 'business', 'mobile'];
const wizardWorkflow = (key) => {
const k = String(key || '');

View file

@ -7,9 +7,10 @@
// 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, section, span } = require("../server/node_modules/hyperaxe");
const { a, button, div, form, h2, h3, img, input, label, nav, p, script, 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
@ -21,16 +22,13 @@ module.exports = ({ i18n }) => {
const renderMobileTopbar = () => {
// solo en movil: en escritorio manda la cabecera de upstream
if (process.env.OASIS_MOBILE !== '1') return "";
// 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__) || "";
// 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
return div(
{ class: "oasis-mobile-topbar" },
a({ class: "omt-logo", href: "/" }, img({ src: "/assets/images/snh-oasis.jpg", alt: "Oasis" })),
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"))
),
div({ class: "omt-menus" }),
a({ class: "omt-avatar", href: "/profile" }, img({ src: (typeof global !== "undefined" && global.__OASIS_SELF_AVATAR__) || "/assets/images/default-avatar.png", alt: i18n.profile || "Profile" }))
);
};
@ -42,7 +40,6 @@ module.exports = ({ i18n }) => {
{ key: 'activity', href: '/activity', glyph: 'ꔙ', labelKey: 'activityTitle', mod: null },
{ key: 'tribes', href: '/tribes', glyph: 'ꖥ', labelKey: 'tribesTitle', mod: 'tribesMod' },
{ key: 'chats', href: '/chats', glyph: 'ꖒ', labelKey: 'chatsTitle', mod: 'chatsMod' },
{ key: 'karvan', href: '/karvan', glyph: '⧗', labelKey: 'karvanTitle', mod: 'karvanMod' },
{ key: 'rooms', href: '/rooms', glyph: 'ꕢ', labelKey: 'roomsTitle', mod: 'roomsMod' },
{ key: 'wiki', href: '/wiki', glyph: 'ꖿ', labelKey: 'wikiTitle', mod: 'wikiMod' },
{ key: 'polls', href: '/polls', glyph: 'ꖙ', labelKey: 'pollsTitle', mod: 'pollsMod' },
@ -94,9 +91,13 @@ 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: "bb-item", href },
span({ class: "bb-ico" }, ico),
{ class: isHere(href) ? "bb-item current" : "bb-item", href, ...(isHere(href) ? { 'aria-current': 'page' } : {}) },
iconNode("bb-ico", iconKeyOf(href), ico),
(badge && badge > 0) ? span({ class: "bb-badge" }, String(badge)) : "",
span({ class: "bb-lbl" }, label)
);
@ -110,9 +111,28 @@ 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" } },
...slots
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' })
);
};
@ -124,7 +144,6 @@ module.exports = ({ i18n }) => {
{ href: '/activity', glyph: 'ꔙ', labelKey: 'activityTitle', mod: null },
{ href: '/feed', glyph: 'ꕿ', labelKey: 'feedTitle', mod: 'feedMod' },
{ href: '/chats', glyph: 'ꖒ', labelKey: 'chatsTitle', mod: 'chatsMod' },
{ href: '/karvan', glyph: '⧗', labelKey: 'karvanTitle', mod: 'karvanMod' },
{ href: '/rooms', glyph: 'ꕢ', labelKey: 'roomsTitle', mod: 'roomsMod' },
{ href: '/forum', glyph: 'ꕒ', labelKey: 'forumTitle', mod: 'forumMod' },
{ href: '/pads', glyph: 'ꔗ', labelKey: 'padsTitle', mod: 'padsMod' },
@ -211,7 +230,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 },
span({ class: 'ln-ico', 'aria-hidden': 'true' }, m.glyph),
iconNode('ln-ico', iconKeyOf(m.href), m.glyph),
span({ class: 'ln-lbl' }, i18n[m.labelKey] || m.href.slice(1)));
const groups = GROUPS
.filter(g => !hiveFilter || g.side === hiveFilter)
@ -243,14 +262,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' },
span({ class: 'bb-manage-ico' }, m.glyph),
iconNode('bb-manage-ico', iconKeyOf(m.href), 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' },
span({ class: 'bb-pick-ico' }, m.glyph),
iconNode('bb-pick-ico', iconKeyOf(m.href), m.glyph),
span({ class: 'bb-pick-lbl' }, i18n[m.labelKey] || m.key)
)
);
@ -279,5 +298,5 @@ module.exports = ({ i18n }) => {
);
};
return { renderMobileTopbar, renderBottomBar, renderHiveNav, PINNABLE_MODULES, bottomBarPickerView };
return { renderMobileTopbar, renderBottomBar, renderHiveNav, PINNABLE_MODULES, bottomBarPickerView, GROUPS };
};

View file

@ -50,20 +50,22 @@ module.exports = ({ i18n, html }) => {
button({ type: "submit" }, i18n.identitiesRestart))
: null;
return section(
return section({ class: "identities" },
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" },
form({ method: "POST", action: "/settings/identity/create", class: "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
p(a({ href: "/legacy", class: "identity-legacy" }, i18n.identitiesBackup)),
restart
a({ href: "/legacy", class: "identity-legacy" }, i18n.identitiesBackup)
)
);
};

View file

@ -1,123 +0,0 @@
"use strict";
// Vista del módulo KARVAN (mensajes temporales + WebRTC). El chat va por relay servidor (RAM,
// autodestrucción) y, si conectan pares, por data-channel WebRTC (instantáneo). El <script> solo
// se carga en la página de sala; el WebRTC usa data-channel (sin cámara/mic => sin permisos extra).
const { div, section, h2, h3, p, a, form, input, button, span, ul, li, script, label, video } =
require("../server/node_modules/hyperaxe");
const { template, i18n } = require("./main_views");
const 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>');

View file

@ -386,7 +386,9 @@ 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
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
? 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'))
@ -403,6 +405,8 @@ 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;
@ -437,8 +441,17 @@ exports.CONTENT_FAV_KIND = CONTENT_FAV_KIND;
exports.CONTENT_SPREADABLE = CONTENT_SPREADABLE;
exports.CONTENT_TORRENTABLE = CONTENT_TORRENTABLE;
exports.contentDeleteAction = contentDeleteAction;
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');
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;
const renderDocumentActions = (kind, id, leadingNodes = []) => {
const base = id ? `/${kind}/${encodeURIComponent(id)}` : `/${kind}`;
@ -961,6 +974,7 @@ 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(
@ -1087,17 +1101,6 @@ const navGroup = ({ id, emoji, title, defaultOpen = false }, ...items) => {
const renderKarvanLink = () => {
const karvanMod = getConfig().modules.karvanMod === "on";
return karvanMod
? navLink({
href: "/karvan",
emoji: "⧗",
text: i18n.karvanTitle,
class: "karvan-link enabled"
})
: "";
};
const renderPollsLink = () => {
const pollsMod = getConfig().modules.pollsMod === "on";
@ -1869,6 +1872,7 @@ 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);
@ -2003,11 +2007,9 @@ 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()}` }), themeLink ]
? [ link({ rel: "stylesheet", href: `/assets/styles/mobile.css?v=${assetVersion()}` }), acampadaBase(theme), themeLink, acampadaLink(theme) ]
: [ themeLink, link({ rel: "stylesheet", href: `/assets/styles/mobile.css?v=${assetVersion()}`, media: "(max-width: 768px)" }) ]),
// el modulo Karvan trae su propia hoja: asi se ve igual en cualquier tema
// y tambien en escritorio, donde no se carga OasisMobile.css
link({ rel: "stylesheet", href: `/assets/styles/karvan.css?v=${assetVersion()}` }),
process.env.OASIS_MOBILE === '1' ? link({ rel: "stylesheet", href: `/assets/styles/mobile_ux.css?v=${assetVersion()}` }) : null,
link({ rel: "icon", href: "/assets/images/favicon.svg" }),
meta({ charset: "utf-8" }),
meta({ name: "description", content: i18n.oasisDescription }),
@ -2177,11 +2179,13 @@ 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" }, i18n.welcomeBannerText),
a({ href: "/welcome", class: "create-button welcome-banner-start" }, i18n.welcomeBannerAction),
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),
form(
{ method: "POST", action: "/welcome/dismiss", class: "welcome-banner-close" },
button({ type: "submit", class: "welcome-banner-close-btn" }, "✕")
@ -2330,8 +2334,7 @@ const template = (titlePrefix, ...elements) => {
renderWikiLink(),
renderMapsLink(),
renderRoomsLink(),
renderChatsLink(),
renderKarvanLink()
renderChatsLink()
),
navGroup(
{
@ -2392,11 +2395,9 @@ 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()}` }), link({ rel: "stylesheet", href: `/assets/themes/${theme}.css?v=${assetVersion()}` }) ]
? [ link({ rel: "stylesheet", href: `/assets/styles/mobile.css?v=${assetVersion()}` }), acampadaBase(theme), link({ rel: "stylesheet", href: `/assets/themes/${theme}.css?v=${assetVersion()}` }), acampadaLink(theme) ]
: [ link({ rel: "stylesheet", href: `/assets/themes/${theme}.css?v=${assetVersion()}` }), link({ rel: "stylesheet", href: `/assets/styles/mobile.css?v=${assetVersion()}`, media: "(max-width: 768px)" }) ]),
// el modulo Karvan trae su propia hoja: asi se ve igual en cualquier tema
// y tambien en escritorio, donde no se carga OasisMobile.css
link({ rel: "stylesheet", href: `/assets/styles/karvan.css?v=${assetVersion()}` }),
process.env.OASIS_MOBILE === '1' ? link({ rel: "stylesheet", href: `/assets/styles/mobile_ux.css?v=${assetVersion()}` }) : null,
link({ rel: "icon", href: "/assets/images/favicon.svg" }),
meta({ charset: "utf-8" }),
meta({ name: "description", content: i18n.oasisDescription }),

106
src/views/mobile_icons.js Normal file
View file

@ -0,0 +1,106 @@
"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 };

View file

@ -0,0 +1,103 @@
"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 };

235
src/views/mobile_ui.js Normal file
View file

@ -0,0 +1,235 @@
"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
};

View file

@ -39,7 +39,6 @@ const modulesView = () => {
{ name: 'industry', label: i18n.modulesIndustryLabel, description: i18n.modulesIndustryDescription },
{ name: 'invites', label: i18n.modulesInvitesLabel, description: i18n.modulesInvitesDescription },
{ name: 'jobs', label: i18n.modulesJobsLabel, description: i18n.modulesJobsDescription },
{ name: 'karvan', label: i18n.modulesKarvanLabel, description: i18n.modulesKarvanDescription },
{ name: 'larp', label: i18n.modulesLarpLabel, description: i18n.modulesLarpDescription },
{ name: 'backup', label: i18n.modulesBackupLabel, description: i18n.modulesBackupDescription },
{ name: 'logs', label: i18n.modulesLogsLabel, description: i18n.modulesLogsDescription },

View file

@ -2,10 +2,11 @@ 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, userLink } = require('./main_views');
const { template, selectedLanguage, i18n, setLanguage, INBOX_BOT_ORDER, inboxBotLabel, THEMES, MOBILE_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 });
@ -58,7 +59,23 @@ const settingsView = (params) => {
const modOn = (name) => (currentConfig.modules || {})[`${name}Mod`] === 'on';
const isMobile = process.env.OASIS_MOBILE === '1';
const workflowSection = section({ id: "workflows" },
// 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" },
div({ class: "tags-header" },
h2(i18n.workflowsTitle),
p(i18n.workflowsDescription),
@ -128,7 +145,7 @@ const settingsView = (params) => {
div({ class: "tags-header" },
h2(i18n.language),
p(i18n.languageDescription),
form(
isMobile ? mobileUi.languagePick() : form(
{ action: "/language", method: "post" },
select({ name: "language" }, [
languageOption("English", "en"),
@ -155,7 +172,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."),
form(
isMobile ? mobileUi.uxPick("/settings/ux", currentConfig) : form(
{ action: "/settings/ux", method: "POST" },
(() => {
const aiNavEnabled = currentConfig.modules && currentConfig.modules.aiNavMod === 'on';
@ -181,7 +198,7 @@ const settingsView = (params) => {
)
)
),
section({ id: "theme" },
isMobile ? skinPicker() : section({ id: "theme" },
div({ class: "tags-header" },
h2(i18n.theme),
p(i18n.themeIntro),
@ -655,7 +672,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 : oasisParts)
...(filter === 'multiverse' ? multiverseParts : filter === 'content' ? contentParts : (isMobile ? mobileUi.settingsOrder(oasisParts) : oasisParts))
);
};

View file

@ -3,6 +3,7 @@ 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');
@ -286,7 +287,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(i18n.mapLocationTitle || 'Map Location'),
label(process.env.OASIS_MOBILE === '1' ? (i18n.tribeMapUrlLabel || 'Map link (optional)') : (i18n.mapLocationTitle || 'Map Location')),
br,
input({ type: 'text', name: 'mapUrl', placeholder: i18n.mapUrlPlaceholder || '/maps/MAP_ID', value: tribeToEdit.mapUrl || '' }),
br,
@ -304,6 +305,7 @@ 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),
@ -312,6 +314,7 @@ 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)
)
@ -322,6 +325,7 @@ 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);
@ -376,7 +380,7 @@ exports.tribesView = async (tribes, filter, tribeId, query = {}, allTribes = nul
: filter === 'gallery'
? renderGallery(shownTribes)
: tribeCards.length > 0
? div({ class: 'tribe-grid' }, tribeCards)
? div({ class: mobileUi.isMobile() ? 'tribe-list' : 'tribe-grid' }, tribeCards)
: div({ class: "no-content-box" }, p(i18n.noTribes))
),
...renderLightbox(shownTribes)
@ -409,6 +413,7 @@ 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' },

View file

@ -4,6 +4,7 @@ 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;
@ -21,7 +22,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" ? "yes" : winner === "NO" ? "no" : "pending";
const variant = (winner === "YES" || winner === "GREEN") ? "yes" : (winner === "NO" || winner === "RED") ? "no" : "pending";
return { text: voteLabel(winner), variant };
};
@ -213,6 +214,7 @@ 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"),
@ -224,7 +226,7 @@ const renderVoteDetail = (v, voteOptionsDefault, firstRow, secondRow, mode, acti
`${i18n.voteResults || "Results"}: `,
span({ class: `vote-outcome vote-outcome-${outcome.variant}` }, outcome.text)
),
div({ class: "vote-table" },
mobileUi.isMobile() ? mobileUi.voteBars(voteOptions, baseCounts, voteLabel) : div({ class: "vote-table" },
table(
tr(...voteOptions.map((opt) => th(voteLabel(opt)))),
tr(...voteOptions.map((opt) => td(baseCounts[opt])))
@ -351,6 +353,7 @@ 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)
)
)

View file

@ -3,6 +3,7 @@ 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)
@ -16,6 +17,7 @@ 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))
@ -38,6 +40,7 @@ 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"),
@ -52,6 +55,7 @@ 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)))
@ -102,7 +106,7 @@ const stepAction = (key, profile, status) => {
exports.welcomeView = async (status, profile = {}) => {
const steps = status.steps || {}
const usable = status.usable || []
const usable = mobileUi.isMobile() ? mobileUi.welcomeSteps(status.usable || []) : (status.usable || [])
const opened = usable.find(key => !steps[key])
const html = template(
i18n.welcomeTitle,
@ -111,7 +115,7 @@ exports.welcomeView = async (status, profile = {}) => {
h2(i18n.welcomeTitle),
p(i18n.welcomeDescription)
),
status.pending
status.pending && !mobileUi.isMobile()
? form({ method: "POST", action: "/welcome/dismiss", class: "welcome-action" },
input({ type: "hidden", name: "returnTo", value: "/" }),
button({ type: "submit" }, i18n.welcomeSkip)
@ -135,7 +139,13 @@ 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
}

View file

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