Compare commits
7 commits
PRUEBAS
...
0.8.7_UX_P
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9eb5123c03 | ||
|
|
8aaba6585a | ||
|
|
6c816bab94 | ||
|
|
c7419a44d5 | ||
|
|
bc0f55e914 | ||
|
|
9c100ed773 | ||
|
|
851042e576 |
195 changed files with 17445 additions and 29344 deletions
|
|
@ -1,53 +0,0 @@
|
|||
# Construye la APK y la deja publicada como borrador en las releases.
|
||||
#
|
||||
# NO se dispara sola con cada commit: la APK son mas de 100 MB y el backend tarda
|
||||
# en empaquetarse. Se lanza a mano desde la interfaz de gitea, o al crear un tag.
|
||||
#
|
||||
# El runner necesita: JDK 17 con compilador, el SDK de Android (plataforma 35 y
|
||||
# build-tools), node, zip y unzip. Un runner pelado NO vale.
|
||||
#
|
||||
# Secretos que hay que dar de alta en el repositorio:
|
||||
# OASIS_KEYSTORE_B64 el almacen de claves en base64
|
||||
# OASIS_KEYSTORE_PASS su contraseña
|
||||
# OASIS_KEY_ALIAS el alias (por defecto "oasis")
|
||||
#
|
||||
# El token para publicar lo pone gitea solo en GITEA_TOKEN.
|
||||
|
||||
name: APK
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # release.sh cuenta commits para el versionCode
|
||||
|
||||
- name: Restaurar el almacen de claves
|
||||
run: |
|
||||
echo "${{ secrets.OASIS_KEYSTORE_B64 }}" | base64 -d > /tmp/oasis.jks
|
||||
chmod 600 /tmp/oasis.jks
|
||||
|
||||
- name: Construir, verificar y publicar
|
||||
env:
|
||||
JAVA_HOME: /usr/lib/jvm/temurin-17-jdk-amd64
|
||||
ANDROID_HOME: /opt/android-sdk
|
||||
OASIS_KEYSTORE: /tmp/oasis.jks
|
||||
OASIS_KEYSTORE_PASS: ${{ secrets.OASIS_KEYSTORE_PASS }}
|
||||
OASIS_KEY_ALIAS: ${{ secrets.OASIS_KEY_ALIAS }}
|
||||
OASIS_GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
OASIS_GITEA_URL: ${{ github.server_url }}
|
||||
OASIS_GITEA_REPO: ${{ github.repository }}
|
||||
run: |
|
||||
cd android
|
||||
./scripts/release.sh --release --publish
|
||||
|
||||
- name: Borrar el almacen de claves
|
||||
if: always()
|
||||
run: rm -f /tmp/oasis.jks
|
||||
400
README.md
400
README.md
|
|
@ -1,184 +1,328 @@
|
|||
# OASIS_MOBILE — rama de desarrollo experimental
|
||||
# Oasis
|
||||
|
||||
> ## ⚠ ESTO NO ES OASIS OFICIAL
|
||||
>
|
||||
> Esta es una **rama de desarrollo experimental** mantenida por el hacklab, **no
|
||||
> afiliada** al proyecto original.
|
||||
>
|
||||
> ### El repositorio oficial de Oasis es el de epsylon: https://github.com/epsylon/oasis
|
||||
>
|
||||
> Si quieres **usar Oasis de verdad, instala el oficial**. Lo que hay aquí puede
|
||||
> romperse, cambiar de forma incompatible o desaparecer sin aviso. No está auditado y
|
||||
> no tiene garantía de ninguna clase.
|
||||
>
|
||||
> **Los fallos de esta rama se reportan aquí, nunca a epsylon.**
|
||||
Oasis is a **libre, open-source, encrypted, peer-to-peer, distributed (not decentralized!) & federated**... project networking application
|
||||
that helps you follow interesting content and discover new ones.
|
||||
|
||||
Versión base: **Oasis 0.9.5** · 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 redefines what it means to be connected in the modern world, giving people
|
||||
the ability to control their online presence and interactions without the need for centralized institutions.
|
||||
|
||||
---
|
||||
----------
|
||||
|
||||
## Lo que añade
|
||||
## Frontend:
|
||||
|
||||
| | 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 | Panal de hexágonos, filtros y barra de accesos rápidos |
|
||||
| **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 |
|
||||
Main features of the Oasis interface are:
|
||||
|
||||
Los nombres de las categorías, los módulos y el resto de la aplicación son los de
|
||||
epsylon, sin tocar.
|
||||
+ Data manipulation is not permitted due to the use of BLOCKCHAIN technology.
|
||||
+ No browser JavaScript. Just pure HTML+CSS. A really secure frontend!.
|
||||
+ Use your favorite web browser to read and write messages to the people you care about.
|
||||
+ Strong cryptography in every single point of the network.
|
||||
+ You are the center of your own distributed network. Online or offline, it works anywhere that you are.
|
||||
+ Initial identities are randomnly generated (no username or password required).
|
||||
+ No personal profile generated (no questions about gender, age, location, etc …).
|
||||
+ Automatic exif stripping (such as GPS coordinates) on images for better privacy.
|
||||
+ No email or associated mobile phone required.
|
||||
+ Automatic updates with new functionalities.
|
||||
|
||||
---
|
||||

|
||||
|
||||
## Qué es Oasis, en cuatro dibujos
|
||||
But it has others features that are also really interesting, for example:
|
||||
|
||||
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.
|
||||
+ Support for multiple languages.
|
||||
|
||||

|
||||

|
||||
|
||||
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.
|
||||
+ Modularity to set your own environment.
|
||||
|
||||

|
||||

|
||||
|
||||
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.
|
||||
+ Support for multiple themes (including Mobile Theme).
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
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.
|
||||
And some other nice modules, like for example:
|
||||
|
||||

|
||||
+ A complex Reddit-styled forum system.
|
||||
|
||||
> 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)**.
|
||||

|
||||
|
||||
---
|
||||
+ Or a collaborative -offline- maps system.
|
||||
|
||||
## Karvan: salas que se borran solas
|
||||

|
||||
|
||||

|
||||
And much more, that we invite you to discover by yourself ;-)
|
||||
|
||||
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.
|
||||

|
||||
|
||||
<p align="center">
|
||||
<img src="https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/raw/img/10-karvan-salas.png" width="270">
|
||||
<img src="https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/raw/img/11-karvan-sala.png" width="270">
|
||||
</p>
|
||||
----------
|
||||
|
||||
---
|
||||
## Modules:
|
||||
|
||||
## Videollamadas sin terceros
|
||||
Oasis is TRULY MODULAR. Here's a list of what comes deployed with the "core".
|
||||
|
||||

|
||||
+ Agenda: Module to manage all your assigned items.
|
||||
+ AI: Module to talk with a LLM called '42'.
|
||||
+ AINav: Module for natural-language queries about the network's content.
|
||||
+ Audios: Module to discover and manage audios.
|
||||
+ Banking: Module to determine the real value of ECOIN and distribute a UBI using the common treasury.
|
||||
+ BlockExplorer: Module to navigate the blockchain.
|
||||
+ Bookmarks: Module to discover and manage bookmarks.
|
||||
+ Calendars: Module to discover and manage calendars.
|
||||
+ Chats: Module to discover and manage encrypted chats.
|
||||
+ Cipher: Module to encrypt and decrypt your text symmetrically (using a shared password).
|
||||
+ Courts: Module to resolve conflicts and emit veredicts.
|
||||
+ Documents: Module to discover and manage documents.
|
||||
+ Events: Module to discover and manage events.
|
||||
+ Favorites: Module to manage your favorite content.
|
||||
+ Fediverse: Manage your other fediverse accounts, including sending and receiving content.
|
||||
+ Feed: Module to discover and share short-texts (feeds).
|
||||
+ Forums: Module to discover and manage forums.
|
||||
+ Games: Module to play and share your scores in various mini-games.
|
||||
+ Governance: Module to discover and manage votes.
|
||||
+ Graphos: Module to explore the network as an interactive map of peers.
|
||||
+ Images: Module to discover and manage images.
|
||||
+ Invites: Module to manage and apply invite codes.
|
||||
+ Jobs: Module to discover and manage jobs.
|
||||
+ Legacy: Module to manage your secret (private key) quickly and securely.
|
||||
+ Latest: Module to receive the most recent posts and discussions.
|
||||
+ L.A.R.P.: Module for a live-action role-playing layer with 9 houses.
|
||||
+ Logs: Module to record (via AI assistant) your experiences.
|
||||
+ Maps: Module to manage and share offline maps.
|
||||
+ Market: Module to exchange goods or services.
|
||||
+ Melody: Module to generate and share the "sound" of your blockchain.
|
||||
+ Multiverse: Module to receive content from other federated peers.
|
||||
+ Opinions: Module to discover and vote on opinions.
|
||||
+ Pads: Module to manage collaborative encrypted text editors.
|
||||
+ Parliament: Module to elect governments and vote on laws.
|
||||
+ Pixelia: Module to draw on a collaborative grid.
|
||||
+ Projects: Module to explore, crowd-funding and manage projects.
|
||||
+ Popular: Module to receive posts that are trending, most viewed, or most commented on.
|
||||
+ Reports: Module to manage and track reports related to issues, bugs, abuses, and content warnings.
|
||||
+ Shops: Module to manage and discover shops.
|
||||
+ Summaries: Module to receive summaries of long discussions or posts.
|
||||
+ Tags: Module to discover and explore taxonomy patterns (tags).
|
||||
+ Tasks: Module to discover and manage tasks.
|
||||
+ Threads: Module to receive conversations grouped by topic or question.
|
||||
+ Topics: Module to receive discussion categories based on shared interests.
|
||||
+ Torrents: Module to explore and manage torrents.
|
||||
+ Transfers: Module to discover and manage smart-contracts (transfers).
|
||||
+ Trending: Module to explore the most popular content.
|
||||
+ Tribes: Module to explore or create tribes (groups).
|
||||
+ Videos: Module to discover and manage videos.
|
||||
+ Wallet: Module to manage your digital assets (ECOin).
|
||||
|
||||
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.
|
||||
Both the codebase and the inhabitants can generate new modules.
|
||||
|
||||
<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.
|
||||
## C-AI (collective artificial intelligence)
|
||||
|
||||
---
|
||||
Oasis contains its own AI model called "42".
|
||||
|
||||
## La interfaz
|
||||
The main idea behind this implementation is to enable distributed learning generated through the collective action of many individuals, with the goal of redistributing the necessary processing load, as well as the ecological footprint and corporate bias.
|
||||
|
||||

|
||||

|
||||
|
||||
<p align="center">
|
||||
<img src="https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/raw/img/01-portada.png" width="240">
|
||||
<img src="https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/raw/img/04-hexagono-network.png" width="240">
|
||||
<img src="https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/raw/img/09-barra-con-karvan.png" width="240">
|
||||
</p>
|
||||
Our AI is trained with content from the OASIS network and its purpose is to take action and obtain answers to individual, but also global, problems.
|
||||
|
||||
Casi cincuenta módulos no caben en la pantalla de un teléfono: se reparten en un panal
|
||||
de categorías, dos filtros arriba y una barra de cuatro atajos que eliges tú.
|
||||
+ https://wiki.solarnethub.com/socialnet/ai
|
||||
|
||||
---
|
||||
----------
|
||||
|
||||
## Empezar desde cero
|
||||
## Parliament
|
||||
|
||||

|
||||
Oasis contains its own Parliament (Government system).
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **[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 |
|
||||

|
||||
|
||||
---
|
||||
## Courts
|
||||
|
||||
## Instalar
|
||||
Oasis contains its own Courts (Justice system).
|
||||
|
||||
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
|
||||
descomprime el servidor.
|
||||
## ECOin
|
||||
|
||||
> **Verifica la huella de la firma** antes de instalar una APK bajada de un servidor
|
||||
> propio: `apksigner verify --print-certs`
|
||||
Oasis contains its own cryptocurrency. With it, you can exchange items and services in the marketplace.
|
||||
|
||||
### Compilar
|
||||

|
||||
|
||||
```sh
|
||||
cd android
|
||||
./scripts/prepare.sh ../../oasis_mobile # librerías nativas y backend, la primera vez
|
||||
./scripts/release.sh # compila, verifica y calcula la huella
|
||||
```
|
||||
You can also receive a -Universal Basic Income- if you contribute to the Tribes and their coordinated actions.
|
||||
|
||||

|
||||
+ https://ecoin.03c8.net
|
||||
|
||||
---
|
||||
## Banking
|
||||
|
||||
## Documentación
|
||||
Oasis contains its own UBI (Universal Basic Income), distributed weekly using ECOin, and calculated by our AI through positive and efficient participation and trust.
|
||||
|
||||
Todo lo de arriba, en detalle, está en la
|
||||
**[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 |
|
||||
| [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 |
|
||||
| [Estructura del código](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/Estructura) | Dónde vive cada fichero y por qué ahí |
|
||||
| [Si algo va mal](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/Problemas) | Los fallos que han pasado de verdad |
|
||||
| [Qué NO tiene](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/Limitaciones) | Lo que falta respecto al oficial, sin adornos |
|
||||
----------
|
||||
|
||||
---
|
||||
## Carbon Footprinting
|
||||
|
||||
## El proyecto original
|
||||
Oasis contains its own carbon footprint meter.
|
||||
|
||||
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
|
||||
All transmissions, stored files, and interactions are measured to understand the environmental impact of the network.
|
||||
|
||||
## Licencia
|
||||

|
||||
|
||||
AGPL-3.0, la misma que el proyecto original.
|
||||
And also of each inhabitant.
|
||||
|
||||

|
||||
|
||||
----------
|
||||
|
||||
## L.A.R.P.
|
||||
|
||||
Oasis contains a L.A.R.P. (real action role-playing) structured around 1+8 main houses.
|
||||
|
||||

|
||||
|
||||
The main objective is to empower the inhabitants to organize around specific proposals and generate federated governments with specific characteristics.
|
||||
|
||||
+ https://wiki.solarnethub.com/socialnet/roleplaying#how_to_play
|
||||
|
||||
Check "The Houses" to review which one fit better with your ambitions:
|
||||
|
||||
+ https://wiki.solarnethub.com/socialnet/roleplaying#the_houses
|
||||
|
||||
----------
|
||||
|
||||
## Fediverse
|
||||
|
||||
Oasis bridges to the **Fediverse**. You can connect your fediverse accounts (ex: **Mastodon**) and use them for content from inside Oasis: read your home timeline, publish (text, images and video), reply, boost and favourite — without storing any third-party content in your SSB log (the feed is fetched live and shown ephemerally; only your credentials are kept locally).
|
||||
|
||||

|
||||
|
||||
Connect your account from **Settings → Fediverse**, then open it from the **Fediverse** menu. Each network lives in its own space.
|
||||
|
||||
+ Mastodon connect guide: [docs/FEDIVERSE/MASTODON/connect.md](docs/FEDIVERSE/MASTODON/connect.md)
|
||||
|
||||
----------
|
||||
|
||||
## Invite codes (for PUBs and TRIBES):
|
||||
|
||||
Oasis is a TRUSTNET. This means you need an invitation code to enter the PUBs (managed by inhabitants or hacklabs).
|
||||
|
||||
Similarly, TRIBES (groups in Oasis) require an entry code.
|
||||
|
||||

|
||||
|
||||
While you can use it and connect to any nodes you want, it's a good idea to get an entry code to connect with the community.
|
||||
|
||||
So you'll need to know someone, or participate in a collective action that distributes invitation codes, to see everything.
|
||||
|
||||
+ https://wiki.solarnethub.com/socialnet/snh#finding_inhabitants
|
||||
|
||||
----------
|
||||
|
||||
## Architecture:
|
||||
|
||||
Oasis uses a gossip protocol or epidemic protocol which is a procedure or process of computer peer-to peer communication
|
||||
that is based on the way epidemics spread.
|
||||
|
||||

|
||||
|
||||
This means that information is able to distribute across multiple machines, without requiring direct connections between them.
|
||||
|
||||

|
||||
|
||||
Even though Alice and Dan lack a direct connection, they can still exchange feeds:
|
||||
|
||||

|
||||
|
||||
This is because gossip creates “transitive” connections between computers. Dan's messages travel through Carla and the PUB
|
||||
to reach Alice, and visa-versa.
|
||||
|
||||
----------
|
||||
|
||||
## Backend:
|
||||
|
||||
Oasis is based on a mesh network and self-hosted social media ecosystem called Secure Scuttlebutt (SSB).
|
||||
|
||||
SSB uses a blockchain like append-only data structure and a fully decentralized P2P network. There are no servers or authorities
|
||||
of any kind. Like a crypto transaction, SSB posts are censorship-resistant and are replicated to the entire network.
|
||||
|
||||

|
||||
|
||||
In SSB each user hosts their own content and the content of the peers they follow, which provides fault tolerance and
|
||||
eventual consistency.
|
||||
|
||||
----------
|
||||
|
||||
## Installing:
|
||||
|
||||
Follow ['INSTALL.md'](docs/install/install.md) to build and install it on your device.
|
||||
|
||||
----------
|
||||
|
||||
## Setup & Deploy:
|
||||
|
||||
Visit ['Settings'](https://wiki.solarnethub.com/socialnet/snh#settings_minimal) to learn how to choose your language, set a theme & configure your avatar.
|
||||
|
||||
----------
|
||||
|
||||
## SNH-Hub (for HackLabs):
|
||||
|
||||
The public content of the ['PUB: "La Plaza"'](https://wiki.solarnethub.com/socialnet/snh-pub) can be visited from outside the [project network](https://wiki.solarnethub.com/socialnet/overview), through the [World Wide Web](https://en.wikipedia.org/wiki/World_Wide_Web) (aka [Clearnet](https://en.wikipedia.org/wiki/Clearnet_(networking))).
|
||||
|
||||

|
||||
|
||||
Just visit: https://pub.solarnethub.com/
|
||||
|
||||

|
||||
|
||||
And also you can visit periodically the public statistic of the SNH-PUB:
|
||||
|
||||

|
||||
|
||||
See stats: https://laplaza.solarnethub.com/
|
||||
|
||||
----------
|
||||
|
||||
## Roadmap:
|
||||
|
||||
Review ['Roadmap'](https://wiki.solarnethub.com/project/roadmap#the_project_network) to know about some required functionalities that can be implemented.
|
||||
|
||||
----------
|
||||
|
||||
## Translations:
|
||||
|
||||
Oasis supports multiple languages. One way to contribute is to translate the interface into your language so other people in your region can use it more intuitively.
|
||||
|
||||
+ https://wiki.solarnethub.com/socialnet/snh#choose_language
|
||||
|
||||
----------
|
||||
|
||||
## Development:
|
||||
|
||||
Oasis is completely coded in: node.js, HTML5 + CSS.
|
||||
|
||||
Check ['Call 4 Hackers'](https://wiki.solarnethub.com/community/hackers) for contributing with developments.
|
||||
|
||||
----------
|
||||
|
||||
## Links:
|
||||
|
||||
+ SNH Website: https://solarnethub.com
|
||||
+ Kräkens.Lab: https://krakenslab.com
|
||||
+ Documentation: https://wiki.solarnethub.com
|
||||
+ Research: https://wiki.solarnethub.com/docs/research
|
||||
+ Code of Conduct: https://wiki.solarnethub.com/docs/code_of_conduct
|
||||
+ The KIT: https://wiki.solarnethub.com/kit/overview
|
||||
+ Ecosystem: https://wiki.solarnethub.com/socialnet/ecosystem
|
||||
+ Project Network: https://wiki.solarnethub.com/socialnet/snh#the_project_network
|
||||
+ Oasis: https://wiki.solarnethub.com/socialnet/overview
|
||||
+ ECOin: https://wiki.solarnethub.com/ecoin/overview
|
||||
+ Role-playing (L.A.R.P): https://wiki.solarnethub.com/socialnet/roleplaying
|
||||
+ Warehouse: https://wiki.solarnethub.com/stock/submit_request
|
||||
+ THS: https://thehackerstyle.com
|
||||
+ PeerTube: https://video.hardlimit.com/c/thehackerstyle/videos
|
||||
+ Youtube: https://www.youtube.com/@thehackerstyle
|
||||
+ Twitch: https://twitch.tv/thehackerstyle
|
||||
|
|
|
|||
7
android/.gitignore
vendored
7
android/.gitignore
vendored
|
|
@ -1,7 +0,0 @@
|
|||
# artefactos y binarios grandes: los prepara scripts/prepare.sh
|
||||
app/src/main/jniLibs/
|
||||
app/src/main/assets/nodejs-project.zip
|
||||
.gradle/
|
||||
build/
|
||||
app/build/
|
||||
local.properties
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
# Wrapper Android propio
|
||||
|
||||
Proyecto Gradle para construir la APK de la rama sin depender del APK oficial como
|
||||
molde.
|
||||
|
||||
## Por que existe
|
||||
|
||||
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:
|
||||
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
|
||||
contenido web solo tras el consentimiento del usuario.
|
||||
|
||||
## Permisos: declarados, no concedidos
|
||||
|
||||
`CAMERA`, `RECORD_AUDIO` y `MODIFY_AUDIO_SETTINGS` figuran en el manifest porque
|
||||
Android exige que la lista sea fija. Pero son permisos peligrosos: **se piden en
|
||||
tiempo de ejecucion la primera vez que se pulsa Llamar**, no al instalar. Si nadie
|
||||
llama, no se pide nada y la app no accede a la camara ni al microfono.
|
||||
|
||||
`uses-feature ... required="false"` evita excluir de la tienda a dispositivos sin
|
||||
camara o sin microfono.
|
||||
|
||||
## Que reutiliza y que es nuevo
|
||||
|
||||
Las tres librerias nativas se toman tal cual del APK existente:
|
||||
|
||||
- `libnode.so` — nodejs-mobile **v18.20.4** oficial (OpenSSL 3.0.13+quic), un
|
||||
artefacto publico; no hay que compilar Node.
|
||||
- `libnative-lib.so` — 6,8 KB, un solo simbolo JNI:
|
||||
`Java_com_solarnethub_oasis_OasisActivity_startNodeWithArguments`.
|
||||
- `libc++_shared.so`
|
||||
|
||||
Por eso el `namespace` del modulo es `com.solarnethub.oasis`: JNI resuelve por
|
||||
nombre y la clase puente tiene que vivir en ese paquete. El `applicationId` si es
|
||||
propio (`net.laenre.oasis`), asi que la app se instala junto a la oficial sin
|
||||
pisarla.
|
||||
|
||||
Si algun dia se quiere soltar el `.so` de epsylon, reimplementar esos 6,8 KB son
|
||||
unas 40 lineas de C++ contra las cabeceras de nodejs-mobile, y ahi si haria falta
|
||||
el NDK.
|
||||
|
||||
## Mejoras sobre el APK actual
|
||||
|
||||
- `targetSdk 35` (el actual se quedo en uno ya caducado para la tienda).
|
||||
- Trafico en claro permitido **solo hacia loopback** via
|
||||
`network_security_config`, en vez de `usesCleartextTraffic="true"` global.
|
||||
- `allowBackup="false"`: `adb backup` deja de poder extraer `~/.ssb`.
|
||||
- El backend corre en un servicio en primer plano, en el proceso `:node`, asi que
|
||||
sobrevive en segundo plano — imprescindible para recibir llamadas. El APK actual
|
||||
declara `FOREGROUND_SERVICE` pero no registra ningun servicio.
|
||||
- Al descomprimir el backend se descartan las entradas con rutas sospechosas
|
||||
(zip slip).
|
||||
|
||||
## Construir
|
||||
|
||||
```sh
|
||||
# 1. dependencias del backend (una vez)
|
||||
(cd ../src/server && npm install)
|
||||
|
||||
# 2. librerias nativas + empaquetado del backend
|
||||
./scripts/prepare.sh ../../oasis_mobile # o la ruta de un APK
|
||||
|
||||
# 3. compilar
|
||||
./gradlew assembleDebug # o assembleRelease
|
||||
```
|
||||
|
||||
Para firmar la release, por entorno (la keystore nunca al repositorio):
|
||||
|
||||
```sh
|
||||
export OASIS_KEYSTORE=/ruta/oasis-alfa-key.jks
|
||||
export OASIS_KEYSTORE_PASS=...
|
||||
export OASIS_KEY_ALIAS=oasis
|
||||
./gradlew assembleRelease
|
||||
```
|
||||
|
||||
## Requisitos
|
||||
|
||||
JDK 17 o superior **con compilador**. El OpenJDK del sistema puede ser solo
|
||||
runtime; `gradle.properties` apunta al JDK que trae Android Studio. Cambia esa
|
||||
linea si compilas en otra maquina.
|
||||
|
||||
## Estado
|
||||
|
||||
Compila y genera APK con los permisos y el `onPermissionRequest` en su sitio.
|
||||
**Falta probarlo en un dispositivo real**: que arranque el backend, que la
|
||||
interfaz cargue y que `getUserMedia` resuelva al pulsar Llamar.
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
}
|
||||
|
||||
android {
|
||||
// El .so nativo exporta Java_com_solarnethub_oasis_OasisActivity_startNodeWithArguments,
|
||||
// asi que la clase puente tiene que vivir en ese paquete para que el simbolo resuelva.
|
||||
// El applicationId si es propio: la app se instala junto a la oficial sin pisarla.
|
||||
namespace = "com.solarnethub.oasis"
|
||||
compileSdk = 35
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "net.laenre.oasis"
|
||||
minSdk = 24
|
||||
targetSdk = 35
|
||||
// el pipeline los pasa por entorno; a mano valen los de aqui
|
||||
versionCode = (System.getenv("OASIS_VERSION_CODE") ?: "1").toInt()
|
||||
versionName = System.getenv("OASIS_VERSION_NAME") ?: "0.9.5"
|
||||
ndk { abiFilters += listOf("arm64-v8a") }
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
val ks = System.getenv("OASIS_KEYSTORE")
|
||||
if (ks != null) {
|
||||
storeFile = file(ks)
|
||||
storePassword = System.getenv("OASIS_KEYSTORE_PASS")
|
||||
keyAlias = System.getenv("OASIS_KEY_ALIAS") ?: "oasis"
|
||||
keyPassword = System.getenv("OASIS_KEY_PASS") ?: System.getenv("OASIS_KEYSTORE_PASS")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
if (System.getenv("OASIS_KEYSTORE") != null) signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
}
|
||||
|
||||
buildFeatures { buildConfig = true }
|
||||
|
||||
packaging {
|
||||
jniLibs {
|
||||
// libnode.so son 49 MB: sin comprimir se carga por mmap y arranca mas rapido
|
||||
useLegacyPackaging = false
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
kotlinOptions { jvmTarget = "17" }
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("androidx.appcompat:appcompat:1.7.0")
|
||||
implementation("androidx.core:core-ktx:1.13.1")
|
||||
}
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<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.
|
||||
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
|
||||
nunca accede a la camara ni al microfono. -->
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||
|
||||
<!-- required=false para no excluir de la tienda a dispositivos sin camara o sin microfono -->
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.microphone" android:required="false" />
|
||||
|
||||
<application
|
||||
android:label="@string/app_name"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:allowBackup="false"
|
||||
android:fullBackupContent="false"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Oasis">
|
||||
|
||||
<!-- El backend Node vive en su propio proceso: asi sobrevive en segundo plano
|
||||
y se puede reiniciar limpio (nodejs-mobile solo permite arrancar node una
|
||||
vez por proceso). -->
|
||||
<service
|
||||
android:name="net.laenre.oasis.NodeService"
|
||||
android:process=":node"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
|
||||
<!-- La lista de configChanges tiene que ser completa: si falta una sola (y la
|
||||
que faltaba era smallestScreenSize), rotar destruye y recrea la Activity,
|
||||
el WebView se pierde y vuelve a cargar la URL, a veces antes de que el
|
||||
backend escuche. -->
|
||||
<activity
|
||||
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">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
</application>
|
||||
</manifest>
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
package com.solarnethub.oasis
|
||||
|
||||
/**
|
||||
* Puente con el runtime de Node.
|
||||
*
|
||||
* El nombre de la clase y del metodo no son libres: libnative-lib.so exporta
|
||||
* Java_com_solarnethub_oasis_OasisActivity_startNodeWithArguments, y JNI resuelve
|
||||
* por nombre. De ahi que el namespace del modulo sea com.solarnethub.oasis aunque
|
||||
* el applicationId sea net.laenre.oasis.
|
||||
*
|
||||
* No es una Activity pese al nombre; solo declara el metodo nativo.
|
||||
*/
|
||||
object OasisActivity {
|
||||
init {
|
||||
System.loadLibrary("node")
|
||||
System.loadLibrary("native-lib")
|
||||
}
|
||||
|
||||
/** Arranca node en el hilo actual. No retorna hasta que el runtime termina. */
|
||||
@JvmStatic
|
||||
external fun startNodeWithArguments(arguments: Array<String>): Int
|
||||
}
|
||||
|
|
@ -1,243 +0,0 @@
|
|||
package net.laenre.oasis
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import android.webkit.PermissionRequest
|
||||
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 androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.solarnethub.oasis.BuildConfig
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var web: WebView
|
||||
private var pending: PermissionRequest? = null
|
||||
private var fileCallback: ValueCallback<Array<Uri>>? = null
|
||||
|
||||
private val pickFiles = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { res ->
|
||||
// Si se cancela hay que devolver null igualmente: si no, el <input> queda
|
||||
// bloqueado y no vuelve a abrir el selector nunca mas.
|
||||
fileCallback?.onReceiveValue(WebChromeClient.FileChooserParams.parseResult(res.resultCode, res.data))
|
||||
fileCallback = null
|
||||
}
|
||||
|
||||
private val askAndroid = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { granted ->
|
||||
val req = pending
|
||||
pending = null
|
||||
if (req == null) return@registerForActivityResult
|
||||
// Solo se concede al contenido web lo que el sistema nos ha concedido a nosotros.
|
||||
val allowed = req.resources.filter { res ->
|
||||
when (res) {
|
||||
PermissionRequest.RESOURCE_VIDEO_CAPTURE -> granted[Manifest.permission.CAMERA] == true
|
||||
PermissionRequest.RESOURCE_AUDIO_CAPTURE -> granted[Manifest.permission.RECORD_AUDIO] == true
|
||||
else -> false
|
||||
}
|
||||
}.toTypedArray()
|
||||
if (allowed.isEmpty()) req.deny() else req.grant(allowed)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
if (Build.VERSION.SDK_INT >= 33 && !has("android.permission.POST_NOTIFICATIONS")) {
|
||||
askAndroid.launch(arrayOf("android.permission.POST_NOTIFICATIONS"))
|
||||
}
|
||||
startService(Intent(this, NodeService::class.java))
|
||||
|
||||
web = WebView(this)
|
||||
setContentView(web)
|
||||
WebView.setWebContentsDebuggingEnabled(BuildConfig.DEBUG)
|
||||
|
||||
web.settings.apply {
|
||||
javaScriptEnabled = true
|
||||
domStorageEnabled = true
|
||||
mediaPlaybackRequiresUserGesture = false // permite reproducir el audio remoto de la llamada
|
||||
allowFileAccess = false
|
||||
allowContentAccess = false
|
||||
}
|
||||
|
||||
// La navegacion se queda dentro del backend local; lo de fuera va al navegador.
|
||||
web.webViewClient = object : WebViewClient() {
|
||||
override fun shouldOverrideUrlLoading(v: WebView?, r: WebResourceRequest?): Boolean {
|
||||
val u = r?.url ?: return false
|
||||
if (isLocal(u)) return false
|
||||
startActivity(Intent(Intent.ACTION_VIEW, u))
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Red de seguridad: si una pagina del backend no carga, se vuelve a esperar
|
||||
* y se reintenta, en vez de dejar el ERR_CONNECTION_REFUSED de Chromium en
|
||||
* pantalla. Pasa cuando el backend todavia no escucha (arranque lento), y
|
||||
* tambien cuando se ha reiniciado —al cambiar de identidad, o porque Android
|
||||
* mato el proceso del servicio estando en segundo plano—.
|
||||
*/
|
||||
override fun onPageFinished(v: WebView?, url: String?) {
|
||||
// se recuerda para poder volver aqui si luego hay que reintentar
|
||||
if (url != null && url.startsWith("http://127.0.0.1:3000")) lastGoodUrl = url
|
||||
}
|
||||
|
||||
override fun onReceivedError(v: WebView?, req: WebResourceRequest?, err: WebResourceError?) {
|
||||
if (req?.isForMainFrame != true) return // un css o una imagen no cuentan
|
||||
val u = req.url ?: return
|
||||
if (!isLocal(u)) return // paginas de fuera, ni tocarlas
|
||||
Log.w("OasisNode", "la pagina no ha cargado (${'$'}u); reintentando")
|
||||
loaded = false
|
||||
waitAndLoad()
|
||||
}
|
||||
}
|
||||
|
||||
// Sin este WebChromeClient.onPermissionRequest, el WebView DENIEGA por defecto
|
||||
// toda peticion de getUserMedia y no hay videollamada posible, aunque los
|
||||
// permisos esten declarados en el manifest. Es la puerta que faltaba.
|
||||
web.webChromeClient = object : WebChromeClient() {
|
||||
override fun onShowFileChooser(
|
||||
view: WebView?,
|
||||
callback: ValueCallback<Array<Uri>>?,
|
||||
params: FileChooserParams?
|
||||
): Boolean {
|
||||
fileCallback?.onReceiveValue(null) // descartar uno anterior sin resolver
|
||||
fileCallback = callback
|
||||
return try {
|
||||
pickFiles.launch(params?.createIntent())
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
fileCallback = null
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPermissionRequest(request: PermissionRequest) {
|
||||
runOnUiThread {
|
||||
if (!isLocal(request.origin)) { request.deny(); return@runOnUiThread }
|
||||
val needed = mutableListOf<String>()
|
||||
for (res in request.resources) {
|
||||
when (res) {
|
||||
PermissionRequest.RESOURCE_VIDEO_CAPTURE ->
|
||||
if (!has(Manifest.permission.CAMERA)) needed += Manifest.permission.CAMERA
|
||||
PermissionRequest.RESOURCE_AUDIO_CAPTURE ->
|
||||
if (!has(Manifest.permission.RECORD_AUDIO)) needed += Manifest.permission.RECORD_AUDIO
|
||||
}
|
||||
}
|
||||
if (needed.isEmpty()) {
|
||||
request.grant(request.resources)
|
||||
} else {
|
||||
// Aqui es donde el usuario ve el dialogo del sistema: al pulsar Llamar,
|
||||
// no al instalar la app.
|
||||
pending = request
|
||||
askAndroid.launch(needed.toTypedArray())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
waitAndLoad()
|
||||
}
|
||||
|
||||
private fun has(p: String) =
|
||||
ContextCompat.checkSelfPermission(this, p) == PackageManager.PERMISSION_GRANTED
|
||||
|
||||
private fun isLocal(u: Uri?): Boolean {
|
||||
val h = u?.host ?: return false
|
||||
return (h == "127.0.0.1" || h == "localhost") && u.port == 3000
|
||||
}
|
||||
|
||||
private fun showStatus(attempt: Int) {
|
||||
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;
|
||||
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>
|
||||
<body><div><h2>Oasis</h2>
|
||||
<p>Preparando la aplicación…<br>El primer arranque descomprime el
|
||||
servidor y puede tardar un minuto. No hace falta hacer nada.</p><p>${s}s</p></div></body></html>
|
||||
""".trimIndent(), "text/html", "utf-8", null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Carga la interfaz reintentando hasta que el backend responda.
|
||||
*
|
||||
* Se hace con el propio WebView en vez de sondear con HttpURLConnection: es el
|
||||
* canal que de verdad va a servir la app, y evita depender de que la peticion de
|
||||
* comprobacion pase los mismos filtros (el backend, por ejemplo, responde 400 a HEAD).
|
||||
*/
|
||||
private var loadAttempt = 0
|
||||
private var loaded = false
|
||||
@Volatile private var waiting = false // hay un hilo esperando al backend
|
||||
private var lastGoodUrl: String? = null // ultima pagina que cargo bien
|
||||
|
||||
/**
|
||||
* Espera a que el backend acepte conexiones y entonces carga la interfaz.
|
||||
*
|
||||
* La comprobacion es un socket TCP, no una peticion HTTP: no depende del metodo
|
||||
* (el backend responde 400 a HEAD), ni de redirecciones (/ redirige a /activity),
|
||||
* ni de la politica de trafico en claro. Si el puerto acepta, esta listo.
|
||||
*
|
||||
* La pantalla de estado se pinta ANTES de empezar a esperar, nunca entre medias:
|
||||
* cargarla mientras el WebView trae la pagina buena cancelaria esa carga.
|
||||
*/
|
||||
/** Ruta a abrir: la del intent si viene con una, o la raiz. */
|
||||
private fun targetUrl(): String {
|
||||
val d = intent?.data
|
||||
if (d != null && isLocal(d)) return d.toString()
|
||||
val path = intent?.getStringExtra("path")
|
||||
if (!path.isNullOrBlank() && path.startsWith("/")) return "http://127.0.0.1:3000${'$'}path"
|
||||
return "http://127.0.0.1:3000/"
|
||||
}
|
||||
|
||||
override fun onNewIntent(newIntent: Intent) {
|
||||
super.onNewIntent(newIntent)
|
||||
intent = newIntent
|
||||
if (loaded) web.loadUrl(targetUrl())
|
||||
}
|
||||
|
||||
private fun waitAndLoad() {
|
||||
// onReceivedError puede dispararse varias veces seguidas (la pagina y sus
|
||||
// recursos); sin esto se acumularian hilos esperando al mismo puerto
|
||||
if (waiting) return
|
||||
waiting = true
|
||||
showStatus(0)
|
||||
Log.i("OasisNode", "esperando al backend en 127.0.0.1:3000")
|
||||
Thread {
|
||||
var i = 0
|
||||
while (i < 600 && !loaded) { // hasta 5 minutos
|
||||
val ok = try {
|
||||
java.net.Socket().use { it.connect(java.net.InetSocketAddress("127.0.0.1", 3000), 1000); true }
|
||||
} catch (e: Exception) {
|
||||
if (i % 10 == 0) Log.w("OasisNode", "socket #" + i + ": " + e.javaClass.simpleName + ": " + e.message)
|
||||
false
|
||||
}
|
||||
if (ok) {
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
loaded = true
|
||||
waiting = false
|
||||
Log.i("OasisNode", "backend listo tras " + (i / 2) + "s, cargando interfaz")
|
||||
web.loadUrl(lastGoodUrl ?: targetUrl())
|
||||
}
|
||||
return@Thread
|
||||
}
|
||||
if (i % 8 == 0) Handler(Looper.getMainLooper()).post { if (!loaded) showStatus(i) }
|
||||
Thread.sleep(500); i++
|
||||
}
|
||||
waiting = false
|
||||
}.start()
|
||||
}
|
||||
|
||||
@Deprecated("Deprecated in Java")
|
||||
override fun onBackPressed() {
|
||||
if (web.canGoBack()) web.goBack() else super.onBackPressed()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,117 +0,0 @@
|
|||
package net.laenre.oasis
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.system.Os
|
||||
import android.util.Log
|
||||
import com.solarnethub.oasis.OasisActivity
|
||||
import com.solarnethub.oasis.R
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.util.zip.ZipInputStream
|
||||
|
||||
/**
|
||||
* Servicio en primer plano que sostiene el backend Node.
|
||||
*
|
||||
* Vive en el proceso :node por dos razones: nodejs-mobile solo permite arrancar el
|
||||
* runtime una vez por proceso —cambiar de identidad exige matar el proceso, no vale
|
||||
* reiniciar dentro—, y asi el backend sobrevive cuando la app pasa a segundo plano,
|
||||
* que es imprescindible para recibir llamadas.
|
||||
*/
|
||||
class NodeService : Service() {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "OasisNode"
|
||||
private const val CHANNEL = "oasis-node"
|
||||
private const val NOTIF_ID = 1
|
||||
@Volatile private var started = false
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
startForeground(NOTIF_ID, buildNotification())
|
||||
if (started) return
|
||||
started = true
|
||||
Thread({ runNode() }, "node-main").start()
|
||||
}
|
||||
|
||||
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)
|
||||
ch.setShowBadge(false)
|
||||
(getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager).createNotificationChannel(ch)
|
||||
}
|
||||
val b = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) Notification.Builder(this, CHANNEL)
|
||||
else @Suppress("DEPRECATION") Notification.Builder(this)
|
||||
return b.setContentTitle(getString(R.string.app_name))
|
||||
.setContentText(getString(R.string.node_running))
|
||||
.setSmallIcon(android.R.drawable.stat_notify_sync)
|
||||
.setOngoing(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun runNode() {
|
||||
try {
|
||||
val root = File(filesDir, "nodejs-project")
|
||||
extractProjectIfNeeded(root)
|
||||
val main = File(root, "main.js")
|
||||
if (!main.exists()) { Log.e(TAG, "no encuentro main.js en ${root.absolutePath}"); return }
|
||||
|
||||
// main.js hace process.env.HOME || resolve(__dirname,'..','..'). En Android HOME
|
||||
// suele venir como "/" —no escribible—, asi que se fija explicitamente al
|
||||
// almacenamiento privado de la app: ahi cuelga ~/.ssb.
|
||||
val home = filesDir.parentFile ?: filesDir
|
||||
val tmp = File(home, "tmp").apply { mkdirs() }
|
||||
Os.setenv("HOME", home.absolutePath, true)
|
||||
Os.setenv("TMPDIR", tmp.absolutePath, true)
|
||||
Log.i(TAG, "HOME=${home.absolutePath}")
|
||||
|
||||
val rc = OasisActivity.startNodeWithArguments(arrayOf("node", main.absolutePath))
|
||||
Log.w(TAG, "el runtime de node ha terminado con codigo $rc")
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, "fallo arrancando node", e)
|
||||
}
|
||||
}
|
||||
|
||||
/** Descomprime assets/nodejs-project.zip la primera vez y tras cada actualizacion. */
|
||||
private fun extractProjectIfNeeded(root: File) {
|
||||
val stamp = File(root, ".version")
|
||||
// Marca por tamano y fecha del asset, no por versionName: al reconstruir la APK
|
||||
// sin subir la version, con versionName la app seguiria con el backend anterior.
|
||||
// lastUpdateTime cambia en cada instalacion, tambien reinstalando la misma version.
|
||||
// (openFd no vale: el asset va comprimido dentro del APK y no admite descriptor.)
|
||||
val current = packageManager.getPackageInfo(packageName, 0).lastUpdateTime.toString()
|
||||
if (stamp.exists() && stamp.readText() == current) return
|
||||
if (root.exists()) root.deleteRecursively()
|
||||
root.mkdirs()
|
||||
assets.open("nodejs-project.zip").use { input ->
|
||||
ZipInputStream(input).use { zip ->
|
||||
var entry = zip.nextEntry
|
||||
while (entry != null) {
|
||||
val out = File(root, entry.name.removePrefix("nodejs-project/"))
|
||||
// no confiar en las rutas del zip: evita escribir fuera del directorio (zip slip)
|
||||
if (!out.canonicalPath.startsWith(root.canonicalPath + File.separator) &&
|
||||
out.canonicalPath != root.canonicalPath) {
|
||||
Log.w(TAG, "entrada de zip descartada por ruta sospechosa: ${entry.name}")
|
||||
} else if (entry.isDirectory) {
|
||||
out.mkdirs()
|
||||
} else {
|
||||
out.parentFile?.mkdirs()
|
||||
FileOutputStream(out).use { fos -> zip.copyTo(fos) }
|
||||
}
|
||||
zip.closeEntry()
|
||||
entry = zip.nextEntry
|
||||
}
|
||||
}
|
||||
}
|
||||
stamp.writeText(current)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Hexagono, el mismo motivo que la rejilla de navegacion de la interfaz -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp" android:height="108dp"
|
||||
android:viewportWidth="108" android:viewportHeight="108">
|
||||
<path
|
||||
android:pathData="M54,28 L74,40 L74,64 L54,76 L34,64 L34,40 Z"
|
||||
android:strokeColor="#FFB400" android:strokeWidth="5" android:fillColor="#00000000"/>
|
||||
<path
|
||||
android:pathData="M54,44 L63,49 L63,59 L54,64 L45,59 L45,49 Z"
|
||||
android:fillColor="#FFB400"/>
|
||||
</vector>
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#121212</color>
|
||||
</resources>
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Oasis</string>
|
||||
<string name="node_channel">Servicio de Oasis</string>
|
||||
<string name="node_running">Oasis esta activo</string>
|
||||
</resources>
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
<?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>
|
||||
</style>
|
||||
</resources>
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- El backend corre en 127.0.0.1:3000 sin TLS, asi que hay que permitir texto en
|
||||
claro, pero SOLO hacia loopback. El resto de la red queda en cifrado obligatorio.
|
||||
El orden importa: el esquema de Android exige base-config antes que domain-config. -->
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="false" />
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
<domain includeSubdomains="false">127.0.0.1</domain>
|
||||
<domain includeSubdomains="false">localhost</domain>
|
||||
</domain-config>
|
||||
</network-security-config>
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
plugins {
|
||||
id("com.android.application") version "8.5.2" apply false
|
||||
id("org.jetbrains.kotlin.android") version "1.9.24" apply false
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
org.gradle.jvmargs=-Xmx2048m
|
||||
android.useAndroidX=true
|
||||
kotlin.code.style=official
|
||||
android.nonTransitiveRClass=true
|
||||
|
||||
# El OpenJDK del sistema es solo runtime (sin javac). Se usa el JDK que trae
|
||||
# Android Studio. Si compilas en otra maquina, cambia esta ruta o instala un JDK 17+.
|
||||
org.gradle.java.home=/opt/android-studio/jbr
|
||||
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
Binary file not shown.
|
|
@ -1,7 +0,0 @@
|
|||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
252
android/gradlew
vendored
252
android/gradlew
vendored
|
|
@ -1,252 +0,0 @@
|
|||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015-2021 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
|
||||
' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
org.gradle.wrapper.GradleWrapperMain \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
94
android/gradlew.bat
vendored
94
android/gradlew.bat
vendored
|
|
@ -1,94 +0,0 @@
|
|||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
|
|
@ -1,80 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# Prepara lo que el proyecto Android no lleva en git:
|
||||
# - las librerias nativas (libnode.so son 49 MB; no tiene sentido versionarlas)
|
||||
# - assets/nodejs-project.zip con el backend Node y sus dependencias
|
||||
#
|
||||
# Uso: ./scripts/prepare.sh [ruta-a-un-apk-o-directorio-con-lib/arm64-v8a]
|
||||
set -euo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
REPO="$(cd "$HERE/.." && pwd)"
|
||||
JNI="$HERE/app/src/main/jniLibs/arm64-v8a"
|
||||
ASSETS="$HERE/app/src/main/assets"
|
||||
SRC="${1:-}"
|
||||
|
||||
mkdir -p "$JNI" "$ASSETS"
|
||||
|
||||
# --- 1) librerias nativas -----------------------------------------------------
|
||||
need_libs=(libnode.so libnative-lib.so libc++_shared.so)
|
||||
missing=0
|
||||
for l in "${need_libs[@]}"; do [ -f "$JNI/$l" ] || missing=1; done
|
||||
|
||||
if [ "$missing" = "1" ]; then
|
||||
if [ -z "$SRC" ]; then
|
||||
echo "Faltan las librerias nativas en $JNI"
|
||||
echo "Pasa un APK o un directorio que tenga lib/arm64-v8a/, por ejemplo:"
|
||||
echo " ./scripts/prepare.sh ../../oasis_mobile"
|
||||
exit 1
|
||||
fi
|
||||
if [ -d "$SRC/lib/arm64-v8a" ]; then
|
||||
cp "$SRC/lib/arm64-v8a"/*.so "$JNI/"
|
||||
elif [ -f "$SRC" ]; then
|
||||
tmp="$(mktemp -d)"; unzip -qo "$SRC" 'lib/arm64-v8a/*' -d "$tmp"
|
||||
cp "$tmp/lib/arm64-v8a"/*.so "$JNI/"; rm -rf "$tmp"
|
||||
else
|
||||
echo "No encuentro librerias en $SRC"; exit 1
|
||||
fi
|
||||
echo "librerias nativas copiadas:"; ls -la "$JNI" | tail -n +2 | awk '{print " "$NF" ("$5" bytes)"}'
|
||||
fi
|
||||
|
||||
# --- 2) backend + dependencias ---------------------------------------------
|
||||
# El node_modules del movil esta podado: son ~137 MB frente a los 1,4 GB de una
|
||||
# instalacion de escritorio. En vez de reinstalarlo, se reutiliza el del APK base:
|
||||
# comprobado que las 98 dependencias son identicas entre 0.9.1 y 0.9.5.
|
||||
BASE_ZIP=""
|
||||
if [ -n "$SRC" ] && [ -f "$SRC" ]; then
|
||||
tmpz="$(mktemp -d)"; unzip -qo "$SRC" 'assets/nodejs-project.zip' -d "$tmpz" 2>/dev/null || true
|
||||
[ -f "$tmpz/assets/nodejs-project.zip" ] && BASE_ZIP="$tmpz/assets/nodejs-project.zip"
|
||||
fi
|
||||
|
||||
work="$(mktemp -d)"
|
||||
if [ -n "$BASE_ZIP" ]; then
|
||||
( cd "$work" && unzip -q "$BASE_ZIP" )
|
||||
mv "$work/nodejs-project/src/server/node_modules" "$work/_nm"
|
||||
rm -rf "$work/nodejs-project/src"
|
||||
elif [ -d "$REPO/src/server/node_modules" ]; then
|
||||
mkdir -p "$work/nodejs-project"
|
||||
cp -r "$REPO/src/server/node_modules" "$work/_nm"
|
||||
else
|
||||
echo "No hay node_modules. Pasa un APK base del que sacarlo, o instalalas:"
|
||||
echo " (cd $REPO/src/server && npm install --omit=dev)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$work/nodejs-project"
|
||||
cp -r "$REPO/src" "$work/nodejs-project/"
|
||||
# backend.js lee README.md con readFileSync SIN try/catch (linea ~1690):
|
||||
# si falta, el backend muere con ENOENT al arrancar.
|
||||
cp "$REPO/main.js" "$REPO/package.json" "$REPO/README.md" "$work/nodejs-project/" 2>/dev/null || true
|
||||
test -f "$work/nodejs-project/README.md" || { echo "FALTA README.md: el backend no arrancaria"; exit 1; }
|
||||
rm -rf "$work/nodejs-project/src/server/node_modules"
|
||||
mv "$work/_nm" "$work/nodejs-project/src/server/node_modules"
|
||||
|
||||
# --- 3) empaquetar ------------------------------------------------------------
|
||||
# Con compresion: sin ella el backend son ~145 MB y la APK se iria por encima de 200.
|
||||
rm -f "$ASSETS/nodejs-project.zip"
|
||||
( cd "$work" && zip -q -r "$ASSETS/nodejs-project.zip" nodejs-project )
|
||||
rm -rf "$work" "${tmpz:-/nonexistent}"
|
||||
echo "backend empaquetado: $(du -h "$ASSETS/nodejs-project.zip" | cut -f1) en assets/nodejs-project.zip"
|
||||
echo
|
||||
echo "Listo. Ahora: ./gradlew assembleDebug (o assembleRelease para firmar)"
|
||||
|
|
@ -1,151 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# Construye la APK, la verifica y (si se le pasa un token) publica la release en
|
||||
# gitea. Pensado para lanzarlo a mano o desde un runner; hace lo mismo en los dos
|
||||
# sitios, que es lo que evita el clasico "en mi maquina si sale".
|
||||
#
|
||||
# Uso:
|
||||
# ./scripts/release.sh # compila debug y verifica
|
||||
# ./scripts/release.sh --release # compila release firmada
|
||||
# ./scripts/release.sh --release --publish
|
||||
#
|
||||
# Variables:
|
||||
# OASIS_KEYSTORE, OASIS_KEYSTORE_PASS, OASIS_KEY_ALIAS firma (obligatorias con --release)
|
||||
# OASIS_GITEA_TOKEN publicar (obligatoria con --publish)
|
||||
# OASIS_APK_BASE APK del que sacar node_modules la primera vez
|
||||
# OASIS_GITEA_URL por defecto https://gitea.laenre.net
|
||||
# OASIS_GITEA_REPO por defecto hacklab/OASIS_MOBILE
|
||||
# ANDROID_HOME por defecto ~/Android/Sdk
|
||||
# JAVA_HOME un JDK 17 o superior CON compilador
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
REPO="$(cd "$HERE/.." && pwd)"
|
||||
cd "$HERE"
|
||||
|
||||
MODE=debug
|
||||
PUBLISH=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--release) MODE=release ;;
|
||||
--publish) PUBLISH=1 ;;
|
||||
*) echo "opcion desconocida: $arg"; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
GITEA_URL="${OASIS_GITEA_URL:-https://gitea.laenre.net}"
|
||||
GITEA_REPO="${OASIS_GITEA_REPO:-hacklab/OASIS_MOBILE}"
|
||||
SDK="${ANDROID_HOME:-$HOME/Android/Sdk}"
|
||||
|
||||
say() { printf '\n== %s\n' "$*"; }
|
||||
die() { printf '\n!! %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
# --- 1) version -------------------------------------------------------------
|
||||
# El nombre sale de package.json, que es de donde lo saca tambien el backend, para
|
||||
# que la version que muestra la aplicacion y la de la APK no puedan discrepar.
|
||||
VERSION_NAME="${OASIS_VERSION_NAME:-$(node -p "require('$REPO/package.json').version" 2>/dev/null || echo 0.0.0)}"
|
||||
# El codigo tiene que subir en cada publicacion o Android rechaza la instalacion:
|
||||
# el numero de commits sirve y no hay que llevarlo a mano.
|
||||
VERSION_CODE="${OASIS_VERSION_CODE:-$(git -C "$REPO" rev-list --count HEAD 2>/dev/null || echo 1)}"
|
||||
TAG="v${VERSION_NAME}-${VERSION_CODE}"
|
||||
export OASIS_VERSION_NAME="$VERSION_NAME" OASIS_VERSION_CODE="$VERSION_CODE"
|
||||
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"
|
||||
[ -d "$SDK" ] || die "no encuentro el SDK de Android en $SDK (define ANDROID_HOME)"
|
||||
|
||||
if [ "$MODE" = release ]; then
|
||||
[ -n "${OASIS_KEYSTORE:-}" ] || die "--release necesita OASIS_KEYSTORE"
|
||||
[ -f "$OASIS_KEYSTORE" ] || die "no existe el almacen de claves: $OASIS_KEYSTORE"
|
||||
[ -n "${OASIS_KEYSTORE_PASS:-}" ] || die "--release necesita OASIS_KEYSTORE_PASS"
|
||||
fi
|
||||
if [ "$PUBLISH" = 1 ]; then
|
||||
[ -n "${OASIS_GITEA_TOKEN:-}" ] || die "--publish necesita OASIS_GITEA_TOKEN"
|
||||
[ "$MODE" = release ] || die "no se publica una APK de depuracion"
|
||||
fi
|
||||
|
||||
# --- 3) backend empaquetado -------------------------------------------------
|
||||
# prepare.sh es idempotente pero tarda; se rehace siempre porque el backend cambia
|
||||
# en cada commit y una APK con el backend viejo es peor que no tener APK.
|
||||
# La primera vez hace falta un APK base del que sacar node_modules: el del movil
|
||||
# esta podado y sin binarios nativos, y un npm install normal no lo reproduce.
|
||||
say "empaquetando el backend"
|
||||
./scripts/prepare.sh ${OASIS_APK_BASE:+"$OASIS_APK_BASE"} \
|
||||
|| die "prepare.sh ha fallado (define OASIS_APK_BASE con un APK del que sacar node_modules)"
|
||||
|
||||
ZIP=app/src/main/assets/nodejs-project.zip
|
||||
[ -f "$ZIP" ] || die "falta $ZIP"
|
||||
ZIP_MB=$(( $(stat -c%s "$ZIP") / 1024 / 1024 ))
|
||||
[ "$ZIP_MB" -ge 20 ] || die "el backend empaquetado son $ZIP_MB MB: se ha quedado a medias"
|
||||
say "backend: ${ZIP_MB} MB"
|
||||
|
||||
# --- 4) compilar ------------------------------------------------------------
|
||||
say "compilando"
|
||||
if [ "$MODE" = release ]; then
|
||||
./gradlew --quiet assembleRelease
|
||||
APK=$(ls app/build/outputs/apk/release/*.apk | head -1)
|
||||
else
|
||||
./gradlew --quiet assembleDebug
|
||||
APK=$(ls app/build/outputs/apk/debug/*.apk | head -1)
|
||||
fi
|
||||
[ -f "$APK" ] || die "no se ha generado ninguna APK"
|
||||
say "APK: $APK ($(( $(stat -c%s "$APK") / 1024 / 1024 )) MB)"
|
||||
|
||||
# --- 5) verificar -----------------------------------------------------------
|
||||
# Estas tres comprobaciones son las que han cazado fallos de verdad: una APK que
|
||||
# compila e instala puede no llevar el backend dentro, o haber perdido los permisos.
|
||||
BT=$(ls -d "$SDK"/build-tools/* 2>/dev/null | sort -V | tail -1)
|
||||
[ -n "$BT" ] || die "no encuentro build-tools en $SDK"
|
||||
|
||||
say "verificando"
|
||||
# ojo con pipefail: grep -q cierra la tuberia y el productor muere con SIGPIPE,
|
||||
# asi que se vuelca a fichero antes de mirar
|
||||
LISTING=$(mktemp); PERMS=$(mktemp)
|
||||
trap 'rm -f "$LISTING" "$PERMS"' EXIT
|
||||
unzip -l "$APK" > "$LISTING"
|
||||
"$BT/aapt2" dump permissions "$APK" > "$PERMS" 2>/dev/null || true
|
||||
|
||||
grep -q "assets/nodejs-project.zip" "$LISTING" || die "la APK no lleva el backend dentro"
|
||||
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"
|
||||
|
||||
if [ "$MODE" = release ]; then
|
||||
"$BT/apksigner" verify --print-certs "$APK" > /tmp/oasis-signer.txt 2>&1 \
|
||||
|| 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
|
||||
|
||||
SHA=$(sha256sum "$APK" | cut -d' ' -f1)
|
||||
say "sha256 de la APK: $SHA"
|
||||
|
||||
# --- 6) publicar ------------------------------------------------------------
|
||||
if [ "$PUBLISH" != 1 ]; then
|
||||
say "listo (sin publicar)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
say "publicando $TAG en $GITEA_REPO"
|
||||
API="$GITEA_URL/api/v1/repos/$GITEA_REPO"
|
||||
AUTH="Authorization: token $OASIS_GITEA_TOKEN"
|
||||
|
||||
NOTES=$(printf 'Rama experimental del hacklab sobre el Oasis de epsylon.\n\nNO es Oasis oficial: https://github.com/epsylon/oasis\n\nVersion base: %s (codigo %s)\n\nsha256 de la APK: %s\nHuella del certificado: %s\n\nComprueba la huella antes de instalar:\n\n apksigner verify --print-certs oasis.apk\n' \
|
||||
"$VERSION_NAME" "$VERSION_CODE" "$SHA" "${FINGERPRINT:-sin firmar}")
|
||||
|
||||
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) \
|
||||
|| 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" \
|
||||
-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"
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
pluginManagement {
|
||||
repositories { google(); mavenCentral(); gradlePluginPortal() }
|
||||
}
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories { google(); mavenCentral() }
|
||||
}
|
||||
rootProject.name = "OasisMobile"
|
||||
include(":app")
|
||||
144
main.js
144
main.js
|
|
@ -67,9 +67,153 @@ console.log('[Oasis] Launching Oasis Social Network Utopia...');
|
|||
console.log('[Oasis] Backend path:', backendPath);
|
||||
console.log('[Oasis] HOME:', process.env.HOME);
|
||||
|
||||
const http = require('http');
|
||||
const keylock = require('./src/models/keylock');
|
||||
const configPath = path.join(process.env.HOME, '.ssb');
|
||||
|
||||
let backendStarted = false;
|
||||
function startBackend() {
|
||||
if (backendStarted) return;
|
||||
backendStarted = true;
|
||||
try {
|
||||
require(backendPath);
|
||||
console.log('[Oasis] Backend started on port 3000');
|
||||
} catch (error) {
|
||||
console.error('[Oasis] Error starting backend:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function unlockAndStart(keys) {
|
||||
global.__OASIS_KEYS__ = keys;
|
||||
for (const s of sockets) { try { s.destroy(); } catch (_) {} }
|
||||
try { lockServer.close(() => startBackend()); } catch (_) { startBackend(); }
|
||||
}
|
||||
|
||||
const MODE = keylock.isConfigured(configPath) ? 'unlock' : 'setup';
|
||||
|
||||
const LOCK_LANGS = ['en','es','fr','eu','de','it','pt','zh','ar','hi','ru'];
|
||||
const LOCK_I18N = {
|
||||
en: { name:"English", t_setup:"Create your password", t_unlock:"Unlock Oasis", s_setup:"It encrypts your identity and keys on this device. <b>If you forget it, you lose the account</b> (no recovery).", s_unlock:"Enter your password to decrypt your keys and start Oasis.", ph:"Password", ph2:"Repeat password", b_setup:"Create and enter", b_unlock:"Enter", e_min:"Minimum 6 characters", e_mism:"Passwords do not match", e_wrong:"Incorrect password", e_pre:"Error: ", m_unlock:"Unlocking…" },
|
||||
es: { name:"Español", t_setup:"Crea tu contraseña", t_unlock:"Desbloquea Oasis", s_setup:"Cifrará tu identidad y tus claves en este dispositivo. <b>Si la olvidas, pierdes la cuenta</b> (no hay recuperación).", s_unlock:"Introduce tu contraseña para descifrar tus claves y arrancar Oasis.", ph:"Contraseña", ph2:"Repite la contraseña", b_setup:"Crear y entrar", b_unlock:"Entrar", e_min:"Mínimo 6 caracteres", e_mism:"Las contraseñas no coinciden", e_wrong:"Contraseña incorrecta", e_pre:"Error: ", m_unlock:"Desbloqueando…" },
|
||||
fr: { name:"Français", t_setup:"Créez votre mot de passe", t_unlock:"Déverrouiller Oasis", s_setup:"Il chiffre votre identité et vos clés sur cet appareil. <b>Si vous l'oubliez, vous perdez le compte</b> (aucune récupération).", s_unlock:"Saisissez votre mot de passe pour déchiffrer vos clés et démarrer Oasis.", ph:"Mot de passe", ph2:"Répétez le mot de passe", b_setup:"Créer et entrer", b_unlock:"Entrer", e_min:"6 caractères minimum", e_mism:"Les mots de passe ne correspondent pas", e_wrong:"Mot de passe incorrect", e_pre:"Erreur : ", m_unlock:"Déverrouillage…" },
|
||||
eu: { name:"Euskara", t_setup:"Sortu zure pasahitza", t_unlock:"Desblokeatu Oasis", s_setup:"Zure identitatea eta gakoak gailu honetan zifratzen ditu. <b>Ahazten baduzu, kontua galtzen duzu</b> (ez dago berreskuratzerik).", s_unlock:"Sartu zure pasahitza gakoak deszifratu eta Oasis abiarazteko.", ph:"Pasahitza", ph2:"Errepikatu pasahitza", b_setup:"Sortu eta sartu", b_unlock:"Sartu", e_min:"Gutxienez 6 karaktere", e_mism:"Pasahitzak ez datoz bat", e_wrong:"Pasahitz okerra", e_pre:"Errorea: ", m_unlock:"Desblokeatzen…" },
|
||||
de: { name:"Deutsch", t_setup:"Erstelle dein Passwort", t_unlock:"Oasis entsperren", s_setup:"Es verschlüsselt deine Identität und Schlüssel auf diesem Gerät. <b>Wenn du es vergisst, verlierst du das Konto</b> (keine Wiederherstellung).", s_unlock:"Gib dein Passwort ein, um deine Schlüssel zu entschlüsseln und Oasis zu starten.", ph:"Passwort", ph2:"Passwort wiederholen", b_setup:"Erstellen und eintreten", b_unlock:"Eintreten", e_min:"Mindestens 6 Zeichen", e_mism:"Passwörter stimmen nicht überein", e_wrong:"Falsches Passwort", e_pre:"Fehler: ", m_unlock:"Entsperren…" },
|
||||
it: { name:"Italiano", t_setup:"Crea la tua password", t_unlock:"Sblocca Oasis", s_setup:"Cifra la tua identità e le tue chiavi su questo dispositivo. <b>Se la dimentichi, perdi l'account</b> (nessun recupero).", s_unlock:"Inserisci la password per decifrare le chiavi e avviare Oasis.", ph:"Password", ph2:"Ripeti la password", b_setup:"Crea ed entra", b_unlock:"Entra", e_min:"Minimo 6 caratteri", e_mism:"Le password non coincidono", e_wrong:"Password errata", e_pre:"Errore: ", m_unlock:"Sblocco…" },
|
||||
pt: { name:"Português", t_setup:"Crie a sua palavra-passe", t_unlock:"Desbloquear o Oasis", s_setup:"Cifra a sua identidade e chaves neste dispositivo. <b>Se a esquecer, perde a conta</b> (sem recuperação).", s_unlock:"Introduza a sua palavra-passe para decifrar as chaves e iniciar o Oasis.", ph:"Palavra-passe", ph2:"Repita a palavra-passe", b_setup:"Criar e entrar", b_unlock:"Entrar", e_min:"Mínimo 6 caracteres", e_mism:"As palavras-passe não coincidem", e_wrong:"Palavra-passe incorreta", e_pre:"Erro: ", m_unlock:"A desbloquear…" },
|
||||
zh: { name:"中文", t_setup:"创建你的密码", t_unlock:"解锁 Oasis", s_setup:"它会在此设备上加密你的身份和密钥。<b>如果忘记,你将失去账户</b>(无法恢复)。", s_unlock:"输入密码以解密你的密钥并启动 Oasis。", ph:"密码", ph2:"重复密码", b_setup:"创建并进入", b_unlock:"进入", e_min:"至少 6 个字符", e_mism:"密码不一致", e_wrong:"密码错误", e_pre:"错误:", m_unlock:"正在解锁…" },
|
||||
ar: { name:"العربية", t_setup:"أنشئ كلمة المرور", t_unlock:"فتح Oasis", s_setup:"يشفّر هويتك ومفاتيحك على هذا الجهاز. <b>إذا نسيتها، تفقد الحساب</b> (لا استعادة).", s_unlock:"أدخل كلمة المرور لفك تشفير مفاتيحك وتشغيل Oasis.", ph:"كلمة المرور", ph2:"أعد كلمة المرور", b_setup:"إنشاء ودخول", b_unlock:"دخول", e_min:"6 أحرف على الأقل", e_mism:"كلمتا المرور غير متطابقتين", e_wrong:"كلمة مرور غير صحيحة", e_pre:"خطأ: ", m_unlock:"جارٍ الفتح…" },
|
||||
hi: { name:"हिन्दी", t_setup:"अपना पासवर्ड बनाएं", t_unlock:"Oasis अनलॉक करें", s_setup:"यह इस डिवाइस पर आपकी पहचान और कुंजियाँ एन्क्रिप्ट करता है। <b>भूल गए तो खाता खो देंगे</b> (कोई रिकवरी नहीं)।", s_unlock:"अपनी कुंजियाँ डिक्रिप्ट करने और Oasis शुरू करने के लिए पासवर्ड दर्ज करें।", ph:"पासवर्ड", ph2:"पासवर्ड दोहराएँ", b_setup:"बनाएँ और प्रवेश करें", b_unlock:"प्रवेश करें", e_min:"कम से कम 6 अक्षर", e_mism:"पासवर्ड मेल नहीं खाते", e_wrong:"गलत पासवर्ड", e_pre:"त्रुटि: ", m_unlock:"अनलॉक हो रहा है…" },
|
||||
ru: { name:"Русский", t_setup:"Создайте пароль", t_unlock:"Разблокировать Oasis", s_setup:"Он шифрует вашу личность и ключи на этом устройстве. <b>Если забудете, потеряете аккаунт</b> (без восстановления).", s_unlock:"Введите пароль, чтобы расшифровать ключи и запустить Oasis.", ph:"Пароль", ph2:"Повторите пароль", b_setup:"Создать и войти", b_unlock:"Войти", e_min:"Минимум 6 символов", e_mism:"Пароли не совпадают", e_wrong:"Неверный пароль", e_pre:"Ошибка: ", m_unlock:"Разблокировка…" },
|
||||
};
|
||||
|
||||
function lockPage(mode) {
|
||||
const confirmField = mode === 'setup'
|
||||
? '<input id="c" type="password" inputmode="text" autocomplete="new-password" />'
|
||||
: '';
|
||||
return '<!doctype html><!-- OASIS_LOCK_MARKER --><html lang="en"><head><meta charset="utf-8">'
|
||||
+ '<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">'
|
||||
+ '<title>Oasis</title><style>'
|
||||
+ '*{box-sizing:border-box}body{margin:0;background:#121212;color:#eee;font-family:system-ui,sans-serif;'
|
||||
+ 'min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px}'
|
||||
+ '.card{width:100%;max-width:360px;text-align:center}h1{color:#FFD700;font-size:22px;margin:0 0 8px}'
|
||||
+ 'p{color:#aaa;font-size:14px;line-height:1.4;margin:0 0 20px}'
|
||||
+ 'input{width:100%;padding:14px;margin:8px 0;border-radius:10px;border:1px solid #333;'
|
||||
+ 'background:#1c1c1c;color:#eee;font-size:16px}'
|
||||
+ 'button[type=submit]{width:100%;padding:14px;margin-top:12px;border:0;border-radius:10px;background:#FFD700;'
|
||||
+ 'color:#111;font-size:16px;font-weight:600}button[type=submit]:disabled{opacity:.5}'
|
||||
+ '#err{color:#ff6b6b;font-size:13px;min-height:18px;margin-top:8px}#msg{color:#FFD700;font-size:14px}'
|
||||
+ '#langbtn{position:fixed;top:calc(env(safe-area-inset-top,0px) + 12px);right:12px;width:42px;height:42px;'
|
||||
+ 'border-radius:50%;border:1px solid #333;background:#1c1c1c;color:#FFD700;font-size:13px;font-weight:700;'
|
||||
+ 'padding:0;z-index:10;cursor:pointer;display:flex;align-items:center;justify-content:center}'
|
||||
+ '.langmenu{position:fixed;top:calc(env(safe-area-inset-top,0px) + 60px);right:12px;background:#1c1c1c;'
|
||||
+ 'border:1px solid #333;border-radius:12px;padding:6px;z-index:11;max-height:70vh;overflow:auto;min-width:150px;'
|
||||
+ 'box-shadow:0 6px 20px rgba(0,0,0,.5)}'
|
||||
+ '.langmenu button{display:block;width:100%;text-align:left;background:transparent;color:#eee;border:0;'
|
||||
+ 'border-radius:8px;padding:11px 12px;font-size:15px;margin:0;font-weight:400;cursor:pointer}'
|
||||
+ '.langmenu button.cur{color:#FFD700;font-weight:700;background:#262626}'
|
||||
+ '[dir=rtl] #langbtn,[dir=rtl] .langmenu{right:auto;left:12px}[dir=rtl] .langmenu button{text-align:right}'
|
||||
+ '</style></head><body>'
|
||||
+ '<button id="langbtn" type="button" aria-label="Language">EN</button>'
|
||||
+ '<div id="langmenu" class="langmenu" hidden></div>'
|
||||
+ '<div class="card"><h1 id="title"></h1><p id="sub"></p>'
|
||||
+ '<form id="f"><input id="p" type="password" inputmode="text" autocomplete="' + (mode === 'setup' ? 'new-password' : 'current-password') + '" autofocus />'
|
||||
+ confirmField
|
||||
+ '<button type="submit" id="btn"></button>'
|
||||
+ '<div id="err"></div><div id="msg"></div></form></div>'
|
||||
+ '<script>'
|
||||
+ 'var MODE="' + mode + '";var I18N=' + JSON.stringify(LOCK_I18N) + ';var LANGS=' + JSON.stringify(LOCK_LANGS) + ';'
|
||||
+ 'var $=function(id){return document.getElementById(id);};'
|
||||
+ 'function getLang(){try{var s=localStorage.getItem("oasisLockLang");if(s&&I18N[s])return s;}catch(e){}return "en";}'
|
||||
+ 'var LANG=getLang();'
|
||||
+ 'function setCookie(l){try{document.cookie="language="+l+";path=/;max-age=31536000";}catch(e){}}'
|
||||
+ 'try{if(localStorage.getItem("oasisLockLang"))setCookie(LANG);}catch(e){}'
|
||||
+ 'function apply(){var t=I18N[LANG]||I18N.en;'
|
||||
+ 'document.documentElement.lang=LANG;document.documentElement.dir=(LANG==="ar")?"rtl":"ltr";'
|
||||
+ '$("title").innerHTML=(MODE==="setup")?t.t_setup:t.t_unlock;'
|
||||
+ '$("sub").innerHTML=(MODE==="setup")?t.s_setup:t.s_unlock;'
|
||||
+ '$("p").placeholder=t.ph;var c=$("c");if(c)c.placeholder=t.ph2;'
|
||||
+ '$("btn").textContent=(MODE==="setup")?t.b_setup:t.b_unlock;'
|
||||
+ '$("langbtn").textContent=LANG.toUpperCase();}'
|
||||
+ 'function buildMenu(){var m=$("langmenu");m.innerHTML="";LANGS.forEach(function(l){'
|
||||
+ 'var b=document.createElement("button");b.type="button";b.textContent=(I18N[l]&&I18N[l].name)||l;'
|
||||
+ 'if(l===LANG)b.className="cur";b.onclick=function(){LANG=l;try{localStorage.setItem("oasisLockLang",l);}catch(e){}'
|
||||
+ 'setCookie(l);apply();buildMenu();m.hidden=true;};m.appendChild(b);});}'
|
||||
+ '$("langbtn").onclick=function(){var m=$("langmenu");m.hidden=!m.hidden;};'
|
||||
+ 'document.addEventListener("click",function(e){var m=$("langmenu");if(!m.hidden&&e.target!==$("langbtn")&&!m.contains(e.target))m.hidden=true;});'
|
||||
+ 'buildMenu();apply();'
|
||||
+ 'var f=$("f"),err=$("err"),msg=$("msg");'
|
||||
+ 'f.addEventListener("submit",async function(e){e.preventDefault();var t=I18N[LANG]||I18N.en;err.textContent="";'
|
||||
+ 'var p=$("p").value,c=$("c");'
|
||||
+ 'if(MODE==="setup"){if(p.length<6){err.textContent=t.e_min;return;}if(c&&c.value!==p){err.textContent=t.e_mism;return;}}'
|
||||
+ 'var btn=$("btn");btn.disabled=true;'
|
||||
+ 'try{var r=await fetch(MODE==="setup"?"/__keylock/setup":"/__keylock/unlock",{method:"POST",'
|
||||
+ 'headers:{"Content-Type":"application/json"},body:JSON.stringify({password:p})});'
|
||||
+ 'if(r.ok){msg.textContent=t.m_unlock;waitReload();}else{err.textContent=t.e_wrong;btn.disabled=false;}}'
|
||||
+ 'catch(e2){err.textContent=t.e_pre+e2.message;btn.disabled=false;}});'
|
||||
+ 'async function waitReload(){for(var i=0;i<120;i++){try{var r=await fetch("/?_k="+Date.now(),{cache:"no-store"});'
|
||||
+ 'var t=await r.text();if(t.indexOf("OASIS_LOCK_MARKER")===-1){location.reload();return;}}catch(e){}'
|
||||
+ 'await new Promise(function(s){setTimeout(s,500);});}location.reload();}'
|
||||
+ '</script></body></html>';
|
||||
}
|
||||
|
||||
function readBody(req) {
|
||||
return new Promise((resolve) => {
|
||||
let b = '';
|
||||
req.on('data', (d) => { b += d; if (b.length > 1e5) req.destroy(); });
|
||||
req.on('end', () => { try { resolve(JSON.parse(b || '{}')); } catch (_) { resolve({}); } });
|
||||
req.on('error', () => resolve({}));
|
||||
});
|
||||
}
|
||||
|
||||
const sockets = new Set();
|
||||
const lockServer = http.createServer(async (req, res) => {
|
||||
try {
|
||||
if (req.method === 'POST' && req.url === '/__keylock/setup') {
|
||||
const { password } = await readBody(req);
|
||||
if (!password || String(password).length < 6) { res.writeHead(400, { 'Connection': 'close' }); return res.end('{"ok":false}'); }
|
||||
keylock.setPassword(configPath, password);
|
||||
const keys = keylock.loadOrCreateSecret(configPath);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json', 'Connection': 'close' });
|
||||
res.end('{"ok":true}');
|
||||
return unlockAndStart(keys);
|
||||
}
|
||||
if (req.method === 'POST' && req.url === '/__keylock/unlock') {
|
||||
const { password } = await readBody(req);
|
||||
const kek = password ? keylock.verifyPassword(configPath, password) : null;
|
||||
if (!kek) { res.writeHead(401, { 'Connection': 'close' }); return res.end('{"ok":false}'); }
|
||||
const keys = keylock.loadOrCreateSecret(configPath);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json', 'Connection': 'close' });
|
||||
res.end('{"ok":true}');
|
||||
return unlockAndStart(keys);
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Connection': 'close' });
|
||||
res.end(lockPage(MODE));
|
||||
} catch (e) {
|
||||
res.writeHead(500, { 'Connection': 'close' });
|
||||
res.end('{"ok":false,"error":"' + String(e && e.message).replace(/"/g, '') + '"}');
|
||||
}
|
||||
});
|
||||
lockServer.on('connection', (s) => { sockets.add(s); s.on('close', () => sockets.delete(s)); });
|
||||
lockServer.listen(3000, '127.0.0.1', () => {
|
||||
console.log('[Oasis] keylock gate escuchando en 127.0.0.1:3000 (modo: ' + MODE + ')');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@krakenslab/oasis",
|
||||
"version": "0.9.5",
|
||||
"version": "0.8.7",
|
||||
"description": "Oasis - Social Networking Utopia",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
|
|
|||
|
|
@ -1,74 +0,0 @@
|
|||
/**
|
||||
* Identidades locales.
|
||||
*
|
||||
* Cada identidad vive en su propio directorio ~/.<cuenta>, con su secret, su base
|
||||
* de datos y sus blobs. Cual esta activa lo decide ssb_config.js al arrancar:
|
||||
* primero OASIS_ACCOUNT, y si no, el fichero que escribe setActive().
|
||||
*
|
||||
* Aqui no se cambia de identidad en caliente: medio Oasis captura la referencia al
|
||||
* sbot al cargarse, asi que cambiarla con el proceso vivo dejaria modulos hablando
|
||||
* con la identidad anterior. El cambio se aplica al reiniciar.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
// mismo criterio que ssb_config.js: este nombre acaba en rutas de fichero
|
||||
const ACCOUNT_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,31}$/;
|
||||
const ACTIVE_FILE = path.join(__dirname, '..', 'configs', 'active-account');
|
||||
|
||||
const isValid = (name) => typeof name === 'string' && ACCOUNT_RE.test(name);
|
||||
|
||||
/** Cuenta anotada como activa, o null si no hay ninguna anotada. */
|
||||
const readActive = () => {
|
||||
try {
|
||||
const raw = fs.readFileSync(ACTIVE_FILE, 'utf8').trim();
|
||||
return isValid(raw) ? raw : null;
|
||||
} catch (_) { return null; }
|
||||
};
|
||||
|
||||
/** Anota cual sera la cuenta activa en el proximo arranque. */
|
||||
const setActive = (name) => {
|
||||
if (!isValid(name)) throw new Error('nombre de cuenta no valido');
|
||||
fs.mkdirSync(path.dirname(ACTIVE_FILE), { recursive: true });
|
||||
fs.writeFileSync(ACTIVE_FILE, name, { mode: 0o600 });
|
||||
return name;
|
||||
};
|
||||
|
||||
/** Directorio de una cuenta. */
|
||||
const dirOf = (name) => path.join(os.homedir(), `.${name}`);
|
||||
|
||||
/**
|
||||
* Identidades que existen en el disco: directorios ~/.<algo> con un secret dentro.
|
||||
* El secret es lo que hace que un directorio sea una identidad y no otra cosa.
|
||||
*/
|
||||
const list = () => {
|
||||
let entries = [];
|
||||
try {
|
||||
entries = fs.readdirSync(os.homedir(), { withFileTypes: true });
|
||||
} catch (_) { return []; }
|
||||
|
||||
return entries
|
||||
.filter((e) => e.isDirectory() && e.name.startsWith('.'))
|
||||
.map((e) => e.name.slice(1))
|
||||
.filter(isValid)
|
||||
.filter((name) => {
|
||||
try { return fs.statSync(path.join(dirOf(name), 'secret')).isFile(); }
|
||||
catch (_) { return false; }
|
||||
})
|
||||
.sort();
|
||||
};
|
||||
|
||||
/** Identidad publica (@...) de una cuenta, leyendo su secret. Null si no se puede. */
|
||||
const feedIdOf = (name) => {
|
||||
if (!isValid(name)) return null;
|
||||
try {
|
||||
const raw = fs.readFileSync(path.join(dirOf(name), 'secret'), 'utf8');
|
||||
const clean = raw.split('\n').filter((l) => !l.trim().startsWith('#')).join('\n');
|
||||
const id = JSON.parse(clean).id;
|
||||
return typeof id === 'string' ? id : null;
|
||||
} catch (_) { return null; }
|
||||
};
|
||||
|
||||
module.exports = { isValid, list, readActive, setActive, feedIdOf, dirOf, ACTIVE_FILE };
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -11,164 +11,13 @@ try {
|
|||
} catch (e) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Quitar metadatos de una imagen SIN sharp.
|
||||
*
|
||||
* sharp no se empaqueta en Android (lleva binarios nativos y el bundle del movil
|
||||
* va sin ninguno), asi que alli esta funcion devolvia la imagen intacta y las
|
||||
* fotos subian con su EXIF entero, ubicacion GPS incluida.
|
||||
*
|
||||
* Esto recorre el contenedor y tira los bloques de metadatos, sin decodificar ni
|
||||
* recomprimir la imagen: los pixeles se quedan byte a byte como estaban.
|
||||
*
|
||||
* De la orientacion se guarda solo eso, la orientacion. Sin sharp no se pueden
|
||||
* girar los pixeles, y tirar la etiqueta dejaria las fotos verticales tumbadas;
|
||||
* asi que se reconstruye un bloque EXIF minimo con esa unica etiqueta, que no
|
||||
* dice nada de quien hizo la foto ni donde.
|
||||
*/
|
||||
|
||||
// JPEG: cada segmento es FF <marca> <longitud:2> <datos>. Se tiran los que llevan
|
||||
// metadatos y se dejan JFIF (APP0) y el perfil de color (APP2), que afectan a como
|
||||
// se ve la imagen.
|
||||
const JPEG_DROP = new Set([
|
||||
0xE1, // APP1 Exif y XMP
|
||||
0xE5, // APP5
|
||||
0xE6, // APP6
|
||||
0xEC, // APP12 Ducky/Picture Info
|
||||
0xED, // APP13 IPTC / Photoshop
|
||||
0xEE, // APP14 Adobe
|
||||
0xFE // COM comentario
|
||||
]);
|
||||
|
||||
const jpegOrientation = (seg) => {
|
||||
// seg empieza en "Exif\0\0"; detras va una cabecera TIFF con la IFD0
|
||||
if (seg.length < 14 || seg.toString("latin1", 0, 6) !== "Exif\0\0") return 0;
|
||||
const tiff = seg.subarray(6);
|
||||
const le = tiff.toString("latin1", 0, 2) === "II";
|
||||
const u16 = (o) => (le ? tiff.readUInt16LE(o) : tiff.readUInt16BE(o));
|
||||
const u32 = (o) => (le ? tiff.readUInt32LE(o) : tiff.readUInt32BE(o));
|
||||
if (u16(2) !== 0x002A) return 0;
|
||||
const ifd0 = u32(4);
|
||||
if (ifd0 + 2 > tiff.length) return 0;
|
||||
const n = u16(ifd0);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const e = ifd0 + 2 + i * 12;
|
||||
if (e + 12 > tiff.length) break;
|
||||
if (u16(e) === 0x0112) { // Orientation
|
||||
const v = u16(e + 8);
|
||||
return v >= 1 && v <= 8 ? v : 0;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
/** Bloque APP1 de 32 bytes con la orientacion y nada mas. */
|
||||
const exifOnlyOrientation = (orientation) => {
|
||||
const b = Buffer.alloc(32);
|
||||
b.write("Exif\0\0", 0, "latin1");
|
||||
b.write("II", 6, "latin1");
|
||||
b.writeUInt16LE(0x002A, 8);
|
||||
b.writeUInt32LE(8, 10); // la IFD0 empieza justo detras de la cabecera TIFF
|
||||
b.writeUInt16LE(1, 14); // una sola entrada
|
||||
b.writeUInt16LE(0x0112, 16); // Orientation
|
||||
b.writeUInt16LE(3, 18); // SHORT
|
||||
b.writeUInt32LE(1, 20); // un valor
|
||||
b.writeUInt16LE(orientation, 24);
|
||||
b.writeUInt32LE(0, 28); // no hay mas IFDs
|
||||
return b;
|
||||
};
|
||||
|
||||
const stripJpeg = (buf) => {
|
||||
if (buf.length < 4 || buf[0] !== 0xFF || buf[1] !== 0xD8) return null;
|
||||
const out = [buf.subarray(0, 2)];
|
||||
let orientation = 0;
|
||||
let i = 2;
|
||||
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
|
||||
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);
|
||||
if (marker === 0xE1 && !orientation) orientation = jpegOrientation(seg);
|
||||
if (!JPEG_DROP.has(marker)) out.push(buf.subarray(i, i + 2 + len));
|
||||
i += 2 + len;
|
||||
}
|
||||
if (orientation > 1) {
|
||||
const exif = exifOnlyOrientation(orientation);
|
||||
const head = Buffer.alloc(4);
|
||||
head.writeUInt16BE(0xFFE1, 0);
|
||||
head.writeUInt16BE(exif.length + 2, 2);
|
||||
out.splice(1, 0, head, exif);
|
||||
}
|
||||
return Buffer.concat(out);
|
||||
};
|
||||
|
||||
// PNG: cadena de bloques <longitud:4> <tipo:4> <datos> <crc:4>.
|
||||
const PNG_DROP = new Set(["tEXt", "zTXt", "iTXt", "eXIf", "tIME", "dSIG"]);
|
||||
|
||||
const stripPng = (buf) => {
|
||||
const SIG = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]);
|
||||
if (buf.length < 8 || !buf.subarray(0, 8).equals(SIG)) return null;
|
||||
const out = [buf.subarray(0, 8)];
|
||||
let i = 8;
|
||||
while (i + 8 <= buf.length) {
|
||||
const len = buf.readUInt32BE(i);
|
||||
const type = buf.toString("latin1", i + 4, i + 8);
|
||||
const end = i + 12 + len;
|
||||
if (end > buf.length) return null;
|
||||
if (!PNG_DROP.has(type)) out.push(buf.subarray(i, end));
|
||||
i = end;
|
||||
if (type === "IEND") break;
|
||||
}
|
||||
return Buffer.concat(out);
|
||||
};
|
||||
|
||||
// WebP: contenedor RIFF con bloques <tipo:4> <longitud:4> <datos> (+1 de relleno
|
||||
// si la longitud es impar).
|
||||
const stripWebp = (buf) => {
|
||||
if (buf.length < 12) return null;
|
||||
if (buf.toString("latin1", 0, 4) !== "RIFF" || buf.toString("latin1", 8, 12) !== "WEBP") return null;
|
||||
const out = [];
|
||||
let i = 12;
|
||||
while (i + 8 <= buf.length) {
|
||||
const type = buf.toString("latin1", i, i + 4);
|
||||
const len = buf.readUInt32LE(i + 4);
|
||||
const end = i + 8 + len + (len % 2);
|
||||
if (end > buf.length) return null;
|
||||
if (type !== "EXIF" && type !== "XMP ") out.push(buf.subarray(i, end));
|
||||
i = end;
|
||||
}
|
||||
const body = Buffer.concat(out);
|
||||
const head = Buffer.alloc(12);
|
||||
head.write("RIFF", 0, "latin1");
|
||||
head.writeUInt32LE(body.length + 4, 4);
|
||||
head.write("WEBP", 8, "latin1");
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
const stripImageMetadata = async (buffer) => {
|
||||
// Con sharp se prefiere su camino: ademas de quitar metadatos, aplica la
|
||||
// orientacion girando los pixeles de verdad.
|
||||
if (typeof sharp === "function") {
|
||||
if (typeof sharp !== "function") return buffer;
|
||||
try {
|
||||
return await sharp(buffer).rotate().toBuffer();
|
||||
} catch {
|
||||
return stripMetadataPure(buffer);
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
return stripMetadataPure(buffer);
|
||||
};
|
||||
|
||||
const PDF_METADATA_KEYS = [
|
||||
|
|
@ -209,30 +58,25 @@ class FileTooLargeError extends Error {
|
|||
}
|
||||
}
|
||||
|
||||
const OGG_VIDEO_MARKERS = ['theora', 'VP80', 'OggDS', 'Dirac'];
|
||||
const OGG_AUDIO_MARKERS = ['vorbis', 'OpusHead', 'FLAC', 'Speex'];
|
||||
const handleBlobUpload = async function (ctx, fileFieldName) {
|
||||
if (!ctx.request.files || !ctx.request.files[fileFieldName]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const oggMime = (buffer) => {
|
||||
const head = buffer.slice(0, 65536);
|
||||
for (const marker of OGG_VIDEO_MARKERS) if (head.includes(Buffer.from(marker))) return 'video/ogg';
|
||||
for (const marker of OGG_AUDIO_MARKERS) if (head.includes(Buffer.from(marker))) return 'audio/ogg';
|
||||
return 'video/ogg';
|
||||
};
|
||||
|
||||
const storeUploadedFile = async function (blobUpload) {
|
||||
if (!blobUpload || !blobUpload.filepath) return null;
|
||||
const blobUpload = ctx.request.files[fileFieldName];
|
||||
if (!blobUpload) return null;
|
||||
|
||||
let data = await promisesFs.readFile(blobUpload.filepath);
|
||||
if (data.length === 0) return null;
|
||||
|
||||
if (data.length > MAX_BLOB_SIZE) {
|
||||
throw new FileTooLargeError(blobUpload.originalFilename || blobUpload.name || 'file', data.length);
|
||||
throw new FileTooLargeError(blobUpload.originalFilename || blobUpload.name || fileFieldName, data.length);
|
||||
}
|
||||
|
||||
const EXTENSION_MIME_MAP = {
|
||||
'.mp4': 'video/mp4', '.webm': 'video/webm', '.ogg': 'video/ogg',
|
||||
'.ogv': 'video/ogg', '.oga': 'audio/ogg', '.mkv': 'video/x-matroska', '.avi': 'video/x-msvideo', '.mov': 'video/quicktime',
|
||||
'.mp3': 'audio/mpeg', '.wav': 'audio/wav',
|
||||
'.ogv': 'video/ogg', '.avi': 'video/x-msvideo', '.mov': 'video/quicktime',
|
||||
'.mkv': 'video/x-matroska', '.mp3': 'audio/mpeg', '.wav': 'audio/wav',
|
||||
'.flac': 'audio/flac', '.aac': 'audio/aac', '.opus': 'audio/opus',
|
||||
'.pdf': 'application/pdf', '.png': 'image/png', '.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp',
|
||||
|
|
@ -249,12 +93,9 @@ const storeUploadedFile = async function (blobUpload) {
|
|||
blob.mime = null;
|
||||
}
|
||||
|
||||
if (blob.mime === 'application/ogg') blob.mime = oggMime(data);
|
||||
|
||||
const GENERIC_MIMES = new Set(['application/octet-stream', 'video/x-matroska']);
|
||||
if ((!blob.mime || GENERIC_MIMES.has(blob.mime)) && blob.name) {
|
||||
if (!blob.mime && blob.name) {
|
||||
const ext = (blob.name.match(/\.[^.]+$/) || [''])[0].toLowerCase();
|
||||
blob.mime = EXTENSION_MIME_MAP[ext] || blob.mime || 'application/octet-stream';
|
||||
blob.mime = EXTENSION_MIME_MAP[ext] || 'application/octet-stream';
|
||||
}
|
||||
|
||||
if (!blob.mime) {
|
||||
|
|
@ -285,25 +126,6 @@ const storeUploadedFile = async function (blobUpload) {
|
|||
return `\n[${blob.name}](${blob.id})`;
|
||||
};
|
||||
|
||||
const handleBlobUpload = async function (ctx, fileFieldName) {
|
||||
if (!ctx.request.files || !ctx.request.files[fileFieldName]) return null;
|
||||
const entry = ctx.request.files[fileFieldName];
|
||||
const first = Array.isArray(entry) ? entry[0] : entry;
|
||||
return storeUploadedFile(first);
|
||||
};
|
||||
|
||||
const handleBlobUploads = async function (ctx, fileFieldName, max = 8) {
|
||||
if (!ctx.request.files || !ctx.request.files[fileFieldName]) return [];
|
||||
const entry = ctx.request.files[fileFieldName];
|
||||
const files = (Array.isArray(entry) ? entry : [entry]).slice(0, Math.max(0, max));
|
||||
const out = [];
|
||||
for (const file of files) {
|
||||
const stored = await storeUploadedFile(file);
|
||||
if (stored) out.push(stored);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
function waitForBlob(ssbClient, blobId, timeoutMs = 8000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let done = false;
|
||||
|
|
@ -407,8 +229,6 @@ const serveBlob = async function (ctx) {
|
|||
}
|
||||
}
|
||||
|
||||
if (mime === 'application/ogg') mime = oggMime(buffer);
|
||||
|
||||
const isSvg = mime === 'image/svg+xml';
|
||||
const qName = ctx.query.name ? String(ctx.query.name).replace(/["\r\n\\]/g, '').trim() : '';
|
||||
const safeRaw = String(raw).replace(/["\r\n\\]/g, '');
|
||||
|
|
@ -455,5 +275,5 @@ const serveBlob = async function (ctx) {
|
|||
}
|
||||
};
|
||||
|
||||
module.exports = { handleBlobUpload, handleBlobUploads, serveBlob, oggMime, FileTooLargeError };
|
||||
module.exports = { handleBlobUpload, serveBlob, FileTooLargeError };
|
||||
|
||||
|
|
|
|||
|
|
@ -1,280 +0,0 @@
|
|||
const { buildDocumentPdf } = require('./pdfDocument');
|
||||
|
||||
const fmtDate = v => {
|
||||
if (!v) return '';
|
||||
const d = new Date(v);
|
||||
return isNaN(d.getTime()) ? String(v) : d.toISOString().replace('T', ' ').slice(0, 19) + ' UTC';
|
||||
};
|
||||
|
||||
const fmtDay = v => {
|
||||
if (!v) return '';
|
||||
const d = new Date(v);
|
||||
return isNaN(d.getTime()) ? String(v) : d.toISOString().slice(0, 10);
|
||||
};
|
||||
|
||||
const asList = v => (Array.isArray(v) ? v : []);
|
||||
const txt = v => (v == null ? '' : String(v));
|
||||
|
||||
const humanLabel = (key) => String(key || '')
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||||
.replace(/^./, c => c.toUpperCase());
|
||||
|
||||
const pushMeta = (out, item) => {
|
||||
out.push({ kind: 'blank' });
|
||||
out.push({ kind: 'section', text: 'METADATA' });
|
||||
if (item.id) out.push({ kind: 'kv', label: 'Content ID', value: item.id });
|
||||
const author = item.author || item.organizer || item.createdBy || item.from || '';
|
||||
if (author) out.push({ kind: 'kv', label: 'Author', value: author });
|
||||
if (item.createdAt) out.push({ kind: 'kv', label: 'Created At', value: fmtDate(item.createdAt) });
|
||||
if (item.updatedAt) out.push({ kind: 'kv', label: 'Updated At', value: fmtDate(item.updatedAt) });
|
||||
};
|
||||
|
||||
const pushOpinions = (out, item) => {
|
||||
const op = item.opinions && typeof item.opinions === 'object' ? item.opinions : {};
|
||||
const entries = Object.entries(op).filter(([, n]) => Number(n) > 0);
|
||||
if (!entries.length) return;
|
||||
out.push({ kind: 'blank' });
|
||||
out.push({ kind: 'section', text: 'OPINIONS' });
|
||||
for (const [cat, n] of entries) out.push({ kind: 'kv', label: humanLabel(cat), value: String(n) });
|
||||
};
|
||||
|
||||
const pushTags = (out, item) => {
|
||||
const tags = asList(item.tags).filter(Boolean);
|
||||
if (tags.length) out.push({ kind: 'kv', label: 'Tags', value: tags.join(', ') });
|
||||
};
|
||||
|
||||
const reportSections = (report) => {
|
||||
const out = [];
|
||||
out.push({ kind: 'title', text: `Report: ${txt(report.title) || '-'}` });
|
||||
out.push({ kind: 'blank' });
|
||||
|
||||
out.push({ kind: 'section', text: 'CLASSIFICATION' });
|
||||
out.push({ kind: 'kv', label: 'Category', value: txt(report.category).toUpperCase() });
|
||||
out.push({ kind: 'kv', label: 'Severity', value: txt(report.severity).toUpperCase() });
|
||||
out.push({ kind: 'kv', label: 'Status', value: txt(report.status).toUpperCase() });
|
||||
pushTags(out, report);
|
||||
|
||||
if (txt(report.description).trim()) {
|
||||
out.push({ kind: 'blank' });
|
||||
out.push({ kind: 'section', text: 'DESCRIPTION' });
|
||||
out.push({ kind: 'text', text: report.description });
|
||||
}
|
||||
|
||||
const tpl = report.template && typeof report.template === 'object' ? report.template : {};
|
||||
const tplEntries = Object.entries(tpl).filter(([, v]) => txt(v).trim());
|
||||
if (tplEntries.length) {
|
||||
out.push({ kind: 'blank' });
|
||||
out.push({ kind: 'section', text: 'DETAILS' });
|
||||
for (const [k, v] of tplEntries) out.push({ kind: 'kv', label: humanLabel(k), value: v });
|
||||
}
|
||||
|
||||
const confirmations = asList(report.confirmations);
|
||||
out.push({ kind: 'blank' });
|
||||
out.push({ kind: 'section', text: 'CONFIRMATIONS' });
|
||||
out.push({ kind: 'kv', label: 'Confirmed', value: String(confirmations.length) });
|
||||
for (const c of confirmations) out.push({ kind: 'kv', label: 'Confirmed by', value: c });
|
||||
|
||||
pushOpinions(out, report);
|
||||
pushMeta(out, report);
|
||||
return out;
|
||||
};
|
||||
|
||||
const voteSections = (vote) => {
|
||||
const out = [];
|
||||
out.push({ kind: 'title', text: `Votation: ${txt(vote.question) || '-'}` });
|
||||
out.push({ kind: 'blank' });
|
||||
|
||||
out.push({ kind: 'section', text: 'TERMS' });
|
||||
out.push({ kind: 'kv', label: 'Status', value: txt(vote.status).toUpperCase() });
|
||||
if (vote.deadline) out.push({ kind: 'kv', label: 'Deadline', value: fmtDate(vote.deadline) });
|
||||
pushTags(out, vote);
|
||||
|
||||
const votes = vote.votes && typeof vote.votes === 'object' ? vote.votes : {};
|
||||
const total = Number(vote.totalVotes) || asList(vote.voters).length;
|
||||
out.push({ kind: 'blank' });
|
||||
out.push({ kind: 'section', text: 'RESULTS' });
|
||||
out.push({ kind: 'kv', label: 'Total votes', value: String(total) });
|
||||
for (const [choice, n] of Object.entries(votes)) {
|
||||
const count = Number(n) || 0;
|
||||
const pct = total > 0 ? Math.round((count / total) * 100) : 0;
|
||||
out.push({ kind: 'kv', label: choice, value: `${count} (${pct}%)` });
|
||||
}
|
||||
|
||||
pushOpinions(out, vote);
|
||||
pushMeta(out, vote);
|
||||
return out;
|
||||
};
|
||||
|
||||
const eventSections = (event) => {
|
||||
const out = [];
|
||||
out.push({ kind: 'title', text: `Event: ${txt(event.title) || '-'}` });
|
||||
out.push({ kind: 'blank' });
|
||||
|
||||
out.push({ kind: 'section', text: 'SCHEDULE' });
|
||||
out.push({ kind: 'kv', label: 'Date', value: fmtDate(event.date) });
|
||||
out.push({ kind: 'kv', label: 'Status', value: txt(event.status).toUpperCase() });
|
||||
if (event.location) out.push({ kind: 'kv', label: 'Location', value: event.location });
|
||||
if (event.mapUrl) out.push({ kind: 'kv', label: 'Map', value: event.mapUrl });
|
||||
if (Number(event.price) > 0) out.push({ kind: 'kv', label: 'Price', value: `${Number(event.price)} ECO` });
|
||||
out.push({ kind: 'kv', label: 'Privacy', value: txt(event.isPublic).toUpperCase() });
|
||||
if (event.url) out.push({ kind: 'kv', label: 'Url', value: event.url });
|
||||
pushTags(out, event);
|
||||
|
||||
if (txt(event.description).trim()) {
|
||||
out.push({ kind: 'blank' });
|
||||
out.push({ kind: 'section', text: 'DESCRIPTION' });
|
||||
out.push({ kind: 'text', text: event.description });
|
||||
}
|
||||
|
||||
const attendees = asList(event.attendees);
|
||||
out.push({ kind: 'blank' });
|
||||
out.push({ kind: 'section', text: 'ATTENDEES' });
|
||||
out.push({ kind: 'kv', label: 'Total', value: String(attendees.length) });
|
||||
for (const a of attendees) out.push({ kind: 'kv', label: 'Attendee', value: a });
|
||||
|
||||
pushOpinions(out, event);
|
||||
pushMeta(out, event);
|
||||
return out;
|
||||
};
|
||||
|
||||
const taskSections = (task) => {
|
||||
const out = [];
|
||||
out.push({ kind: 'title', text: `Task: ${txt(task.title) || '-'}` });
|
||||
out.push({ kind: 'blank' });
|
||||
|
||||
out.push({ kind: 'section', text: 'SCHEDULE' });
|
||||
out.push({ kind: 'kv', label: 'Status', value: txt(task.status).toUpperCase() });
|
||||
out.push({ kind: 'kv', label: 'Priority', value: txt(task.priority).toUpperCase() });
|
||||
if (task.startTime) out.push({ kind: 'kv', label: 'Starts', value: fmtDate(task.startTime) });
|
||||
if (task.endTime) out.push({ kind: 'kv', label: 'Ends', value: fmtDate(task.endTime) });
|
||||
if (task.location) out.push({ kind: 'kv', label: 'Location', value: task.location });
|
||||
out.push({ kind: 'kv', label: 'Privacy', value: txt(task.isPublic).toUpperCase() });
|
||||
pushTags(out, task);
|
||||
|
||||
if (txt(task.description).trim()) {
|
||||
out.push({ kind: 'blank' });
|
||||
out.push({ kind: 'section', text: 'DESCRIPTION' });
|
||||
out.push({ kind: 'text', text: task.description });
|
||||
}
|
||||
|
||||
const assignees = asList(task.assignees);
|
||||
out.push({ kind: 'blank' });
|
||||
out.push({ kind: 'section', text: 'ASSIGNEES' });
|
||||
out.push({ kind: 'kv', label: 'Total', value: String(assignees.length) });
|
||||
for (const a of assignees) out.push({ kind: 'kv', label: 'Assignee', value: a });
|
||||
|
||||
pushOpinions(out, task);
|
||||
pushMeta(out, task);
|
||||
return out;
|
||||
};
|
||||
|
||||
const calendarSections = (calendar, extra = {}) => {
|
||||
const dates = asList(extra.dates);
|
||||
const notesByDate = extra.notesByDate && typeof extra.notesByDate === 'object' ? extra.notesByDate : {};
|
||||
|
||||
const out = [];
|
||||
out.push({ kind: 'title', text: `Calendar: ${txt(calendar.title) || '-'}` });
|
||||
out.push({ kind: 'blank' });
|
||||
|
||||
out.push({ kind: 'section', text: 'SUMMARY' });
|
||||
out.push({ kind: 'kv', label: 'Status', value: calendar.isClosed ? 'CLOSED' : txt(calendar.status).toUpperCase() });
|
||||
if (calendar.deadline) out.push({ kind: 'kv', label: 'Deadline', value: fmtDate(calendar.deadline) });
|
||||
if (calendar.mapUrl) out.push({ kind: 'kv', label: 'Map', value: calendar.mapUrl });
|
||||
out.push({ kind: 'kv', label: 'Participants', value: String(asList(calendar.participants).length) });
|
||||
out.push({ kind: 'kv', label: 'Dates', value: String(dates.length) });
|
||||
const noteTotal = Object.values(notesByDate).reduce((n, arr) => n + asList(arr).length, 0);
|
||||
out.push({ kind: 'kv', label: 'Notes', value: String(noteTotal) });
|
||||
pushTags(out, calendar);
|
||||
|
||||
const participants = asList(calendar.participants);
|
||||
if (participants.length) {
|
||||
out.push({ kind: 'blank' });
|
||||
out.push({ kind: 'section', text: 'PARTICIPANTS' });
|
||||
for (const p of participants) out.push({ kind: 'kv', label: 'Participant', value: p });
|
||||
}
|
||||
|
||||
if (dates.length) {
|
||||
out.push({ kind: 'blank' });
|
||||
out.push({ kind: 'section', text: 'DATES' });
|
||||
for (const d of dates) {
|
||||
const day = fmtDay(d && d.date) || txt(d && d.date);
|
||||
out.push({ kind: 'kv', label: day, value: txt(d && d.label) });
|
||||
for (const note of asList(notesByDate[d && d.key])) {
|
||||
const noteText = txt(note && note.text);
|
||||
if (noteText) out.push({ kind: 'text', text: ` - ${noteText}` });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pushMeta(out, calendar);
|
||||
return out;
|
||||
};
|
||||
|
||||
const cvSections = (cv) => {
|
||||
const out = [];
|
||||
out.push({ kind: 'title', text: `Curriculum: ${txt(cv.name) || txt(cv.author) || '-'}` });
|
||||
out.push({ kind: 'blank' });
|
||||
|
||||
out.push({ kind: 'section', text: 'PROFILE' });
|
||||
if (cv.location) out.push({ kind: 'kv', label: 'Location', value: cv.location });
|
||||
if (cv.status) out.push({ kind: 'kv', label: 'Status', value: txt(cv.status).toUpperCase() });
|
||||
if (cv.preferences) out.push({ kind: 'kv', label: 'Preferences', value: txt(cv.preferences).toUpperCase() });
|
||||
if (cv.languages) out.push({ kind: 'kv', label: 'Languages', value: cv.languages });
|
||||
|
||||
if (txt(cv.description).trim()) {
|
||||
out.push({ kind: 'blank' });
|
||||
out.push({ kind: 'section', text: 'DESCRIPTION' });
|
||||
out.push({ kind: 'text', text: cv.description });
|
||||
}
|
||||
|
||||
const blocks = [
|
||||
['PERSONAL', cv.personalExperiences, cv.personalSkills],
|
||||
['EDUCATION', cv.educationExperiences, cv.educationalSkills],
|
||||
['PROFESSIONAL', cv.professionalExperiences, cv.professionalSkills],
|
||||
['OASIS', cv.oasisExperiences, cv.oasisSkills]
|
||||
];
|
||||
|
||||
for (const [heading, experiences, skills] of blocks) {
|
||||
const skillList = asList(skills).filter(Boolean);
|
||||
const body = txt(experiences).trim();
|
||||
if (!body && !skillList.length) continue;
|
||||
out.push({ kind: 'blank' });
|
||||
out.push({ kind: 'section', text: heading });
|
||||
if (body) out.push({ kind: 'text', text: body });
|
||||
if (skillList.length) out.push({ kind: 'kv', label: 'Skills', value: skillList.join(', ') });
|
||||
}
|
||||
|
||||
pushMeta(out, cv);
|
||||
return out;
|
||||
};
|
||||
|
||||
const BUILDERS = {
|
||||
reports: { title: 'OASIS - Report', sections: reportSections, name: item => item.title },
|
||||
votes: { title: 'OASIS - Votation', sections: voteSections, name: item => item.question },
|
||||
events: { title: 'OASIS - Event', sections: eventSections, name: item => item.title },
|
||||
tasks: { title: 'OASIS - Task', sections: taskSections, name: item => item.title },
|
||||
calendars: { title: 'OASIS - Calendar', sections: calendarSections, name: item => item.title },
|
||||
cv: { title: 'OASIS - Curriculum', sections: cvSections, name: item => item.name || item.author }
|
||||
};
|
||||
|
||||
const isSupported = kind => Object.prototype.hasOwnProperty.call(BUILDERS, kind);
|
||||
|
||||
const pdfFilename = (kind, item) => {
|
||||
const b = BUILDERS[kind];
|
||||
const raw = b ? txt(b.name(item || {})) : '';
|
||||
const slug = raw.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40);
|
||||
return `oasis-${kind}-${slug || 'document'}.pdf`;
|
||||
};
|
||||
|
||||
const buildContentPdf = (kind, item, extra = {}, viewerId = null) => {
|
||||
const b = BUILDERS[kind];
|
||||
if (!b) throw new Error('Unsupported pdf kind');
|
||||
return buildDocumentPdf({
|
||||
title: b.title,
|
||||
issuedToLabel: 'Issued to',
|
||||
issuedTo: viewerId || null,
|
||||
sections: b.sections(item || {}, extra)
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = { buildContentPdf, pdfFilename, isSupported };
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
const crypto = require('crypto');
|
||||
|
||||
const ALG = 'aes-256-gcm';
|
||||
const IV_LEN = 12;
|
||||
const TAG_LEN = 16;
|
||||
const KEY_LEN = 32;
|
||||
const DEFAULT_CHUNK_SIZE = 5 * 1024 * 1024;
|
||||
|
||||
const generateFileKey = () => crypto.randomBytes(KEY_LEN);
|
||||
|
||||
const keyToHex = (key) => Buffer.isBuffer(key) ? key.toString('hex') : String(key);
|
||||
|
||||
const keyFromHex = (hex) => {
|
||||
const buf = Buffer.from(String(hex), 'hex');
|
||||
if (buf.length !== KEY_LEN) throw new Error('Invalid file key');
|
||||
return buf;
|
||||
};
|
||||
|
||||
const encryptChunk = (plaintext, key) => {
|
||||
const iv = crypto.randomBytes(IV_LEN);
|
||||
const cipher = crypto.createCipheriv(ALG, key, iv);
|
||||
const ct = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return Buffer.concat([iv, tag, ct]);
|
||||
};
|
||||
|
||||
const decryptChunk = (payload, key) => {
|
||||
if (!Buffer.isBuffer(payload) || payload.length < IV_LEN + TAG_LEN) throw new Error('Corrupt chunk');
|
||||
const iv = payload.subarray(0, IV_LEN);
|
||||
const tag = payload.subarray(IV_LEN, IV_LEN + TAG_LEN);
|
||||
const ct = payload.subarray(IV_LEN + TAG_LEN);
|
||||
const decipher = crypto.createDecipheriv(ALG, key, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
return Buffer.concat([decipher.update(ct), decipher.final()]);
|
||||
};
|
||||
|
||||
const sha256Hex = (buffer) => crypto.createHash('sha256').update(buffer).digest('hex');
|
||||
|
||||
const splitBuffer = (buffer, chunkSize = DEFAULT_CHUNK_SIZE) => {
|
||||
const size = Math.max(1, Number(chunkSize) || DEFAULT_CHUNK_SIZE);
|
||||
const out = [];
|
||||
for (let off = 0; off < buffer.length; off += size) {
|
||||
out.push(buffer.subarray(off, Math.min(off + size, buffer.length)));
|
||||
}
|
||||
if (!out.length) out.push(Buffer.alloc(0));
|
||||
return out;
|
||||
};
|
||||
|
||||
const buildManifest = ({ filename, mime, size, chunkSize, chunkHashes, plainSha256 }) => ({
|
||||
v: 1,
|
||||
alg: ALG,
|
||||
filename: String(filename || 'file'),
|
||||
mime: String(mime || 'application/octet-stream'),
|
||||
size: Number(size) || 0,
|
||||
chunkSize: Number(chunkSize) || DEFAULT_CHUNK_SIZE,
|
||||
chunks: Array.isArray(chunkHashes) ? chunkHashes.slice() : [],
|
||||
plainSha256: plainSha256 || null
|
||||
});
|
||||
|
||||
const encryptManifest = (manifest, key) => encryptChunk(Buffer.from(JSON.stringify(manifest), 'utf8'), key);
|
||||
|
||||
const decryptManifest = (payload, key) => {
|
||||
const json = decryptChunk(payload, key).toString('utf8');
|
||||
const m = JSON.parse(json);
|
||||
if (!m || m.v !== 1 || !Array.isArray(m.chunks)) throw new Error('Invalid manifest');
|
||||
return m;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
ALG, IV_LEN, TAG_LEN, KEY_LEN, DEFAULT_CHUNK_SIZE,
|
||||
generateFileKey, keyToHex, keyFromHex,
|
||||
encryptChunk, decryptChunk, sha256Hex, splitBuffer,
|
||||
buildManifest, encryptManifest, decryptManifest
|
||||
};
|
||||
|
|
@ -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();
|
||||
};
|
||||
|
|
@ -3,7 +3,7 @@ const path = require('path');
|
|||
|
||||
const LOGO_PATH = path.join(__dirname, '..', 'client', 'assets', 'images', 'snh-oasis.jpg');
|
||||
|
||||
const { escapePdf } = require('./pdfDocument');
|
||||
const escapePdf = s => String(s || '').replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)');
|
||||
|
||||
const linkPattern = /(?:https?:\/\/[^\s]+|www\.[^\s]+|@[A-Za-z0-9+/=.\-]+\.ed25519|[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,})/g;
|
||||
|
||||
|
|
@ -94,8 +94,8 @@ function buildLogsPdf(entries, oasisId, opts = {}) {
|
|||
|
||||
const catalogId = addObj(null);
|
||||
const pagesId = addObj(null);
|
||||
const fontId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier /Encoding /WinAnsiEncoding >>');
|
||||
const fontBoldId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier-Bold /Encoding /WinAnsiEncoding >>');
|
||||
const fontId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier >>');
|
||||
const fontBoldId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier-Bold >>');
|
||||
|
||||
let logoXObjId = null;
|
||||
if (logoBuf && logoDims) {
|
||||
|
|
@ -156,7 +156,7 @@ function buildLogsPdf(entries, oasisId, opts = {}) {
|
|||
parts.push(`BT\n/F1 8 Tf\n${pageW - marginX - pageLabelW} ${footerH - 10} Td\n(${escapePdf(pageLabel)}) Tj\nET`);
|
||||
|
||||
const content = parts.join('\n');
|
||||
const stream = `<< /Length ${Buffer.byteLength(content, 'latin1')} >>\nstream\n${content}\nendstream`;
|
||||
const stream = `<< /Length ${Buffer.byteLength(content)} >>\nstream\n${content}\nendstream`;
|
||||
const cid = addObj(stream);
|
||||
contentIds.push(cid);
|
||||
const pid = addObj(null);
|
||||
|
|
|
|||
|
|
@ -1,40 +1,22 @@
|
|||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const TEMPLATE = path.join(__dirname, "../configs/content_favorites.json");
|
||||
|
||||
const storePath = () => {
|
||||
try {
|
||||
return require("../server/ssb_config").statePath("content_favorites.json") || TEMPLATE;
|
||||
} catch (_) { return TEMPLATE; }
|
||||
};
|
||||
const FILE = path.join(__dirname, "../configs/media-favorites.json");
|
||||
|
||||
const DEFAULT = {
|
||||
audios: [],
|
||||
blogs: [],
|
||||
bookmarks: [],
|
||||
calendars: [],
|
||||
chats: [],
|
||||
documents: [],
|
||||
events: [],
|
||||
forum: [],
|
||||
housing: [],
|
||||
images: [],
|
||||
jobs: [],
|
||||
logs: [],
|
||||
maps: [],
|
||||
pads: [],
|
||||
polls: [],
|
||||
projects: [],
|
||||
reports: [],
|
||||
shops: [],
|
||||
market: [],
|
||||
shopProducts: [],
|
||||
tasks: [],
|
||||
torrents: [],
|
||||
transfers: [],
|
||||
videos: [],
|
||||
votes: []
|
||||
videos: []
|
||||
};
|
||||
|
||||
const safeArr = (v) => (Array.isArray(v) ? v : []);
|
||||
|
|
@ -57,32 +39,32 @@ const normalize = (raw) => {
|
|||
|
||||
const ensureFile = async () => {
|
||||
try {
|
||||
await fs.promises.access(storePath());
|
||||
await fs.promises.access(FILE);
|
||||
} catch (e) {
|
||||
const dir = path.dirname(storePath());
|
||||
const dir = path.dirname(FILE);
|
||||
await fs.promises.mkdir(dir, { recursive: true });
|
||||
await fs.promises.writeFile(storePath(), JSON.stringify(DEFAULT, null, 2), "utf8");
|
||||
await fs.promises.writeFile(FILE, JSON.stringify(DEFAULT, null, 2), "utf8");
|
||||
}
|
||||
};
|
||||
|
||||
const readAll = async () => {
|
||||
await ensureFile();
|
||||
try {
|
||||
const txt = await fs.promises.readFile(storePath(), "utf8");
|
||||
const txt = await fs.promises.readFile(FILE, "utf8");
|
||||
return normalize(JSON.parse(txt || "{}"));
|
||||
} catch (e) {
|
||||
const fixed = normalize(DEFAULT);
|
||||
await fs.promises.writeFile(storePath(), JSON.stringify(fixed, null, 2), "utf8");
|
||||
await fs.promises.writeFile(FILE, JSON.stringify(fixed, null, 2), "utf8");
|
||||
return fixed;
|
||||
}
|
||||
};
|
||||
|
||||
const writeAll = async (data) => {
|
||||
const dir = path.dirname(storePath());
|
||||
const tmp = path.join(dir, `.content_favorites.${process.pid}.${Date.now()}.tmp`);
|
||||
const dir = path.dirname(FILE);
|
||||
const tmp = path.join(dir, `.media-favorites.${process.pid}.${Date.now()}.tmp`);
|
||||
const txt = JSON.stringify(normalize(data), null, 2);
|
||||
await fs.promises.writeFile(tmp, txt, "utf8");
|
||||
await fs.promises.rename(tmp, storePath());
|
||||
await fs.promises.rename(tmp, FILE);
|
||||
};
|
||||
|
||||
const assertKind = (kind) => {
|
||||
|
|
@ -106,15 +88,6 @@ exports.getFavoriteSet = async (kind) => {
|
|||
return new Set(safeArr(data[k]).map(String));
|
||||
};
|
||||
|
||||
exports.getFavoriteIndex = async () => {
|
||||
const data = await readAll();
|
||||
const index = new Map();
|
||||
for (const kind of Object.keys(DEFAULT)) {
|
||||
for (const id of safeArr(data[kind])) index.set(String(id), kind);
|
||||
}
|
||||
return index;
|
||||
};
|
||||
|
||||
exports.addFavorite = async (kind, id) =>
|
||||
withLock(async () => {
|
||||
const k = assertKind(kind);
|
||||
|
|
@ -150,4 +123,4 @@ exports.removeFromFavorites = async (...args) => {
|
|||
const id = idFromArgs(args);
|
||||
return exports.removeFavorite(kind, id);
|
||||
};
|
||||
exports.storePath = storePath;
|
||||
|
||||
|
|
@ -1,199 +0,0 @@
|
|||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const LOGO_PATH = path.join(__dirname, '..', 'client', 'assets', 'images', 'snh-oasis.jpg');
|
||||
|
||||
const WIN_ANSI_HIGH = {
|
||||
'\u20AC': '\x80', '\u201A': '\x82', '\u0192': '\x83', '\u201E': '\x84',
|
||||
'\u2026': '\x85', '\u2020': '\x86', '\u2021': '\x87', '\u02C6': '\x88',
|
||||
'\u2030': '\x89', '\u0160': '\x8A', '\u2039': '\x8B', '\u0152': '\x8C',
|
||||
'\u017D': '\x8E', '\u2018': '\x91', '\u2019': '\x92', '\u201C': '\x93',
|
||||
'\u201D': '\x94', '\u2022': '\x95', '\u2013': '\x96', '\u2014': '\x97',
|
||||
'\u02DC': '\x98', '\u2122': '\x99', '\u0161': '\x9A', '\u203A': '\x9B',
|
||||
'\u0153': '\x9C', '\u017E': '\x9E', '\u0178': '\x9F'
|
||||
};
|
||||
|
||||
const escapePdf = s => String(s == null ? '' : s)
|
||||
.replace(/[\r\n\t]+/g, ' ')
|
||||
.replace(/[\u0152\u0153\u0160\u0161\u0178\u017D\u017E\u0192\u02C6\u02DC\u2013\u2014\u2018\u2019\u201A\u201C\u201D\u201E\u2020\u2021\u2022\u2026\u2030\u2039\u203A\u20AC\u2122]/g,
|
||||
(c) => WIN_ANSI_HIGH[c] || '?')
|
||||
.replace(/[^\x20-\xFF\x80-\x9F]/g, '?')
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/\(/g, '\\(')
|
||||
.replace(/\)/g, '\\)');
|
||||
|
||||
const wrap = (txt, max = 82) => {
|
||||
const out = [];
|
||||
for (const raw of String(txt == null ? '' : txt).split('\n')) {
|
||||
let line = raw;
|
||||
while (line.length > max) {
|
||||
let cut = line.lastIndexOf(' ', max);
|
||||
if (cut <= 0) cut = max;
|
||||
out.push(line.slice(0, cut));
|
||||
line = line.slice(cut).replace(/^\s+/, '');
|
||||
}
|
||||
out.push(line);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const readJpegDims = (buf) => {
|
||||
let i = 2;
|
||||
while (i < buf.length) {
|
||||
if (buf[i] !== 0xFF) return null;
|
||||
const marker = buf[i + 1];
|
||||
if (marker === 0xD8 || marker === 0xD9) { i += 2; continue; }
|
||||
const len = buf.readUInt16BE(i + 2);
|
||||
if (marker >= 0xC0 && marker <= 0xCF && marker !== 0xC4 && marker !== 0xC8 && marker !== 0xCC) {
|
||||
const h = buf.readUInt16BE(i + 5);
|
||||
const w = buf.readUInt16BE(i + 7);
|
||||
const c = buf[i + 9];
|
||||
return { w, h, c };
|
||||
}
|
||||
i += 2 + len;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const flattenSections = (sections) => {
|
||||
const lines = [];
|
||||
for (const s of Array.isArray(sections) ? sections : []) {
|
||||
if (!s) continue;
|
||||
if (s.kind === 'kv') {
|
||||
const txt = `${s.label}: ${s.value == null ? '' : s.value}`;
|
||||
for (const w of wrap(txt, 82)) lines.push({ kind: 'kv', text: w });
|
||||
} else if (s.kind === 'text') {
|
||||
for (const w of wrap(s.text, 82)) lines.push({ kind: 'kv', text: w });
|
||||
} else {
|
||||
lines.push({ kind: s.kind, text: s.text });
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
};
|
||||
|
||||
function buildDocumentPdf({ title, issuedToLabel, issuedTo, sections } = {}) {
|
||||
const pageW = 612;
|
||||
const pageH = 792;
|
||||
const marginX = 50;
|
||||
const headerH = 90;
|
||||
const footerH = 40;
|
||||
const bodyTop = pageH - headerH - 24;
|
||||
const bodyBottom = footerH + 10;
|
||||
const lineH = 14;
|
||||
|
||||
let logoBuf = null;
|
||||
let logoDims = null;
|
||||
try {
|
||||
logoBuf = fs.readFileSync(LOGO_PATH);
|
||||
logoDims = readJpegDims(logoBuf);
|
||||
} catch (_) {}
|
||||
|
||||
const lines = flattenSections(sections);
|
||||
|
||||
const maxBodyLines = Math.floor((bodyTop - bodyBottom) / lineH);
|
||||
const pages = [];
|
||||
for (let i = 0; i < lines.length; i += maxBodyLines) pages.push(lines.slice(i, i + maxBodyLines));
|
||||
if (!pages.length) pages.push([]);
|
||||
|
||||
const objects = [];
|
||||
const addObj = body => { objects.push(body); return objects.length; };
|
||||
|
||||
const catalogId = addObj(null);
|
||||
const pagesId = addObj(null);
|
||||
const fontId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier /Encoding /WinAnsiEncoding >>');
|
||||
const fontBoldId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier-Bold /Encoding /WinAnsiEncoding >>');
|
||||
let logoXObjId = null;
|
||||
if (logoBuf && logoDims) {
|
||||
const cs = logoDims.c === 1 ? '/DeviceGray' : '/DeviceRGB';
|
||||
const dict = `<< /Type /XObject /Subtype /Image /Width ${logoDims.w} /Height ${logoDims.h} /ColorSpace ${cs} /BitsPerComponent 8 /Filter /DCTDecode /Length ${logoBuf.length} >>`;
|
||||
logoXObjId = addObj({ dict, stream: logoBuf });
|
||||
}
|
||||
|
||||
const exportDate = new Date().toISOString().replace('T', ' ').slice(0, 19) + ' UTC';
|
||||
const footerLeft = `Generated: ${exportDate}`;
|
||||
|
||||
const pageIds = [];
|
||||
const contentIds = [];
|
||||
|
||||
pages.forEach((pg, pgIdx) => {
|
||||
const parts = [];
|
||||
if (logoXObjId) {
|
||||
const logoH = 60;
|
||||
const logoW = Math.round((logoDims.w / logoDims.h) * logoH);
|
||||
parts.push(`q\n${logoW} 0 0 ${logoH} ${marginX} ${pageH - headerH + 15} cm\n/Logo Do\nQ`);
|
||||
}
|
||||
const titleX = (logoXObjId ? marginX + 80 : marginX);
|
||||
const titleY = pageH - 45;
|
||||
parts.push(`BT\n/F2 16 Tf\n${titleX} ${titleY} Td\n(${escapePdf(title || 'OASIS')}) Tj\nET`);
|
||||
if (issuedTo) {
|
||||
const prefix = `${issuedToLabel || 'Issued to'}: `;
|
||||
const prefixW = prefix.length * 5.4;
|
||||
parts.push(`BT\n/F1 9 Tf\n${titleX} ${titleY - 16} Td\n(${escapePdf(prefix)}) Tj\nET`);
|
||||
parts.push(`BT\n/F2 9 Tf\n${titleX + prefixW} ${titleY - 16} Td\n(${escapePdf(String(issuedTo))}) Tj\nET`);
|
||||
}
|
||||
|
||||
parts.push(`q\n0.6 0.6 0.6 RG\n0.5 w\n${marginX} ${pageH - headerH} m\n${pageW - marginX} ${pageH - headerH} l\nS\nQ`);
|
||||
|
||||
let y = bodyTop;
|
||||
for (const ln of pg) {
|
||||
if (ln.kind === 'title') {
|
||||
parts.push(`BT\n/F2 14 Tf\n0 0 0 rg\n${marginX} ${y} Td\n(${escapePdf(ln.text)}) Tj\nET`);
|
||||
} else if (ln.kind === 'subtitle') {
|
||||
parts.push(`BT\n/F1 11 Tf\n0.2 0.2 0.2 rg\n${marginX} ${y} Td\n(${escapePdf(ln.text)}) Tj\nET`);
|
||||
} else if (ln.kind === 'section') {
|
||||
parts.push(`BT\n/F2 11 Tf\n0 0 0 rg\n${marginX} ${y} Td\n(${escapePdf(ln.text)}) Tj\nET`);
|
||||
parts.push(`q\n0 0 0 RG\n0.5 w\n${marginX} ${y - 3} m\n${pageW - marginX} ${y - 3} l\nS\nQ`);
|
||||
} else if (ln.kind === 'kv') {
|
||||
parts.push(`BT\n/F1 10 Tf\n0 0 0 rg\n${marginX} ${y} Td\n(${escapePdf(ln.text)}) Tj\nET`);
|
||||
}
|
||||
y -= lineH;
|
||||
}
|
||||
|
||||
parts.push(`q\n0.6 0.6 0.6 RG\n0.5 w\n${marginX} ${footerH + 5} m\n${pageW - marginX} ${footerH + 5} l\nS\nQ`);
|
||||
parts.push(`BT\n/F1 8 Tf\n${marginX} ${footerH - 10} Td\n(${escapePdf(footerLeft)}) Tj\nET`);
|
||||
const pageLabel = `Page ${pgIdx + 1} of ${pages.length}`;
|
||||
const pageLabelW = pageLabel.length * 4.8;
|
||||
parts.push(`BT\n/F1 8 Tf\n${pageW - marginX - pageLabelW} ${footerH - 10} Td\n(${escapePdf(pageLabel)}) Tj\nET`);
|
||||
|
||||
const content = parts.join('\n');
|
||||
const stream = `<< /Length ${Buffer.byteLength(content, 'latin1')} >>\nstream\n${content}\nendstream`;
|
||||
const cid = addObj(stream);
|
||||
contentIds.push(cid);
|
||||
const pid = addObj(null);
|
||||
pageIds.push(pid);
|
||||
});
|
||||
|
||||
for (let i = 0; i < pageIds.length; i++) {
|
||||
const resources = logoXObjId
|
||||
? `<< /Font << /F1 ${fontId} 0 R /F2 ${fontBoldId} 0 R >> /XObject << /Logo ${logoXObjId} 0 R >> >>`
|
||||
: `<< /Font << /F1 ${fontId} 0 R /F2 ${fontBoldId} 0 R >> >>`;
|
||||
objects[pageIds[i] - 1] = `<< /Type /Page /Parent ${pagesId} 0 R /MediaBox [0 0 ${pageW} ${pageH}] /Contents ${contentIds[i]} 0 R /Resources ${resources} >>`;
|
||||
}
|
||||
objects[catalogId - 1] = `<< /Type /Catalog /Pages ${pagesId} 0 R >>`;
|
||||
objects[pagesId - 1] = `<< /Type /Pages /Kids [${pageIds.map(id => `${id} 0 R`).join(' ')}] /Count ${pageIds.length} >>`;
|
||||
|
||||
const chunks = [];
|
||||
const offsets = [0];
|
||||
let byteLen = 0;
|
||||
const push = (buf) => { chunks.push(buf); byteLen += buf.length; };
|
||||
push(Buffer.from('%PDF-1.4\n%\xE2\xE3\xCF\xD3\n', 'binary'));
|
||||
for (let i = 0; i < objects.length; i++) {
|
||||
offsets.push(byteLen);
|
||||
const obj = objects[i];
|
||||
if (obj && typeof obj === 'object' && obj.dict && obj.stream) {
|
||||
push(Buffer.from(`${i + 1} 0 obj\n${obj.dict}\nstream\n`, 'binary'));
|
||||
push(obj.stream);
|
||||
push(Buffer.from('\nendstream\nendobj\n', 'binary'));
|
||||
} else {
|
||||
push(Buffer.from(`${i + 1} 0 obj\n${obj}\nendobj\n`, 'binary'));
|
||||
}
|
||||
}
|
||||
const xrefStart = byteLen;
|
||||
let xref = `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`;
|
||||
for (let i = 1; i <= objects.length; i++) xref += `${String(offsets[i]).padStart(10, '0')} 00000 n \n`;
|
||||
xref += `trailer\n<< /Size ${objects.length + 1} /Root ${catalogId} 0 R >>\nstartxref\n${xrefStart}\n%%EOF`;
|
||||
push(Buffer.from(xref, 'binary'));
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
module.exports = { buildDocumentPdf, escapePdf, wrap };
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
const fs = require('fs');
|
||||
|
||||
const ANNOUNCE_SEEN_FILE = 'oasis-political-seen';
|
||||
|
||||
const isMutual = (relationship) => !!(relationship && relationship.following && relationship.followsMe);
|
||||
|
||||
const isRecipientAllowed = ({ pmVisibility, viewerId, recipientId, relationship } = {}) => {
|
||||
if (recipientId && viewerId && recipientId === viewerId) return true;
|
||||
if (pmVisibility !== 'mutuals') return true;
|
||||
return isMutual(relationship);
|
||||
};
|
||||
|
||||
const announceSeenPath = () => {
|
||||
try { return require('../server/ssb_config').statePath(ANNOUNCE_SEEN_FILE); } catch (_) { return null; }
|
||||
};
|
||||
|
||||
const readAnnounceSeen = () => {
|
||||
const file = announceSeenPath();
|
||||
if (!file) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
return parsed && typeof parsed === 'object' ? parsed : {};
|
||||
} catch (_) { return {}; }
|
||||
};
|
||||
|
||||
const writeAnnounceSeen = (state) => {
|
||||
const file = announceSeenPath();
|
||||
if (!file) return false;
|
||||
try { fs.writeFileSync(file, JSON.stringify(state || {})); return true; } catch (_) { return false; }
|
||||
};
|
||||
|
||||
const everAnnounced = (announced, subject) => {
|
||||
for (const entry of (announced || [])) {
|
||||
if (String(entry).startsWith(`${subject}|`)) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const decideAnnouncement = ({ subject, ref, announced = new Set(), seen = {} } = {}) => {
|
||||
if (!subject || !ref) return { send: false, remember: false };
|
||||
const key = String(ref);
|
||||
if (announced.has(`${subject}|${key}`)) return { send: false, remember: false };
|
||||
if (seen[subject] === key) return { send: false, remember: false };
|
||||
if (!everAnnounced(announced, subject) && seen[subject] === undefined) {
|
||||
return { send: false, remember: true };
|
||||
}
|
||||
return { send: true, remember: true };
|
||||
};
|
||||
|
||||
module.exports = { isRecipientAllowed, isMutual, announceSeenPath, readAnnounceSeen, writeAnnounceSeen, decideAnnouncement };
|
||||
|
|
@ -3,7 +3,7 @@ const path = require('path');
|
|||
|
||||
const LOGO_PATH = path.join(__dirname, '..', 'client', 'assets', 'images', 'snh-oasis.jpg');
|
||||
|
||||
const { escapePdf } = require('./pdfDocument');
|
||||
const escapePdf = s => String(s || '').replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)');
|
||||
|
||||
const wrap = (txt, max = 82) => {
|
||||
const out = [];
|
||||
|
|
@ -131,8 +131,8 @@ function buildSmartContractPdf({ transfer, block, viewerId }) {
|
|||
|
||||
const catalogId = addObj(null);
|
||||
const pagesId = addObj(null);
|
||||
const fontId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier /Encoding /WinAnsiEncoding >>');
|
||||
const fontBoldId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier-Bold /Encoding /WinAnsiEncoding >>');
|
||||
const fontId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier >>');
|
||||
const fontBoldId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier-Bold >>');
|
||||
let logoXObjId = null;
|
||||
if (logoBuf && logoDims) {
|
||||
const cs = logoDims.c === 1 ? '/DeviceGray' : '/DeviceRGB';
|
||||
|
|
@ -185,7 +185,7 @@ function buildSmartContractPdf({ transfer, block, viewerId }) {
|
|||
parts.push(`BT\n/F1 8 Tf\n${pageW - marginX - pageLabelW} ${footerH - 10} Td\n(${escapePdf(pageLabel)}) Tj\nET`);
|
||||
|
||||
const content = parts.join('\n');
|
||||
const stream = `<< /Length ${Buffer.byteLength(content, 'latin1')} >>\nstream\n${content}\nendstream`;
|
||||
const stream = `<< /Length ${Buffer.byteLength(content)} >>\nstream\n${content}\nendstream`;
|
||||
const cid = addObj(stream);
|
||||
contentIds.push(cid);
|
||||
const pid = addObj(null);
|
||||
|
|
|
|||
|
|
@ -1,50 +0,0 @@
|
|||
"use strict";
|
||||
// Credenciales efimeras para un TURN propio (coturn con use-auth-secret).
|
||||
//
|
||||
// Es el mecanismo REST de coturn: el servidor no guarda usuarios, valida que
|
||||
// credential == base64(HMAC-SHA1(static-auth-secret, username))
|
||||
// y que el timestamp del username no haya caducado. Asi las credenciales se
|
||||
// generan sin hablar con coturn y caducan solas.
|
||||
//
|
||||
// Portado del turn.js de karvan-web, cambiando sessionId por el feed id: lo que
|
||||
// identifica a quien llama en Oasis es su feed, no una sesion anonima.
|
||||
|
||||
const crypto = require("crypto");
|
||||
|
||||
const DEFAULT_TTL = 3600;
|
||||
|
||||
// username = <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.
|
Before Width: | Height: | Size: 2.3 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.5 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 3.2 KiB |
|
|
@ -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; }
|
||||
|
|
@ -89,6 +89,7 @@ code {
|
|||
}
|
||||
|
||||
.search-input,
|
||||
.feed-search-input,
|
||||
.activity-search-input {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
|
|
@ -509,28 +510,28 @@ h3 { font-size: 1em !important; }
|
|||
}
|
||||
|
||||
.forum-comment {
|
||||
margin-left: 0;
|
||||
padding-left: 8px;
|
||||
margin-left: 0 !important;
|
||||
padding-left: 8px !important;
|
||||
}
|
||||
|
||||
.comment-body-row {
|
||||
flex-direction: column;
|
||||
flex-direction: column !important;
|
||||
}
|
||||
|
||||
.comment-vote-col,
|
||||
.comment-text-col {
|
||||
width: 100%;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.forum-score-box,
|
||||
.forum-score-form {
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
flex-direction: row !important;
|
||||
justify-content: center !important;
|
||||
}
|
||||
|
||||
.new-message-form textarea,
|
||||
.comment-textarea {
|
||||
width: 100%;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
[style*="grid-template-columns: repeat(6"] {
|
||||
|
|
@ -913,32 +914,3 @@ a.create-button {
|
|||
.peers-technical-block .block-info-table td[data-label=""]::before { content: none !important; }
|
||||
.peers-list .block-info-table td .peer-key,
|
||||
.peers-technical-block .block-info-table td .peer-key { text-align: right !important; }
|
||||
|
||||
.relationship-status .relationship-actions { flex-direction: column !important; align-items: stretch !important; gap: 8px !important; }
|
||||
.relationship-status .relationship-actions form,
|
||||
.relationship-status .relationship-actions button { width: 100% !important; }
|
||||
|
||||
.profile-sensors-box { align-items: center !important; text-align: center !important; }
|
||||
.profile-sensors-box > * { margin-left: auto !important; margin-right: auto !important; justify-content: center !important; }
|
||||
|
||||
.tribe-feed .refeed-column,
|
||||
.tribe-feed-full .refeed-column { align-self: center !important; align-items: center !important; justify-content: center !important; }
|
||||
.tribe-feed .card-footer,
|
||||
.tribe-feed-full .card-footer { display: flex !important; flex-direction: column !important; align-items: center !important; text-align: center !important; gap: 2px !important; }
|
||||
|
||||
.invite-qr-card { display: flex !important; justify-content: center !important; border: none !important; background: transparent !important; padding: 0 !important; box-shadow: none !important; margin: 0 auto !important; }
|
||||
.invite-qr-img { display: block; margin: 0 auto; }
|
||||
|
||||
.stats-kpi { display: flex !important; flex-direction: column !important; align-items: center !important; text-align: center !important; gap: 2px !important; }
|
||||
.stats-kpi-label,
|
||||
.stats-kpi-value { width: 100% !important; text-align: center !important; }
|
||||
|
||||
.main-column audio,
|
||||
.main-column video { width: 100% !important; max-width: 100% !important; }
|
||||
|
||||
.main-column section:has(.chat-messages-list) { border: none !important; background: transparent !important; padding: 0 !important; box-shadow: none !important; }
|
||||
.tribe-details:has(.chat-messages-list) { display: block !important; border: none !important; background: transparent !important; padding: 6px !important; margin: 0 !important; box-shadow: none !important; }
|
||||
.chat-full-width { border: none !important; background: transparent !important; padding: 0 !important; }
|
||||
.chat-messages-list { padding: 0 !important; }
|
||||
.chat-message { max-width: 92% !important; }
|
||||
.chat-message.chat-poll { max-width: 97% !important; }
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,385 +0,0 @@
|
|||
body {
|
||||
background-color: #F9F9F9 !important;
|
||||
color: #2C2C2C !important;
|
||||
font-family: 'Roboto', sans-serif !important;
|
||||
}
|
||||
|
||||
.main-column {
|
||||
background-color: #FFFFFF !important;
|
||||
border: 1px solid #E0E0E0 !important;
|
||||
}
|
||||
|
||||
button, input[type="submit"], input[type="button"] {
|
||||
background-color: #FF6F00 !important;
|
||||
color: #FFFFFF !important;
|
||||
border: none !important;
|
||||
border-radius: 6px !important;
|
||||
padding: 10px 20px !important;
|
||||
cursor: pointer !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
|
||||
button:hover, input[type="submit"]:hover, input[type="button"]:hover {
|
||||
background-color: #FF8F00 !important;
|
||||
}
|
||||
|
||||
input, textarea, select {
|
||||
background-color: #FFFFFF !important;
|
||||
color: #2C2C2C !important;
|
||||
border: 1px solid #E0E0E0 !important;
|
||||
border-radius: 4px !important;
|
||||
padding: 8px !important;
|
||||
font-size: 16px !important;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #007BFF !important;
|
||||
text-decoration: none !important;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline !important;
|
||||
}
|
||||
|
||||
p {
|
||||
color: black !important;
|
||||
text-decoration: none !important;
|
||||
}
|
||||
|
||||
.created-at, .about-time, .time {
|
||||
font-size: 0.9rem;
|
||||
color: black;
|
||||
}
|
||||
|
||||
table {
|
||||
background-color: #FFFFFF !important;
|
||||
color: #2C2C2C !important;
|
||||
width: 100% !important;
|
||||
border-collapse: collapse !important;
|
||||
}
|
||||
|
||||
table th {
|
||||
background-color: #F8F8F8 !important;
|
||||
padding: 12px 15px !important;
|
||||
text-align: left !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
|
||||
table tr:nth-child(even) {
|
||||
background-color: #FAFAFA !important;
|
||||
}
|
||||
|
||||
table td {
|
||||
padding: 12px 15px !important;
|
||||
}
|
||||
|
||||
.profile {
|
||||
background-color: #FFFFFF !important;
|
||||
padding: 20px !important;
|
||||
border-radius: 8px !important;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1) !important;
|
||||
}
|
||||
|
||||
.profile .name {
|
||||
color: #FF6F00 !important;
|
||||
font-size: 20px !important;
|
||||
font-weight: 700 !important;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
border: 3px solid #FF6F00 !important;
|
||||
border-radius: 50% !important;
|
||||
width: 60px !important;
|
||||
height: 60px !important;
|
||||
}
|
||||
|
||||
article, section {
|
||||
background-color: #FFFFFF !important;
|
||||
color: #2C2C2C !important;
|
||||
padding: 20px !important;
|
||||
border-radius: 8px !important;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05) !important;
|
||||
}
|
||||
|
||||
.post-preview img {
|
||||
border-radius: 8px !important;
|
||||
max-width: 100% !important;
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
div {
|
||||
background-color: #FFFFFF !important;
|
||||
border: 1px solid #E0E0E0 !important;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: #B0B0B0 !important;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background-color: #F9F9F9 !important;
|
||||
}
|
||||
|
||||
.action-container {
|
||||
background-color: #FFFFFF !important;
|
||||
border-color: #E0E0E0 !important;
|
||||
color: #2C2C2C !important;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05) !important;
|
||||
}
|
||||
|
||||
footer {
|
||||
background-color: #FFFFFF !important;
|
||||
border-top: 1px solid #E0E0E0 !important;
|
||||
padding: 15px 0 !important;
|
||||
}
|
||||
|
||||
footer a {
|
||||
background-color: #007BFF !important;
|
||||
color: #FFFFFF !important;
|
||||
padding: 10px 20px !important;
|
||||
border-radius: 6px !important;
|
||||
text-decoration: none !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
|
||||
footer a:hover {
|
||||
background-color: #0056b3 !important;
|
||||
}
|
||||
|
||||
.sidebar-left nav ul,
|
||||
.sidebar-right nav ul {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
}
|
||||
|
||||
.sidebar-left nav ul,
|
||||
.sidebar-right nav ul {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sidebar-left nav ul li,
|
||||
.sidebar-right nav ul li {
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.sidebar-left nav ul li a,
|
||||
.sidebar-right nav ul li a,
|
||||
.header nav ul li a {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
border-radius: 6px;
|
||||
background-color: #ffffff !important;
|
||||
color: #2C2C2C !important;
|
||||
border: 1px solid #D0D0D0 !important;
|
||||
text-align: left;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.sidebar-left nav ul li a:hover,
|
||||
.sidebar-right nav ul li a:hover,
|
||||
.header nav ul li a:hover {
|
||||
background-color: #f0f0f0 !important;
|
||||
}
|
||||
|
||||
.filter-btn,
|
||||
.create-button,
|
||||
.edit-btn,
|
||||
.delete-btn,
|
||||
.join-btn,
|
||||
.leave-btn,
|
||||
.buy-btn {
|
||||
background-color: #FF6F00 !important;
|
||||
color: #FFFFFF !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.filter-btn:hover,
|
||||
.create-button:hover {
|
||||
background-color: #FF8F00 !important;
|
||||
color: #FFFFFF !important;
|
||||
}
|
||||
|
||||
.card {
|
||||
color: #4A4A4A;
|
||||
box-shadow: 0 4px 30px 0 rgba(0, 0, 0, 0.1);
|
||||
background-color: #F4F4F4;
|
||||
}
|
||||
|
||||
.card-label {
|
||||
color: #2D2D2D;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
color: #6C6C6C;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.card-field {
|
||||
background: none;
|
||||
}
|
||||
|
||||
.card-tags a.tag-link {
|
||||
color: #181818;
|
||||
background: #D94F4F;
|
||||
}
|
||||
|
||||
.card-tags a.tag-link:hover {
|
||||
background: #D94F4F;
|
||||
color: #111;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
a.user-link {
|
||||
background-color: #FFD600;
|
||||
color: #FFFFFF;
|
||||
border-color: #FFD600;
|
||||
transition: background-color 0.3s, color 0.3s, border-color 0.3s;
|
||||
}
|
||||
|
||||
a.user-link:hover {
|
||||
background-color: #FFD600;
|
||||
border-color: #FFD600;
|
||||
color: #FFFFFF;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
a.user-link:focus {
|
||||
background-color: #9A2F2F;
|
||||
border-color: #9A2F2F;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
.date-link {
|
||||
background-color: #2F3C32;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.date-link:hover {
|
||||
background-color: #3E4A3D;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.activitySpreadInhabitant2 {
|
||||
background-color: #3E4A3D;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.activityVotePost {
|
||||
background-color: #3B5C42;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
|
||||
.update-banner {
|
||||
background-color: #FFF3E0 !important;
|
||||
border-bottom-color: #FFE0B2 !important;
|
||||
color: #E65100 !important;
|
||||
}
|
||||
|
||||
.update-banner-link {
|
||||
color: #FF6F00 !important;
|
||||
}
|
||||
|
||||
.snh-invite-code {
|
||||
color: #007BFF !important;
|
||||
}
|
||||
|
||||
.carbon-bar-track {
|
||||
background: #e0e0e0 !important;
|
||||
}
|
||||
|
||||
.carbon-bar-max {
|
||||
background: #cc7700 !important;
|
||||
}
|
||||
|
||||
.stats-kpi-label { color: #2D2D2D !important; }
|
||||
.stats-kpi-value { color: #007BFF !important; }
|
||||
.carbon-bar-note, .carbon-bar-formula { color: #007BFF !important; }
|
||||
.graphos-node-label { fill: #2C2C2C !important; }
|
||||
.graphos-node-label-me { fill: #cc7700 !important; }
|
||||
.data-best-label { color: #cc7700 !important; }
|
||||
.data-best-card { background: #FFF3E0 !important; border: 1px solid rgba(204, 119, 0, .4) !important; }
|
||||
.graphos-legend { color: #2C2C2C !important; }
|
||||
.graphos-edge { stroke: #BBB !important; }
|
||||
.graphos-edge-discovered { stroke: #b8860b !important; }
|
||||
.graphos-edge-unknown { stroke: #999 !important; }
|
||||
.graphos-node-circle-discovered { fill: #ffd700 !important; stroke: #b8860b !important; }
|
||||
.graphos-legend-dot.graphos-node-circle-discovered { background: #ffd700 !important; border-color: #b8860b !important; }
|
||||
/* Blockexplorer */
|
||||
.blockchain-view { background-color: #F4F4F4 !important; color: #2C2C2C !important; }
|
||||
.block { background: #FFFFFF !important; box-shadow: 0 2px 12px rgba(0,0,0,0.06) !important; }
|
||||
.block:hover { box-shadow: 0 8px 32px rgba(0,0,0,0.10) !important; }
|
||||
.blockchain-card-label, .block-info-table .card-label, .pm-info-table .card-label, .block-content-label { color: #2D2D2D !important; }
|
||||
.blockchain-card-value, .block-info-table .card-value, .pm-info-table .card-value, .block-timestamp, .json-content { color: #007BFF !important; }
|
||||
.block-content-preview, .block-content { background: #F8F8F8 !important; color: #2C2C2C !important; }
|
||||
.block-author { color: #FF6F00 !important; background: rgba(255,111,0,0.08) !important; }
|
||||
.block-author:hover { color: #FF8F00 !important; }
|
||||
.block-url { color: #007BFF !important; }
|
||||
.block-row--details .block-url { background: #F0F0F0 !important; }
|
||||
.block-row--details .block-url:hover { background: #E0E0E0 !important; color: #FF6F00 !important; }
|
||||
.btn-singleview { background: #F0F0F0 !important; color: #FF6F00 !important; }
|
||||
.btn-singleview:hover { background: #E0E0E0 !important; color: #FF8F00 !important; }
|
||||
.btn-spread-on, .content-actions .btn-pin-on { background: #FF6F00 !important; color: #FFFFFF !important; }
|
||||
.btn-spread-on:hover, .content-actions .btn-pin-on:hover { background: #FF8F00 !important; color: #FFFFFF !important; }
|
||||
.btn-back { background: #FF6F00 !important; color: #FFFFFF !important; }
|
||||
.btn-back:hover { background: #FF8F00 !important; color: #FFFFFF !important; }
|
||||
.block-info-table td, .pm-info-table td { border-color: #E0E0E0 !important; }
|
||||
.block-diagram { border-color: #E0E0E0 !important; background: #FFFFFF !important; }
|
||||
.block-diagram-ruler { color: #2D2D2D !important; background: #F8F8F8 !important; border-bottom-color: #E0E0E0 !important; }
|
||||
.block-diagram-ruler span { color: #2D2D2D !important; }
|
||||
.block-diagram-cell { border-color: #E0E0E0 !important; background: #FFFFFF !important; }
|
||||
.bd-label { color: #2D2D2D !important; }
|
||||
.bd-value { color: #007BFF !important; }
|
||||
.deleted-label { color: #D32F2F !important; }
|
||||
|
||||
/* Tribes */
|
||||
.tribe-card { background: #FFFFFF !important; border-color: #E0E0E0 !important; }
|
||||
.tribe-card:hover { border-color: #007BFF !important; }
|
||||
.tribe-card-title { color: #2D2D2D !important; }
|
||||
.tribe-card-description { color: #555 !important; }
|
||||
.tribe-info-table td { border-color: #E0E0E0 !important; }
|
||||
.tribe-info-label { color: #2D2D2D !important; background: #FFFFFF !important; }
|
||||
.tribe-info-value { color: #007BFF !important; background: #FFFFFF !important; }
|
||||
.tribe-info-empty { color: #999 !important; }
|
||||
.tribe-card-members { border-color: #E0E0E0 !important; background: #F8F8F8 !important; }
|
||||
.tribe-members-count { color: #FF6F00 !important; }
|
||||
.tribe-card-actions { border-color: #E0E0E0 !important; background: #F8F8F8 !important; }
|
||||
.tribe-action-btn { border-color: #FF6F00 !important; color: #FF6F00 !important; }
|
||||
.tribe-action-btn:hover { background: #FF6F00 !important; color: #fff !important; }
|
||||
.tribe-subtribe-link { background: #F0F0F0 !important; border-color: #E0E0E0 !important; color: #FF6F00 !important; }
|
||||
.tribe-thumb-link { border-color: #E0E0E0 !important; }
|
||||
.tribe-thumb-link:hover { border-color: #FF6F00 !important; }
|
||||
.tribe-subtribe-link:hover { background: #E0E0E0 !important; }
|
||||
.tribe-parent-image { border-color: #E0E0E0 !important; }
|
||||
.tribe-parent-box { background: #FFFFFF !important; }
|
||||
.chat-message { background: #FFFFFF !important; border: 1px solid #E0E0E0 !important; }
|
||||
.chat-bubble-row-self .chat-message { background: #FFF3E0 !important; border-color: rgba(204, 119, 0, .35) !important; }
|
||||
.chat-message-author { background: #FDF6EC !important; }
|
||||
.chat-bubble-sender { color: #1565C0 !important; }
|
||||
.chat-bubble-sender-owner { color: #cc7700 !important; }
|
||||
.chat-bubble-time { color: #999 !important; }
|
||||
.chat-day-chip { background: #F0F0F0 !important; color: #666 !important; border-color: #DDD !important; }
|
||||
.chat-topics { background: #FAFAFA !important; border-color: #E0E0E0 !important; }
|
||||
.chat-topic:hover { background: #F0F0F0 !important; }
|
||||
.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; }
|
||||
|
|
@ -1,364 +0,0 @@
|
|||
body {
|
||||
background-color: #121212;
|
||||
color: #FFD700;
|
||||
}
|
||||
|
||||
header, footer {
|
||||
background-color: #1F1F1F;
|
||||
}
|
||||
|
||||
.sidebar-left, .sidebar-right {
|
||||
background-color: #1A1A1A;
|
||||
border: 1px solid #333;
|
||||
}
|
||||
|
||||
.main-column {
|
||||
background-color: #1C1C1C;
|
||||
}
|
||||
|
||||
button, input[type="submit"], input[type="button"] {
|
||||
background-color: #444;
|
||||
color: #FFD700;
|
||||
border: 1px solid #444;
|
||||
}
|
||||
|
||||
button:hover, input[type="submit"]:hover, input[type="button"]:hover {
|
||||
background-color: #333;
|
||||
border-color: #666;
|
||||
}
|
||||
|
||||
input, textarea, select {
|
||||
background-color: #333;
|
||||
color: #FFD700;
|
||||
border: 1px solid #555;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #FFD700;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #FFDD44;
|
||||
}
|
||||
|
||||
table {
|
||||
background-color: #222;
|
||||
color: #FFD700;
|
||||
}
|
||||
|
||||
table th {
|
||||
background-color: #333;
|
||||
}
|
||||
|
||||
table tr:nth-child(even) {
|
||||
background-color: #2A2A2A;
|
||||
}
|
||||
|
||||
nav ul li a:hover {
|
||||
color: #FFDD44;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.profile {
|
||||
background-color: #222;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.profile .name {
|
||||
color: #FFD700;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
border: 3px solid #FFD700;
|
||||
}
|
||||
|
||||
article, section {
|
||||
background-color: #1C1C1C;
|
||||
color: #FFD700;
|
||||
}
|
||||
|
||||
.article img {
|
||||
border: 3px solid #FFD700;
|
||||
}
|
||||
|
||||
.post-preview img {
|
||||
border: 3px solid #FFD700;
|
||||
}
|
||||
|
||||
.post-preview .image-container {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
div {
|
||||
background-color: #1A1A1A;
|
||||
border: 1px solid #333;
|
||||
}
|
||||
|
||||
div .header-content {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: #444;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background-color: #222;
|
||||
}
|
||||
|
||||
.action-container {
|
||||
background-color: #1A1A1A;
|
||||
border: 1px solid #333;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
color: #FFD700;
|
||||
}
|
||||
|
||||
footer {
|
||||
background-color: #1F1F1F;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
footer a {
|
||||
background-color: #444;
|
||||
color: #FFD700;
|
||||
text-align: center;
|
||||
padding: 8px 16px;
|
||||
border-radius: 5px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
footer a:hover {
|
||||
background-color: #333;
|
||||
color: #FFDD44;
|
||||
}
|
||||
|
||||
.card {
|
||||
border-radius: 16px;
|
||||
padding: 0px 24px 10px 24px;
|
||||
margin-bottom: 16px;
|
||||
color: #FFD600;
|
||||
font-family: inherit;
|
||||
box-shadow: 0 2px 20px 0 #FFD60024;
|
||||
}
|
||||
|
||||
.card-section {
|
||||
border:none;
|
||||
padding: 10px 0 0 16px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-bottom: 0px;
|
||||
margin-top: 8px;
|
||||
padding-top: 0px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.card-label {
|
||||
color: #ffa300;
|
||||
font-weight: bold;
|
||||
letter-spacing: 1.5px;
|
||||
line-height: 1.2;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
margin-top: 6px;
|
||||
font-weight: 500;
|
||||
color: #ff9900;
|
||||
font-size: 1.07em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: none;
|
||||
border: none;
|
||||
padding-top: 0;
|
||||
margin-bottom: 6;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
margin-top: 0;
|
||||
margin-bottom: 4;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.card-field {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
padding: 0;
|
||||
margin-bottom: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.card-tags {
|
||||
margin: 5px 0 3px 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.card-tags a.tag-link {
|
||||
text-decoration: none;
|
||||
color: #181818;
|
||||
background: #FFD600;
|
||||
padding: 5px 13px 4px 13px;
|
||||
border-radius: 7px;
|
||||
font-size: .98em;
|
||||
border: none;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.card-tags a.tag-link:hover {
|
||||
background: #ffe86a;
|
||||
color: #111;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
a.user-link {
|
||||
background-color: #FFA500;
|
||||
color: #000;
|
||||
padding: 8px 16px;
|
||||
border-radius: 5px;
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
border: 2px solid transparent;
|
||||
transition: background-color 0.3s, color 0.3s, border-color 0.3s;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
a.user-link:hover {
|
||||
background-color: #FFD700;
|
||||
border-color: #FFD700;
|
||||
color: #000;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
a.user-link:focus {
|
||||
background-color: #007B9F;
|
||||
border-color: #007B9F;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.date-link {
|
||||
background-color: #444;
|
||||
color: #FFD600;
|
||||
padding: 8px 16px;
|
||||
border-radius: 5px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.date-link:hover {
|
||||
background-color: #555;
|
||||
color: #FFD700;
|
||||
}
|
||||
|
||||
.activitySpreadInhabitant2 {
|
||||
background-color: #007B9F;
|
||||
color: #fff;
|
||||
padding: 8px 16px;
|
||||
border-radius: 5px;
|
||||
font-weight: bold;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
|
||||
.activityVotePost {
|
||||
background-color: #557d3b;
|
||||
color: #fff;
|
||||
padding: 8px 16px;
|
||||
border-radius: 5px;
|
||||
font-weight: bold;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
|
||||
.update-banner {
|
||||
background-color: #1a1400;
|
||||
border-bottom-color: #3a2e00;
|
||||
color: #FFD700;
|
||||
}
|
||||
|
||||
.update-banner-link {
|
||||
color: #FFD700;
|
||||
}
|
||||
|
||||
.oasis-footer-center a {
|
||||
color: #FFA500;
|
||||
}
|
||||
|
||||
.oasis-footer-center a:hover {
|
||||
color: #FFD700;
|
||||
}
|
||||
|
||||
.snh-invite-code {
|
||||
color: #FFA500 !important;
|
||||
}
|
||||
|
||||
/* Blockexplorer */
|
||||
.stats-kpi-label { color: #ffa300 !important; }
|
||||
.stats-kpi-value { color: #FFD700 !important; }
|
||||
.carbon-bar-note, .carbon-bar-formula { color: #FFD700 !important; }
|
||||
.graphos-node-label { fill: #ddd !important; }
|
||||
.graphos-node-label-me { fill: #ffa500 !important; }
|
||||
.graphos-legend { color: #ddd !important; }
|
||||
.blockchain-view { background-color: #191b20 !important; color: #FFD700 !important; }
|
||||
.block { background: #23242a !important; }
|
||||
.block:hover { box-shadow: 0 8px 32px rgba(35,40,50,0.18) !important; }
|
||||
.blockchain-card-label, .block-info-table .card-label, .pm-info-table .card-label, .block-content-label { color: #ffa300 !important; }
|
||||
.blockchain-card-value, .block-info-table .card-value, .pm-info-table .card-value, .block-timestamp, .json-content { color: #FFD700 !important; }
|
||||
.block-content-preview, .block-content { background: #222326 !important; color: #FFD700 !important; }
|
||||
.block-author { color: #FFD700 !important; background: rgba(255,163,0,0.08) !important; }
|
||||
.block-author:hover { color: #FFDD44 !important; }
|
||||
.block-url { color: #FFD700 !important; }
|
||||
.block-row--details .block-url { background: #1f2023 !important; }
|
||||
.block-row--details .block-url:hover { background: #292b36 !important; color: #ffa300 !important; }
|
||||
.btn-singleview { background: #1e1f23 !important; color: #ffa300 !important; }
|
||||
.btn-singleview:hover { background: #2d2e34 !important; color: #FFD700 !important; }
|
||||
.btn-spread-on, .content-actions .btn-pin-on { background: #ffa300 !important; color: #000000 !important; }
|
||||
.btn-spread-on:hover, .content-actions .btn-pin-on:hover { background: #ffb733 !important; color: #000000 !important; }
|
||||
.btn-back { background: #21232b !important; color: #ffa300 !important; }
|
||||
.btn-back:hover { background: #292b36 !important; color: #FFD700 !important; }
|
||||
.block-info-table td, .pm-info-table td { border-color: #444 !important; }
|
||||
.block-diagram { border-color: #555 !important; background: #191b20 !important; }
|
||||
.block-diagram-ruler { color: #ffa300 !important; background: #111 !important; border-bottom-color: #555 !important; }
|
||||
.block-diagram-ruler span { color: #ffa300 !important; }
|
||||
.block-diagram-cell { border-color: #555 !important; background: #1e1f23 !important; }
|
||||
.bd-label { color: #ffa300 !important; }
|
||||
.bd-value { color: #FFD700 !important; }
|
||||
|
||||
/* Tribes */
|
||||
.tribe-card { background: #23242a !important; border-color: #444 !important; }
|
||||
.tribe-card:hover { border-color: #ffa300 !important; }
|
||||
.tribe-card-description { color: #cfd3e1 !important; }
|
||||
.tribe-info-table td { border-color: #444 !important; }
|
||||
.tribe-info-label { color: #ffa300 !important; background: #1e1f23 !important; }
|
||||
.tribe-info-value { color: #FFD700 !important; background: #1e1f23 !important; }
|
||||
.tribe-info-empty { color: #9aa3b2 !important; }
|
||||
.tribe-card-members { border-color: #444 !important; background: #1e1f23 !important; }
|
||||
.tribe-members-count { color: #ffa300 !important; }
|
||||
.tribe-card-actions { border-color: #444 !important; background: #1e1f23 !important; }
|
||||
.tribe-action-btn { border-color: #ffa300 !important; color: #ffa300 !important; }
|
||||
.tribe-action-btn:hover { background: #ffa300 !important; color: #000 !important; }
|
||||
.tribe-subtribe-link { background: #191b20 !important; border-color: #444 !important; color: #ffa300 !important; }
|
||||
.tribe-thumb-link { border-color: #444 !important; }
|
||||
.tribe-thumb-link:hover { border-color: #ffa300 !important; }
|
||||
.tribe-subtribe-link:hover { background: #333 !important; }
|
||||
.tribe-parent-image { border-color: #ffa300 !important; }
|
||||
.tribe-parent-box { background: #1e1f23 !important; }
|
||||
|
|
@ -1,382 +0,0 @@
|
|||
body {
|
||||
background-color: #000000 !important;
|
||||
color: #00FF00 !important;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
header, footer {
|
||||
background-color: #000000;
|
||||
color: #00FF00;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
footer {
|
||||
border-top: 2px solid #00FF00;
|
||||
}
|
||||
|
||||
.sidebar-left, .sidebar-right {
|
||||
background-color: #000000;
|
||||
color: #00FF00;
|
||||
border-right: 2px solid #00FF00;
|
||||
padding: 15px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.main-column {
|
||||
background-color: #000000;
|
||||
color: #00FF00;
|
||||
padding: 20px;
|
||||
border-left: 2px solid #00FF00;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
button, input[type="submit"], input[type="button"] {
|
||||
background-color: #000000;
|
||||
color: #00FF00;
|
||||
border: 2px solid #00FF00;
|
||||
border-radius: 8px;
|
||||
padding: 10px 20px;
|
||||
cursor: pointer;
|
||||
font-family: 'Courier New', monospace;
|
||||
text-transform: none;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
button:hover, input[type="submit"]:hover, input[type="button"]:hover {
|
||||
background-color: #00FF00;
|
||||
color: #000000;
|
||||
border-color: #00FF00;
|
||||
box-shadow: 0 0 10px rgba(0, 255, 0, 0.8);
|
||||
}
|
||||
|
||||
input, textarea, select {
|
||||
background-color: #1A1A1A;
|
||||
color: #00FF00;
|
||||
border: 1px solid #00FF00;
|
||||
border-radius: 5px;
|
||||
padding: 10px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #00FF00;
|
||||
text-decoration: none;
|
||||
font-weight: normal;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
table {
|
||||
background-color: #000000;
|
||||
color: #00FF00;
|
||||
width: 100%;
|
||||
border: 1px solid #00FF00;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
table th {
|
||||
background-color: #00FF00;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
table tr:nth-child(even) {
|
||||
background-color: #1A1A1A;
|
||||
}
|
||||
|
||||
nav ul {
|
||||
background-color: #000000;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
nav ul li {
|
||||
display: inline-block;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
nav ul li a {
|
||||
color: #00FF00;
|
||||
padding: 10px;
|
||||
display: inline-block;
|
||||
font-family: 'Courier New', monospace;
|
||||
text-transform: none;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.profile {
|
||||
background-color: #1A1A1A;
|
||||
color: #00FF00;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #00FF00;
|
||||
box-shadow: 0 2px 5px rgba(0,255,0,0.5);
|
||||
}
|
||||
|
||||
.profile .name {
|
||||
color: #00FF00;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
border: 4px solid #00FF00;
|
||||
border-radius: 50%;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
article, section {
|
||||
background-color: #1A1A1A;
|
||||
color: #00FF00;
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #00FF00;
|
||||
box-shadow: 0 4px 10px rgba(0, 255, 0, 0.5);
|
||||
}
|
||||
|
||||
.post-preview {
|
||||
background-color: #00FF00;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.post-preview img {
|
||||
border-radius: 8px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: #00FF00;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background-color: #000000;
|
||||
}
|
||||
|
||||
.action-container {
|
||||
background-color: #1A1A1A;
|
||||
border-color: #00FF00;
|
||||
color: #00FF00;
|
||||
}
|
||||
|
||||
footer a {
|
||||
background-color: #00FF00;
|
||||
color: #000000;
|
||||
padding: 8px 20px;
|
||||
border-radius: 5px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.top-bar-left,
|
||||
.top-bar-mid,
|
||||
.top-bar-right {
|
||||
background-color: #000000 !important;
|
||||
border: 2px solid #00FF00 !important;
|
||||
padding: 12px 16px;
|
||||
box-shadow: 0 0 12px #00FF00;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sidebar-left,
|
||||
.sidebar-right {
|
||||
background-color: #000000 !important;
|
||||
border: 2px solid #00FF00 !important;
|
||||
box-shadow: 0 0 15px #00FF00;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.sidebar-left nav ul,
|
||||
.sidebar-right nav ul {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.sidebar-left nav ul li,
|
||||
.sidebar-right nav ul li {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sidebar-left nav ul li a,
|
||||
.sidebar-right nav ul li a,
|
||||
.header nav ul li a {
|
||||
background-color: #000000 !important;
|
||||
color: #00FF00 !important;
|
||||
border: 1px solid #00FF00 !important;
|
||||
font-weight: bold;
|
||||
border-radius: 6px;
|
||||
padding: 10px 14px;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
box-shadow: 0 0 6px #00FF00;
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
}
|
||||
|
||||
.sidebar-left nav ul li a:hover,
|
||||
.sidebar-right nav ul li a:hover,
|
||||
.header nav ul li a:hover {
|
||||
background-color: #00FF00 !important;
|
||||
color: #000000 !important;
|
||||
}
|
||||
|
||||
.card {
|
||||
color: #00FF00;
|
||||
font-family: 'Courier New', monospace;
|
||||
background-color: #1A1A1A;
|
||||
}
|
||||
|
||||
.card-label {
|
||||
color: #00FF00;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
color: #00FF00;
|
||||
}
|
||||
|
||||
.card-tags a.tag-link {
|
||||
color: #00FF00;
|
||||
background: #000000;
|
||||
}
|
||||
|
||||
.card-tags a.tag-link:hover {
|
||||
background: #00FF00;
|
||||
color: #000000;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
a.user-link {
|
||||
background-color: #00FF00;
|
||||
color: #000000;
|
||||
border-color: #00FF00;
|
||||
transition: background-color 0.3s, color 0.3s, border-color 0.3s;
|
||||
}
|
||||
|
||||
a.user-link:hover {
|
||||
background-color: #000000;
|
||||
border-color: #00FF00;
|
||||
color: #00FF00;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
a.user-link:focus {
|
||||
background-color: #00FF00;
|
||||
border-color: #00FF00;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.date-link {
|
||||
background-color: #00FF00;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.date-link:hover {
|
||||
background-color: #000000;
|
||||
color: #00FF00;
|
||||
}
|
||||
|
||||
.activitySpreadInhabitant2 {
|
||||
background-color: #1A1A1A;
|
||||
color: #00FF00;
|
||||
}
|
||||
|
||||
.activityVotePost {
|
||||
background-color: #00FF00;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.update-banner {
|
||||
background-color: #001a00;
|
||||
border-bottom-color: #003300;
|
||||
color: #00FF00;
|
||||
}
|
||||
|
||||
.update-banner-link {
|
||||
color: #00FF00;
|
||||
}
|
||||
|
||||
.snh-invite-code {
|
||||
color: #00FF00 !important;
|
||||
}
|
||||
|
||||
.stats-kpi-label { color: #00FF00 !important; }
|
||||
.stats-kpi-value { color: #00FF00 !important; }
|
||||
.carbon-bar-note, .carbon-bar-formula { color: #00FF00 !important; }
|
||||
.graphos-node-label { fill: #00FF00 !important; }
|
||||
.graphos-node-label-me { fill: #ffa500 !important; font-weight: bold !important; }
|
||||
.graphos-legend { color: #00FF00 !important; }
|
||||
.graphos-edge { stroke: #003300 !important; }
|
||||
/* Blockexplorer */
|
||||
.blockchain-view { background-color: #000000 !important; color: #00FF00 !important; }
|
||||
.block { background: #1A1A1A !important; border: 1px solid #00FF00 !important; }
|
||||
.block:hover { box-shadow: 0 0 15px rgba(0,255,0,0.3) !important; }
|
||||
.blockchain-card-label, .block-info-table .card-label, .pm-info-table .card-label, .block-content-label { color: #00FF00 !important; }
|
||||
.blockchain-card-value, .block-info-table .card-value, .pm-info-table .card-value, .block-timestamp, .json-content { color: #00FF00 !important; }
|
||||
.block-content-preview, .block-content { background: #1A1A1A !important; color: #00FF00 !important; }
|
||||
.block-author { color: #00FF00 !important; background: rgba(0,255,0,0.08) !important; }
|
||||
.block-author:hover { color: #00FF00 !important; }
|
||||
.block-url { color: #00FF00 !important; }
|
||||
.block-row--details .block-url { background: #1A1A1A !important; }
|
||||
.block-row--details .block-url:hover { background: #00FF00 !important; color: #000000 !important; }
|
||||
.btn-singleview { background: #000000 !important; color: #00FF00 !important; border: 1px solid #00FF00 !important; }
|
||||
.btn-singleview:hover { background: #00FF00 !important; color: #000000 !important; }
|
||||
.btn-spread-on, .content-actions .btn-pin-on { background: #00FF00 !important; color: #000000 !important; }
|
||||
.btn-spread-on:hover, .content-actions .btn-pin-on:hover { background: #66FF66 !important; color: #000000 !important; }
|
||||
.btn-back { background: #000000 !important; color: #00FF00 !important; border: 1px solid #00FF00 !important; }
|
||||
.btn-back:hover { background: #00FF00 !important; color: #000000 !important; }
|
||||
.block-info-table td, .pm-info-table td { border-color: #00FF00 !important; }
|
||||
.block-diagram { border-color: #00FF00 !important; background: #000000 !important; }
|
||||
.block-diagram-ruler { color: #00FF00 !important; background: #000000 !important; border-bottom-color: #00FF00 !important; }
|
||||
.block-diagram-ruler span { color: #00FF00 !important; }
|
||||
.block-diagram-cell { border-color: #00FF00 !important; background: #1A1A1A !important; }
|
||||
.bd-label { color: #00FF00 !important; }
|
||||
.bd-value { color: #00FF00 !important; }
|
||||
.deleted-label { color: #ff3333 !important; }
|
||||
|
||||
/* Tribes */
|
||||
.tribe-card { background: #1A1A1A !important; border-color: #00FF00 !important; }
|
||||
.tribe-card:hover { box-shadow: 0 0 15px rgba(0,255,0,0.3) !important; }
|
||||
.tribe-card-title { color: #00FF00 !important; }
|
||||
.tribe-card-description { color: #00FF00 !important; }
|
||||
.tribe-info-table td { border-color: #00FF00 !important; }
|
||||
.tribe-info-label { color: #00FF00 !important; background: #1A1A1A !important; }
|
||||
.tribe-info-value { color: #00FF00 !important; background: #1A1A1A !important; }
|
||||
.tribe-info-empty { color: #006600 !important; }
|
||||
.tribe-card-members { border-color: #00FF00 !important; background: #1A1A1A !important; }
|
||||
.tribe-members-count { color: #00FF00 !important; }
|
||||
.tribe-card-actions { border-color: #00FF00 !important; background: #1A1A1A !important; }
|
||||
.tribe-action-btn { border-color: #00FF00 !important; color: #00FF00 !important; }
|
||||
.tribe-action-btn:hover { background: #00FF00 !important; color: #000 !important; }
|
||||
.tribe-subtribe-link { background: #000000 !important; border-color: #00FF00 !important; color: #00FF00 !important; }
|
||||
.tribe-thumb-link { border-color: #00FF00 !important; }
|
||||
.tribe-thumb-link:hover { box-shadow: 0 0 10px rgba(0,255,0,0.3) !important; }
|
||||
.tribe-subtribe-link:hover { background: #00FF00 !important; color: #000 !important; }
|
||||
.tribe-parent-image { border-color: #00FF00 !important; }
|
||||
.tribe-parent-box { background: #1A1A1A !important; }
|
||||
|
||||
.oasis-nav-header { color: #00FF00 !important; border-color: #00FF00 !important; background: #000000 !important; }
|
||||
.oasis-nav-header:hover { color: #000000 !important; background: #00FF00 !important; }
|
||||
.oasis-nav-header .emoji, .oasis-nav-header .nav-text, .oasis-nav-arrow { color: inherit !important; }
|
||||
.oasis-nav-list li a { color: #00FF00 !important; }
|
||||
.oasis-nav-list li a:hover { color: #00FF00 !important; opacity: 1 !important; }
|
||||
.oasis-nav-list .emoji, .oasis-nav-list .nav-text { color: inherit !important; }
|
||||
nav, .sidebar-left nav, .sidebar-right nav, .sidebar-left ul, .sidebar-right ul { color: #00FF00 !important; }
|
||||
.sidebar-left a, .sidebar-right a { color: #00FF00 !important; }
|
||||
|
||||
.ai-ask-form .ai-ask-input, .ai-ask-form .ai-ask-btn { color: #00FF00 !important; border-color: #00FF00 !important; background: #000000 !important; }
|
||||
.ai-ask-form .ai-ask-input::placeholder { color: rgba(0,255,0,0.55) !important; }
|
||||
.ai-ask-form .ai-ask-input:focus { border-color: #00FF00 !important; background: #1A1A1A !important; box-shadow: 0 0 6px rgba(0,255,0,0.4) !important; }
|
||||
.ai-ask-form .ai-ask-btn:hover { background: #00FF00 !important; color: #000000 !important; border-color: #00FF00 !important; }
|
||||
.ai-nav-results, .ai-nav-result-card { color: #00FF00 !important; border-color: #00FF00 !important; background: #000000 !important; }
|
||||
.ai-nav-result-card .card-label { color: #00FF00 !important; }
|
||||
.ai-nav-result-card a { color: #00FF00 !important; }
|
||||
.ai-nav-result-card a:hover { background: #00FF00 !important; color: #000000 !important; }
|
||||
.ai-nav-query { color: #00FF00 !important; }
|
||||
.trending-card.own-content { box-shadow: inset 3px 0 0 #00FF00; }
|
||||
File diff suppressed because it is too large
Load diff
777
src/client/assets/themes/OasisMobileUX.css
Normal file
777
src/client/assets/themes/OasisMobileUX.css
Normal file
|
|
@ -0,0 +1,777 @@
|
|||
html { overflow-x: clip !important; overflow-y: visible !important; height: auto !important; }
|
||||
body { overflow-x: clip !important; overflow-y: visible !important; }
|
||||
|
||||
|
||||
.oasis-bottombar {
|
||||
display: flex !important;
|
||||
position: fixed !important;
|
||||
left: 0 !important; right: 0 !important; bottom: 0 !important;
|
||||
z-index: 1000 !important;
|
||||
justify-content: space-around !important;
|
||||
align-items: center !important;
|
||||
gap: 4px !important;
|
||||
background: #1F1F1F !important;
|
||||
border-top: 1px solid #333 !important;
|
||||
padding: 8px 6px calc(8px + env(safe-area-inset-bottom, 0px)) !important;
|
||||
}
|
||||
.oasis-bottombar .bb-item {
|
||||
position: relative !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
align-items: center !important;
|
||||
gap: 3px !important;
|
||||
flex: 1 1 0 !important;
|
||||
min-width: 0 !important;
|
||||
padding: 2px 0 !important;
|
||||
text-decoration: none !important;
|
||||
}
|
||||
.oasis-bottombar .bb-ico { font-size: 18px !important; line-height: 1 !important; color: #FFA500 !important; }
|
||||
.oasis-bottombar .bb-lbl { font-size: 9px !important; color: #ccc !important; white-space: nowrap !important; }
|
||||
.oasis-bottombar .bb-badge {
|
||||
position: absolute !important;
|
||||
top: -4px !important;
|
||||
left: calc(50% + 6px) !important;
|
||||
min-width: 15px !important; height: 15px !important;
|
||||
padding: 0 3px !important;
|
||||
border-radius: 8px !important;
|
||||
background: #FFA500 !important;
|
||||
color: #121212 !important;
|
||||
font-size: 9px !important; font-weight: bold !important;
|
||||
display: flex !important; align-items: center !important; justify-content: center !important;
|
||||
}
|
||||
|
||||
body { padding-bottom: calc(64px + env(safe-area-inset-bottom, 0px)) !important; }
|
||||
|
||||
.oasis-mobile-topbar {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: space-between !important;
|
||||
width: 100% !important;
|
||||
padding: 8px 12px !important;
|
||||
background: transparent !important;
|
||||
box-shadow: none !important; border: 0 !important;
|
||||
}
|
||||
|
||||
.oasis-mobile-topbar .omt-logo img,
|
||||
.oasis-mobile-topbar .omt-avatar img {
|
||||
width: 32px !important; height: 32px !important;
|
||||
border-radius: 50% !important;
|
||||
border: 2px solid #FFA500 !important;
|
||||
object-fit: cover !important; display: block !important;
|
||||
}
|
||||
|
||||
.oasis-mobile-topbar .omt-title {
|
||||
flex: 1 1 auto !important;
|
||||
text-align: center !important;
|
||||
font-size: 26px !important; font-weight: bold !important;
|
||||
letter-spacing: 10px !important; text-indent: 10px !important;
|
||||
color: #FFD700 !important; text-decoration: none !important;
|
||||
white-space: nowrap !important;
|
||||
background: transparent !important; border: 0 !important; box-shadow: none !important; padding: 0 !important;
|
||||
}
|
||||
|
||||
.header .top-bar-left > a.logo-icon { display: none !important; }
|
||||
|
||||
.oasis-mobile-menus { display: flex !important; gap: 8px !important; padding: 0 10px 6px !important; }
|
||||
.oasis-mobile-menus .oasis-menu-ico { display: none !important; }
|
||||
.oasis-mobile-menus .oasis-menu-btn {
|
||||
flex: 1 1 0 !important;
|
||||
display: flex !important; align-items: center !important; justify-content: center !important;
|
||||
padding: 12px !important;
|
||||
border: 1.5px solid #333 !important; border-radius: 10px !important;
|
||||
background: #121212 !important; color: #ccc !important;
|
||||
font-weight: bold !important; letter-spacing: 1px !important; cursor: pointer !important;
|
||||
}
|
||||
|
||||
#nav-left-toggle:checked ~ .header .oasis-menu-btn[for="nav-left-toggle"] {
|
||||
background: #FFA500 !important; color: #121212 !important; border-color: #FFA500 !important;
|
||||
}
|
||||
|
||||
#nav-right-toggle:checked ~ .header .oasis-menu-btn[for="nav-right-toggle"] {
|
||||
background: #FFD700 !important; color: #121212 !important; border-color: #FFD700 !important;
|
||||
}
|
||||
|
||||
:root {
|
||||
--space-1: 4px; --space-2: 8px; --space-3: 16px; --space-4: 24px; --space-5: 32px;
|
||||
--fs-caption: 12px; --fs-body: 14px; --fs-body-lg: 16px; --fs-title: 20px;
|
||||
--lh-body: 1.5; --measure: 66ch;
|
||||
--c-muted: #8c8c8c; --radius-md: 12px;
|
||||
}
|
||||
|
||||
.oasis-bottombar .bb-fab .bb-ico {
|
||||
width: 46px !important; height: 46px !important;
|
||||
margin-top: -22px !important;
|
||||
border-radius: 50% !important;
|
||||
background: #FFA500 !important; color: #121212 !important;
|
||||
display: flex !important; align-items: center !important; justify-content: center !important;
|
||||
font-size: 22px !important;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,.4) !important;
|
||||
}
|
||||
.oasis-bottombar .bb-fab .bb-lbl { color: #FFA500 !important; font-weight: bold !important; }
|
||||
|
||||
.main-column, .main-column p, .main-column li, .main-column a,
|
||||
.main-column span, .main-column td, .main-column h1, .main-column h2, .main-column h3 {
|
||||
overflow-wrap: anywhere !important;
|
||||
word-break: break-word !important;
|
||||
}
|
||||
.main-column { padding: var(--space-3) !important; }
|
||||
.main-column p { line-height: var(--lh-body) !important; }
|
||||
|
||||
.main-column > * + * { margin-top: var(--space-3) !important; }
|
||||
|
||||
.main-column img, .main-column video, .main-column table, .main-column pre, .main-column iframe {
|
||||
max-width: 100% !important; height: auto !important;
|
||||
}
|
||||
.main-column pre { white-space: pre-wrap !important; }
|
||||
|
||||
.oasis-nav-group {
|
||||
list-style: none !important;
|
||||
background: #1c1c1c !important;
|
||||
border: 1px solid #333 !important;
|
||||
border-radius: var(--radius-md) !important;
|
||||
overflow: hidden !important;
|
||||
margin-bottom: var(--space-2) !important;
|
||||
}
|
||||
.oasis-nav-header {
|
||||
display: flex !important; align-items: center !important; gap: var(--space-2) !important;
|
||||
min-height: 48px !important; padding: 0 var(--space-3) !important;
|
||||
cursor: pointer !important;
|
||||
color: #FFD700 !important; font-weight: bold !important;
|
||||
text-transform: uppercase !important; letter-spacing: .04em !important;
|
||||
font-size: var(--fs-caption) !important;
|
||||
}
|
||||
.oasis-nav-header .nav-text { flex: 1 1 auto !important; }
|
||||
.oasis-nav-header .oasis-nav-arrow { color: var(--c-muted) !important; transition: transform 150ms ease !important; }
|
||||
.oasis-nav-toggle:checked + .oasis-nav-header .oasis-nav-arrow { transform: rotate(180deg) !important; }
|
||||
.oasis-nav-header:active { background: #262626 !important; }
|
||||
.oasis-nav-list { list-style: none !important; padding: 0 !important; margin: 0 !important; }
|
||||
.oasis-nav-list li a {
|
||||
display: flex !important; align-items: center !important; gap: var(--space-3) !important;
|
||||
min-height: 48px !important; padding: 0 var(--space-3) !important;
|
||||
color: #e8e8e8 !important; text-decoration: none !important;
|
||||
border-top: 1px solid #262626 !important;
|
||||
}
|
||||
.oasis-nav-list li a .emoji { flex: 0 0 auto !important; width: 22px !important; text-align: center !important; color: #FFA500 !important; }
|
||||
.oasis-nav-list li a .nav-text { font-size: var(--fs-body) !important; }
|
||||
.oasis-nav-list li a:active { background: #262626 !important; }
|
||||
.oasis-nav-list li a.current { color: #FFA500 !important; font-weight: bold !important; }
|
||||
|
||||
.main-column h1 { font-size: var(--fs-title) !important; font-weight: 700 !important; line-height: 1.25 !important; margin: 0 0 var(--space-2) !important; }
|
||||
.main-column h2 { font-size: 18px !important; font-weight: 700 !important; line-height: 1.3 !important; margin: 0 0 var(--space-2) !important; }
|
||||
.main-column h3 { font-size: var(--fs-body-lg) !important; font-weight: 600 !important; margin: 0 0 var(--space-1) !important; }
|
||||
.main-column p { font-size: var(--fs-body-lg) !important; }
|
||||
.main-column .created-at, .main-column .small, .main-column .time,
|
||||
.main-column .meta, .main-column .author-meta, .main-column .subtitle {
|
||||
color: var(--c-muted) !important; font-size: var(--fs-caption) !important;
|
||||
}
|
||||
|
||||
.main-column article,
|
||||
.main-column [class*="-card"] {
|
||||
background: #1c1c1c !important;
|
||||
border: 1px solid #333 !important;
|
||||
border-radius: var(--radius-md) !important;
|
||||
padding: var(--space-3) !important;
|
||||
}
|
||||
.main-column article:active,
|
||||
.main-column [class*="-card"]:active { background: #202020 !important; }
|
||||
|
||||
.btn, button, input[type="submit"], input[type="button"], .submit-button,
|
||||
.filter-btn, .create-button, .edit-btn, .delete-btn, .join-btn, .leave-btn, .buy-btn {
|
||||
min-height: 48px !important;
|
||||
}
|
||||
button:active, input[type="submit"]:active, .submit-button:active,
|
||||
.filter-btn:active, .create-button:active, .edit-btn:active,
|
||||
.delete-btn:active, .join-btn:active, .leave-btn:active, .buy-btn:active {
|
||||
filter: brightness(.85) !important;
|
||||
}
|
||||
.main-column a:active { opacity: .6 !important; }
|
||||
|
||||
* { -webkit-tap-highlight-color: transparent !important; }
|
||||
a, button, label, .tap, .oasis-menu-btn, .bb-item, .oasis-nav-header, .oasis-nav-list li a {
|
||||
touch-action: manipulation !important;
|
||||
}
|
||||
body { overscroll-behavior: none !important; }
|
||||
|
||||
.main-content, .main-column, .header,
|
||||
.sidebar-left, .sidebar-right { overflow: visible !important; }
|
||||
|
||||
.sidebar-left, .sidebar-right { display: none !important; }
|
||||
#nav-left-toggle:checked ~ .main-content .sidebar-left { display: block !important; }
|
||||
#nav-right-toggle:checked ~ .main-content .sidebar-right { display: block !important; }
|
||||
|
||||
.bb-manage { margin: 8px 0 16px !important; }
|
||||
.bb-manage h3, .bb-manage-add h3 {
|
||||
font-size: 13px !important; text-transform: uppercase !important; letter-spacing: .05em !important;
|
||||
color: #FFA500 !important; margin: 14px 0 8px !important;
|
||||
}
|
||||
.bb-manage-list { display: flex !important; flex-direction: column !important; gap: 8px !important; }
|
||||
.bb-manage-form { margin: 0 !important; width: 100% !important; }
|
||||
.bb-manage-btn {
|
||||
width: 100% !important;
|
||||
display: flex !important; align-items: center !important; gap: 12px !important;
|
||||
min-height: 52px !important; padding: 0 14px !important;
|
||||
background: #1c1c1c !important; border: 1px solid #333 !important; border-radius: 10px !important;
|
||||
color: #eee !important; cursor: pointer !important; text-align: left !important;
|
||||
}
|
||||
.bb-manage-ico { font-size: 20px !important; color: #FFA500 !important; width: 26px !important; text-align: center !important; }
|
||||
.bb-manage-lbl { flex: 1 1 auto !important; font-size: 15px !important; }
|
||||
.bb-manage-x { font-size: 16px !important; color: #ff6b6b !important; font-weight: bold !important; }
|
||||
.bb-manage-empty, .bb-manage-full { color: #999 !important; font-size: 14px !important; padding: 8px 2px !important; }
|
||||
.bb-manage-done {
|
||||
display: block !important; text-align: center !important;
|
||||
margin-top: 16px !important; min-height: 48px !important; line-height: 48px !important;
|
||||
}
|
||||
|
||||
.hive-nav { --hx: min(64px, calc((100vw - 40px) / 4)); --hxm: min(58px, calc((100vw - 36px) / 5)); margin: 8px 0 16px !important; overflow: visible !important; background: transparent !important; padding: 0 !important; border: 0 !important; box-shadow: none !important; }
|
||||
.hive-radio { position: absolute !important; opacity: 0 !important; width: 0 !important; height: 0 !important; pointer-events: none !important; }
|
||||
.hive-grid { display: flex !important; flex-direction: column !important; align-items: center !important; overflow: visible !important; background: transparent !important; padding: 0 !important; margin: 0 !important; box-shadow: none !important; }
|
||||
.hive-row { display: flex !important; justify-content: center !important; gap: 3px !important; overflow: visible !important; background: transparent !important; padding: 0 !important; margin: 0 !important; box-shadow: none !important; }
|
||||
.hive-cats .hive-row + .hive-row { margin-top: calc(var(--hx) * -0.30) !important; }
|
||||
.hex-cat, .hex-mod {
|
||||
position: relative !important;
|
||||
-webkit-clip-path: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%) !important;
|
||||
clip-path: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%) !important;
|
||||
display: flex !important; flex-direction: column !important; align-items: center !important; justify-content: center !important; gap: 2px !important;
|
||||
text-decoration: none !important; cursor: pointer !important; box-sizing: border-box !important;
|
||||
background: #D9A400 !important;
|
||||
filter: drop-shadow(0 1px 2px rgba(0,0,0,.6)) !important;
|
||||
}
|
||||
.hex-cat::before, .hex-mod::before {
|
||||
content: '' !important; position: absolute !important; inset: 1.5px !important; z-index: 0 !important;
|
||||
-webkit-clip-path: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%) !important;
|
||||
clip-path: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%) !important;
|
||||
background: #161616 !important;
|
||||
}
|
||||
.hex-cat { width: var(--hx) !important; height: calc(var(--hx) * 1.15) !important; }
|
||||
.hex-mod { width: var(--hxm) !important; height: calc(var(--hxm) * 1.15) !important; }
|
||||
.hex-cat .hx-ico, .hex-mod .hx-ico { color: #FFA500 !important; line-height: 1 !important; position: relative !important; z-index: 1 !important; }
|
||||
.hex-cat .hx-ico { font-size: 17px !important; }
|
||||
.hex-mod .hx-ico { font-size: 16px !important; }
|
||||
.hex-cat .hx-lbl, .hex-mod .hx-lbl { color: #d8d8d8 !important; text-align: center !important; line-height: 1 !important; font-weight: bold !important; overflow: hidden !important; position: relative !important; z-index: 1 !important; }
|
||||
.hex-cat .hx-lbl { font-size: 8px !important; max-width: 88% !important; }
|
||||
.hex-mod .hx-lbl { font-size: 8px !important; max-width: 92% !important; }
|
||||
.hex-cat:active::before, .hex-mod:active::before { background: #242424 !important; }
|
||||
#hive-personal:checked ~ .hive-grid .hex-cat[for="hive-personal"],
|
||||
#hive-governance:checked ~ .hive-grid .hex-cat[for="hive-governance"],
|
||||
#hive-office:checked ~ .hive-grid .hex-cat[for="hive-office"],
|
||||
#hive-economy:checked ~ .hive-grid .hex-cat[for="hive-economy"],
|
||||
#hive-tools:checked ~ .hive-grid .hex-cat[for="hive-tools"],
|
||||
#hive-network:checked ~ .hive-grid .hex-cat[for="hive-network"],
|
||||
#hive-blogs:checked ~ .hive-grid .hex-cat[for="hive-blogs"],
|
||||
#hive-creative:checked ~ .hive-grid .hex-cat[for="hive-creative"],
|
||||
#hive-media:checked ~ .hive-grid .hex-cat[for="hive-media"],
|
||||
#hive-fediverse:checked ~ .hive-grid .hex-cat[for="hive-fediverse"] { background: #FFA500 !important; }
|
||||
#hive-personal:checked ~ .hive-grid .hex-cat[for="hive-personal"]::before,
|
||||
#hive-governance:checked ~ .hive-grid .hex-cat[for="hive-governance"]::before,
|
||||
#hive-office:checked ~ .hive-grid .hex-cat[for="hive-office"]::before,
|
||||
#hive-economy:checked ~ .hive-grid .hex-cat[for="hive-economy"]::before,
|
||||
#hive-tools:checked ~ .hive-grid .hex-cat[for="hive-tools"]::before,
|
||||
#hive-network:checked ~ .hive-grid .hex-cat[for="hive-network"]::before,
|
||||
#hive-blogs:checked ~ .hive-grid .hex-cat[for="hive-blogs"]::before,
|
||||
#hive-creative:checked ~ .hive-grid .hex-cat[for="hive-creative"]::before,
|
||||
#hive-media:checked ~ .hive-grid .hex-cat[for="hive-media"]::before,
|
||||
#hive-fediverse:checked ~ .hive-grid .hex-cat[for="hive-fediverse"]::before { background: #FFA500 !important; }
|
||||
#hive-personal:checked ~ .hive-grid .hex-cat[for="hive-personal"] :is(.hx-ico,.hx-lbl),
|
||||
#hive-governance:checked ~ .hive-grid .hex-cat[for="hive-governance"] :is(.hx-ico,.hx-lbl),
|
||||
#hive-office:checked ~ .hive-grid .hex-cat[for="hive-office"] :is(.hx-ico,.hx-lbl),
|
||||
#hive-economy:checked ~ .hive-grid .hex-cat[for="hive-economy"] :is(.hx-ico,.hx-lbl),
|
||||
#hive-tools:checked ~ .hive-grid .hex-cat[for="hive-tools"] :is(.hx-ico,.hx-lbl),
|
||||
#hive-network:checked ~ .hive-grid .hex-cat[for="hive-network"] :is(.hx-ico,.hx-lbl),
|
||||
#hive-blogs:checked ~ .hive-grid .hex-cat[for="hive-blogs"] :is(.hx-ico,.hx-lbl),
|
||||
#hive-creative:checked ~ .hive-grid .hex-cat[for="hive-creative"] :is(.hx-ico,.hx-lbl),
|
||||
#hive-media:checked ~ .hive-grid .hex-cat[for="hive-media"] :is(.hx-ico,.hx-lbl),
|
||||
#hive-fediverse:checked ~ .hive-grid .hex-cat[for="hive-fediverse"] :is(.hx-ico,.hx-lbl) { color: #121212 !important; }
|
||||
.hive-panel { display: none !important; margin: 12px auto 0 !important; max-width: 340px !important; background: #101010 !important; border: 1px solid #242424 !important; border-radius: 16px !important; padding: 12px 8px !important; overflow: visible !important; box-shadow: none !important; }
|
||||
#hive-personal:checked ~ #hive-panel-personal,
|
||||
#hive-governance:checked ~ #hive-panel-governance,
|
||||
#hive-office:checked ~ #hive-panel-office,
|
||||
#hive-economy:checked ~ #hive-panel-economy,
|
||||
#hive-tools:checked ~ #hive-panel-tools,
|
||||
#hive-network:checked ~ #hive-panel-network,
|
||||
#hive-blogs:checked ~ #hive-panel-blogs,
|
||||
#hive-creative:checked ~ #hive-panel-creative,
|
||||
#hive-media:checked ~ #hive-panel-media,
|
||||
#hive-fediverse:checked ~ #hive-panel-fediverse { display: block !important; }
|
||||
.hive-mods { display: flex !important; flex-wrap: wrap !important; justify-content: center !important; gap: 4px !important; margin: 0 !important; padding: 0 !important; background: transparent !important; overflow: visible !important; }
|
||||
|
||||
.header .top-bar-left nav,
|
||||
.header .top-bar-right nav { display: none !important; }
|
||||
|
||||
.oasis-mobile-topbar { padding-top: calc(env(safe-area-inset-top, 0px) + 10px) !important; }
|
||||
|
||||
.oasis-bottombar .bb-pair {
|
||||
display: flex !important; align-items: center !important;
|
||||
flex: 0 0 auto !important;
|
||||
gap: 2px !important; padding: 3px !important;
|
||||
background: #262626 !important;
|
||||
border: 1px solid #3a3a3a !important;
|
||||
border-radius: 999px !important;
|
||||
}
|
||||
.oasis-bottombar .bb-pair .bb-item { flex: 0 0 auto !important; padding: 2px 8px !important; }
|
||||
.oasis-bottombar .bb-pair .bb-lbl { font-size: 8px !important; }
|
||||
|
||||
.oasis-bottombar a.bb-item.current .bb-ico {
|
||||
background: #FFA500 !important; color: #121212 !important;
|
||||
border-radius: 999px !important; padding: 4px 12px !important;
|
||||
}
|
||||
.oasis-bottombar a.bb-item.current .bb-lbl { color: #FFA500 !important; font-weight: bold !important; }
|
||||
.oasis-bottombar a.bb-item.current .bb-badge { background: #121212 !important; color: #FFA500 !important; }
|
||||
|
||||
.main-column, .main-column p, .main-column li, .main-column a,
|
||||
.main-column span, .main-column td, .main-column th, .main-column label,
|
||||
.main-column h1, .main-column h2, .main-column h3, .main-column h4,
|
||||
.main-column strong, .main-column em, .main-column small,
|
||||
.created-at, .post-meta, .feed-post-meta,
|
||||
.feed-row .small, .feed-row .time, .feed-row .created-at,
|
||||
.profile-sensors-box > *,
|
||||
.card-field .card-value, .card-field .card-label {
|
||||
overflow-wrap: break-word !important;
|
||||
word-break: normal !important;
|
||||
-webkit-hyphens: none !important;
|
||||
hyphens: none !important;
|
||||
}
|
||||
|
||||
.main-column pre, .main-column code, kbd, samp,
|
||||
.peer-key, .user-link.peer-key, .oasis-id-copy, .search-oasis-id,
|
||||
.pm-sent-key-value, .kpi__value,
|
||||
.block-info-table td .peer-key, .block-info-table td .user-link,
|
||||
.pm-info-table td .user-link,
|
||||
.main-column a[href*="ed25519"], .main-column a[href^="ssb:"],
|
||||
.main-column a[href^="/author/"] {
|
||||
overflow-wrap: anywhere !important;
|
||||
word-break: break-all !important;
|
||||
}
|
||||
|
||||
button, input[type="submit"], input[type="button"],
|
||||
.filter-btn, a.filter-btn, .create-button, .edit-btn, .delete-btn,
|
||||
.join-btn, .leave-btn, .buy-btn, .submit-button, .btn,
|
||||
.conn-actions button, .peers-conn-actions button,
|
||||
.mode-buttons button, .mode-buttons .filter-btn,
|
||||
.filter-group button, .filter-group .filter-btn,
|
||||
.main-column button, .main-column input[type="submit"] {
|
||||
white-space: normal !important;
|
||||
word-break: normal !important;
|
||||
overflow-wrap: break-word !important;
|
||||
-webkit-hyphens: none !important;
|
||||
hyphens: none !important;
|
||||
}
|
||||
|
||||
.conn-actions, .peers-conn-actions {
|
||||
display: flex !important;
|
||||
flex-direction: row !important;
|
||||
flex-wrap: wrap !important;
|
||||
justify-content: center !important;
|
||||
align-items: stretch !important;
|
||||
gap: 8px !important;
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
overflow-x: hidden !important;
|
||||
}
|
||||
.conn-actions > form, .peers-conn-actions > form {
|
||||
flex: 0 1 auto !important;
|
||||
width: auto !important;
|
||||
min-width: 0 !important;
|
||||
margin: 0 !important;
|
||||
display: flex !important;
|
||||
}
|
||||
|
||||
.pm-info-table, .pm-info-table tbody,
|
||||
.block-info-table, .block-info-table tbody {
|
||||
display: block !important;
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
border: 0 !important;
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
table-layout: auto !important;
|
||||
}
|
||||
.pm-info-table thead, .block-info-table thead { display: none !important; }
|
||||
.pm-info-table tr, .block-info-table tr {
|
||||
display: block !important;
|
||||
width: 100% !important;
|
||||
box-sizing: border-box !important;
|
||||
border: 0 !important;
|
||||
border-bottom: 1px solid rgba(255,165,0,0.18) !important;
|
||||
border-radius: 0 !important;
|
||||
margin: 0 0 8px 0 !important;
|
||||
padding: 6px 0 !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
.pm-info-table td, .block-info-table td {
|
||||
display: block !important;
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
box-sizing: border-box !important;
|
||||
border: 0 !important;
|
||||
background: transparent !important;
|
||||
padding: 1px 0 !important;
|
||||
text-align: left !important;
|
||||
overflow-wrap: break-word !important;
|
||||
word-break: normal !important;
|
||||
}
|
||||
|
||||
.pm-info-table td.card-label, .block-info-table td.card-label {
|
||||
color: var(--c-muted, #8c8c8c) !important;
|
||||
font-size: 11px !important;
|
||||
font-weight: 700 !important;
|
||||
text-transform: uppercase !important;
|
||||
letter-spacing: .03em !important;
|
||||
margin-bottom: 2px !important;
|
||||
padding-bottom: 0 !important;
|
||||
}
|
||||
.pm-info-table td.card-value, .block-info-table td.card-value {
|
||||
font-size: 15px !important;
|
||||
line-height: 1.5 !important;
|
||||
margin-bottom: 6px !important;
|
||||
}
|
||||
|
||||
.block-info-table tr:first-child:has(> td.card-label) { display: none !important; }
|
||||
.block-info-table td[data-label]:not([data-label=""])::before {
|
||||
content: attr(data-label);
|
||||
display: block !important;
|
||||
color: var(--c-muted, #8c8c8c) !important;
|
||||
font-size: 10px !important;
|
||||
font-weight: 700 !important;
|
||||
text-transform: uppercase !important;
|
||||
letter-spacing: .03em !important;
|
||||
margin-bottom: 1px !important;
|
||||
white-space: nowrap !important;
|
||||
word-break: normal !important;
|
||||
}
|
||||
.block-info-table td[data-label=""]::before { content: none !important; }
|
||||
|
||||
.peers-list .block-info-table td,
|
||||
.peers-technical-block .block-info-table td,
|
||||
.gov-overview tbody td {
|
||||
word-break: normal !important;
|
||||
overflow-wrap: break-word !important;
|
||||
}
|
||||
|
||||
.pm-info-table td .user-link, .pm-info-table td .peer-key, .pm-info-table td a[href],
|
||||
.block-info-table td .peer-key, .block-info-table td .user-link, .block-info-table td a[href] {
|
||||
display: inline-block !important;
|
||||
max-width: 100% !important;
|
||||
overflow-wrap: anywhere !important;
|
||||
word-break: break-all !important;
|
||||
}
|
||||
|
||||
.pm-info-table td form, .block-info-table td form,
|
||||
.pm-info-table td button, .block-info-table td button {
|
||||
width: 100% !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.main-content { width: 100% !important; max-width: 100% !important; }
|
||||
.main-column {
|
||||
width: 100% !important; max-width: 100% !important;
|
||||
padding: 16px !important; box-sizing: border-box !important;
|
||||
}
|
||||
.main-column .card-field {
|
||||
flex-direction: column !important;
|
||||
align-items: stretch !important;
|
||||
gap: 2px !important;
|
||||
}
|
||||
.main-column .card-field .card-value {
|
||||
width: 100% !important; max-width: 100% !important; font-size: 15px !important;
|
||||
}
|
||||
.main-column .card-field .card-label {
|
||||
color: var(--c-muted, #8c8c8c) !important; font-size: 11px !important;
|
||||
}
|
||||
.main-column img, .main-column video, .main-column iframe, .main-column canvas {
|
||||
max-width: 100% !important; height: auto !important;
|
||||
}
|
||||
.main-column hr {
|
||||
width: 100% !important; border: 0 !important;
|
||||
border-top: 1px solid #333 !important; margin: 16px 0 !important;
|
||||
}
|
||||
|
||||
.activity-filter-acc { width: 100% !important; margin-bottom: 8px !important; }
|
||||
|
||||
.activity-quick-modes {
|
||||
display: flex !important; flex-wrap: nowrap !important; gap: 6px !important; margin-bottom: 8px !important;
|
||||
}
|
||||
.activity-quick-modes form { flex: 1 1 0 !important; min-width: 0 !important; margin: 0 !important; display: flex !important; }
|
||||
.activity-quick-modes .filter-btn {
|
||||
flex: 1 1 0 !important; width: 100% !important; min-height: 44px !important;
|
||||
white-space: nowrap !important; word-break: keep-all !important; overflow-wrap: normal !important;
|
||||
padding-left: 6px !important; padding-right: 6px !important; font-size: 14px !important;
|
||||
}
|
||||
|
||||
.activity-groups { list-style: none !important; padding: 0 !important; margin: 0 !important; }
|
||||
|
||||
.activity-groups .oasis-nav-header .emoji { display: none !important; }
|
||||
.activity-groups .oasis-nav-header .nav-text { flex: 1 1 auto !important; }
|
||||
.activity-groups .act-count {
|
||||
flex: 0 0 auto !important; min-width: 20px !important; text-align: center !important;
|
||||
font-size: 11px !important; font-weight: 700 !important; color: #121212 !important;
|
||||
background: #FFA500 !important; border-radius: 999px !important; padding: 1px 7px !important;
|
||||
margin: 0 8px 0 0 !important;
|
||||
}
|
||||
|
||||
.activity-groups .oasis-nav-list li { list-style: none !important; margin: 0 !important; padding: 0 !important; }
|
||||
.activity-groups .oasis-nav-list form { margin: 0 !important; display: block !important; width: 100% !important; }
|
||||
.activity-groups .oasis-nav-list .filter-btn {
|
||||
display: block !important; width: 100% !important; text-align: left !important;
|
||||
min-height: 44px !important; margin: 2px 0 !important; box-sizing: border-box !important;
|
||||
}
|
||||
.activity-groups .oasis-nav-list .filter-btn.active {
|
||||
outline: 2px solid #FFA500 !important; outline-offset: -2px !important;
|
||||
}
|
||||
|
||||
.message-list { display: flex !important; flex-direction: column !important; gap: 4px !important; width: 100% !important; }
|
||||
.message-list .pm-thread { display: flex !important; flex-direction: column !important; width: 100% !important; align-self: stretch !important; }
|
||||
.pm-thread-replies { display: flex !important; flex-direction: column !important; gap: 4px !important; }
|
||||
|
||||
.pm-card.pm-bubble {
|
||||
max-width: 82% !important; width: fit-content !important; min-width: 0 !important;
|
||||
box-sizing: border-box !important; padding: 8px 12px !important; margin: 2px 0 !important;
|
||||
border-radius: 16px !important; border: 1px solid #333 !important;
|
||||
background: #1e1e1e !important; box-shadow: none !important; cursor: default !important;
|
||||
}
|
||||
|
||||
.pm-card.pm-bubble.pm-recv {
|
||||
align-self: flex-start !important; margin-left: 0 !important; margin-right: auto !important;
|
||||
border-bottom-left-radius: 4px !important; background: #1e1e1e !important; border-color: #343434 !important;
|
||||
}
|
||||
|
||||
.pm-card.pm-bubble.pm-sent {
|
||||
align-self: flex-end !important; margin-left: auto !important; margin-right: 0 !important;
|
||||
border-bottom-right-radius: 4px !important; background: #2a2413 !important; border-color: #5c4c1f !important;
|
||||
}
|
||||
|
||||
.pm-bubble .pm-bubble-sender { font-size: 11px !important; font-weight: 700 !important; color: #FFA500 !important; margin-bottom: 3px !important; }
|
||||
.pm-bubble .pm-bubble-sender a { color: #FFA500 !important; }
|
||||
|
||||
.pm-bubble .message-text { font-size: 15px !important; line-height: 1.42 !important; color: #ececec !important; margin: 0 !important; }
|
||||
.pm-bubble .message-text a { color: #FFD700 !important; }
|
||||
|
||||
.pm-bubble .pm-bubble-meta {
|
||||
display: flex !important; flex-wrap: wrap !important; align-items: center !important;
|
||||
gap: 6px !important; margin-top: 4px !important; font-size: 10px !important; color: var(--c-muted, #8c8c8c) !important;
|
||||
}
|
||||
.pm-card.pm-sent .pm-bubble-meta { justify-content: flex-end !important; }
|
||||
.pm-card.pm-recv .pm-bubble-meta { justify-content: flex-start !important; }
|
||||
.pm-bubble .pm-bubble-subject { font-style: italic !important; opacity: .75 !important; max-width: 50% !important; overflow: hidden !important; text-overflow: ellipsis !important; white-space: nowrap !important; }
|
||||
.pm-bubble .pm-bubble-lock { opacity: .85 !important; }
|
||||
.pm-bubble .pm-bubble-eco { font-size: 9px !important; opacity: .9 !important; }
|
||||
.pm-bubble .pm-bubble-eco * { font-size: 9px !important; }
|
||||
|
||||
.pm-bubble .pm-actions { margin-top: 6px !important; display: flex !important; gap: 6px !important; }
|
||||
.pm-card.pm-sent .pm-actions { justify-content: flex-end !important; }
|
||||
.pm-bubble .pm-actions .pm-btn { font-size: 10px !important; padding: 3px 8px !important; min-height: 28px !important; }
|
||||
.pm-bubble .pm-actions form { margin: 0 !important; }
|
||||
|
||||
.pm-bubble .pm-info-table { display: none !important; }
|
||||
|
||||
.pm-thread .pm-thread-details { width: 100% !important; }
|
||||
.pm-thread .pm-thread-toggle { font-size: 12px !important; color: var(--c-muted, #8c8c8c) !important; padding: 4px 0 !important; cursor: pointer !important; }
|
||||
|
||||
.main-column h1, .main-column h2, .main-column h3, .main-column h4,
|
||||
.tags-header h2, .tags-header h3, .title-with-chip h2, .pm-title, h2.pm-title,
|
||||
.cn-name, h1.cn-name, .cn-section, h2.cn-section, .profile-side-name, h2.profile-side-name {
|
||||
text-wrap: balance !important;
|
||||
overflow-wrap: break-word !important;
|
||||
word-break: keep-all !important;
|
||||
-webkit-hyphens: none !important; hyphens: none !important;
|
||||
text-align: left !important;
|
||||
}
|
||||
.main-column h1, .tags-header h2, .cn-name {
|
||||
font-size: var(--fs-title, 20px) !important; font-weight: 700 !important;
|
||||
line-height: 1.22 !important; letter-spacing: -0.01em !important;
|
||||
margin: 0 0 var(--space-2, 8px) !important;
|
||||
}
|
||||
.main-column h2, .pm-title, .title-with-chip h2, .cn-section {
|
||||
font-size: 18px !important; font-weight: 700 !important;
|
||||
line-height: 1.28 !important; margin: var(--space-1, 4px) 0 var(--space-2, 8px) !important;
|
||||
}
|
||||
.main-column h3 {
|
||||
font-size: var(--fs-body-lg, 16px) !important; font-weight: 600 !important;
|
||||
line-height: 1.35 !important; margin: var(--space-1, 4px) 0 !important;
|
||||
}
|
||||
.main-column h1, .main-column h2, .main-column h3,
|
||||
.tags-header h2, .pm-title, .cn-name, .cn-section {
|
||||
max-width: 40ch !important; width: auto !important; min-width: 0 !important;
|
||||
}
|
||||
|
||||
.main-column h1 code, .main-column h2 code, .main-column h3 code,
|
||||
.tags-header h2 code, .pm-title code,
|
||||
.main-column h1 a[href*="ed25519"], .main-column h2 a[href*="ed25519"],
|
||||
.main-column h1 a[href^="ssb:"], .main-column h2 a[href^="ssb:"] {
|
||||
word-break: break-all !important; overflow-wrap: anywhere !important;
|
||||
}
|
||||
|
||||
.main-column .tags-header {
|
||||
display: flex !important; flex-direction: column !important; align-items: flex-start !important;
|
||||
gap: var(--space-1, 4px) !important; width: 100% !important; max-width: 100% !important;
|
||||
margin: 0 0 var(--space-2, 8px) !important;
|
||||
}
|
||||
.main-column .tags-header h2 { margin: 0 !important; }
|
||||
|
||||
.main-column .title-with-chip {
|
||||
display: flex !important; flex-wrap: wrap !important; align-items: center !important;
|
||||
gap: var(--space-2, 8px) !important; width: 100% !important; max-width: 100% !important;
|
||||
}
|
||||
.main-column .title-with-chip h2 { flex: 1 1 auto !important; min-width: 0 !important; margin: 0 !important; max-width: none !important; }
|
||||
.main-column .title-with-chip > :not(h2) { flex: 0 0 auto !important; }
|
||||
|
||||
.main-column p, .main-column li,
|
||||
.tags-header p, .tags-header .prefs-help, .indexing-note, .indexing-percent,
|
||||
.error-page-message, .welcome-text, .message-text, .pm-bubble .message-text {
|
||||
text-wrap: pretty !important;
|
||||
line-height: 1.55 !important;
|
||||
max-width: min(66ch, 100%) !important;
|
||||
overflow-wrap: anywhere !important;
|
||||
word-break: normal !important;
|
||||
min-width: 0 !important;
|
||||
-webkit-hyphens: none !important; hyphens: none !important;
|
||||
}
|
||||
.main-column p + p { margin-top: var(--space-3, 16px) !important; }
|
||||
.tags-header p, .tags-header .prefs-help, .indexing-note { line-height: 1.5 !important; margin: 4px 0 0 0 !important; }
|
||||
.pm-bubble .message-text { line-height: 1.45 !important; max-width: 100% !important; }
|
||||
|
||||
[style*="auto-fill"],
|
||||
[style*="minmax(240px"], [style*="minmax(280px"], [style*="minmax(300px"],
|
||||
[style*="minmax( 240px"], [style*="minmax( 280px"], [style*="minmax( 300px"],
|
||||
.trending-container, .cn-hub-grid, .cn-products {
|
||||
display: grid !important; grid-template-columns: 1fr !important; gap: 12px !important;
|
||||
width: 100% !important; max-width: 100% !important;
|
||||
}
|
||||
.trending-container > *, .games-grid > *, .market-grid > *, .tribe-grid > *,
|
||||
.feed-container, .feed-card, .trending-card,
|
||||
.main-column article, .main-column [class*="-card"], .main-column [class*="-item"],
|
||||
.main-column [class*="-entry"], .main-column .card {
|
||||
width: 100% !important; max-width: 100% !important; min-width: 0 !important;
|
||||
box-sizing: border-box !important; margin-left: 0 !important; margin-right: 0 !important;
|
||||
}
|
||||
.feed-card .feed-text, .trending-card .feed-text,
|
||||
.main-column article p, .main-column [class*="-card"] p { max-width: var(--measure, 66ch) !important; }
|
||||
.main-column form, .comment-form, .comment-form-wrapper, .market-form,
|
||||
.new-message-form, .project-control-form, .filter-box {
|
||||
display: block !important; width: 100% !important; max-width: 100% !important;
|
||||
min-width: 0 !important; box-sizing: border-box !important;
|
||||
}
|
||||
.main-column input:not([type="checkbox"]):not([type="radio"]):not(.oasis-nav-toggle):not(.oasis-nav-side-toggle),
|
||||
.main-column textarea, .main-column select {
|
||||
width: 100% !important; max-width: 100% !important; min-width: 0 !important; box-sizing: border-box !important;
|
||||
}
|
||||
.main-column form > button:only-of-type, .main-column form > .create-button,
|
||||
.comment-form button[type="submit"], .market-form button[type="submit"],
|
||||
.new-message-form button[type="submit"] {
|
||||
display: block !important; width: 100% !important; max-width: 100% !important;
|
||||
box-sizing: border-box !important; margin-top: 8px !important;
|
||||
}
|
||||
[style*="width:33%"], [style*="width: 33%"], [style*="width:25%"], [style*="width: 25%"],
|
||||
[style*="width:60%"], [style*="width: 60%"], [style*="width:40%"], [style*="width: 40%"] {
|
||||
width: 100% !important; max-width: 100% !important;
|
||||
}
|
||||
.main-column [style*="max-width: 800px"], .main-column [style*="max-width:800px"],
|
||||
.ai-response, .ai-train-bar {
|
||||
max-width: 100% !important; width: 100% !important; margin-left: 0 !important; margin-right: 0 !important; box-sizing: border-box !important;
|
||||
}
|
||||
.message-list, .pm-thread, .pm-thread-replies { width: 100% !important; max-width: 100% !important; box-sizing: border-box !important; }
|
||||
|
||||
.main-column p .user-link, .main-column p a[href^="/author/"],
|
||||
.main-column li .user-link, .main-column span .user-link,
|
||||
.main-column .message-text .user-link, .main-column .message-text a[href^="/author/"],
|
||||
.pm-bubble .message-text .user-link, .pm-bubble .message-text a[href^="/author/"],
|
||||
.pm-quote a[href^="/author/"] {
|
||||
display: inline !important; white-space: normal !important;
|
||||
overflow-wrap: anywhere !important; word-break: keep-all !important;
|
||||
-webkit-hyphens: none !important; hyphens: none !important;
|
||||
}
|
||||
|
||||
.peer-key, .user-link.peer-key, .block-info-table td .peer-key, .pm-info-table td .peer-key,
|
||||
.pm-info-table td .user-link, .block-info-table td .user-link,
|
||||
.search-oasis-id, .pm-sent-key-value, .kpi__value {
|
||||
display: inline-block !important; max-width: 100% !important;
|
||||
overflow-wrap: anywhere !important; word-break: break-all !important;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace !important;
|
||||
font-variant-numeric: tabular-nums !important; line-height: 1.45 !important;
|
||||
}
|
||||
|
||||
.oasis-id-copy {
|
||||
display: block !important; width: 100% !important; white-space: nowrap !important;
|
||||
overflow: hidden !important; text-overflow: ellipsis !important;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace !important;
|
||||
}
|
||||
.invites-list .user-link.peer-key, .peers-list .block-info-table td .peer-key {
|
||||
display: block !important; max-width: 100% !important; white-space: nowrap !important;
|
||||
overflow: hidden !important; text-overflow: ellipsis !important; word-break: normal !important;
|
||||
}
|
||||
|
||||
.main-column .message-text a[href^="http"], .main-column p a[href^="http"],
|
||||
.pm-bubble .message-text a[href^="http"], .main-column .message-text a.oasis-path-link,
|
||||
.main-column .message-text a.job-link, .main-column .message-text a.project-link,
|
||||
.main-column .message-text a.market-link {
|
||||
overflow-wrap: anywhere !important; word-break: keep-all !important;
|
||||
-webkit-hyphens: none !important; hyphens: none !important;
|
||||
}
|
||||
.main-column code:not(pre code), .pm-bubble .message-text code {
|
||||
display: inline !important; max-width: 100% !important;
|
||||
overflow-wrap: anywhere !important; word-break: break-word !important; white-space: pre-wrap !important;
|
||||
}
|
||||
|
||||
.main-column .tags-header { margin-bottom: 6px !important; gap: 2px !important; }
|
||||
.main-column .tags-header h2, .tags-header h2 {
|
||||
font-size: 17px !important; line-height: 1.2 !important; letter-spacing: 0 !important;
|
||||
}
|
||||
.main-column .tags-header p { font-size: 13px !important; line-height: 1.35 !important; margin: 0 !important; }
|
||||
|
||||
.main-column { padding: 10px 12px 16px 12px !important; }
|
||||
.activity-filter-acc { margin-bottom: 4px !important; }
|
||||
|
||||
.empty-state {
|
||||
display: flex !important; flex-direction: column !important; align-items: center !important;
|
||||
justify-content: center !important; text-align: center !important;
|
||||
padding: 40px 16px !important; gap: 8px !important; width: 100% !important; box-sizing: border-box !important;
|
||||
}
|
||||
.empty-state-icon { font-size: 44px !important; line-height: 1 !important; opacity: 0.55 !important; }
|
||||
.empty-state-title { font-size: 16px !important; font-weight: 700 !important; margin: 4px 0 0 0 !important; color: #eee !important; }
|
||||
.empty-state-text { color: var(--c-muted, #8c8c8c) !important; font-size: 14px !important; line-height: 1.4 !important; max-width: 40ch !important; margin: 0 !important; }
|
||||
.empty-state-cta { margin-top: 10px !important; min-height: 48px !important; display: inline-flex !important; align-items: center !important; }
|
||||
|
||||
.activity-filter-collapsed {
|
||||
display: flex !important; align-items: center !important; gap: 10px !important;
|
||||
flex-wrap: wrap !important; margin-bottom: 8px !important; width: 100% !important;
|
||||
}
|
||||
.activity-back {
|
||||
display: inline-flex !important; align-items: center !important; gap: 4px !important;
|
||||
min-height: 44px !important; padding: 6px 14px 6px 10px !important; flex: 0 0 auto !important;
|
||||
}
|
||||
.activity-back-arrow { font-size: 22px !important; line-height: 1 !important; font-weight: bold !important; }
|
||||
.activity-active-filter {
|
||||
font-weight: 700 !important; color: #FFA500 !important; text-transform: uppercase !important;
|
||||
font-size: 13px !important; letter-spacing: .03em !important;
|
||||
}
|
||||
|
||||
.oasis-bottombar .bb-item { min-height: 48px !important; }
|
||||
.oasis-bottombar .bb-pair .bb-item { min-height: 44px !important; padding: 4px 10px !important; }
|
||||
.oasis-mobile-menus .oasis-menu-btn { min-height: 48px !important; }
|
||||
.pm-bubble .pm-actions .pm-btn { min-height: 40px !important; padding: 6px 12px !important; }
|
||||
.score-btn, .vote-btn, .feed-action-btn {
|
||||
min-height: 44px !important; min-width: 44px !important;
|
||||
display: inline-flex !important; align-items: center !important; justify-content: center !important;
|
||||
}
|
||||
.score-btn:active, .vote-btn:active, .feed-action-btn:active { filter: brightness(.85) !important; }
|
||||
|
||||
.oasis-bottombar .bb-add .bb-ico {
|
||||
background: #FFA500 !important; color: #121212 !important; border-radius: 999px !important;
|
||||
width: 30px !important; height: 30px !important; display: inline-flex !important;
|
||||
align-items: center !important; justify-content: center !important;
|
||||
font-weight: bold !important; font-size: 20px !important; line-height: 1 !important;
|
||||
}
|
||||
.bb-pick-grid {
|
||||
display: grid !important; grid-template-columns: repeat(3, 1fr) !important;
|
||||
gap: 8px !important; width: 100% !important; margin-top: 8px !important;
|
||||
}
|
||||
.bb-pick-form { margin: 0 !important; display: block !important; }
|
||||
.bb-pick-btn {
|
||||
display: flex !important; flex-direction: column !important; align-items: center !important;
|
||||
justify-content: center !important; gap: 6px !important; width: 100% !important;
|
||||
min-height: 76px !important; padding: 10px 6px !important; box-sizing: border-box !important;
|
||||
background: #1c1c1c !important; border: 1px solid #333 !important; border-radius: 12px !important; color: #eee !important;
|
||||
}
|
||||
.bb-pick-btn.active { border-color: #FFA500 !important; background: #2a2412 !important; }
|
||||
.bb-pick-ico { font-size: 26px !important; color: #FFA500 !important; line-height: 1 !important; }
|
||||
.bb-pick-lbl { font-size: 11px !important; text-align: center !important; line-height: 1.2 !important; }
|
||||
|
||||
|
|
@ -1,398 +0,0 @@
|
|||
body {
|
||||
background-color: #4B0A6D;
|
||||
color: #E5E5E5;
|
||||
font-family: 'Arial', sans-serif;
|
||||
}
|
||||
|
||||
footer {
|
||||
border-top: 2px solid #9B1C96;
|
||||
}
|
||||
|
||||
.sidebar-left, .sidebar-right {
|
||||
background-color: #39006D;
|
||||
border: 1px solid #9B1C96;
|
||||
color: #E5E5E5;
|
||||
padding: 15px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.main-column {
|
||||
background-color: #1A1A1A;
|
||||
border: 1px solid #9B1C96;
|
||||
padding: 20px;
|
||||
color: #E5E5E5;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
button, input[type="submit"], input[type="button"] {
|
||||
background-color: #9B1C96;
|
||||
color: #E5E5E5;
|
||||
border: 2px solid #6A0066;
|
||||
border-radius: 10px;
|
||||
padding: 12px 24px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover, input[type="submit"]:hover, input[type="button"]:hover {
|
||||
background-color: #6A0066;
|
||||
border-color: #9B1C96;
|
||||
box-shadow: 0 0 15px rgba(155, 28, 150, 0.8);
|
||||
}
|
||||
|
||||
input, textarea, select {
|
||||
background-color: #333333;
|
||||
color: #E5E5E5;
|
||||
border: 1px solid #9B1C96;
|
||||
border-radius: 5px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
input:focus, textarea:focus, select:focus {
|
||||
background-color: #39006D;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #9B1C96;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #E5E5E5;
|
||||
}
|
||||
|
||||
table {
|
||||
background-color: #1A1A1A;
|
||||
color: #E5E5E5;
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
table th {
|
||||
background-color: #6A0066;
|
||||
}
|
||||
|
||||
table tr:nth-child(even) {
|
||||
background-color: #333333;
|
||||
}
|
||||
|
||||
nav ul {
|
||||
background-color: #39006D;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
nav ul li {
|
||||
display: inline-block;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
nav ul li a {
|
||||
color: #E5E5E5;
|
||||
padding: 15px;
|
||||
display: inline-block;
|
||||
text-transform: none;
|
||||
font-weight: bold;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.profile {
|
||||
background-color: #333333;
|
||||
color: #E5E5E5;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 15px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.profile .name {
|
||||
color: #9B1C96;
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
border: 4px solid #9B1C96;
|
||||
border-radius: 50%;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
article, section {
|
||||
background-color: #333333;
|
||||
color: #E5E5E5;
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 15px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.post-preview {
|
||||
background-color: #39006D;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.post-preview img {
|
||||
border-radius: 8px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: #9B1C96;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background-color: #1A1A1A;
|
||||
}
|
||||
|
||||
.action-container {
|
||||
background-color: #333333;
|
||||
border-color: #9B1C96;
|
||||
color: #E5E5E5;
|
||||
}
|
||||
|
||||
footer a {
|
||||
background-color: #9B1C96;
|
||||
color: #FFFFFF;
|
||||
padding: 8px 20px;
|
||||
border-radius: 5px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
footer a:hover {
|
||||
background-color: #6A0066;
|
||||
}
|
||||
|
||||
|
||||
.sidebar-left nav ul li a,
|
||||
.sidebar-right nav ul li a,
|
||||
.header nav ul li a {
|
||||
background-color: #682B94 !important;
|
||||
color: #FFE082 !important;
|
||||
border: 1px solid #B86ADE !important;
|
||||
font-weight: 600;
|
||||
border-radius: 6px;
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
box-sizing: border-box;
|
||||
transition: background-color 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.sidebar-left nav ul li a:hover,
|
||||
.sidebar-right nav ul li a:hover,
|
||||
.header nav ul li a:hover {
|
||||
background-color: #682B94 !important;
|
||||
border-color: #FFD54F !important;
|
||||
color: #FFFFFF !important;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #2D0B47 !important;
|
||||
}
|
||||
|
||||
.main-column,
|
||||
article,
|
||||
section,
|
||||
.action-container,
|
||||
.profile,
|
||||
.post-preview {
|
||||
background-color: #3C1360 !important;
|
||||
border-color: #B86ADE !important;
|
||||
color: #FFEEDB !important;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
background-color: #4B1A72 !important;
|
||||
color: #FFFFFF !important;
|
||||
border-color: #BB5EFF !important;
|
||||
}
|
||||
|
||||
button,
|
||||
input[type="submit"],
|
||||
input[type="button"] {
|
||||
background-color: #A34AD8 !important;
|
||||
color: #FFFFFF !important;
|
||||
border-color: #751E9F !important;
|
||||
}
|
||||
|
||||
header {
|
||||
background-color: #5A1A85 !important;
|
||||
border-bottom: 1px solid #B86ADE !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.header {
|
||||
background-color: #5A1A85 !important;
|
||||
}
|
||||
|
||||
.top-bar-left,
|
||||
.top-bar-mid,
|
||||
.top-bar-right {
|
||||
background-color: #39006D !important;
|
||||
border: 1px solid #9B1C96 !important;
|
||||
padding: 15px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.sidebar-left,
|
||||
.sidebar-right {
|
||||
padding: 16px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.sidebar-left nav ul,
|
||||
.sidebar-right nav ul {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.sidebar-left nav ul li,
|
||||
.sidebar-right nav ul li {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.card {
|
||||
color: #E5E5E5;
|
||||
box-shadow: 0 4px 30px 0 rgba(0, 0, 0, 0.2);
|
||||
background-color: #3C1360;
|
||||
border-color: #B86ADE;
|
||||
}
|
||||
|
||||
.card-label {
|
||||
color: #9B1C96;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
color: #B86ADE;
|
||||
}
|
||||
|
||||
.card-tags a.tag-link {
|
||||
color: #FFFFFF;
|
||||
background: #D94F4F;
|
||||
}
|
||||
|
||||
.card-tags a.tag-link:hover {
|
||||
background: #B86ADE;
|
||||
color: #E5E5E5;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
a.user-link {
|
||||
background-color: #FFD600;
|
||||
color: #3C1360;
|
||||
border-color: #FFD600;
|
||||
transition: background-color 0.3s, color 0.3s, border-color 0.3s;
|
||||
}
|
||||
|
||||
a.user-link:hover {
|
||||
background-color: #FFB600;
|
||||
border-color: #FFB600;
|
||||
color: #3C1360;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
a.user-link:focus {
|
||||
background-color: #9A2F2F;
|
||||
border-color: #9A2F2F;
|
||||
color: #E5E5E5;
|
||||
}
|
||||
|
||||
.date-link {
|
||||
background-color: #2F3C32;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.date-link:hover {
|
||||
background-color: #3E4A3D;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.activitySpreadInhabitant2 {
|
||||
background-color: #3E4A3D;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.activityVotePost {
|
||||
background-color: #3B5C42;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.update-banner {
|
||||
background-color: #1a0025;
|
||||
border-bottom-color: #3d004d;
|
||||
color: #E5E5E5;
|
||||
}
|
||||
|
||||
.update-banner-link {
|
||||
color: #c44bc1;
|
||||
}
|
||||
|
||||
.snh-invite-code {
|
||||
color: #9B1C96 !important;
|
||||
}
|
||||
|
||||
.stats-kpi-label { color: #B86ADE !important; }
|
||||
.stats-kpi-value { color: #FFEEDB !important; }
|
||||
.carbon-bar-note, .carbon-bar-formula { color: #FFEEDB !important; }
|
||||
.graphos-node-label { fill: #FFEEDB !important; }
|
||||
.graphos-node-label-me { fill: #ffa500 !important; }
|
||||
.graphos-legend { color: #FFEEDB !important; }
|
||||
/* Blockexplorer */
|
||||
.blockchain-view { background-color: #2D0B47 !important; color: #FFEEDB !important; }
|
||||
.block { background: #3C1360 !important; border: 1px solid #B86ADE !important; }
|
||||
.block:hover { box-shadow: 0 0 15px rgba(184,106,222,0.3) !important; }
|
||||
.blockchain-card-label, .block-info-table .card-label, .pm-info-table .card-label, .block-content-label { color: #B86ADE !important; }
|
||||
.blockchain-card-value, .block-info-table .card-value, .pm-info-table .card-value, .block-timestamp, .json-content { color: #FFEEDB !important; }
|
||||
.block-content-preview, .block-content { background: #4B1A72 !important; color: #FFEEDB !important; }
|
||||
.block-author { color: #FFD600 !important; background: rgba(255,214,0,0.08) !important; }
|
||||
.block-author:hover { color: #FFB600 !important; }
|
||||
.block-url { color: #B86ADE !important; }
|
||||
.block-row--details .block-url { background: #4B1A72 !important; }
|
||||
.block-row--details .block-url:hover { background: #5A1A85 !important; color: #FFD600 !important; }
|
||||
.btn-singleview { background: #4B1A72 !important; color: #B86ADE !important; }
|
||||
.btn-singleview:hover { background: #5A1A85 !important; color: #FFD600 !important; }
|
||||
.btn-spread-on, .content-actions .btn-pin-on { background: #B86ADE !important; color: #2A0E42 !important; }
|
||||
.btn-spread-on:hover, .content-actions .btn-pin-on:hover { background: #CE8CE9 !important; color: #2A0E42 !important; }
|
||||
.btn-back { background: #A34AD8 !important; color: #FFFFFF !important; }
|
||||
.btn-back:hover { background: #751E9F !important; color: #FFFFFF !important; }
|
||||
.block-info-table td, .pm-info-table td { border-color: #B86ADE !important; }
|
||||
.block-diagram { border-color: #B86ADE !important; background: #2D0B47 !important; }
|
||||
.block-diagram-ruler { color: #B86ADE !important; background: #1A0030 !important; border-bottom-color: #B86ADE !important; }
|
||||
.block-diagram-ruler span { color: #B86ADE !important; }
|
||||
.block-diagram-cell { border-color: #B86ADE !important; background: #3C1360 !important; }
|
||||
.bd-label { color: #B86ADE !important; }
|
||||
.bd-value { color: #FFEEDB !important; }
|
||||
.deleted-label { color: #ff5555 !important; }
|
||||
|
||||
/* Tribes */
|
||||
.tribe-card { background: #3C1360 !important; border-color: #B86ADE !important; }
|
||||
.tribe-card:hover { box-shadow: 0 0 15px rgba(184,106,222,0.3) !important; }
|
||||
.tribe-card-title { color: #FFEEDB !important; }
|
||||
.tribe-card-description { color: #FFEEDB !important; }
|
||||
.tribe-info-table td { border-color: #B86ADE !important; }
|
||||
.tribe-info-label { color: #B86ADE !important; background: #3C1360 !important; }
|
||||
.tribe-info-value { color: #FFEEDB !important; background: #3C1360 !important; }
|
||||
.tribe-info-empty { color: #8844aa !important; }
|
||||
.tribe-card-members { border-color: #B86ADE !important; background: #2D0B47 !important; }
|
||||
.tribe-members-count { color: #FFD600 !important; }
|
||||
.tribe-card-actions { border-color: #B86ADE !important; background: #2D0B47 !important; }
|
||||
.tribe-action-btn { border-color: #B86ADE !important; color: #B86ADE !important; }
|
||||
.tribe-action-btn:hover { background: #B86ADE !important; color: #000 !important; }
|
||||
.tribe-subtribe-link { background: #4B1A72 !important; border-color: #B86ADE !important; color: #FFD600 !important; }
|
||||
.tribe-thumb-link { border-color: #B86ADE !important; }
|
||||
.tribe-thumb-link:hover { border-color: #FFD600 !important; }
|
||||
.tribe-subtribe-link:hover { background: #5A1A85 !important; }
|
||||
.tribe-parent-image { border-color: #B86ADE !important; }
|
||||
.tribe-parent-box { background: #3C1360 !important; }
|
||||
.trending-card.own-content { box-shadow: inset 3px 0 0 #B86ADE; }
|
||||
|
|
@ -12,9 +12,4 @@ languages.forEach(language => {
|
|||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const overlay = require('./i18n_fork.js');
|
||||
languages.forEach(l => Object.assign(i18n[l] = i18n[l] || {}, overlay[l] || overlay.en || {}));
|
||||
} catch (e) {}
|
||||
|
||||
module.exports = i18n;
|
||||
|
|
|
|||
|
|
@ -1,718 +0,0 @@
|
|||
// Claves de traduccion propias de la rama experimental.
|
||||
// Se fusionan sobre las de upstream desde i18n.js, para no editar los 11
|
||||
// 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,
|
||||
// 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...).
|
||||
// 3. Textos de upstream que la rama corrige (el grupo spread en castellano).
|
||||
//
|
||||
// Los nombres de las categorias NO se tocan: son los de epsylon y se leen de
|
||||
// upstream, para que la rama no se separe de el en algo tan visible.
|
||||
//
|
||||
// Los 11 idiomas estan completos. Si anades una clave, anadela en los 11.
|
||||
|
||||
|
||||
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",
|
||||
// Identidades
|
||||
identitiesActive: "In use",
|
||||
identitiesBackup: "Export or import an identity",
|
||||
identitiesCreate: "Create identity",
|
||||
identitiesDescription: "Each identity lives in its own folder, with its own key, database and blobs. The change applies on restart.",
|
||||
identitiesInvalidName: "Invalid name. Letters, numbers, hyphen and underscore, up to 32.",
|
||||
identitiesNewName: "Name for the new identity",
|
||||
identitiesRestart: "Restart now",
|
||||
identitiesRestartNeeded: "Selected. Restart Oasis to enter with this identity.",
|
||||
identitiesRestarting: "Restarting. Open Oasis again in a few seconds.",
|
||||
identitiesSameFeed: "Shares key with another identity: never publish from both.",
|
||||
identitiesTitle: "Identities",
|
||||
identitiesUse: "Use this one",
|
||||
// Barra inferior
|
||||
bbAdd: "Add",
|
||||
bbDone: "Done",
|
||||
bbFull: "Maximum reached",
|
||||
bbManage: "Edit",
|
||||
bbPickDesc: "Choose up to 4 shortcuts for the bottom bar.",
|
||||
bbPickNone: "None",
|
||||
bbPickTitle: "Customize the bar",
|
||||
bbQuickActions: "Quick actions",
|
||||
bbRemove: "Remove",
|
||||
bbYourBar: "Your bar",
|
||||
// Menu y filtros
|
||||
menuAll: "All",
|
||||
menuBlogs: "Blogs",
|
||||
menuSocial: "Social",
|
||||
// Rescatadas de 0.9.1 y otras
|
||||
bookmarkCategoryLabel: "Category",
|
||||
bookmarkCategoryPlaceholder: "Optional",
|
||||
chatReply: "Reply",
|
||||
chatShareUrl: "Share URL",
|
||||
filter: "Filter",
|
||||
forumFilterHot: "Hot",
|
||||
peerLastChange: "Last change",
|
||||
profileVisibilityDevice: "Device",
|
||||
publishBlog: "Publish Blog",
|
||||
spreadHint: "Spread this to your supporters (replicates via your feed).",
|
||||
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",
|
||||
// Identidades
|
||||
identitiesActive: "En uso",
|
||||
identitiesBackup: "Exportar o importar una identidad",
|
||||
identitiesCreate: "Crear identidad",
|
||||
identitiesDescription: "Cada identidad vive en su propia carpeta, con su clave, su base de datos y sus blobs. El cambio se aplica al reiniciar.",
|
||||
identitiesInvalidName: "Nombre no válido. Letras, números, guion y guion bajo, hasta 32.",
|
||||
identitiesNewName: "Nombre de la nueva identidad",
|
||||
identitiesRestart: "Reiniciar ahora",
|
||||
identitiesRestartNeeded: "Seleccionada. Reinicia Oasis para entrar con esta identidad.",
|
||||
identitiesRestarting: "Reiniciando. Vuelve a abrir Oasis en unos segundos.",
|
||||
identitiesSameFeed: "Comparte clave con otra identidad: no publiques nunca desde las dos.",
|
||||
identitiesTitle: "Identidades",
|
||||
identitiesUse: "Usar esta",
|
||||
// Barra inferior
|
||||
bbAdd: "Añadir",
|
||||
bbDone: "Hecho",
|
||||
bbFull: "Máximo alcanzado",
|
||||
bbManage: "Editar",
|
||||
bbPickDesc: "Elige hasta 4 accesos directos para la barra inferior.",
|
||||
bbPickNone: "Ninguno",
|
||||
bbPickTitle: "Personalizar la barra",
|
||||
bbQuickActions: "Acciones rápidas",
|
||||
bbRemove: "Quitar",
|
||||
bbYourBar: "Tu barra",
|
||||
// Menu y filtros
|
||||
menuAll: "Todo",
|
||||
menuBlogs: "Blogs",
|
||||
menuSocial: "Social",
|
||||
// Rescatadas de 0.9.1 y otras
|
||||
bookmarkCategoryLabel: "Categoría",
|
||||
bookmarkCategoryPlaceholder: "Opcional",
|
||||
chatReply: "Responder",
|
||||
chatShareUrl: "Compartir enlace",
|
||||
filter: "Filtrar",
|
||||
forumFilterHot: "Candente",
|
||||
peerLastChange: "Último cambio",
|
||||
profileVisibilityDevice: "Dispositivo",
|
||||
publishBlog: "Publicar blog",
|
||||
spreadHint: "Difunde esto entre quienes te siguen (se replica por tu feed).",
|
||||
spreadMore: "más",
|
||||
// Correcciones al castellano de upstream
|
||||
spreadChron: "Difusión",
|
||||
spreadLabel: "Difundir",
|
||||
spreaded: "Difundido",
|
||||
spreadedDescription: "Lista de posts replicados por habitante.",
|
||||
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",
|
||||
// Identidades
|
||||
identitiesActive: "En cours d'utilisation",
|
||||
identitiesBackup: "Exporter ou importer une identité",
|
||||
identitiesCreate: "Créer une identité",
|
||||
identitiesDescription: "Chaque identité vit dans son propre dossier, avec sa clé, sa base de données et ses blobs. Le changement s'applique au redémarrage.",
|
||||
identitiesInvalidName: "Nom invalide. Lettres, chiffres, tiret et tiret bas, jusqu'à 32.",
|
||||
identitiesNewName: "Nom de la nouvelle identité",
|
||||
identitiesRestart: "Redémarrer maintenant",
|
||||
identitiesRestartNeeded: "Sélectionnée. Redémarrez Oasis pour entrer avec cette identité.",
|
||||
identitiesRestarting: "Redémarrage. Rouvrez Oasis dans quelques secondes.",
|
||||
identitiesSameFeed: "Partage sa clé avec une autre identité : ne publiez jamais depuis les deux.",
|
||||
identitiesTitle: "Identités",
|
||||
identitiesUse: "Utiliser celle-ci",
|
||||
// Barra inferior
|
||||
bbAdd: "Ajouter",
|
||||
bbDone: "Terminé",
|
||||
bbFull: "Maximum atteint",
|
||||
bbManage: "Modifier",
|
||||
bbPickDesc: "Choisissez jusqu'à 4 raccourcis pour la barre du bas.",
|
||||
bbPickNone: "Aucun",
|
||||
bbPickTitle: "Personnaliser la barre",
|
||||
bbQuickActions: "Actions rapides",
|
||||
bbRemove: "Retirer",
|
||||
bbYourBar: "Votre barre",
|
||||
// Menu y filtros
|
||||
menuAll: "Tout",
|
||||
menuBlogs: "Blogs",
|
||||
menuSocial: "Social",
|
||||
// Rescatadas de 0.9.1 y otras
|
||||
bookmarkCategoryLabel: "Catégorie",
|
||||
bookmarkCategoryPlaceholder: "Facultatif",
|
||||
chatReply: "Répondre",
|
||||
chatShareUrl: "Partager le lien",
|
||||
filter: "Filtrer",
|
||||
forumFilterHot: "Populaire",
|
||||
peerLastChange: "Dernier changement",
|
||||
profileVisibilityDevice: "Appareil",
|
||||
publishBlog: "Publier le blog",
|
||||
spreadHint: "Diffusez ceci auprès de vos soutiens (se réplique via votre flux).",
|
||||
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",
|
||||
// Identidades
|
||||
identitiesActive: "Erabiltzen",
|
||||
identitiesBackup: "Esportatu edo inportatu nortasun bat",
|
||||
identitiesCreate: "Sortu nortasuna",
|
||||
identitiesDescription: "Nortasun bakoitza bere karpetan bizi da, bere gakoarekin, datu-basearekin eta blob-ekin. Aldaketa berrabiaraztean aplikatzen da.",
|
||||
identitiesInvalidName: "Izen baliogabea. Letrak, zenbakiak, marratxoa eta azpimarra, 32 arte.",
|
||||
identitiesNewName: "Nortasun berriaren izena",
|
||||
identitiesRestart: "Berrabiarazi orain",
|
||||
identitiesRestartNeeded: "Hautatuta. Berrabiarazi Oasis nortasun honekin sartzeko.",
|
||||
identitiesRestarting: "Berrabiarazten. Ireki Oasis berriro segundo batzuk barru.",
|
||||
identitiesSameFeed: "Beste nortasun batekin gakoa partekatzen du: ez argitaratu inoiz bietatik.",
|
||||
identitiesTitle: "Nortasunak",
|
||||
identitiesUse: "Erabili hau",
|
||||
// Barra inferior
|
||||
bbAdd: "Gehitu",
|
||||
bbDone: "Eginda",
|
||||
bbFull: "Gehienekora iritsi zara",
|
||||
bbManage: "Editatu",
|
||||
bbPickDesc: "Aukeratu 4 lasterbide arte beheko barrarako.",
|
||||
bbPickNone: "Bat ere ez",
|
||||
bbPickTitle: "Pertsonalizatu barra",
|
||||
bbQuickActions: "Ekintza bizkorrak",
|
||||
bbRemove: "Kendu",
|
||||
bbYourBar: "Zure barra",
|
||||
// Menu y filtros
|
||||
menuAll: "Dena",
|
||||
menuBlogs: "Blogak",
|
||||
menuSocial: "Soziala",
|
||||
// Rescatadas de 0.9.1 y otras
|
||||
bookmarkCategoryLabel: "Kategoria",
|
||||
bookmarkCategoryPlaceholder: "Aukerakoa",
|
||||
chatReply: "Erantzun",
|
||||
chatShareUrl: "Partekatu esteka",
|
||||
filter: "Iragazi",
|
||||
forumFilterHot: "Bero",
|
||||
peerLastChange: "Azken aldaketa",
|
||||
profileVisibilityDevice: "Gailua",
|
||||
publishBlog: "Argitaratu bloga",
|
||||
spreadHint: "Zabaldu hau zure jarraitzaileen artean (zure jarioaren bidez errepikatzen da).",
|
||||
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",
|
||||
// Identidades
|
||||
identitiesActive: "In Benutzung",
|
||||
identitiesBackup: "Identität exportieren oder importieren",
|
||||
identitiesCreate: "Identität erstellen",
|
||||
identitiesDescription: "Jede Identität liegt in einem eigenen Ordner, mit eigenem Schlüssel, eigener Datenbank und eigenen Blobs. Die Änderung greift beim Neustart.",
|
||||
identitiesInvalidName: "Ungültiger Name. Buchstaben, Ziffern, Bindestrich und Unterstrich, bis zu 32.",
|
||||
identitiesNewName: "Name der neuen Identität",
|
||||
identitiesRestart: "Jetzt neu starten",
|
||||
identitiesRestartNeeded: "Ausgewählt. Starte Oasis neu, um mit dieser Identität einzutreten.",
|
||||
identitiesRestarting: "Neustart. Öffne Oasis in ein paar Sekunden erneut.",
|
||||
identitiesSameFeed: "Teilt den Schlüssel mit einer anderen Identität: niemals von beiden veröffentlichen.",
|
||||
identitiesTitle: "Identitäten",
|
||||
identitiesUse: "Diese verwenden",
|
||||
// Barra inferior
|
||||
bbAdd: "Hinzufügen",
|
||||
bbDone: "Fertig",
|
||||
bbFull: "Maximum erreicht",
|
||||
bbManage: "Bearbeiten",
|
||||
bbPickDesc: "Wähle bis zu 4 Verknüpfungen für die untere Leiste.",
|
||||
bbPickNone: "Keine",
|
||||
bbPickTitle: "Leiste anpassen",
|
||||
bbQuickActions: "Schnellzugriff",
|
||||
bbRemove: "Entfernen",
|
||||
bbYourBar: "Deine Leiste",
|
||||
// Menu y filtros
|
||||
menuAll: "Alle",
|
||||
menuBlogs: "Blogs",
|
||||
menuSocial: "Sozial",
|
||||
// Rescatadas de 0.9.1 y otras
|
||||
bookmarkCategoryLabel: "Kategorie",
|
||||
bookmarkCategoryPlaceholder: "Optional",
|
||||
chatReply: "Antworten",
|
||||
chatShareUrl: "Link teilen",
|
||||
filter: "Filtern",
|
||||
forumFilterHot: "Angesagt",
|
||||
peerLastChange: "Letzte Änderung",
|
||||
profileVisibilityDevice: "Gerät",
|
||||
publishBlog: "Blog veröffentlichen",
|
||||
spreadHint: "Verbreite dies an deine Unterstützer (repliziert über deinen Feed).",
|
||||
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",
|
||||
// Identidades
|
||||
identitiesActive: "In uso",
|
||||
identitiesBackup: "Esporta o importa un'identità",
|
||||
identitiesCreate: "Crea identità",
|
||||
identitiesDescription: "Ogni identità vive nella sua cartella, con la sua chiave, il suo database e i suoi blob. Il cambio si applica al riavvio.",
|
||||
identitiesInvalidName: "Nome non valido. Lettere, numeri, trattino e trattino basso, fino a 32.",
|
||||
identitiesNewName: "Nome della nuova identità",
|
||||
identitiesRestart: "Riavvia ora",
|
||||
identitiesRestartNeeded: "Selezionata. Riavvia Oasis per entrare con questa identità.",
|
||||
identitiesRestarting: "Riavvio in corso. Riapri Oasis tra qualche secondo.",
|
||||
identitiesSameFeed: "Condivide la chiave con un'altra identità: non pubblicare mai da entrambe.",
|
||||
identitiesTitle: "Identità",
|
||||
identitiesUse: "Usa questa",
|
||||
// Barra inferior
|
||||
bbAdd: "Aggiungi",
|
||||
bbDone: "Fatto",
|
||||
bbFull: "Massimo raggiunto",
|
||||
bbManage: "Modifica",
|
||||
bbPickDesc: "Scegli fino a 4 scorciatoie per la barra in basso.",
|
||||
bbPickNone: "Nessuno",
|
||||
bbPickTitle: "Personalizza la barra",
|
||||
bbQuickActions: "Azioni rapide",
|
||||
bbRemove: "Rimuovi",
|
||||
bbYourBar: "La tua barra",
|
||||
// Menu y filtros
|
||||
menuAll: "Tutto",
|
||||
menuBlogs: "Blog",
|
||||
menuSocial: "Sociale",
|
||||
// Rescatadas de 0.9.1 y otras
|
||||
bookmarkCategoryLabel: "Categoria",
|
||||
bookmarkCategoryPlaceholder: "Facoltativo",
|
||||
chatReply: "Rispondi",
|
||||
chatShareUrl: "Condividi link",
|
||||
filter: "Filtra",
|
||||
forumFilterHot: "Caldo",
|
||||
peerLastChange: "Ultima modifica",
|
||||
profileVisibilityDevice: "Dispositivo",
|
||||
publishBlog: "Pubblica blog",
|
||||
spreadHint: "Diffondi questo tra chi ti segue (si replica tramite il tuo feed).",
|
||||
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",
|
||||
// Identidades
|
||||
identitiesActive: "Em uso",
|
||||
identitiesBackup: "Exportar ou importar uma identidade",
|
||||
identitiesCreate: "Criar identidade",
|
||||
identitiesDescription: "Cada identidade vive na sua própria pasta, com a sua chave, base de dados e blobs. A mudança aplica-se ao reiniciar.",
|
||||
identitiesInvalidName: "Nome inválido. Letras, números, hífen e sublinhado, até 32.",
|
||||
identitiesNewName: "Nome da nova identidade",
|
||||
identitiesRestart: "Reiniciar agora",
|
||||
identitiesRestartNeeded: "Selecionada. Reinicia o Oasis para entrar com esta identidade.",
|
||||
identitiesRestarting: "A reiniciar. Volta a abrir o Oasis dentro de alguns segundos.",
|
||||
identitiesSameFeed: "Partilha a chave com outra identidade: nunca publiques a partir das duas.",
|
||||
identitiesTitle: "Identidades",
|
||||
identitiesUse: "Usar esta",
|
||||
// Barra inferior
|
||||
bbAdd: "Adicionar",
|
||||
bbDone: "Concluído",
|
||||
bbFull: "Máximo atingido",
|
||||
bbManage: "Editar",
|
||||
bbPickDesc: "Escolhe até 4 atalhos para a barra inferior.",
|
||||
bbPickNone: "Nenhum",
|
||||
bbPickTitle: "Personalizar a barra",
|
||||
bbQuickActions: "Ações rápidas",
|
||||
bbRemove: "Remover",
|
||||
bbYourBar: "A tua barra",
|
||||
// Menu y filtros
|
||||
menuAll: "Tudo",
|
||||
menuBlogs: "Blogues",
|
||||
menuSocial: "Social",
|
||||
// Rescatadas de 0.9.1 y otras
|
||||
bookmarkCategoryLabel: "Categoria",
|
||||
bookmarkCategoryPlaceholder: "Opcional",
|
||||
chatReply: "Responder",
|
||||
chatShareUrl: "Partilhar ligação",
|
||||
filter: "Filtrar",
|
||||
forumFilterHot: "Em alta",
|
||||
peerLastChange: "Última alteração",
|
||||
profileVisibilityDevice: "Dispositivo",
|
||||
publishBlog: "Publicar blogue",
|
||||
spreadHint: "Difunde isto entre quem te segue (replica-se pelo teu feed).",
|
||||
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",
|
||||
// Identidades
|
||||
identitiesActive: "使用中",
|
||||
identitiesBackup: "导出或导入身份",
|
||||
identitiesCreate: "创建身份",
|
||||
identitiesDescription: "每个身份都有自己的文件夹,包含各自的密钥、数据库和 blob。更改在重启后生效。",
|
||||
identitiesInvalidName: "名称无效。字母、数字、连字符和下划线,最多 32 个字符。",
|
||||
identitiesNewName: "新身份的名称",
|
||||
identitiesRestart: "立即重启",
|
||||
identitiesRestartNeeded: "已选择。重启 Oasis 以使用该身份进入。",
|
||||
identitiesRestarting: "正在重启。请过几秒再打开 Oasis。",
|
||||
identitiesSameFeed: "与另一个身份共用密钥:切勿从两者同时发布。",
|
||||
identitiesTitle: "身份",
|
||||
identitiesUse: "使用这个",
|
||||
// Barra inferior
|
||||
bbAdd: "添加",
|
||||
bbDone: "完成",
|
||||
bbFull: "已达上限",
|
||||
bbManage: "编辑",
|
||||
bbPickDesc: "为底部工具栏选择最多 4 个快捷方式。",
|
||||
bbPickNone: "无",
|
||||
bbPickTitle: "自定义工具栏",
|
||||
bbQuickActions: "快捷操作",
|
||||
bbRemove: "移除",
|
||||
bbYourBar: "你的工具栏",
|
||||
// Menu y filtros
|
||||
menuAll: "全部",
|
||||
menuBlogs: "博客",
|
||||
menuSocial: "社群",
|
||||
// Rescatadas de 0.9.1 y otras
|
||||
bookmarkCategoryLabel: "分类",
|
||||
bookmarkCategoryPlaceholder: "可选",
|
||||
chatReply: "回复",
|
||||
chatShareUrl: "分享链接",
|
||||
filter: "筛选",
|
||||
forumFilterHot: "热门",
|
||||
peerLastChange: "最后变更",
|
||||
profileVisibilityDevice: "设备",
|
||||
publishBlog: "发布博客",
|
||||
spreadHint: "把它扩散给支持你的人(通过你的 feed 复制传播)。",
|
||||
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",
|
||||
// Identidades
|
||||
identitiesActive: "قيد الاستخدام",
|
||||
identitiesBackup: "تصدير هوية أو استيرادها",
|
||||
identitiesCreate: "إنشاء هوية",
|
||||
identitiesDescription: "كل هوية تعيش في مجلدها الخاص، بمفتاحها وقاعدة بياناتها وملفاتها. يسري التغيير عند إعادة التشغيل.",
|
||||
identitiesInvalidName: "اسم غير صالح. حروف وأرقام وشرطة وشرطة سفلية، حتى 32 حرفًا.",
|
||||
identitiesNewName: "اسم الهوية الجديدة",
|
||||
identitiesRestart: "أعد التشغيل الآن",
|
||||
identitiesRestartNeeded: "تم الاختيار. أعد تشغيل Oasis للدخول بهذه الهوية.",
|
||||
identitiesRestarting: "جارٍ إعادة التشغيل. افتح Oasis مجددًا بعد ثوانٍ.",
|
||||
identitiesSameFeed: "تشترك في المفتاح مع هوية أخرى: لا تنشر أبدًا من كلتيهما.",
|
||||
identitiesTitle: "الهويات",
|
||||
identitiesUse: "استخدم هذه",
|
||||
// Barra inferior
|
||||
bbAdd: "إضافة",
|
||||
bbDone: "تم",
|
||||
bbFull: "بلغت الحد الأقصى",
|
||||
bbManage: "تحرير",
|
||||
bbPickDesc: "اختر حتى 4 اختصارات للشريط السفلي.",
|
||||
bbPickNone: "لا شيء",
|
||||
bbPickTitle: "تخصيص الشريط",
|
||||
bbQuickActions: "إجراءات سريعة",
|
||||
bbRemove: "إزالة",
|
||||
bbYourBar: "شريطك",
|
||||
// Menu y filtros
|
||||
menuAll: "الكل",
|
||||
menuBlogs: "المدونات",
|
||||
menuSocial: "اجتماعي",
|
||||
// Rescatadas de 0.9.1 y otras
|
||||
bookmarkCategoryLabel: "الفئة",
|
||||
bookmarkCategoryPlaceholder: "اختياري",
|
||||
chatReply: "رد",
|
||||
chatShareUrl: "مشاركة الرابط",
|
||||
filter: "تصفية",
|
||||
forumFilterHot: "الأكثر تفاعلًا",
|
||||
peerLastChange: "آخر تغيير",
|
||||
profileVisibilityDevice: "الجهاز",
|
||||
publishBlog: "نشر المدونة",
|
||||
spreadHint: "انشر هذا بين مؤيديك (يُنسخ عبر تدفقك).",
|
||||
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",
|
||||
// Identidades
|
||||
identitiesActive: "उपयोग में",
|
||||
identitiesBackup: "पहचान निर्यात या आयात करें",
|
||||
identitiesCreate: "पहचान बनाएँ",
|
||||
identitiesDescription: "हर पहचान अपने अलग फ़ोल्डर में रहती है, अपनी कुंजी, डेटाबेस और ब्लॉब्स के साथ। बदलाव पुनः आरंभ करने पर लागू होता है।",
|
||||
identitiesInvalidName: "अमान्य नाम। अक्षर, अंक, हाइफ़न और अंडरस्कोर, अधिकतम 32।",
|
||||
identitiesNewName: "नई पहचान का नाम",
|
||||
identitiesRestart: "अभी पुनः आरंभ करें",
|
||||
identitiesRestartNeeded: "चयनित। इस पहचान से प्रवेश करने के लिए Oasis पुनः आरंभ करें।",
|
||||
identitiesRestarting: "पुनः आरंभ हो रहा है। कुछ सेकंड बाद Oasis फिर खोलें।",
|
||||
identitiesSameFeed: "किसी अन्य पहचान से कुंजी साझा करती है: दोनों से कभी प्रकाशित न करें।",
|
||||
identitiesTitle: "पहचानें",
|
||||
identitiesUse: "इसका उपयोग करें",
|
||||
// Barra inferior
|
||||
bbAdd: "जोड़ें",
|
||||
bbDone: "हो गया",
|
||||
bbFull: "अधिकतम सीमा",
|
||||
bbManage: "संपादित करें",
|
||||
bbPickDesc: "नीचे की बार के लिए अधिकतम 4 शॉर्टकट चुनें।",
|
||||
bbPickNone: "कोई नहीं",
|
||||
bbPickTitle: "बार अनुकूलित करें",
|
||||
bbQuickActions: "त्वरित क्रियाएँ",
|
||||
bbRemove: "हटाएँ",
|
||||
bbYourBar: "आपकी बार",
|
||||
// Menu y filtros
|
||||
menuAll: "सभी",
|
||||
menuBlogs: "ब्लॉग",
|
||||
menuSocial: "सामाजिक",
|
||||
// Rescatadas de 0.9.1 y otras
|
||||
bookmarkCategoryLabel: "श्रेणी",
|
||||
bookmarkCategoryPlaceholder: "वैकल्पिक",
|
||||
chatReply: "उत्तर दें",
|
||||
chatShareUrl: "लिंक साझा करें",
|
||||
filter: "छानें",
|
||||
forumFilterHot: "चर्चित",
|
||||
peerLastChange: "अंतिम बदलाव",
|
||||
profileVisibilityDevice: "डिवाइस",
|
||||
publishBlog: "ब्लॉग प्रकाशित करें",
|
||||
spreadHint: "इसे अपने समर्थकों तक फैलाएँ (आपकी फ़ीड से प्रतिकृति होती है)।",
|
||||
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",
|
||||
// Identidades
|
||||
identitiesActive: "Используется",
|
||||
identitiesBackup: "Экспортировать или импортировать личность",
|
||||
identitiesCreate: "Создать личность",
|
||||
identitiesDescription: "Каждая личность живёт в своей папке — со своим ключом, базой данных и блобами. Изменение вступает в силу после перезапуска.",
|
||||
identitiesInvalidName: "Недопустимое имя. Буквы, цифры, дефис и подчёркивание, до 32 символов.",
|
||||
identitiesNewName: "Имя новой личности",
|
||||
identitiesRestart: "Перезапустить сейчас",
|
||||
identitiesRestartNeeded: "Выбрано. Перезапустите Oasis, чтобы войти с этой личностью.",
|
||||
identitiesRestarting: "Перезапуск. Откройте Oasis снова через несколько секунд.",
|
||||
identitiesSameFeed: "Ключ совпадает с другой личностью: никогда не публикуйте с обеих.",
|
||||
identitiesTitle: "Личности",
|
||||
identitiesUse: "Использовать эту",
|
||||
// Barra inferior
|
||||
bbAdd: "Добавить",
|
||||
bbDone: "Готово",
|
||||
bbFull: "Достигнут максимум",
|
||||
bbManage: "Изменить",
|
||||
bbPickDesc: "Выберите до 4 ярлыков для нижней панели.",
|
||||
bbPickNone: "Нет",
|
||||
bbPickTitle: "Настроить панель",
|
||||
bbQuickActions: "Быстрые действия",
|
||||
bbRemove: "Убрать",
|
||||
bbYourBar: "Ваша панель",
|
||||
// Menu y filtros
|
||||
menuAll: "Все",
|
||||
menuBlogs: "Блоги",
|
||||
menuSocial: "Социальное",
|
||||
// Rescatadas de 0.9.1 y otras
|
||||
bookmarkCategoryLabel: "Категория",
|
||||
bookmarkCategoryPlaceholder: "Необязательно",
|
||||
chatReply: "Ответить",
|
||||
chatShareUrl: "Поделиться ссылкой",
|
||||
filter: "Фильтр",
|
||||
forumFilterHot: "Горячее",
|
||||
peerLastChange: "Последнее изменение",
|
||||
profileVisibilityDevice: "Устройство",
|
||||
publishBlog: "Опубликовать блог",
|
||||
spreadHint: "Распространите это среди тех, кто вас читает (реплицируется через вашу ленту).",
|
||||
spreadMore: "ещё"
|
||||
}
|
||||
};
|
||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -42,7 +42,7 @@ const collectLocalIPs = () => {
|
|||
|
||||
module.exports = ({ host, port, middleware, allowHost }) => {
|
||||
const assets = new Koa()
|
||||
assets.use(koaStatic(join(__dirname, "..", "client", "assets"), { maxage: 60 * 60 * 1000 }));
|
||||
assets.use(koaStatic(join(__dirname, "..", "client", "assets")));
|
||||
|
||||
const app = new Koa();
|
||||
const validHosts = [];
|
||||
|
|
@ -79,14 +79,12 @@ module.exports = ({ host, port, middleware, allowHost }) => {
|
|||
return true;
|
||||
};
|
||||
|
||||
const httpDebug = process.argv.includes('--debug') || process.env.OASIS_DEBUG === '1' || process.env.OASIS_DEBUG === 'true';
|
||||
|
||||
app.on("error", (err, ctx) => {
|
||||
if (err && (err.code === 'ECONNRESET' || err.code === 'EPIPE')) {
|
||||
return;
|
||||
}
|
||||
if (err && (err.name === 'BadRequestError' || err.status === 400)) {
|
||||
if (httpDebug) console.error(`[400] ${err.message}`);
|
||||
console.error(`[400] ${err.message}`);
|
||||
return null;
|
||||
}
|
||||
console.error(err);
|
||||
|
|
@ -117,7 +115,7 @@ module.exports = ({ host, port, middleware, allowHost }) => {
|
|||
|
||||
app.use(async (ctx, next) => {
|
||||
|
||||
if (httpDebug) console.log(`[http] ${ctx.method} ${ctx.path}`);
|
||||
//console.log("Requesting:", ctx.path); // uncomment to check for HTTP requests
|
||||
|
||||
const isClearnet = isClearnetPath(ctx.request);
|
||||
const csp = isClearnet
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
})();
|
||||
36
src/client/public/js/oasis-mobile-nav.js
Normal file
36
src/client/public/js/oasis-mobile-nav.js
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
(function () {
|
||||
function mark() {
|
||||
var p = (location.pathname || "/").replace(/\/+$/, "") || "/";
|
||||
var links = document.querySelectorAll(".oasis-bottombar a.bb-item");
|
||||
var best = null, bestLen = -1;
|
||||
links.forEach(function (a) {
|
||||
var h = (a.getAttribute("href") || "").replace(/\/+$/, "") || "/";
|
||||
if (h === "/") return;
|
||||
if (p === h || p.indexOf(h + "/") === 0) {
|
||||
if (h.length > bestLen) { best = a; bestLen = h.length; }
|
||||
}
|
||||
});
|
||||
if (best) best.classList.add("current");
|
||||
}
|
||||
|
||||
function onToggle(e) {
|
||||
var t = e.target;
|
||||
if (!t || !t.classList || !t.classList.contains("oasis-nav-toggle")) return;
|
||||
if (!t.checked) return;
|
||||
var group = t.closest(".oasis-nav-group");
|
||||
var scope = group && group.parentElement;
|
||||
if (!scope) return;
|
||||
var others = scope.querySelectorAll(
|
||||
":scope > .oasis-nav-group > .oasis-nav-toggle"
|
||||
);
|
||||
others.forEach(function (o) { if (o !== t) o.checked = false; });
|
||||
}
|
||||
|
||||
function init() {
|
||||
mark();
|
||||
document.addEventListener("change", onToggle);
|
||||
}
|
||||
|
||||
if (document.readyState !== "loading") init();
|
||||
else document.addEventListener("DOMContentLoaded", init);
|
||||
})();
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
document.addEventListener('DOMContentLoaded', () => {
|
||||
if (typeof pdfjsLib === 'undefined') return;
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = '/js/pdf.worker.min.mjs';
|
||||
|
||||
document.querySelectorAll('.pdf-viewer-container').forEach(async container => {
|
||||
const pdfUrl = container.getAttribute('data-pdf-url');
|
||||
if (!pdfUrl) return;
|
||||
|
||||
const pdf = await pdfjsLib.getDocument(pdfUrl).promise;
|
||||
let currentPage = 1;
|
||||
let scale = 1.5;
|
||||
let rotation = 0;
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
container.innerHTML = '';
|
||||
container.appendChild(canvas);
|
||||
|
||||
const controls = document.createElement('div');
|
||||
controls.className = 'pdf-controls';
|
||||
controls.innerHTML = `
|
||||
<button id="prev">⬅️</button>
|
||||
<button id="next">➡️</button>
|
||||
<button id="zoomIn">🔍+</button>
|
||||
<button id="zoomOut">🔍−</button>
|
||||
<button id="rotate">↻</button>
|
||||
<button id="download">⬇️</button>
|
||||
<button id="fullscreen">🔲</button>
|
||||
<button id="metadata">ℹ️</button>
|
||||
`;
|
||||
|
||||
container.appendChild(controls);
|
||||
|
||||
const renderPage = async (num) => {
|
||||
const page = await pdf.getPage(num);
|
||||
const viewport = page.getViewport({ scale, rotation });
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
await page.render({ canvasContext: ctx, viewport }).promise;
|
||||
};
|
||||
|
||||
const goToPage = (delta) => {
|
||||
const newPage = currentPage + delta;
|
||||
if (newPage >= 1 && newPage <= pdf.numPages) {
|
||||
currentPage = newPage;
|
||||
renderPage(currentPage);
|
||||
}
|
||||
};
|
||||
|
||||
renderPage(currentPage);
|
||||
|
||||
controls.querySelector('#prev').onclick = () => goToPage(-1);
|
||||
controls.querySelector('#next').onclick = () => goToPage(1);
|
||||
controls.querySelector('#zoomIn').onclick = () => { scale += 0.2; renderPage(currentPage); };
|
||||
controls.querySelector('#zoomOut').onclick = () => { scale = Math.max(0.5, scale - 0.2); renderPage(currentPage); };
|
||||
controls.querySelector('#rotate').onclick = () => { rotation = (rotation + 90) % 360; renderPage(currentPage); };
|
||||
controls.querySelector('#download').onclick = () => {
|
||||
const a = document.createElement('a');
|
||||
a.href = pdfUrl;
|
||||
a.download = 'document.pdf';
|
||||
a.click();
|
||||
};
|
||||
controls.querySelector('#fullscreen').onclick = () => {
|
||||
if (canvas.requestFullscreen) canvas.requestFullscreen();
|
||||
else if (canvas.webkitRequestFullscreen) canvas.webkitRequestFullscreen();
|
||||
else if (canvas.mozRequestFullScreen) canvas.mozRequestFullScreen();
|
||||
else if (canvas.msRequestFullscreen) canvas.msRequestFullscreen();
|
||||
};
|
||||
controls.querySelector('#metadata').onclick = async () => {
|
||||
const info = await pdf.getMetadata();
|
||||
alert(`Title: ${info.info.Title || 'N/A'}\nAuthor: ${info.info.Author || 'N/A'}\nPDF Producer: ${info.info.Producer || 'N/A'}`);
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -12,21 +12,16 @@ if (!fs.existsSync(configFilePath)) {
|
|||
"current": "blocks"
|
||||
},
|
||||
"modules": {
|
||||
"blogsMod": "on",
|
||||
"housingMod": "off",
|
||||
"karvanMod": "on",
|
||||
"popularMod": "on",
|
||||
"topicsMod": "on",
|
||||
"summariesMod": "on",
|
||||
"latestMod": "on",
|
||||
"threadsMod": "on",
|
||||
"multiverseMod": "on",
|
||||
"pollsMod": "on",
|
||||
"fediverseMod": "off",
|
||||
"invitesMod": "on",
|
||||
"walletMod": "on",
|
||||
"legacyMod": "on",
|
||||
"devMod": "on",
|
||||
"cipherMod": "on",
|
||||
"bookmarksMod": "on",
|
||||
"videosMod": "on",
|
||||
|
|
@ -56,7 +51,6 @@ if (!fs.existsSync(configFilePath)) {
|
|||
"jobsMod": "on",
|
||||
"shopsMod": "on",
|
||||
"projectsMod": "on",
|
||||
"industryMod": "on",
|
||||
"bankingMod": "on",
|
||||
"parliamentMod": "on",
|
||||
"courtsMod": "on",
|
||||
|
|
@ -84,6 +78,7 @@ if (!fs.existsSync(configFilePath)) {
|
|||
"limit": 2000
|
||||
},
|
||||
"homePage": "activity",
|
||||
"bottomBarPins": ["search", "inbox", "publish"],
|
||||
"language": "en",
|
||||
"wish": "whole",
|
||||
"pmVisibility": "whole",
|
||||
|
|
@ -100,10 +95,8 @@ const getConfig = () => {
|
|||
if (typeof cfg.ux === 'string') cfg.ux = { current: cfg.ux };
|
||||
if (!cfg.ux || typeof cfg.ux !== 'object') cfg.ux = { current: 'blocks' };
|
||||
if (cfg.ux.current === 'menus') cfg.ux.current = 'blocks';
|
||||
if (cfg.ux.current !== 'blocks' && cfg.ux.current !== 'ainav' && cfg.ux.current !== 'chats') cfg.ux.current = 'blocks';
|
||||
if (cfg.ux.current !== 'blocks' && cfg.ux.current !== 'ainav') cfg.ux.current = 'blocks';
|
||||
if (cfg.ux.current === 'ainav' && cfg.modules && cfg.modules.aiNavMod !== 'on') cfg.ux.current = 'blocks';
|
||||
if (cfg.ux.current === 'chats' && cfg.modules && cfg.modules.chatsMod !== 'on') cfg.ux.current = 'blocks';
|
||||
|
||||
let pins = Array.isArray(cfg.bottomBarPins)
|
||||
? cfg.bottomBarPins
|
||||
: (typeof cfg.bottomBarPin === 'string'
|
||||
|
|
|
|||
|
|
@ -1,27 +0,0 @@
|
|||
{
|
||||
"audios": [],
|
||||
"blogs": [],
|
||||
"bookmarks": [],
|
||||
"calendars": [],
|
||||
"chats": [],
|
||||
"documents": [],
|
||||
"events": [],
|
||||
"forum": [],
|
||||
"housing": [],
|
||||
"images": [],
|
||||
"jobs": [],
|
||||
"logs": [],
|
||||
"maps": [],
|
||||
"market": [],
|
||||
"pads": [],
|
||||
"polls": [],
|
||||
"projects": [],
|
||||
"reports": [],
|
||||
"shopProducts": [],
|
||||
"shops": [],
|
||||
"tasks": [],
|
||||
"torrents": [],
|
||||
"transfers": [],
|
||||
"videos": [],
|
||||
"votes": []
|
||||
}
|
||||
13
src/configs/media-favorites.json
Normal file
13
src/configs/media-favorites.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"audios": [],
|
||||
"bookmarks": [],
|
||||
"calendars": [],
|
||||
"chats": [],
|
||||
"documents": [],
|
||||
"images": [],
|
||||
"maps": [],
|
||||
"pads": [],
|
||||
"shops": [],
|
||||
"torrents": [],
|
||||
"videos": []
|
||||
}
|
||||
|
|
@ -6,14 +6,16 @@
|
|||
"current": "blocks"
|
||||
},
|
||||
"modules": {
|
||||
"blogsMod": "on",
|
||||
"pollsMod": "on",
|
||||
"popularMod": "on",
|
||||
"topicsMod": "on",
|
||||
"summariesMod": "on",
|
||||
"latestMod": "on",
|
||||
"threadsMod": "on",
|
||||
"multiverseMod": "on",
|
||||
"fediverseMod": "on",
|
||||
"invitesMod": "on",
|
||||
"walletMod": "off",
|
||||
"legacyMod": "on",
|
||||
"devMod": "off",
|
||||
"industryMod": "on",
|
||||
"legacyMod": "off",
|
||||
"cipherMod": "on",
|
||||
"bookmarksMod": "on",
|
||||
"videosMod": "on",
|
||||
|
|
@ -30,7 +32,6 @@
|
|||
"reportsMod": "on",
|
||||
"opinionsMod": "on",
|
||||
"padsMod": "on",
|
||||
"karvanMod": "on",
|
||||
"calendarsMod": "on",
|
||||
"transfersMod": "off",
|
||||
"feedMod": "on",
|
||||
|
|
@ -53,8 +54,7 @@
|
|||
"chatsMod": "on",
|
||||
"torrentsMod": "off",
|
||||
"graphosMod": "on",
|
||||
"larpMod": "on",
|
||||
"housingMod": "off"
|
||||
"larpMod": "on"
|
||||
},
|
||||
"wallet": {
|
||||
"url": "http://localhost:7474",
|
||||
|
|
@ -75,20 +75,5 @@
|
|||
"language": "en",
|
||||
"wish": "whole",
|
||||
"pmVisibility": "whole",
|
||||
"lanBroadcasting": true,
|
||||
"bottomBarPins": [
|
||||
"search",
|
||||
"inbox",
|
||||
"publish",
|
||||
"activity"
|
||||
],
|
||||
"rtc": {
|
||||
"stun": [],
|
||||
"turn": {
|
||||
"urls": [],
|
||||
"secret": "",
|
||||
"ttlSec": 3600
|
||||
},
|
||||
"relayOnly": false
|
||||
}
|
||||
"lanBroadcasting": true
|
||||
}
|
||||
|
|
@ -9,9 +9,6 @@ let _ecoValue = null;
|
|||
let _lastActivity = null;
|
||||
let _maxBlockBytes = 0;
|
||||
let _inhabitantCount = 0;
|
||||
let _mentionsCount = 0;
|
||||
let _bestMatch = null;
|
||||
let _dismissedSuggestion = null;
|
||||
module.exports = {
|
||||
getInboxCount: () => _inboxCount,
|
||||
setInboxCount: (n) => { _inboxCount = n; },
|
||||
|
|
@ -33,12 +30,6 @@ module.exports = {
|
|||
setLastActivity: (a) => { _lastActivity = a; },
|
||||
getMaxBlockBytes: () => _maxBlockBytes,
|
||||
setMaxBlockBytes: (n) => { if (Number(n) > _maxBlockBytes) _maxBlockBytes = Number(n); },
|
||||
getMentionsCount: () => _mentionsCount,
|
||||
setMentionsCount: (n) => { _mentionsCount = Math.max(0, Number(n) || 0); },
|
||||
getInhabitantCount: () => _inhabitantCount,
|
||||
setInhabitantCount: (n) => { _inhabitantCount = Math.max(0, Number(n) || 0); },
|
||||
getBestMatch: () => _bestMatch,
|
||||
setBestMatch: (m) => { _bestMatch = m || null; },
|
||||
getDismissedSuggestion: () => _dismissedSuggestion,
|
||||
setDismissedSuggestion: (href) => { _dismissedSuggestion = href || null; }
|
||||
setInhabitantCount: (n) => { _inhabitantCount = Math.max(0, Number(n) || 0); }
|
||||
};
|
||||
|
|
|
|||
|
|
@ -81,21 +81,14 @@ pins = json.loads(sys.argv[1])
|
|||
im = Image.open(sys.argv[2]).copy()
|
||||
draw = ImageDraw.Draw(im)
|
||||
|
||||
def draw_pin(draw, x, y, main):
|
||||
sw = 7 if main else 5
|
||||
sh = 34 if main else 26
|
||||
head = 11 if main else 8
|
||||
for p in pins:
|
||||
x, y, main = p['x'], p['y'], p.get('main', False)
|
||||
sw = 3 if main else 2
|
||||
sh = 18 if main else 13
|
||||
clr = '#e74c3c' if main else '#3498db'
|
||||
dark = '#c0392b' if main else '#2980b9'
|
||||
cy = y - sh
|
||||
draw.polygon([(x, y + 2), (x - sw - 1, cy + sw), (x + sw + 1, cy + sw)], fill='white')
|
||||
draw.polygon([(x, y), (x - sw, cy + sw), (x + sw, cy + sw)], fill=clr)
|
||||
draw.ellipse([x - head - 2, cy - head - 2, x + head + 2, cy + head + 2], fill='white')
|
||||
draw.ellipse([x - head, cy - head, x + head, cy + head], fill=clr, outline=dark, width=2)
|
||||
draw.ellipse([x - head // 3, cy - head // 3, x + head // 3, cy + head // 3], fill='white')
|
||||
|
||||
for p in pins:
|
||||
draw_pin(draw, p['x'], p['y'], p.get('main', False))
|
||||
draw.polygon([(x, y + 2), (x - sw, y - sh + sw * 2), (x + sw, y - sh + sw * 2)], fill=clr)
|
||||
draw.ellipse([x - sw - 1, y - sh - sw, x + sw + 1, y - sh + sw], fill=dark, outline='white', width=1)
|
||||
|
||||
im.save(sys.argv[3], optimize=True)
|
||||
`;
|
||||
|
|
@ -200,23 +193,15 @@ cropped = canvas.crop((int(crop_x), int(crop_y), int(crop_x + vw), int(crop_y +
|
|||
result = cropped.resize((1024, 1024), Image.LANCZOS)
|
||||
|
||||
draw = ImageDraw.Draw(result)
|
||||
def draw_pin(draw, x, y, main):
|
||||
sw = 7 if main else 5
|
||||
sh = 34 if main else 26
|
||||
head = 11 if main else 8
|
||||
clr = '#e74c3c' if main else '#3498db'
|
||||
dark = '#c0392b' if main else '#2980b9'
|
||||
cy = y - sh
|
||||
draw.polygon([(x, y + 2), (x - sw - 1, cy + sw), (x + sw + 1, cy + sw)], fill='white')
|
||||
draw.polygon([(x, y), (x - sw, cy + sw), (x + sw, cy + sw)], fill=clr)
|
||||
draw.ellipse([x - head - 2, cy - head - 2, x + head + 2, cy + head + 2], fill='white')
|
||||
draw.ellipse([x - head, cy - head, x + head, cy + head], fill=clr, outline=dark, width=2)
|
||||
draw.ellipse([x - head // 3, cy - head // 3, x + head // 3, cy + head // 3], fill='white')
|
||||
|
||||
for p in pins:
|
||||
px, py, main = p['px'], p['py'], p.get('main', False)
|
||||
if -40 <= px <= 1064 and -40 <= py <= 1064:
|
||||
draw_pin(draw, px, py, main)
|
||||
if -20 <= px <= 1044 and -20 <= py <= 1044:
|
||||
sw = 3 if main else 2
|
||||
sh = 18 if main else 13
|
||||
clr = '#e74c3c' if main else '#3498db'
|
||||
dark = '#c0392b' if main else '#2980b9'
|
||||
draw.polygon([(px, py + 2), (px - sw, py - sh + sw * 2), (px + sw, py - sh + sw * 2)], fill=clr)
|
||||
draw.ellipse([px - sw - 1, py - sh - sw, px + sw + 1, py - sh + sw], fill=dark, outline='white', width=1)
|
||||
|
||||
result.save(out_file, optimize=True)
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ const HIDDEN_ENVELOPE_TYPES = new Set([
|
|||
'courts-key'
|
||||
]);
|
||||
|
||||
module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }) => {
|
||||
module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel }) => {
|
||||
let ssb;
|
||||
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb };
|
||||
|
||||
|
|
@ -143,74 +143,6 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }
|
|||
return t.length > max ? t.slice(0, max - 1) + '…' : t;
|
||||
};
|
||||
|
||||
// Builds self-contained chatThread items (title + recent messages embedded) for
|
||||
// general (non-tribe) OPEN chats that have activity. Emitted as one item per chat
|
||||
// with ts = latest message, so it survives the recent/all/mine filters intact.
|
||||
const CHAT_THREAD_LIMIT = 6;
|
||||
const buildChatThreads = async (ssbClient, idToAction, rootOf) => {
|
||||
const replacedChatIds = new Set();
|
||||
for (const a of idToAction.values()) {
|
||||
if (a.type !== 'chat') continue;
|
||||
const rep = a.content && a.content.replaces;
|
||||
if (rep) replacedChatIds.add(rep);
|
||||
}
|
||||
const stateByRoot = new Map();
|
||||
for (const a of idToAction.values()) {
|
||||
if (a.type !== 'chat') continue;
|
||||
const root = rootOf(a.id);
|
||||
const isTip = !replacedChatIds.has(a.id);
|
||||
const prev = stateByRoot.get(root);
|
||||
// Resolve the chain tip by replaces (a non-replaced action wins), breaking ts
|
||||
// ties correctly — the mock and real SSB can stamp equal timestamps.
|
||||
if (prev && !((isTip && !prev.isTip) || (isTip === prev.isTip && (a.ts || 0) >= prev.ts))) continue;
|
||||
const cc = a.content || {};
|
||||
stateByRoot.set(root, { ts: a.ts || 0, isTip, status: String(cc.status || '').toUpperCase(), tribeId: cc.tribeId || null, title: String(cc.title || ''), description: String(cc.description || ''), members: Array.isArray(cc.members) ? cc.members.length : 0 });
|
||||
}
|
||||
const msgsByRoot = new Map();
|
||||
for (const a of idToAction.values()) {
|
||||
if (a.type !== 'chatMessage') continue;
|
||||
const c = a.content || {};
|
||||
if (c.tribeId || c.encryptedText) continue;
|
||||
const root = c.chatId;
|
||||
if (!root || typeof c.text !== 'string' || !c.text) continue;
|
||||
if (!msgsByRoot.has(root)) msgsByRoot.set(root, []);
|
||||
msgsByRoot.get(root).push(a);
|
||||
}
|
||||
for (const root of msgsByRoot.keys()) {
|
||||
if (stateByRoot.has(root)) continue;
|
||||
const val = await getMsg(ssbClient, root);
|
||||
const cc = val && val.content;
|
||||
if (cc && typeof cc === 'object' && cc.type === 'chat') {
|
||||
stateByRoot.set(root, { ts: 0, status: String(cc.status || '').toUpperCase(), tribeId: cc.tribeId || null, title: String(cc.title || ''), description: String(cc.description || ''), members: Array.isArray(cc.members) ? cc.members.length : 0 });
|
||||
} else {
|
||||
stateByRoot.set(root, null);
|
||||
}
|
||||
}
|
||||
const items = [];
|
||||
for (const [root, msgs] of msgsByRoot.entries()) {
|
||||
const info = stateByRoot.get(root);
|
||||
if (!info || info.tribeId || info.status !== 'OPEN') continue;
|
||||
const asc = msgs.slice().sort((x, y) => (x.ts || 0) - (y.ts || 0));
|
||||
const last = asc[asc.length - 1];
|
||||
items.push({
|
||||
id: `chatthread:${root}`,
|
||||
type: 'chatThread',
|
||||
author: last.author,
|
||||
ts: last.ts || 0,
|
||||
content: {
|
||||
type: 'chatThread',
|
||||
chatRoot: root,
|
||||
title: info.title || '',
|
||||
description: info.description || '',
|
||||
members: info.members || 0,
|
||||
messageCount: asc.length,
|
||||
replies: asc.slice(-CHAT_THREAD_LIMIT).map(m => ({ id: m.id, author: m.author, ts: m.ts || 0, text: (m.content && m.content.text) || '' }))
|
||||
}
|
||||
});
|
||||
}
|
||||
return items;
|
||||
};
|
||||
|
||||
return {
|
||||
invalidateCache() {
|
||||
_feedCache = null;
|
||||
|
|
@ -253,21 +185,6 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }
|
|||
const idToAction = new Map();
|
||||
const rawById = new Map();
|
||||
const voteTally = buildVoteTally(results);
|
||||
const pollTally = new Map();
|
||||
for (const m of results) {
|
||||
const c = m && m.value && m.value.content;
|
||||
if (!c || c.type !== 'pollVote' || typeof c.target !== 'string') continue;
|
||||
if (!Array.isArray(c.choices)) continue;
|
||||
const entry = pollTally.get(c.target) || { counts: {}, voters: new Map() };
|
||||
entry.voters.set(m.value.author, { ts: m.value.timestamp || 0, choices: c.choices });
|
||||
pollTally.set(c.target, entry);
|
||||
}
|
||||
for (const entry of pollTally.values()) {
|
||||
entry.counts = {};
|
||||
for (const v of entry.voters.values()) {
|
||||
for (const choice of v.choices) entry.counts[choice] = (entry.counts[choice] || 0) + 1;
|
||||
}
|
||||
}
|
||||
const fpIdx = tribeCrypto ? tribeCrypto.buildFingerprintIndex() : null;
|
||||
const accessibleTribeIds = await buildAccessibleTribeIds();
|
||||
|
||||
|
|
@ -339,7 +256,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }
|
|||
const target = String(c.target || '');
|
||||
const author = v.author;
|
||||
if (!target || !author) continue;
|
||||
const key = `${target}\u0000${author}`;
|
||||
const key = `${target} | ||||