Compare commits
No commits in common. "PRUEBAS" and "UX_OASIS" have entirely different histories.
186 changed files with 15733 additions and 20493 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
|
|
||||||
410
README.md
410
README.md
|
|
@ -1,184 +1,336 @@
|
||||||
# OASIS_MOBILE — rama de desarrollo experimental
|
# Oasis Mobile 0.9.1
|
||||||
|
|
||||||
> ## ⚠ ESTO NO ES OASIS OFICIAL
|
Mejoras de **navegación móvil** para Android (WebView + nodejs-mobile) sobre Oasis 0.9.0:
|
||||||
>
|
**topbar** (logo→inicio, avatar→perfil), **navegación por hexágonos** y **barra inferior**
|
||||||
> Esta es una **rama de desarrollo experimental** mantenida por el hacklab, **no
|
personalizable. Debajo, el README original de Oasis.
|
||||||
> 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.**
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Lo que añade
|
# Oasis
|
||||||
|
|
||||||
| | Oasis oficial | Esta rama |
|
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.
|
||||||
| **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 |
|
|
||||||
|
|
||||||
Los nombres de las categorías, los módulos y el resto de la aplicación son los de
|

|
||||||
epsylon, sin tocar.
|
|
||||||
|
|
||||||
---
|
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.
|
||||||
|
|
||||||
## Qué es Oasis, en cuatro dibujos
|
----------
|
||||||
|
|
||||||
Una red social **sin servidor**: publicas en tu propio equipo y los demás se traen una
|
## Frontend:
|
||||||
copia. No hay cuenta, no hay contraseña y nadie puede borrarte.
|
|
||||||
|
|
||||||

|
Main features of the Oasis interface are:
|
||||||
|
|
||||||
Los mensajes viajan **de vecino en vecino**, así que llegan a gente con la que nunca te
|
+ Data manipulation is not permitted due to the use of BLOCKCHAIN technology.
|
||||||
has conectado. Por eso funciona incluso sin internet, entre dos móviles en la misma wifi.
|
+ 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.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
Un **pub** es solo un nodo que no se apaga nunca. No es un servidor con tus datos: no
|
But it has others features that are also really interesting, for example:
|
||||||
guarda tu identidad, no decide qué ves y no puede borrarte.
|
|
||||||
|
|
||||||

|
+ Support for multiple languages.
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||

|
+ Modularity to set your own environment.
|
||||||
|
|
||||||
> 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)**.
|
|
||||||
|
|
||||||
---
|
+ Support for multiple themes (including Mobile Theme).
|
||||||
|
|
||||||
## Karvan: salas que se borran solas
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||

|
And some other nice modules, like for example:
|
||||||
|
|
||||||
Lo que se dice dentro **no toca el disco**, no entra en tu registro y no se replica a
|
+ A complex Reddit-styled forum system.
|
||||||
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>
|
|
||||||
|
|
||||||
---
|
+ Or a collaborative -offline- maps system.
|
||||||
|
|
||||||
## Videollamadas sin terceros
|

|
||||||
|
|
||||||

|
And much more, that we invite you to discover by yourself ;-)
|
||||||
|
|
||||||
El servidor solo sirve para que os encontréis: **la voz y el vídeo van directos** entre
|

|
||||||
los participantes, cifrados de extremo a extremo. Sin STUN de Google.
|
|
||||||
|
|
||||||
<p align="center">
|
----------
|
||||||
<img src="https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/raw/img/12-permiso-camara.png" width="270">
|
|
||||||
<img src="https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki/raw/img/15-llamada-controles.png" width="270">
|
|
||||||
</p>
|
|
||||||
|
|
||||||
Esto **no es posible con la APK oficial**: no declara los permisos de cámara y su
|
## Modules:
|
||||||
navegador interno los deniega. Por eso esta rama trae su propio envoltorio Android.
|
|
||||||
|
|
||||||
---
|
Oasis is TRULY MODULAR. Here's a list of what comes deployed with the "core".
|
||||||
|
|
||||||
## La interfaz
|
+ 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).
|
||||||
|
|
||||||

|
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/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>
|
|
||||||
|
|
||||||
Casi cincuenta módulos no caben en la pantalla de un teléfono: se reparten en un panal
|
## C-AI (collective artificial intelligence)
|
||||||
de categorías, dos filtros arriba y una barra de cuatro atajos que eliges tú.
|
|
||||||
|
|
||||||
---
|
Oasis contains its own AI model called "42".
|
||||||
|
|
||||||
## Empezar desde cero
|
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.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
| | |
|
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.
|
||||||
|---|---|
|
|
||||||
| **[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 |
|
|
||||||
|
|
||||||
---
|
+ https://wiki.solarnethub.com/socialnet/ai
|
||||||
|
|
||||||
## Instalar
|
----------
|
||||||
|
|
||||||
La APK **no está en ninguna tienda**: se descarga de las *releases* de este repositorio
|
## Parliament
|
||||||
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
|
Oasis contains its own Parliament (Government system).
|
||||||
descomprime el servidor.
|
|
||||||
|
|
||||||
> **Verifica la huella de la firma** antes de instalar una APK bajada de un servidor
|

|
||||||
> propio: `apksigner verify --print-certs`
|
|
||||||
|
|
||||||
### Compilar
|
## Courts
|
||||||
|
|
||||||
```sh
|
Oasis contains its own Courts (Justice system).
|
||||||
cd android
|
|
||||||
./scripts/prepare.sh ../../oasis_mobile # librerías nativas y backend, la primera vez
|
|
||||||
./scripts/release.sh # compila, verifica y calcula la huella
|
|
||||||
```
|
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
---
|
## ECOin
|
||||||
|
|
||||||
## Documentación
|
Oasis contains its own cryptocurrency. With it, you can exchange items and services in the marketplace.
|
||||||
|
|
||||||
Todo lo de arriba, en detalle, está en la
|

|
||||||
**[wiki](https://gitea.laenre.net/hacklab/OASIS_MOBILE/wiki)**:
|
|
||||||
|
|
||||||
| | |
|
You can also receive a -Universal Basic Income- if you contribute to the Tribes and their coordinated actions.
|
||||||
|---|---|
|
|
||||||
| [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 |
|
|
||||||
|
|
||||||
---
|
+ https://ecoin.03c8.net
|
||||||
|
|
||||||
## El proyecto original
|
## Banking
|
||||||
|
|
||||||
Esta rama existe **gracias a** y **por encima de** el trabajo de epsylon. El código, la
|
Oasis contains its own UBI (Universal Basic Income), distributed weekly using ECOin, and calculated by our AI through positive and efficient participation and trust.
|
||||||
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
|
|
||||||
|
|
||||||
## Licencia
|
----------
|
||||||
|
|
||||||
AGPL-3.0, la misma que el proyecto original.
|
## Carbon Footprinting
|
||||||
|
|
||||||
|
Oasis contains its own carbon footprint meter.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
All transmissions, stored files, and interactions are measured to understand the environmental impact of the network.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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")
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@krakenslab/oasis",
|
"name": "@krakenslab/oasis",
|
||||||
"version": "0.9.5",
|
"version": "0.9.1",
|
||||||
"description": "Oasis - Social Networking Utopia",
|
"description": "Oasis - Social Networking Utopia",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"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) {
|
} 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) => {
|
const stripImageMetadata = async (buffer) => {
|
||||||
// Con sharp se prefiere su camino: ademas de quitar metadatos, aplica la
|
if (typeof sharp !== "function") return buffer;
|
||||||
// orientacion girando los pixeles de verdad.
|
|
||||||
if (typeof sharp === "function") {
|
|
||||||
try {
|
try {
|
||||||
return await sharp(buffer).rotate().toBuffer();
|
return await sharp(buffer).rotate().toBuffer();
|
||||||
} catch {
|
} catch {
|
||||||
return stripMetadataPure(buffer);
|
return buffer;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return stripMetadataPure(buffer);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const PDF_METADATA_KEYS = [
|
const PDF_METADATA_KEYS = [
|
||||||
|
|
@ -209,30 +58,25 @@ class FileTooLargeError extends Error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const OGG_VIDEO_MARKERS = ['theora', 'VP80', 'OggDS', 'Dirac'];
|
const handleBlobUpload = async function (ctx, fileFieldName) {
|
||||||
const OGG_AUDIO_MARKERS = ['vorbis', 'OpusHead', 'FLAC', 'Speex'];
|
if (!ctx.request.files || !ctx.request.files[fileFieldName]) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const oggMime = (buffer) => {
|
const blobUpload = ctx.request.files[fileFieldName];
|
||||||
const head = buffer.slice(0, 65536);
|
if (!blobUpload) return null;
|
||||||
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;
|
|
||||||
|
|
||||||
let data = await promisesFs.readFile(blobUpload.filepath);
|
let data = await promisesFs.readFile(blobUpload.filepath);
|
||||||
if (data.length === 0) return null;
|
if (data.length === 0) return null;
|
||||||
|
|
||||||
if (data.length > MAX_BLOB_SIZE) {
|
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 = {
|
const EXTENSION_MIME_MAP = {
|
||||||
'.mp4': 'video/mp4', '.webm': 'video/webm', '.ogg': 'video/ogg',
|
'.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',
|
'.ogv': 'video/ogg', '.avi': 'video/x-msvideo', '.mov': 'video/quicktime',
|
||||||
'.mp3': 'audio/mpeg', '.wav': 'audio/wav',
|
'.mkv': 'video/x-matroska', '.mp3': 'audio/mpeg', '.wav': 'audio/wav',
|
||||||
'.flac': 'audio/flac', '.aac': 'audio/aac', '.opus': 'audio/opus',
|
'.flac': 'audio/flac', '.aac': 'audio/aac', '.opus': 'audio/opus',
|
||||||
'.pdf': 'application/pdf', '.png': 'image/png', '.jpg': 'image/jpeg',
|
'.pdf': 'application/pdf', '.png': 'image/png', '.jpg': 'image/jpeg',
|
||||||
'.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp',
|
'.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp',
|
||||||
|
|
@ -249,12 +93,9 @@ const storeUploadedFile = async function (blobUpload) {
|
||||||
blob.mime = null;
|
blob.mime = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (blob.mime === 'application/ogg') blob.mime = oggMime(data);
|
if (!blob.mime && blob.name) {
|
||||||
|
|
||||||
const GENERIC_MIMES = new Set(['application/octet-stream', 'video/x-matroska']);
|
|
||||||
if ((!blob.mime || GENERIC_MIMES.has(blob.mime)) && blob.name) {
|
|
||||||
const ext = (blob.name.match(/\.[^.]+$/) || [''])[0].toLowerCase();
|
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) {
|
if (!blob.mime) {
|
||||||
|
|
@ -285,25 +126,6 @@ const storeUploadedFile = async function (blobUpload) {
|
||||||
return `\n[${blob.name}](${blob.id})`;
|
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) {
|
function waitForBlob(ssbClient, blobId, timeoutMs = 8000) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
let done = false;
|
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 isSvg = mime === 'image/svg+xml';
|
||||||
const qName = ctx.query.name ? String(ctx.query.name).replace(/["\r\n\\]/g, '').trim() : '';
|
const qName = ctx.query.name ? String(ctx.query.name).replace(/["\r\n\\]/g, '').trim() : '';
|
||||||
const safeRaw = String(raw).replace(/["\r\n\\]/g, '');
|
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,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 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;
|
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 catalogId = addObj(null);
|
||||||
const pagesId = addObj(null);
|
const pagesId = addObj(null);
|
||||||
const fontId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier /Encoding /WinAnsiEncoding >>');
|
const fontId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier >>');
|
||||||
const fontBoldId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier-Bold /Encoding /WinAnsiEncoding >>');
|
const fontBoldId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier-Bold >>');
|
||||||
|
|
||||||
let logoXObjId = null;
|
let logoXObjId = null;
|
||||||
if (logoBuf && logoDims) {
|
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`);
|
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 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);
|
const cid = addObj(stream);
|
||||||
contentIds.push(cid);
|
contentIds.push(cid);
|
||||||
const pid = addObj(null);
|
const pid = addObj(null);
|
||||||
|
|
|
||||||
|
|
@ -1,40 +1,22 @@
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
|
|
||||||
const TEMPLATE = path.join(__dirname, "../configs/content_favorites.json");
|
const FILE = path.join(__dirname, "../configs/media-favorites.json");
|
||||||
|
|
||||||
const storePath = () => {
|
|
||||||
try {
|
|
||||||
return require("../server/ssb_config").statePath("content_favorites.json") || TEMPLATE;
|
|
||||||
} catch (_) { return TEMPLATE; }
|
|
||||||
};
|
|
||||||
|
|
||||||
const DEFAULT = {
|
const DEFAULT = {
|
||||||
audios: [],
|
audios: [],
|
||||||
blogs: [],
|
|
||||||
bookmarks: [],
|
bookmarks: [],
|
||||||
calendars: [],
|
calendars: [],
|
||||||
chats: [],
|
chats: [],
|
||||||
documents: [],
|
documents: [],
|
||||||
events: [],
|
|
||||||
forum: [],
|
|
||||||
housing: [],
|
|
||||||
images: [],
|
images: [],
|
||||||
jobs: [],
|
|
||||||
logs: [],
|
|
||||||
maps: [],
|
maps: [],
|
||||||
pads: [],
|
pads: [],
|
||||||
polls: [],
|
|
||||||
projects: [],
|
|
||||||
reports: [],
|
|
||||||
shops: [],
|
shops: [],
|
||||||
market: [],
|
market: [],
|
||||||
shopProducts: [],
|
shopProducts: [],
|
||||||
tasks: [],
|
|
||||||
torrents: [],
|
torrents: [],
|
||||||
transfers: [],
|
videos: []
|
||||||
videos: [],
|
|
||||||
votes: []
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const safeArr = (v) => (Array.isArray(v) ? v : []);
|
const safeArr = (v) => (Array.isArray(v) ? v : []);
|
||||||
|
|
@ -57,32 +39,32 @@ const normalize = (raw) => {
|
||||||
|
|
||||||
const ensureFile = async () => {
|
const ensureFile = async () => {
|
||||||
try {
|
try {
|
||||||
await fs.promises.access(storePath());
|
await fs.promises.access(FILE);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const dir = path.dirname(storePath());
|
const dir = path.dirname(FILE);
|
||||||
await fs.promises.mkdir(dir, { recursive: true });
|
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 () => {
|
const readAll = async () => {
|
||||||
await ensureFile();
|
await ensureFile();
|
||||||
try {
|
try {
|
||||||
const txt = await fs.promises.readFile(storePath(), "utf8");
|
const txt = await fs.promises.readFile(FILE, "utf8");
|
||||||
return normalize(JSON.parse(txt || "{}"));
|
return normalize(JSON.parse(txt || "{}"));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const fixed = normalize(DEFAULT);
|
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;
|
return fixed;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const writeAll = async (data) => {
|
const writeAll = async (data) => {
|
||||||
const dir = path.dirname(storePath());
|
const dir = path.dirname(FILE);
|
||||||
const tmp = path.join(dir, `.content_favorites.${process.pid}.${Date.now()}.tmp`);
|
const tmp = path.join(dir, `.media-favorites.${process.pid}.${Date.now()}.tmp`);
|
||||||
const txt = JSON.stringify(normalize(data), null, 2);
|
const txt = JSON.stringify(normalize(data), null, 2);
|
||||||
await fs.promises.writeFile(tmp, txt, "utf8");
|
await fs.promises.writeFile(tmp, txt, "utf8");
|
||||||
await fs.promises.rename(tmp, storePath());
|
await fs.promises.rename(tmp, FILE);
|
||||||
};
|
};
|
||||||
|
|
||||||
const assertKind = (kind) => {
|
const assertKind = (kind) => {
|
||||||
|
|
@ -106,15 +88,6 @@ exports.getFavoriteSet = async (kind) => {
|
||||||
return new Set(safeArr(data[k]).map(String));
|
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) =>
|
exports.addFavorite = async (kind, id) =>
|
||||||
withLock(async () => {
|
withLock(async () => {
|
||||||
const k = assertKind(kind);
|
const k = assertKind(kind);
|
||||||
|
|
@ -150,4 +123,4 @@ exports.removeFromFavorites = async (...args) => {
|
||||||
const id = idFromArgs(args);
|
const id = idFromArgs(args);
|
||||||
return exports.removeFavorite(kind, id);
|
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,7 +1,3 @@
|
||||||
const fs = require('fs');
|
|
||||||
|
|
||||||
const ANNOUNCE_SEEN_FILE = 'oasis-political-seen';
|
|
||||||
|
|
||||||
const isMutual = (relationship) => !!(relationship && relationship.following && relationship.followsMe);
|
const isMutual = (relationship) => !!(relationship && relationship.following && relationship.followsMe);
|
||||||
|
|
||||||
const isRecipientAllowed = ({ pmVisibility, viewerId, recipientId, relationship } = {}) => {
|
const isRecipientAllowed = ({ pmVisibility, viewerId, recipientId, relationship } = {}) => {
|
||||||
|
|
@ -10,41 +6,4 @@ const isRecipientAllowed = ({ pmVisibility, viewerId, recipientId, relationship
|
||||||
return isMutual(relationship);
|
return isMutual(relationship);
|
||||||
};
|
};
|
||||||
|
|
||||||
const announceSeenPath = () => {
|
module.exports = { isRecipientAllowed, isMutual };
|
||||||
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 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 wrap = (txt, max = 82) => {
|
||||||
const out = [];
|
const out = [];
|
||||||
|
|
@ -131,8 +131,8 @@ function buildSmartContractPdf({ transfer, block, viewerId }) {
|
||||||
|
|
||||||
const catalogId = addObj(null);
|
const catalogId = addObj(null);
|
||||||
const pagesId = addObj(null);
|
const pagesId = addObj(null);
|
||||||
const fontId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier /Encoding /WinAnsiEncoding >>');
|
const fontId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier >>');
|
||||||
const fontBoldId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier-Bold /Encoding /WinAnsiEncoding >>');
|
const fontBoldId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier-Bold >>');
|
||||||
let logoXObjId = null;
|
let logoXObjId = null;
|
||||||
if (logoBuf && logoDims) {
|
if (logoBuf && logoDims) {
|
||||||
const cs = logoDims.c === 1 ? '/DeviceGray' : '/DeviceRGB';
|
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`);
|
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 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);
|
const cid = addObj(stream);
|
||||||
contentIds.push(cid);
|
contentIds.push(cid);
|
||||||
const pid = addObj(null);
|
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,
|
.search-input,
|
||||||
|
.feed-search-input,
|
||||||
.activity-search-input {
|
.activity-search-input {
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
max-width: 100% !important;
|
max-width: 100% !important;
|
||||||
|
|
@ -509,28 +510,28 @@ h3 { font-size: 1em !important; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.forum-comment {
|
.forum-comment {
|
||||||
margin-left: 0;
|
margin-left: 0 !important;
|
||||||
padding-left: 8px;
|
padding-left: 8px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.comment-body-row {
|
.comment-body-row {
|
||||||
flex-direction: column;
|
flex-direction: column !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.comment-vote-col,
|
.comment-vote-col,
|
||||||
.comment-text-col {
|
.comment-text-col {
|
||||||
width: 100%;
|
width: 100% !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.forum-score-box,
|
.forum-score-box,
|
||||||
.forum-score-form {
|
.forum-score-form {
|
||||||
flex-direction: row;
|
flex-direction: row !important;
|
||||||
justify-content: center;
|
justify-content: center !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.new-message-form textarea,
|
.new-message-form textarea,
|
||||||
.comment-textarea {
|
.comment-textarea {
|
||||||
width: 100%;
|
width: 100% !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
[style*="grid-template-columns: repeat(6"] {
|
[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-technical-block .block-info-table td[data-label=""]::before { content: none !important; }
|
||||||
.peers-list .block-info-table td .peer-key,
|
.peers-list .block-info-table td .peer-key,
|
||||||
.peers-technical-block .block-info-table td .peer-key { text-align: right !important; }
|
.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; }
|
|
||||||
|
|
@ -956,23 +956,23 @@ button, input[type="submit"], input[type="button"],
|
||||||
El problema era una cadena de flex-stretch (comment-vote-col flex → estiraba la caja a 135px).
|
El problema era una cadena de flex-stretch (comment-vote-col flex → estiraba la caja a 135px).
|
||||||
Rompemos el estiramiento con height:auto + inline-flex en toda la cadena, con !important
|
Rompemos el estiramiento con height:auto + inline-flex en toda la cadena, con !important
|
||||||
para vencer el min-height:44px de .score-btn (762) y el !important de epsylon en mobile.css. --- */
|
para vencer el min-height:44px de .score-btn (762) y el !important de epsylon en mobile.css. --- */
|
||||||
.comment-vote-col { display: block; height: auto; min-height: 0; width: auto; }
|
.comment-vote-col { display: block !important; height: auto !important; min-height: 0 !important; width: auto !important; }
|
||||||
.forum-score-box {
|
.forum-score-box {
|
||||||
display: inline-flex; height: auto; min-height: 0;
|
display: inline-flex !important; height: auto !important; min-height: 0 !important;
|
||||||
min-width: 0; width: auto;
|
min-width: 0 !important; width: auto !important;
|
||||||
padding: 4px 8px; border-radius: 10px;
|
padding: 4px 8px !important; border-radius: 10px !important;
|
||||||
flex-direction: row; flex-wrap: nowrap; gap: 12px; align-items: center;
|
flex-direction: row !important; flex-wrap: nowrap !important; gap: 12px !important; align-items: center !important;
|
||||||
}
|
}
|
||||||
.forum-score-box .forum-score-form {
|
.forum-score-form {
|
||||||
display: inline-flex; height: auto; width: auto;
|
display: inline-flex !important; height: auto !important; width: auto !important;
|
||||||
flex-direction: row; flex-wrap: nowrap; gap: 12px; align-items: center;
|
flex-direction: row !important; flex-wrap: nowrap !important; gap: 12px !important; align-items: center !important;
|
||||||
}
|
}
|
||||||
.score-btn {
|
.score-btn {
|
||||||
flex: 0 0 auto !important;
|
flex: 0 0 auto !important;
|
||||||
width: 30px !important; height: 30px !important; min-width: 30px !important; min-height: 30px !important;
|
width: 30px !important; height: 30px !important; min-width: 30px !important; min-height: 30px !important;
|
||||||
font-size: 0.95em !important; border-radius: 8px !important; padding: 0 !important;
|
font-size: 0.95em !important; border-radius: 8px !important; padding: 0 !important;
|
||||||
}
|
}
|
||||||
.score-total { flex: 0 0 auto; font-size: 1.15em; min-height: 0; height: auto; margin: 0; white-space: nowrap; }
|
.score-total { flex: 0 0 auto !important; font-size: 1.15em !important; min-height: 0 !important; height: auto !important; margin: 0 !important; white-space: nowrap; }
|
||||||
.comment-votes .votes-count { font-size: 11px; }
|
.comment-votes .votes-count { font-size: 11px; }
|
||||||
|
|
||||||
/* Aplanar el comentario: quitar las cajas/recuadros internos para que sea UN bloque limpio
|
/* Aplanar el comentario: quitar las cajas/recuadros internos para que sea UN bloque limpio
|
||||||
|
|
@ -982,27 +982,25 @@ button, input[type="submit"], input[type="button"],
|
||||||
.forum-comment .comment-vote-col,
|
.forum-comment .comment-vote-col,
|
||||||
.forum-comment .comment-text-col,
|
.forum-comment .comment-text-col,
|
||||||
.forum-comment .new-reply {
|
.forum-comment .new-reply {
|
||||||
background: transparent; border: none; box-shadow: none;
|
background: transparent !important; border: none !important; box-shadow: none !important;
|
||||||
padding: 0; margin: 6px 0 0; border-radius: 0;
|
padding: 0 !important; margin: 6px 0 0 !important; border-radius: 0 !important;
|
||||||
}
|
}
|
||||||
.forum-comment .comment-header { margin-top: 0; display: flex; flex-wrap: wrap; align-items: center; gap: 8px; font-size: 12px; }
|
.forum-comment .comment-header { margin-top: 0 !important; display: flex; flex-wrap: wrap; align-items: center; gap: 8px; font-size: 12px; }
|
||||||
/* la barra de votos de abajo ya muestra el score → ocultar el ▲:0 ▼:0 redundante de la cabecera */
|
/* la barra de votos de abajo ya muestra el score → ocultar el ▲:0 ▼:0 redundante de la cabecera */
|
||||||
.forum-comment .comment-header .comment-votes { display: none; }
|
.forum-comment .comment-header .comment-votes { display: none !important; }
|
||||||
.forum-comment .comment-text-col p { margin: 4px 0; }
|
.forum-comment .comment-text-col p { margin: 4px 0 !important; }
|
||||||
.forum-comment .comment-text-col > div { background: transparent; border: none; padding: 0; }
|
.forum-comment .comment-text-col > div { background: transparent !important; border: none !important; padding: 0 !important; }
|
||||||
.forum-comment { padding: 10px 12px; margin-bottom: 8px; }
|
.forum-comment { padding: 10px 12px !important; margin-bottom: 8px !important; }
|
||||||
/* barra de votos alineada a la izquierda, no centrada, para que ocupe poco.
|
/* barra de votos alineada a la izquierda, no centrada, para que ocupe poco */
|
||||||
`display:flex !important` es DE PELEA: vence a mobile.css:164 `.comment-body-row{display:block!important}` */
|
.forum-comment .comment-body-row { display: flex !important; flex-direction: column !important; align-items: flex-start !important; }
|
||||||
.forum-comment .comment-body-row { display: flex !important; flex-direction: column; align-items: flex-start; }
|
|
||||||
|
|
||||||
/* --- Reply/compose del foro estilo Signal: fila [textarea que crece] + [botón REDONDO con icono ↩] --- */
|
/* --- Reply/compose del foro estilo Signal: fila [textarea que crece] + [botón REDONDO con icono ↩] --- */
|
||||||
/* especificidad extra (.main-column …) para ganar a nuestra propia regla `.main-column form{display:block}` sin !important */
|
.new-reply .comment-form,
|
||||||
.main-column .new-reply .comment-form,
|
.new-message-form { display: flex !important; flex-direction: row !important; align-items: flex-end !important; gap: 8px !important; margin: 6px 0 0 !important; }
|
||||||
.main-column .new-message-form { display: flex; flex-direction: row; align-items: flex-end; gap: 8px; margin: 6px 0 0; }
|
.new-message-form br { display: none !important; }
|
||||||
.main-column .new-message-form br { display: none; }
|
.comment-textarea, .new-message-form textarea {
|
||||||
.main-column .comment-textarea, .main-column .new-message-form textarea {
|
flex: 1 1 auto !important; box-sizing: border-box; border-radius: 14px;
|
||||||
flex: 1 1 auto; box-sizing: border-box; border-radius: 14px;
|
padding: 9px 14px; min-height: 42px; resize: none; font-size: 14px; margin: 0 !important;
|
||||||
padding: 9px 14px; min-height: 42px; resize: none; font-size: 14px; margin: 0;
|
|
||||||
}
|
}
|
||||||
.forum-send-btn {
|
.forum-send-btn {
|
||||||
flex: 0 0 auto !important;
|
flex: 0 0 auto !important;
|
||||||
|
|
@ -1015,12 +1013,12 @@ button, input[type="submit"], input[type="button"],
|
||||||
.new-reply { margin-top: 6px; }
|
.new-reply { margin-top: 6px; }
|
||||||
|
|
||||||
/* --- Fase 2: hilos anidados. mobile.css fuerza margin-left:0 !important → necesitamos !important para ganar --- */
|
/* --- Fase 2: hilos anidados. mobile.css fuerza margin-left:0 !important → necesitamos !important para ganar --- */
|
||||||
.forum-comment.level-1 { margin-left: 10px; border-left: 2px solid #333; }
|
.forum-comment.level-1 { margin-left: 10px !important; border-left: 2px solid #333; }
|
||||||
.forum-comment.level-2 { margin-left: 20px; border-left: 2px solid #3a3a3a; }
|
.forum-comment.level-2 { margin-left: 20px !important; border-left: 2px solid #3a3a3a; }
|
||||||
.forum-comment.level-3 { margin-left: 28px; border-left: 2px solid #404040; }
|
.forum-comment.level-3 { margin-left: 28px !important; border-left: 2px solid #404040; }
|
||||||
.forum-comment.level-4 { margin-left: 34px; border-left: 2px solid #454545; }
|
.forum-comment.level-4 { margin-left: 34px !important; border-left: 2px solid #454545; }
|
||||||
/* comentario destacado: acento sutil a la izquierda en vez del marco naranja enorme */
|
/* comentario destacado: acento sutil a la izquierda en vez del marco naranja enorme */
|
||||||
.forum-comment.highlighted-reply { border: 1px solid #3a3a3a; border-left: 3px solid #FFD700; background: #1b1a16; }
|
.forum-comment.highlighted-reply { border: 1px solid #3a3a3a !important; border-left: 3px solid #FFD700 !important; background: #1b1a16 !important; }
|
||||||
|
|
||||||
/* --- Reply del foro SIN tocar la vista de epsylon: la cajita se agranda al enfocarla;
|
/* --- Reply del foro SIN tocar la vista de epsylon: la cajita se agranda al enfocarla;
|
||||||
el botón redondo ↩ está SIEMPRE visible (el usuario pidió icono de reply, no gesto/JS). --- */
|
el botón redondo ↩ está SIEMPRE visible (el usuario pidió icono de reply, no gesto/JS). --- */
|
||||||
|
|
@ -1042,74 +1040,7 @@ button, input[type="submit"], input[type="button"],
|
||||||
/* --- hive ahora global en el header (debajo de los 2 botones) --- */
|
/* --- hive ahora global en el header (debajo de los 2 botones) --- */
|
||||||
.header .hive-nav { margin: 34px auto 10px; }
|
.header .hive-nav { margin: 34px auto 10px; }
|
||||||
|
|
||||||
/* holgura bajo la topbar fija (el hive va en el header, solo en la home) */
|
/* --- bottombar FIJA de 6 (Inbox·PM·Write·Search·Graphos·Peers): que quepan --- */
|
||||||
body { padding-top: 84px; }
|
.oasis-bottombar-fixed .bb-item { padding: 2px 0; gap: 2px; }
|
||||||
|
.oasis-bottombar-fixed .bb-ico { font-size: 17px; }
|
||||||
/* ==========================================================================
|
.oasis-bottombar-fixed .bb-lbl { font-size: 8.5px; }
|
||||||
FORK_IA_UX · Módulo KARVAN (mensajes temporales + WebRTC) — chat estilo burbujas
|
|
||||||
========================================================================== */
|
|
||||||
.karvan-intro h2 { color: #FFD700; margin: 0 0 6px; }
|
|
||||||
.karvan-sub { color: #FFDD44; font-size: 13px; }
|
|
||||||
.karvan-create input[type="text"] { width: 100%; box-sizing: border-box; padding: 10px 12px;
|
|
||||||
background: #161616; border: 1px solid #333; border-radius: 10px; color: #FFD700; }
|
|
||||||
.karvan-ttl { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; margin: 10px 0; }
|
|
||||||
.karvan-ttl-lbl { color: #FFDD44; font-size: 13px; }
|
|
||||||
.karvan-ttl-opt { display: inline-flex; align-items: center; gap: 4px; color: #FFD700; font-size: 13px; }
|
|
||||||
.karvan-create button, .karvan-compose button { padding: 10px 16px; border: 1px solid #FFD700;
|
|
||||||
border-radius: 10px; background: #FFD700; color: #121212; font-weight: 700; cursor: pointer; }
|
|
||||||
.karvan-rooms { list-style: none; margin: 8px 0 0; padding: 0; }
|
|
||||||
.karvan-rooms li { margin: 0 0 8px; }
|
|
||||||
.karvan-room-item { display: flex; justify-content: space-between; align-items: center; gap: 10px;
|
|
||||||
padding: 12px 14px; background: #161616; border: 1px solid #333; border-radius: 12px;
|
|
||||||
color: #FFD700; text-decoration: none; min-height: 48px; box-sizing: border-box; }
|
|
||||||
.karvan-room-item .kr-title { font-weight: 600; }
|
|
||||||
.karvan-room-item .kr-meta { color: #FFDD44; font-size: 12px; white-space: nowrap; }
|
|
||||||
.karvan-empty { color: #FFDD44; }
|
|
||||||
|
|
||||||
/* sala */
|
|
||||||
.karvan-room-head { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
|
|
||||||
.karvan-back { color: #FFD700; text-decoration: none; font-weight: 600; }
|
|
||||||
.karvan-room-title { flex: 1 1 auto; color: #FFD700; font-weight: 700; text-align: center;
|
|
||||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
||||||
.karvan-expire { color: #FFDD44; font-size: 12px; white-space: nowrap; }
|
|
||||||
.karvan-live { display: inline-flex; align-items: center; gap: 6px; color: #FFDD44; font-size: 12px; margin-bottom: 8px; }
|
|
||||||
.karvan-live .kl-dot { width: 8px; height: 8px; border-radius: 50%; background: #777; }
|
|
||||||
.karvan-live.on .kl-dot { background: #35d07f; }
|
|
||||||
.karvan-live.on .kl-txt { color: #35d07f; }
|
|
||||||
.karvan-msgs { list-style: none; margin: 0; padding: 6px 0; display: flex; flex-direction: column; gap: 6px;
|
|
||||||
max-height: 58vh; overflow-y: auto; }
|
|
||||||
.karvan-msg { max-width: 82%; align-self: flex-start; background: #1c1c1c; border: 1px solid #333;
|
|
||||||
border-radius: 14px; padding: 7px 11px; box-sizing: border-box; }
|
|
||||||
.karvan-msg-self { align-self: flex-end; background: #2a2412; border-color: #FFD700; }
|
|
||||||
.karvan-msg-live { border-left: 2px solid #35d07f; } /* recibido al instante por WebRTC (P2P) */
|
|
||||||
.karvan-msg .km-from { display: block; font-size: 10px; color: #FFDD44; margin-bottom: 2px; }
|
|
||||||
.karvan-msg .km-text { color: #FFD700; word-break: break-word; }
|
|
||||||
.karvan-compose { display: flex; gap: 8px; margin-top: 10px; }
|
|
||||||
.karvan-compose input[type="text"] { flex: 1 1 auto; min-width: 0; padding: 10px 12px;
|
|
||||||
background: #161616; border: 1px solid #333; border-radius: 10px; color: #FFD700; }
|
|
||||||
/* Fase 2: invitar a un contacto por feed id */
|
|
||||||
.karvan-invite { display: flex; gap: 8px; margin-top: 8px; }
|
|
||||||
.karvan-invite input[type="text"] { flex: 1 1 auto; min-width: 0; padding: 9px 12px;
|
|
||||||
background: #161616; border: 1px solid #333; border-radius: 10px; color: #FFD700; font-family: monospace; font-size: 12px; }
|
|
||||||
.karvan-invite button { flex: 0 0 auto; padding: 9px 14px; border: 1px solid #FFD700; background: #161616; color: #FFD700; border-radius: 10px; cursor: pointer; }
|
|
||||||
.karvan-invite-msg { font-size: 12px; margin: 6px 0 0; }
|
|
||||||
.karvan-invite-msg.ok { color: #35d07f; }
|
|
||||||
.karvan-invite-msg.err { color: #ff8a8a; }
|
|
||||||
|
|
||||||
/* --- Karvan videollamada (Fase 1): media P2P sobre el mismo WebRTC del chat --- */
|
|
||||||
.karvan-call, .karvan-videos, .karvan-call-bar, .kc-status {
|
|
||||||
background-color: transparent; box-shadow: none; border-radius: 0; padding: 0; margin: 0; /* anular div{#222} */
|
|
||||||
}
|
|
||||||
.karvan-call { margin: 10px 0; }
|
|
||||||
.karvan-videos { display: none; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 6px; margin-bottom: 8px; }
|
|
||||||
.karvan-call.active .karvan-videos { display: grid; }
|
|
||||||
.kc-video { width: 100%; aspect-ratio: 4 / 3; background: #000; border-radius: 10px; border: 1px solid #333; object-fit: cover; display: block; }
|
|
||||||
.kc-local { transform: scaleX(-1); } /* espejo, como cámara frontal */
|
|
||||||
.kc-remote { display: contents; } /* los vídeos remotos entran en la misma rejilla */
|
|
||||||
.karvan-call-bar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
|
||||||
.kc-btn { min-height: 44px; min-width: 44px; padding: 8px 12px; border-radius: 999px;
|
|
||||||
border: 1px solid #333; background: #161616; color: #FFD700; line-height: 1; cursor: pointer; }
|
|
||||||
.kc-btn.on { background: #FFD700; color: #121212; border-color: #FFD700; }
|
|
||||||
.kc-btn-start { font-weight: 600; }
|
|
||||||
.kc-btn-stop { background: #241414; border-color: #5a2a2a; }
|
|
||||||
.kc-status { font-size: 12px; color: #FFDD44; margin-top: 6px; min-height: 14px; }
|
|
||||||
|
|
|
||||||
|
|
@ -1,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;
|
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 }) => {
|
module.exports = ({ host, port, middleware, allowHost }) => {
|
||||||
const assets = new Koa()
|
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 app = new Koa();
|
||||||
const validHosts = [];
|
const validHosts = [];
|
||||||
|
|
@ -79,14 +79,12 @@ module.exports = ({ host, port, middleware, allowHost }) => {
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const httpDebug = process.argv.includes('--debug') || process.env.OASIS_DEBUG === '1' || process.env.OASIS_DEBUG === 'true';
|
|
||||||
|
|
||||||
app.on("error", (err, ctx) => {
|
app.on("error", (err, ctx) => {
|
||||||
if (err && (err.code === 'ECONNRESET' || err.code === 'EPIPE')) {
|
if (err && (err.code === 'ECONNRESET' || err.code === 'EPIPE')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (err && (err.name === 'BadRequestError' || err.status === 400)) {
|
if (err && (err.name === 'BadRequestError' || err.status === 400)) {
|
||||||
if (httpDebug) console.error(`[400] ${err.message}`);
|
console.error(`[400] ${err.message}`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
@ -117,7 +115,7 @@ module.exports = ({ host, port, middleware, allowHost }) => {
|
||||||
|
|
||||||
app.use(async (ctx, next) => {
|
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 isClearnet = isClearnetPath(ctx.request);
|
||||||
const csp = isClearnet
|
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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
@ -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,16 +12,12 @@ if (!fs.existsSync(configFilePath)) {
|
||||||
"current": "blocks"
|
"current": "blocks"
|
||||||
},
|
},
|
||||||
"modules": {
|
"modules": {
|
||||||
"blogsMod": "on",
|
|
||||||
"housingMod": "off",
|
|
||||||
"karvanMod": "on",
|
|
||||||
"popularMod": "on",
|
"popularMod": "on",
|
||||||
"topicsMod": "on",
|
"topicsMod": "on",
|
||||||
"summariesMod": "on",
|
"summariesMod": "on",
|
||||||
"latestMod": "on",
|
"latestMod": "on",
|
||||||
"threadsMod": "on",
|
"threadsMod": "on",
|
||||||
"multiverseMod": "on",
|
"multiverseMod": "on",
|
||||||
"pollsMod": "on",
|
|
||||||
"fediverseMod": "off",
|
"fediverseMod": "off",
|
||||||
"invitesMod": "on",
|
"invitesMod": "on",
|
||||||
"walletMod": "on",
|
"walletMod": "on",
|
||||||
|
|
@ -100,10 +96,8 @@ const getConfig = () => {
|
||||||
if (typeof cfg.ux === 'string') cfg.ux = { current: cfg.ux };
|
if (typeof cfg.ux === 'string') cfg.ux = { current: cfg.ux };
|
||||||
if (!cfg.ux || typeof cfg.ux !== 'object') cfg.ux = { current: 'blocks' };
|
if (!cfg.ux || typeof cfg.ux !== 'object') cfg.ux = { current: 'blocks' };
|
||||||
if (cfg.ux.current === 'menus') 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 === '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)
|
let pins = Array.isArray(cfg.bottomBarPins)
|
||||||
? cfg.bottomBarPins
|
? cfg.bottomBarPins
|
||||||
: (typeof cfg.bottomBarPin === 'string'
|
: (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,8 +6,12 @@
|
||||||
"current": "blocks"
|
"current": "blocks"
|
||||||
},
|
},
|
||||||
"modules": {
|
"modules": {
|
||||||
"blogsMod": "on",
|
"popularMod": "on",
|
||||||
"pollsMod": "on",
|
"topicsMod": "on",
|
||||||
|
"summariesMod": "on",
|
||||||
|
"latestMod": "on",
|
||||||
|
"threadsMod": "on",
|
||||||
|
"multiverseMod": "on",
|
||||||
"fediverseMod": "on",
|
"fediverseMod": "on",
|
||||||
"invitesMod": "on",
|
"invitesMod": "on",
|
||||||
"walletMod": "off",
|
"walletMod": "off",
|
||||||
|
|
@ -30,7 +34,6 @@
|
||||||
"reportsMod": "on",
|
"reportsMod": "on",
|
||||||
"opinionsMod": "on",
|
"opinionsMod": "on",
|
||||||
"padsMod": "on",
|
"padsMod": "on",
|
||||||
"karvanMod": "on",
|
|
||||||
"calendarsMod": "on",
|
"calendarsMod": "on",
|
||||||
"transfersMod": "off",
|
"transfersMod": "off",
|
||||||
"feedMod": "on",
|
"feedMod": "on",
|
||||||
|
|
@ -53,8 +56,7 @@
|
||||||
"chatsMod": "on",
|
"chatsMod": "on",
|
||||||
"torrentsMod": "off",
|
"torrentsMod": "off",
|
||||||
"graphosMod": "on",
|
"graphosMod": "on",
|
||||||
"larpMod": "on",
|
"larpMod": "on"
|
||||||
"housingMod": "off"
|
|
||||||
},
|
},
|
||||||
"wallet": {
|
"wallet": {
|
||||||
"url": "http://localhost:7474",
|
"url": "http://localhost:7474",
|
||||||
|
|
@ -81,14 +83,5 @@
|
||||||
"inbox",
|
"inbox",
|
||||||
"publish",
|
"publish",
|
||||||
"activity"
|
"activity"
|
||||||
],
|
]
|
||||||
"rtc": {
|
|
||||||
"stun": [],
|
|
||||||
"turn": {
|
|
||||||
"urls": [],
|
|
||||||
"secret": "",
|
|
||||||
"ttlSec": 3600
|
|
||||||
},
|
|
||||||
"relayOnly": false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
@ -9,9 +9,6 @@ let _ecoValue = null;
|
||||||
let _lastActivity = null;
|
let _lastActivity = null;
|
||||||
let _maxBlockBytes = 0;
|
let _maxBlockBytes = 0;
|
||||||
let _inhabitantCount = 0;
|
let _inhabitantCount = 0;
|
||||||
let _mentionsCount = 0;
|
|
||||||
let _bestMatch = null;
|
|
||||||
let _dismissedSuggestion = null;
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
getInboxCount: () => _inboxCount,
|
getInboxCount: () => _inboxCount,
|
||||||
setInboxCount: (n) => { _inboxCount = n; },
|
setInboxCount: (n) => { _inboxCount = n; },
|
||||||
|
|
@ -33,12 +30,6 @@ module.exports = {
|
||||||
setLastActivity: (a) => { _lastActivity = a; },
|
setLastActivity: (a) => { _lastActivity = a; },
|
||||||
getMaxBlockBytes: () => _maxBlockBytes,
|
getMaxBlockBytes: () => _maxBlockBytes,
|
||||||
setMaxBlockBytes: (n) => { if (Number(n) > _maxBlockBytes) _maxBlockBytes = Number(n); },
|
setMaxBlockBytes: (n) => { if (Number(n) > _maxBlockBytes) _maxBlockBytes = Number(n); },
|
||||||
getMentionsCount: () => _mentionsCount,
|
|
||||||
setMentionsCount: (n) => { _mentionsCount = Math.max(0, Number(n) || 0); },
|
|
||||||
getInhabitantCount: () => _inhabitantCount,
|
getInhabitantCount: () => _inhabitantCount,
|
||||||
setInhabitantCount: (n) => { _inhabitantCount = Math.max(0, Number(n) || 0); },
|
setInhabitantCount: (n) => { _inhabitantCount = Math.max(0, Number(n) || 0); }
|
||||||
getBestMatch: () => _bestMatch,
|
|
||||||
setBestMatch: (m) => { _bestMatch = m || null; },
|
|
||||||
getDismissedSuggestion: () => _dismissedSuggestion,
|
|
||||||
setDismissedSuggestion: (href) => { _dismissedSuggestion = href || null; }
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -81,21 +81,14 @@ pins = json.loads(sys.argv[1])
|
||||||
im = Image.open(sys.argv[2]).copy()
|
im = Image.open(sys.argv[2]).copy()
|
||||||
draw = ImageDraw.Draw(im)
|
draw = ImageDraw.Draw(im)
|
||||||
|
|
||||||
def draw_pin(draw, x, y, main):
|
for p in pins:
|
||||||
sw = 7 if main else 5
|
x, y, main = p['x'], p['y'], p.get('main', False)
|
||||||
sh = 34 if main else 26
|
sw = 3 if main else 2
|
||||||
head = 11 if main else 8
|
sh = 18 if main else 13
|
||||||
clr = '#e74c3c' if main else '#3498db'
|
clr = '#e74c3c' if main else '#3498db'
|
||||||
dark = '#c0392b' if main else '#2980b9'
|
dark = '#c0392b' if main else '#2980b9'
|
||||||
cy = y - sh
|
draw.polygon([(x, y + 2), (x - sw, y - sh + sw * 2), (x + sw, y - sh + sw * 2)], fill=clr)
|
||||||
draw.polygon([(x, y + 2), (x - sw - 1, cy + sw), (x + sw + 1, cy + sw)], fill='white')
|
draw.ellipse([x - sw - 1, y - sh - sw, x + sw + 1, y - sh + sw], fill=dark, outline='white', width=1)
|
||||||
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))
|
|
||||||
|
|
||||||
im.save(sys.argv[3], optimize=True)
|
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)
|
result = cropped.resize((1024, 1024), Image.LANCZOS)
|
||||||
|
|
||||||
draw = ImageDraw.Draw(result)
|
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:
|
for p in pins:
|
||||||
px, py, main = p['px'], p['py'], p.get('main', False)
|
px, py, main = p['px'], p['py'], p.get('main', False)
|
||||||
if -40 <= px <= 1064 and -40 <= py <= 1064:
|
if -20 <= px <= 1044 and -20 <= py <= 1044:
|
||||||
draw_pin(draw, px, py, main)
|
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)
|
result.save(out_file, optimize=True)
|
||||||
`;
|
`;
|
||||||
|
|
|
||||||
|
|
@ -164,7 +164,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }
|
||||||
// ties correctly — the mock and real SSB can stamp equal timestamps.
|
// 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;
|
if (prev && !((isTip && !prev.isTip) || (isTip === prev.isTip && (a.ts || 0) >= prev.ts))) continue;
|
||||||
const cc = a.content || {};
|
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 });
|
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 || '') });
|
||||||
}
|
}
|
||||||
const msgsByRoot = new Map();
|
const msgsByRoot = new Map();
|
||||||
for (const a of idToAction.values()) {
|
for (const a of idToAction.values()) {
|
||||||
|
|
@ -181,7 +181,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }
|
||||||
const val = await getMsg(ssbClient, root);
|
const val = await getMsg(ssbClient, root);
|
||||||
const cc = val && val.content;
|
const cc = val && val.content;
|
||||||
if (cc && typeof cc === 'object' && cc.type === 'chat') {
|
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 });
|
stateByRoot.set(root, { ts: 0, status: String(cc.status || '').toUpperCase(), tribeId: cc.tribeId || null, title: String(cc.title || ''), description: String(cc.description || '') });
|
||||||
} else {
|
} else {
|
||||||
stateByRoot.set(root, null);
|
stateByRoot.set(root, null);
|
||||||
}
|
}
|
||||||
|
|
@ -202,8 +202,6 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }
|
||||||
chatRoot: root,
|
chatRoot: root,
|
||||||
title: info.title || '',
|
title: info.title || '',
|
||||||
description: info.description || '',
|
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) || '' }))
|
replies: asc.slice(-CHAT_THREAD_LIMIT).map(m => ({ id: m.id, author: m.author, ts: m.ts || 0, text: (m.content && m.content.text) || '' }))
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -253,21 +251,6 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }
|
||||||
const idToAction = new Map();
|
const idToAction = new Map();
|
||||||
const rawById = new Map();
|
const rawById = new Map();
|
||||||
const voteTally = buildVoteTally(results);
|
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 fpIdx = tribeCrypto ? tribeCrypto.buildFingerprintIndex() : null;
|
||||||
const accessibleTribeIds = await buildAccessibleTribeIds();
|
const accessibleTribeIds = await buildAccessibleTribeIds();
|
||||||
|
|
||||||
|
|
@ -594,11 +577,6 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }
|
||||||
const actionRoot = rootOf(a.id);
|
const actionRoot = rootOf(a.id);
|
||||||
const extra = a.type === 'aiExchange' ? { helpfulVotes: aiHelpfulCounts.get(a.id) || 0 } : {};
|
const extra = a.type === 'aiExchange' ? { helpfulVotes: aiHelpfulCounts.get(a.id) || 0 } : {};
|
||||||
const voteAgg = a.type === 'votes' ? voteTally.get(a.id) : null;
|
const voteAgg = a.type === 'votes' ? voteTally.get(a.id) : null;
|
||||||
if (a.type === 'poll') {
|
|
||||||
const tally = pollTally.get(a.id) || pollTally.get(actionRoot);
|
|
||||||
a.pollCounts = tally ? tally.counts : {};
|
|
||||||
a.pollVoters = tally ? tally.voters.size : 0;
|
|
||||||
}
|
|
||||||
let content = voteAgg ? { ...c, ...voteAgg } : a.content;
|
let content = voteAgg ? { ...c, ...voteAgg } : a.content;
|
||||||
if (a.type === 'feed') {
|
if (a.type === 'feed') {
|
||||||
const base = a.content || {};
|
const base = a.content || {};
|
||||||
|
|
@ -710,13 +688,12 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }
|
||||||
}
|
}
|
||||||
|
|
||||||
const tribeInternalTypes = new Set(['tribe-content', 'tribeParliamentCandidature', 'tribeParliamentTerm', 'tribeParliamentProposal', 'tribeParliamentRule', 'tribeParliamentLaw', 'tribeParliamentRevocation']);
|
const tribeInternalTypes = new Set(['tribe-content', 'tribeParliamentCandidature', 'tribeParliamentTerm', 'tribeParliamentProposal', 'tribeParliamentRule', 'tribeParliamentLaw', 'tribeParliamentRevocation']);
|
||||||
const hiddenTypes = new Set(['padEntry', 'chatMessage', 'calendarDate', 'calendarNote', 'calendarReminderSent', 'taskReminderSent', 'feed-action', 'pubBalance', 'pubAvailability', 'log', 'logPublic', 'gameScore', 'pollVote', 'pollClose', 'pollOpinion', 'curriculum']);
|
const hiddenTypes = new Set(['padEntry', 'chatMessage', 'calendarDate', 'calendarNote', 'calendarReminderSent', 'taskReminderSent', 'feed-action', 'pubBalance', 'pubAvailability', 'log', 'gameScore']);
|
||||||
const chatThreadItems = await buildChatThreads(ssbClient, idToAction, rootOf, deduped);
|
const chatThreadItems = await buildChatThreads(ssbClient, idToAction, rootOf, deduped);
|
||||||
deduped = deduped.concat(chatThreadItems);
|
deduped = deduped.concat(chatThreadItems);
|
||||||
const isAllowedTribeActivity = (a) => {
|
const isAllowedTribeActivity = (a) => {
|
||||||
if (tribeInternalTypes.has(a.type)) return false;
|
if (tribeInternalTypes.has(a.type)) return false;
|
||||||
const c = a.content || {};
|
const c = a.content || {};
|
||||||
if (a.type === 'poll' && c.chatId) return false;
|
|
||||||
if (c.tribeId) return false;
|
if (c.tribeId) return false;
|
||||||
if (a.type === 'tribe') {
|
if (a.type === 'tribe') {
|
||||||
const isInitial = !c.replaces;
|
const isInitial = !c.replaces;
|
||||||
|
|
@ -727,8 +704,8 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
const itemVisible = (a, opts = {}) => {
|
const itemVisible = (a) => {
|
||||||
if (hiddenTypes.has(a.type) && !(opts.allowHidden && opts.allowHidden.has(a.type))) return false;
|
if (hiddenTypes.has(a.type)) return false;
|
||||||
const c = a.content || {};
|
const c = a.content || {};
|
||||||
if (c.encryptedPayload) return false;
|
if (c.encryptedPayload) return false;
|
||||||
if (a.type === 'pad' && c.status !== 'OPEN') return false;
|
if (a.type === 'pad' && c.status !== 'OPEN') return false;
|
||||||
|
|
@ -738,7 +715,6 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }
|
||||||
if (a.type === 'task' && String(c.isPublic || '').toUpperCase() === 'PRIVATE' && a.author !== userId && !(Array.isArray(c.assignees) && c.assignees.includes(userId))) return false;
|
if (a.type === 'task' && String(c.isPublic || '').toUpperCase() === 'PRIVATE' && a.author !== userId && !(Array.isArray(c.assignees) && c.assignees.includes(userId))) return false;
|
||||||
if (a.type === 'forum' && c.isPrivate === true && a.author !== userId) return false;
|
if (a.type === 'forum' && c.isPrivate === true && a.author !== userId) return false;
|
||||||
if (a.type === 'job' && String(c.visibility || '').toUpperCase() === 'HIDDEN' && a.author !== userId && !(Array.isArray(c.subscribers) && c.subscribers.includes(userId))) return false;
|
if (a.type === 'job' && String(c.visibility || '').toUpperCase() === 'HIDDEN' && a.author !== userId && !(Array.isArray(c.subscribers) && c.subscribers.includes(userId))) return false;
|
||||||
if (a.type === 'housing' && String(c.visibility || '').toUpperCase() === 'HIDDEN' && a.author !== userId) return false;
|
|
||||||
if (a.type === 'market' && String(c.visibility || '').toUpperCase() === 'HIDDEN' && c.seller !== userId) return false;
|
if (a.type === 'market' && String(c.visibility || '').toUpperCase() === 'HIDDEN' && c.seller !== userId) return false;
|
||||||
if (a.type === 'shop' && String(c.visibility || '').toUpperCase() === 'CLOSED' && a.author !== userId) return false;
|
if (a.type === 'shop' && String(c.visibility || '').toUpperCase() === 'CLOSED' && a.author !== userId) return false;
|
||||||
if (a.type === 'curriculum' && String(c.visibility || '').toUpperCase() === 'HIDDEN' && a.author !== userId) return false;
|
if (a.type === 'curriculum' && String(c.visibility || '').toUpperCase() === 'HIDDEN' && a.author !== userId) return false;
|
||||||
|
|
@ -746,8 +722,8 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }
|
||||||
if (a.type === 'transfer' && String(c.status || '').toUpperCase() === 'UNCONFIRMED' && a.author !== userId && c.from !== userId && c.to !== userId) return false;
|
if (a.type === 'transfer' && String(c.status || '').toUpperCase() === 'UNCONFIRMED' && a.author !== userId && c.from !== userId && c.to !== userId) return false;
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
const isVisible = (a, opts) => {
|
const isVisible = (a) => {
|
||||||
if (!itemVisible(a, opts)) return false;
|
if (!itemVisible(a)) return false;
|
||||||
if (a.type === 'post' || a.type === 'opinion') {
|
if (a.type === 'post' || a.type === 'opinion') {
|
||||||
const c = a.content || {};
|
const c = a.content || {};
|
||||||
const ref = (typeof c.root === 'string' && c.root)
|
const ref = (typeof c.root === 'string' && c.root)
|
||||||
|
|
@ -766,14 +742,6 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }
|
||||||
if (filter === 'mine') out = deduped.filter(a => a.author === userId && isAllowedTribeActivity(a) && isVisible(a));
|
if (filter === 'mine') out = deduped.filter(a => a.author === userId && isAllowedTribeActivity(a) && isVisible(a));
|
||||||
else if (filter === 'recent') { const cutoff = Date.now() - 24 * 60 * 60 * 1000; out = deduped.filter(a => (a.ts || 0) >= cutoff && isAllowedTribeActivity(a) && isVisible(a)) }
|
else if (filter === 'recent') { const cutoff = Date.now() - 24 * 60 * 60 * 1000; out = deduped.filter(a => (a.ts || 0) >= cutoff && isAllowedTribeActivity(a) && isVisible(a)) }
|
||||||
else if (filter === 'all') out = deduped.filter(a => isAllowedTribeActivity(a) && isVisible(a));
|
else if (filter === 'all') out = deduped.filter(a => isAllowedTribeActivity(a) && isVisible(a));
|
||||||
else if (filter === 'top') {
|
|
||||||
const visible = deduped.filter(a => isAllowedTribeActivity(a) && isVisible(a));
|
|
||||||
const perAuthor = new Map();
|
|
||||||
for (const a of visible) perAuthor.set(a.author, (perAuthor.get(a.author) || 0) + 1);
|
|
||||||
out = visible
|
|
||||||
.map(a => ({ ...a, authorActions: perAuthor.get(a.author) || 0 }))
|
|
||||||
.sort((x, y) => (y.authorActions - x.authorActions) || ((y.ts || 0) - (x.ts || 0)));
|
|
||||||
}
|
|
||||||
else if (filter === 'banking') out = deduped.filter(a => a.type === 'bankWallet' || a.type === 'bankClaim' || a.type === 'ubiClaim');
|
else if (filter === 'banking') out = deduped.filter(a => a.type === 'bankWallet' || a.type === 'bankClaim' || a.type === 'ubiClaim');
|
||||||
else if (filter === 'tribe') out = deduped.filter(a => a.type === 'tribe' || String(a.type || '').startsWith('tribe'));
|
else if (filter === 'tribe') out = deduped.filter(a => a.type === 'tribe' || String(a.type || '').startsWith('tribe'));
|
||||||
else if (filter === 'spread') out = deduped.filter(a => a.type === 'spread');
|
else if (filter === 'spread') out = deduped.filter(a => a.type === 'spread');
|
||||||
|
|
@ -788,8 +756,6 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }
|
||||||
});
|
});
|
||||||
else if (filter === 'task')
|
else if (filter === 'task')
|
||||||
out = deduped.filter(a => a.type === 'task' || a.type === 'taskAssignment');
|
out = deduped.filter(a => a.type === 'task' || a.type === 'taskAssignment');
|
||||||
else if (filter === 'votes')
|
|
||||||
out = deduped.filter(a => (a.type === 'votes' || a.type === 'poll') && isAllowedTribeActivity(a) && isVisible(a));
|
|
||||||
else if (filter === 'industry')
|
else if (filter === 'industry')
|
||||||
out = deduped.filter(a => ['industry', 'industryBuild', 'industryBlueprint', 'industryAllocation'].includes(a.type) && isVisible(a));
|
out = deduped.filter(a => ['industry', 'industryBuild', 'industryBlueprint', 'industryAllocation'].includes(a.type) && isVisible(a));
|
||||||
else if (filter === 'pad') out = deduped.filter(a => a.type === 'pad' && (a.content || {}).status === 'OPEN');
|
else if (filter === 'pad') out = deduped.filter(a => a.type === 'pad' && (a.content || {}).status === 'OPEN');
|
||||||
|
|
@ -798,7 +764,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }
|
||||||
else if (filter === 'transfer') out = deduped.filter(a => a.type === 'transfer' && isVisible(a));
|
else if (filter === 'transfer') out = deduped.filter(a => a.type === 'transfer' && isVisible(a));
|
||||||
else out = deduped.filter(a => a.type === filter);
|
else out = deduped.filter(a => a.type === filter);
|
||||||
|
|
||||||
if (filter !== 'top') out.sort((a, b) => (b.ts || 0) - (a.ts || 0));
|
out.sort((a, b) => (b.ts || 0) - (a.ts || 0));
|
||||||
return out;
|
return out;
|
||||||
})();
|
})();
|
||||||
_feedCacheInflight.set(cacheKey, promise);
|
_feedCacheInflight.set(cacheKey, promise);
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ function writeAgendaConfig(cfg) {
|
||||||
fs.writeFileSync(agendaConfigPath, JSON.stringify(cfg, null, 2));
|
fs.writeFileSync(agendaConfigPath, JSON.stringify(cfg, null, 2));
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = ({ cooler, calendarsModel, eventsModel, tasksModel, marketModel, jobsModel, projectsModel, industryModel, housingModel }) => {
|
module.exports = ({ cooler, calendarsModel, eventsModel, tasksModel, marketModel, jobsModel, projectsModel, industryModel }) => {
|
||||||
let ssb;
|
let ssb;
|
||||||
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
|
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
|
||||||
|
|
||||||
|
|
@ -188,11 +188,7 @@ module.exports = ({ cooler, calendarsModel, eventsModel, tasksModel, marketModel
|
||||||
? industryModel.listMyBuilds().then(normalize).catch(() => [])
|
? industryModel.listMyBuilds().then(normalize).catch(() => [])
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
const housingViaModel = housingModel && typeof housingModel.listHousing === 'function'
|
const [tasksAll, eventsAll, transfersAll, tribesAll, marketAll, reportsAll, jobsAll, projectsAll, calendarsAll, industryAll] = await Promise.all([
|
||||||
? housingModel.listHousing('ALL', userId).then(normalize).catch(() => [])
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const [tasksAll, eventsAll, transfersAll, tribesAll, marketAll, reportsAll, jobsAll, projectsAll, calendarsAll, industryAll, housingAll] = await Promise.all([
|
|
||||||
tasksViaModel,
|
tasksViaModel,
|
||||||
eventsViaModel,
|
eventsViaModel,
|
||||||
fetchItems('transfer'),
|
fetchItems('transfer'),
|
||||||
|
|
@ -202,8 +198,7 @@ module.exports = ({ cooler, calendarsModel, eventsModel, tasksModel, marketModel
|
||||||
jobsViaModel,
|
jobsViaModel,
|
||||||
projectsViaModel,
|
projectsViaModel,
|
||||||
calendarsViaModel,
|
calendarsViaModel,
|
||||||
industryViaModel,
|
industryViaModel
|
||||||
housingViaModel
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const tasks = tasksAll.filter(c => Array.isArray(c.assignees) && c.assignees.includes(userId)).map(t => ({ ...t, type: 'task' }));
|
const tasks = tasksAll.filter(c => Array.isArray(c.assignees) && c.assignees.includes(userId)).map(t => ({ ...t, type: 'task' }));
|
||||||
|
|
@ -217,9 +212,6 @@ module.exports = ({ cooler, calendarsModel, eventsModel, tasksModel, marketModel
|
||||||
const jobs = jobsAll.filter(c => c.author === userId || (Array.isArray(c.subscribers) && c.subscribers.includes(userId))).map(j => ({ ...j, type: 'job', title: j.title }));
|
const jobs = jobsAll.filter(c => c.author === userId || (Array.isArray(c.subscribers) && c.subscribers.includes(userId))).map(j => ({ ...j, type: 'job', title: j.title }));
|
||||||
const projects = projectsAll.map(p => ({ ...p, type: 'project' }));
|
const projects = projectsAll.map(p => ({ ...p, type: 'project' }));
|
||||||
const industryBuilds = industryAll.map(b => ({ ...b, type: 'industry', title: b.title }));
|
const industryBuilds = industryAll.map(b => ({ ...b, type: 'industry', title: b.title }));
|
||||||
const housingPlaces = housingAll
|
|
||||||
.filter(h => h.author === userId || (Array.isArray(h.requests) && h.requests.includes(userId)))
|
|
||||||
.map(h => ({ ...h, type: 'housing', date: h.availableFrom || h.createdAt, requested: Array.isArray(h.requests) && h.requests.includes(userId) }));
|
|
||||||
const myCalendars = calendarsAll
|
const myCalendars = calendarsAll
|
||||||
.filter(c => c.author === userId || (Array.isArray(c.participants) && c.participants.includes(userId)));
|
.filter(c => c.author === userId || (Array.isArray(c.participants) && c.participants.includes(userId)));
|
||||||
const calendars = myCalendars.map(c => ({ ...c, type: 'calendar' }));
|
const calendars = myCalendars.map(c => ({ ...c, type: 'calendar' }));
|
||||||
|
|
@ -256,7 +248,6 @@ module.exports = ({ cooler, calendarsModel, eventsModel, tasksModel, marketModel
|
||||||
...jobs,
|
...jobs,
|
||||||
...projects,
|
...projects,
|
||||||
...industryBuilds,
|
...industryBuilds,
|
||||||
...housingPlaces,
|
|
||||||
...calendars,
|
...calendars,
|
||||||
...calendarDates
|
...calendarDates
|
||||||
];
|
];
|
||||||
|
|
@ -277,7 +268,6 @@ module.exports = ({ cooler, calendarsModel, eventsModel, tasksModel, marketModel
|
||||||
else if (filter === 'jobs') filtered = filtered.filter(i => i.type === 'job');
|
else if (filter === 'jobs') filtered = filtered.filter(i => i.type === 'job');
|
||||||
else if (filter === 'projects') filtered = filtered.filter(i => i.type === 'project');
|
else if (filter === 'projects') filtered = filtered.filter(i => i.type === 'project');
|
||||||
else if (filter === 'industry') filtered = filtered.filter(i => i.type === 'industry');
|
else if (filter === 'industry') filtered = filtered.filter(i => i.type === 'industry');
|
||||||
else if (filter === 'housing') filtered = filtered.filter(i => i.type === 'housing');
|
|
||||||
else if (filter === 'calendars') filtered = filtered.filter(i => i.type === 'calendar' || i.type === 'calendarDate');
|
else if (filter === 'calendars') filtered = filtered.filter(i => i.type === 'calendar' || i.type === 'calendarDate');
|
||||||
else if (filter === 'today') {
|
else if (filter === 'today') {
|
||||||
const startOfDay = new Date(); startOfDay.setHours(0, 0, 0, 0);
|
const startOfDay = new Date(); startOfDay.setHours(0, 0, 0, 0);
|
||||||
|
|
@ -332,7 +322,6 @@ module.exports = ({ cooler, calendarsModel, eventsModel, tasksModel, marketModel
|
||||||
jobs: mainItems.filter(i => i.type === 'job').length,
|
jobs: mainItems.filter(i => i.type === 'job').length,
|
||||||
projects: mainItems.filter(i => i.type === 'project').length,
|
projects: mainItems.filter(i => i.type === 'project').length,
|
||||||
industry: mainItems.filter(i => i.type === 'industry').length,
|
industry: mainItems.filter(i => i.type === 'industry').length,
|
||||||
housing: mainItems.filter(i => i.type === 'housing').length,
|
|
||||||
calendars: mainItems.filter(i => i.type === 'calendar' || i.type === 'calendarDate').length,
|
calendars: mainItems.filter(i => i.type === 'calendar' || i.type === 'calendarDate').length,
|
||||||
today: mainItems.filter(i => { const d = itemTs(i); return d >= startOfDay.getTime() && d <= endOfDay.getTime(); }).length,
|
today: mainItems.filter(i => { const d = itemTs(i); return d >= startOfDay.getTime() && d <= endOfDay.getTime(); }).length,
|
||||||
upcoming: mainItems.filter(i => itemTs(i) > now).length,
|
upcoming: mainItems.filter(i => itemTs(i) > now).length,
|
||||||
|
|
|
||||||
|
|
@ -1,168 +0,0 @@
|
||||||
const pull = require('../server/node_modules/pull-stream');
|
|
||||||
const { getConfig } = require('../configs/config-manager.js');
|
|
||||||
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
|
|
||||||
const logLimit = getConfig().ssbLogStream?.limit || 1000;
|
|
||||||
|
|
||||||
const OPINION_TYPE = 'blogOpinion';
|
|
||||||
|
|
||||||
module.exports = ({ cooler, isPublic = false }) => {
|
|
||||||
let ssb;
|
|
||||||
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
|
|
||||||
|
|
||||||
const getAllMessages = async (ssbClient) =>
|
|
||||||
new Promise((resolve, reject) => {
|
|
||||||
pull(
|
|
||||||
ssbClient.createLogStream({ limit: logLimit }),
|
|
||||||
pull.collect((err, msgs) => (err ? reject(err) : resolve(msgs)))
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const isRootPost = (c) =>
|
|
||||||
!!c && c.type === 'post' && typeof c.text === 'string' &&
|
|
||||||
!c.root && !c.fork && !c.branch && !c.about && !c.target;
|
|
||||||
|
|
||||||
const buildIndex = (messages) => {
|
|
||||||
const tomb = buildValidatedTombstoneSet(messages);
|
|
||||||
const posts = new Map();
|
|
||||||
const commentsByRoot = new Map();
|
|
||||||
const opinionsByRoot = new Map();
|
|
||||||
|
|
||||||
for (const m of messages) {
|
|
||||||
const v = m && m.value;
|
|
||||||
const c = v && v.content;
|
|
||||||
if (!c || typeof c !== 'object') continue;
|
|
||||||
if (tomb.has(m.key)) continue;
|
|
||||||
|
|
||||||
if (c.type === 'post' && typeof c.text === 'string') {
|
|
||||||
if (isRootPost(c)) {
|
|
||||||
posts.set(m.key, { key: m.key, author: v.author, ts: v.timestamp || m.timestamp || 0, c });
|
|
||||||
} else if (typeof c.root === 'string') {
|
|
||||||
commentsByRoot.set(c.root, (commentsByRoot.get(c.root) || 0) + 1);
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (c.type === OPINION_TYPE && typeof c.target === 'string') {
|
|
||||||
const entry = opinionsByRoot.get(c.target) || { counts: {}, voters: [] };
|
|
||||||
if (!entry.voters.includes(v.author)) {
|
|
||||||
entry.voters.push(v.author);
|
|
||||||
entry.counts[c.category] = (entry.counts[c.category] || 0) + 1;
|
|
||||||
}
|
|
||||||
opinionsByRoot.set(c.target, entry);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { tomb, posts, commentsByRoot, opinionsByRoot };
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildBlog = (node, idx) => {
|
|
||||||
const c = node.c || {};
|
|
||||||
const op = idx.opinionsByRoot.get(node.key) || { counts: {}, voters: [] };
|
|
||||||
return {
|
|
||||||
id: node.key,
|
|
||||||
key: node.key,
|
|
||||||
author: node.author,
|
|
||||||
subject: typeof c.contentWarning === 'string' ? c.contentWarning : '',
|
|
||||||
text: c.text || '',
|
|
||||||
mentions: Array.isArray(c.mentions) ? c.mentions : [],
|
|
||||||
allowComments: c.allowComments !== false,
|
|
||||||
createdAt: new Date(node.ts).toISOString(),
|
|
||||||
commentCount: idx.commentsByRoot.get(node.key) || 0,
|
|
||||||
opinions: op.counts,
|
|
||||||
opinions_inhabitants: op.voters
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const collect = async () => {
|
|
||||||
const ssbClient = await openSsb();
|
|
||||||
const messages = await getAllMessages(ssbClient);
|
|
||||||
const idx = buildIndex(messages);
|
|
||||||
const list = [];
|
|
||||||
for (const node of idx.posts.values()) list.push(buildBlog(node, idx));
|
|
||||||
return { list, idx, viewerId: ssbClient.id };
|
|
||||||
};
|
|
||||||
|
|
||||||
const opinionScore = (blog) =>
|
|
||||||
Object.values(blog.opinions || {}).reduce((n, v) => n + (Number(v) || 0), 0);
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: 'post',
|
|
||||||
|
|
||||||
async listAll(filter = 'ALL', opts = {}) {
|
|
||||||
const { list, viewerId } = await collect();
|
|
||||||
const f = String(filter || 'ALL').toUpperCase();
|
|
||||||
const favorites = Array.isArray(opts.favorites) ? new Set(opts.favorites.map(String)) : null;
|
|
||||||
let out = list.slice();
|
|
||||||
|
|
||||||
if (f === 'MINE') out = out.filter(b => String(b.author) === String(viewerId));
|
|
||||||
else if (f === 'FAVORITES') out = favorites ? out.filter(b => favorites.has(String(b.id))) : [];
|
|
||||||
|
|
||||||
const q = String(opts.q || '').trim().toLowerCase();
|
|
||||||
if (q) out = out.filter(b =>
|
|
||||||
b.text.toLowerCase().includes(q) || b.subject.toLowerCase().includes(q));
|
|
||||||
|
|
||||||
if (f === 'TOP') {
|
|
||||||
out.sort((a, b) => (opinionScore(b) + b.commentCount) - (opinionScore(a) + a.commentCount) ||
|
|
||||||
new Date(b.createdAt) - new Date(a.createdAt));
|
|
||||||
} else {
|
|
||||||
out.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
|
|
||||||
}
|
|
||||||
|
|
||||||
return out;
|
|
||||||
},
|
|
||||||
|
|
||||||
async getBlogById(id) {
|
|
||||||
const { list } = await collect();
|
|
||||||
const blog = list.find(b => b.id === id);
|
|
||||||
if (!blog) throw new Error('Blog not found');
|
|
||||||
return blog;
|
|
||||||
},
|
|
||||||
|
|
||||||
async createBlog({ text, subject = '', mentions = [], allowComments = true } = {}) {
|
|
||||||
if (isPublic) throw new Error('Not available in public mode');
|
|
||||||
const body = String(text || '').trim();
|
|
||||||
if (!body) throw new Error('Blog text is required');
|
|
||||||
const ssbClient = await openSsb();
|
|
||||||
const content = {
|
|
||||||
type: 'post',
|
|
||||||
text: body,
|
|
||||||
allowComments: allowComments !== false,
|
|
||||||
...(Array.isArray(mentions) && mentions.length ? { mentions } : {}),
|
|
||||||
...(String(subject || '').trim() ? { contentWarning: String(subject).trim() } : {})
|
|
||||||
};
|
|
||||||
return new Promise((res, rej) => ssbClient.publish(content, (err, msg) => err ? rej(err) : res(msg)));
|
|
||||||
},
|
|
||||||
|
|
||||||
async createOpinion(id, category) {
|
|
||||||
const categories = require('../backend/opinion_categories');
|
|
||||||
if (!categories.includes(category)) throw new Error('Invalid opinion category');
|
|
||||||
const ssbClient = await openSsb();
|
|
||||||
const userId = ssbClient.id;
|
|
||||||
const blog = await this.getBlogById(id);
|
|
||||||
if (blog.opinions_inhabitants.includes(userId)) throw new Error('Already opined');
|
|
||||||
const content = { type: OPINION_TYPE, target: blog.id, category, createdAt: new Date().toISOString() };
|
|
||||||
return new Promise((res, rej) => ssbClient.publish(content, (err, result) => err ? rej(err) : res(result)));
|
|
||||||
},
|
|
||||||
|
|
||||||
async resolveRootId(id) {
|
|
||||||
const ssbClient = await openSsb();
|
|
||||||
const msg = await new Promise((res) => ssbClient.get(id, (err, m) => res(err ? null : m)));
|
|
||||||
const c = msg && (msg.content || (msg.value && msg.value.content));
|
|
||||||
if (c && c.type === 'post' && typeof c.root === 'string') return c.root;
|
|
||||||
return id;
|
|
||||||
},
|
|
||||||
|
|
||||||
async blogHrefFor(id) {
|
|
||||||
const ssbClient = await openSsb();
|
|
||||||
const msg = await new Promise((res) => ssbClient.get(id, (err, m) => res(err ? null : m)));
|
|
||||||
const c = msg && (msg.content || (msg.value && msg.value.content));
|
|
||||||
if (!c || c.type !== 'post' || typeof c.text !== 'string') return null;
|
|
||||||
if (c.private === true || Array.isArray(c.recps)) return null;
|
|
||||||
if (typeof c.root === 'string') {
|
|
||||||
return `/blogs/${encodeURIComponent(c.root)}#${encodeURIComponent(id)}`;
|
|
||||||
}
|
|
||||||
if (c.fork || c.branch || c.about || c.target) return null;
|
|
||||||
return `/blogs/${encodeURIComponent(id)}`;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
@ -145,6 +145,7 @@ module.exports = ({ cooler }) => {
|
||||||
updatedAt: c.updatedAt || null,
|
updatedAt: c.updatedAt || null,
|
||||||
lastVisit: c.lastVisit || null,
|
lastVisit: c.lastVisit || null,
|
||||||
tags: safeArr(c.tags),
|
tags: safeArr(c.tags),
|
||||||
|
category: c.category || "",
|
||||||
description: c.description || "",
|
description: c.description || "",
|
||||||
opinions,
|
opinions,
|
||||||
opinions_inhabitants: voters,
|
opinions_inhabitants: voters,
|
||||||
|
|
@ -181,7 +182,7 @@ module.exports = ({ cooler }) => {
|
||||||
return root;
|
return root;
|
||||||
},
|
},
|
||||||
|
|
||||||
async createBookmark(url, tagsRaw, description, lastVisit) {
|
async createBookmark(url, tagsRaw, description, category, lastVisit) {
|
||||||
const ssbClient = await openSsb();
|
const ssbClient = await openSsb();
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
|
@ -194,6 +195,7 @@ module.exports = ({ cooler }) => {
|
||||||
url: u,
|
url: u,
|
||||||
tags: normalizeTags(tagsRaw),
|
tags: normalizeTags(tagsRaw),
|
||||||
description: description || "",
|
description: description || "",
|
||||||
|
category: category || "",
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
lastVisit: coerceLastVisit(lastVisit),
|
lastVisit: coerceLastVisit(lastVisit),
|
||||||
|
|
@ -229,6 +231,7 @@ module.exports = ({ cooler }) => {
|
||||||
url,
|
url,
|
||||||
tags: updatedData.tags !== undefined ? normalizeTags(updatedData.tags) : safeArr(oldMsg.content.tags),
|
tags: updatedData.tags !== undefined ? normalizeTags(updatedData.tags) : safeArr(oldMsg.content.tags),
|
||||||
description: updatedData.description !== undefined ? updatedData.description || "" : oldMsg.content.description || "",
|
description: updatedData.description !== undefined ? updatedData.description || "" : oldMsg.content.description || "",
|
||||||
|
category: updatedData.category !== undefined ? updatedData.category || "" : oldMsg.content.category || "",
|
||||||
lastVisit: coerceLastVisit(updatedData.lastVisit || oldMsg.content.lastVisit),
|
lastVisit: coerceLastVisit(updatedData.lastVisit || oldMsg.content.lastVisit),
|
||||||
createdAt: oldMsg.content.createdAt,
|
createdAt: oldMsg.content.createdAt,
|
||||||
updatedAt: now
|
updatedAt: now
|
||||||
|
|
@ -293,10 +296,11 @@ module.exports = ({ cooler }) => {
|
||||||
if (q) {
|
if (q) {
|
||||||
list = list.filter((b) => {
|
list = list.filter((b) => {
|
||||||
const url = String(b.url || "").toLowerCase();
|
const url = String(b.url || "").toLowerCase();
|
||||||
|
const cat = String(b.category || "").toLowerCase();
|
||||||
const desc = String(b.description || "").toLowerCase();
|
const desc = String(b.description || "").toLowerCase();
|
||||||
const tags = safeArr(b.tags).join(" ").toLowerCase();
|
const tags = safeArr(b.tags).join(" ").toLowerCase();
|
||||||
const author = String(b.author || "").toLowerCase();
|
const author = String(b.author || "").toLowerCase();
|
||||||
return url.includes(q) || desc.includes(q) || tags.includes(q) || author.includes(q);
|
return url.includes(q) || cat.includes(q) || desc.includes(q) || tags.includes(q) || author.includes(q);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,26 +14,25 @@ const normalizeTags = (raw) => {
|
||||||
return String(raw).split(",").map(t => t.trim()).filter(Boolean)
|
return String(raw).split(",").map(t => t.trim()).filter(Boolean)
|
||||||
}
|
}
|
||||||
const hasAnyInterval = (w, m, y) => !!(w || m || y)
|
const hasAnyInterval = (w, m, y) => !!(w || m || y)
|
||||||
const { expandRecurrence } = require('./recurrence')
|
const expandRecurrence = (firstDate, deadline, weekly, monthly, yearly) => {
|
||||||
|
const start = new Date(firstDate)
|
||||||
const ts = (v) => {
|
const out = [start]
|
||||||
const t = new Date(v).getTime()
|
if (!deadline || !hasAnyInterval(weekly, monthly, yearly)) return out
|
||||||
return Number.isFinite(t) ? t : null
|
const end = new Date(deadline).getTime()
|
||||||
}
|
const seen = new Set([start.getTime()])
|
||||||
|
const walk = (mutate) => {
|
||||||
const assertCalendarDates = ({ deadline, date, until }) => {
|
const n = new Date(start)
|
||||||
const now = Date.now()
|
mutate(n)
|
||||||
const dl = deadline ? ts(deadline) : null
|
while (n.getTime() <= end) {
|
||||||
const dt = date ? ts(date) : null
|
const t = n.getTime()
|
||||||
const un = until ? ts(until) : null
|
if (!seen.has(t)) { seen.add(t); out.push(new Date(n)) }
|
||||||
if (deadline && dl === null) throw new Error("Deadline is not a valid date")
|
mutate(n)
|
||||||
if (date && dt === null) throw new Error("Date is not a valid date")
|
}
|
||||||
if (until && un === null) throw new Error("The recurrence end is not a valid date")
|
}
|
||||||
if (dl !== null && dl <= now) throw new Error("Deadline must be in the future")
|
if (weekly) walk((d) => d.setDate(d.getDate() + 7))
|
||||||
if (dt !== null && dt <= now) throw new Error("Date must be in the future")
|
if (monthly) walk((d) => d.setMonth(d.getMonth() + 1))
|
||||||
if (dt !== null && dl !== null && dt > dl) throw new Error("Date cannot be later than the deadline")
|
if (yearly) walk((d) => d.setFullYear(d.getFullYear() + 1))
|
||||||
if (un !== null && dt !== null && un < dt) throw new Error("The recurrence cannot end before it starts")
|
return out.sort((a, b) => a.getTime() - b.getTime())
|
||||||
if (un !== null && dl !== null && un > dl) throw new Error("The recurrence cannot end after the deadline")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel }) => {
|
module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel }) => {
|
||||||
|
|
@ -109,12 +108,6 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
|
||||||
return tribeCrypto.encryptContent(content, [key], true)
|
return tribeCrypto.encryptContent(content, [key], true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const decryptScoped = async (content, rootId) => {
|
|
||||||
if (!content) return content
|
|
||||||
if (content.tribeId) return await decryptIfTribe(content)
|
|
||||||
return decryptCalendarRoot(content, rootId)
|
|
||||||
}
|
|
||||||
|
|
||||||
const decryptCalendarRoot = (content, rootId) => {
|
const decryptCalendarRoot = (content, rootId) => {
|
||||||
if (!content || !content.encryptedPayload) return content
|
if (!content || !content.encryptedPayload) return content
|
||||||
if (!tribeCrypto) return content
|
if (!tribeCrypto) return content
|
||||||
|
|
@ -266,7 +259,6 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
|
||||||
status: c.status || "OPEN",
|
status: c.status || "OPEN",
|
||||||
deadline: undec ? "" : (c.deadline || ""),
|
deadline: undec ? "" : (c.deadline || ""),
|
||||||
tags: Array.isArray(c.tags) ? c.tags : [],
|
tags: Array.isArray(c.tags) ? c.tags : [],
|
||||||
mapUrl: typeof c.mapUrl === "string" ? c.mapUrl : "",
|
|
||||||
author: c.author || node.author,
|
author: c.author || node.author,
|
||||||
participants: Array.isArray(participants) ? participants : (Array.isArray(c.participants) ? c.participants : []),
|
participants: Array.isArray(participants) ? participants : (Array.isArray(c.participants) ? c.participants : []),
|
||||||
invites: Array.isArray(c.invites) ? c.invites : [],
|
invites: Array.isArray(c.invites) ? c.invites : [],
|
||||||
|
|
@ -347,18 +339,14 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
|
||||||
return tip
|
return tip
|
||||||
},
|
},
|
||||||
|
|
||||||
async createCalendar({ title, status, deadline, tags, firstDate, firstDateLabel, firstNote, intervalWeekly, intervalMonthly, intervalYearly, intervalDeadline, mapUrl, tribeId }) {
|
async createCalendar({ title, status, deadline, tags, firstDate, firstDateLabel, firstNote, intervalWeekly, intervalMonthly, intervalYearly, tribeId }) {
|
||||||
const ssbClient = await openSsb()
|
const ssbClient = await openSsb()
|
||||||
const userId = ssbClient.id
|
const userId = ssbClient.id
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
const validStatus = ["OPEN", "CLOSED"].includes(String(status).toUpperCase()) ? String(status).toUpperCase() : "OPEN"
|
const validStatus = ["OPEN", "CLOSED"].includes(String(status).toUpperCase()) ? String(status).toUpperCase() : "OPEN"
|
||||||
|
|
||||||
if (!firstDate) throw new Error("First date must be in the future")
|
if (deadline && new Date(deadline).getTime() <= Date.now()) throw new Error("Deadline must be in the future")
|
||||||
assertCalendarDates({
|
if (!firstDate || new Date(firstDate).getTime() <= Date.now()) throw new Error("First date must be in the future")
|
||||||
deadline,
|
|
||||||
date: firstDate,
|
|
||||||
until: hasAnyInterval(intervalWeekly, intervalMonthly, intervalYearly) ? intervalDeadline : ""
|
|
||||||
})
|
|
||||||
|
|
||||||
let plainContent = {
|
let plainContent = {
|
||||||
type: "calendar",
|
type: "calendar",
|
||||||
|
|
@ -366,7 +354,6 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
|
||||||
status: validStatus,
|
status: validStatus,
|
||||||
deadline: deadline || "",
|
deadline: deadline || "",
|
||||||
tags: normalizeTags(tags),
|
tags: normalizeTags(tags),
|
||||||
mapUrl: safeText(mapUrl),
|
|
||||||
author: userId,
|
author: userId,
|
||||||
participants: [userId],
|
participants: [userId],
|
||||||
invites: [],
|
invites: [],
|
||||||
|
|
@ -413,7 +400,6 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
|
||||||
status: validStatus,
|
status: validStatus,
|
||||||
deadline: dec.deadline || "",
|
deadline: dec.deadline || "",
|
||||||
tags: Array.isArray(dec.tags) ? dec.tags : [],
|
tags: Array.isArray(dec.tags) ? dec.tags : [],
|
||||||
mapUrl: dec.mapUrl || "",
|
|
||||||
author: userId,
|
author: userId,
|
||||||
participants: [userId],
|
participants: [userId],
|
||||||
invites: [{ code: pubCode, ek, salt: inviteSalt, gen: 1, public: true }],
|
invites: [{ code: pubCode, ek, salt: inviteSalt, gen: 1, public: true }],
|
||||||
|
|
@ -438,7 +424,7 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
|
||||||
...(intervalWeekly ? { intervalWeekly: true } : {}),
|
...(intervalWeekly ? { intervalWeekly: true } : {}),
|
||||||
...(intervalMonthly ? { intervalMonthly: true } : {}),
|
...(intervalMonthly ? { intervalMonthly: true } : {}),
|
||||||
...(intervalYearly ? { intervalYearly: true } : {}),
|
...(intervalYearly ? { intervalYearly: true } : {}),
|
||||||
...(hasAnyInterval(intervalWeekly, intervalMonthly, intervalYearly) && (intervalDeadline || deadline) ? { intervalDeadline: intervalDeadline || deadline } : {}),
|
...(deadline && hasAnyInterval(intervalWeekly, intervalMonthly, intervalYearly) ? { intervalDeadline: deadline } : {}),
|
||||||
...(tribeId ? { tribeId } : {})
|
...(tribeId ? { tribeId } : {})
|
||||||
}
|
}
|
||||||
if (tribeId) dateContent = await encryptIfTribe(dateContent)
|
if (tribeId) dateContent = await encryptIfTribe(dateContent)
|
||||||
|
|
@ -481,15 +467,12 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
|
||||||
: decryptCalendarRoot(item.content, rootId)
|
: decryptCalendarRoot(item.content, rootId)
|
||||||
assertReadable(oldDec, "Calendar")
|
assertReadable(oldDec, "Calendar")
|
||||||
if ((oldDec.author || item.content.author) !== userId) throw new Error("Not the author")
|
if ((oldDec.author || item.content.author) !== userId) throw new Error("Not the author")
|
||||||
const nextDeadline = data.deadline !== undefined ? data.deadline : (oldDec.deadline || "")
|
|
||||||
if (nextDeadline !== (oldDec.deadline || "")) assertCalendarDates({ deadline: nextDeadline })
|
|
||||||
let updated = {
|
let updated = {
|
||||||
type: "calendar",
|
type: "calendar",
|
||||||
title: data.title !== undefined ? safeText(data.title) : (oldDec.title || ""),
|
title: data.title !== undefined ? safeText(data.title) : (oldDec.title || ""),
|
||||||
status: data.status !== undefined ? (["OPEN","CLOSED"].includes(String(data.status).toUpperCase()) ? String(data.status).toUpperCase() : oldDec.status) : (oldDec.status || "OPEN"),
|
status: data.status !== undefined ? (["OPEN","CLOSED"].includes(String(data.status).toUpperCase()) ? String(data.status).toUpperCase() : oldDec.status) : (oldDec.status || "OPEN"),
|
||||||
deadline: nextDeadline,
|
deadline: data.deadline !== undefined ? data.deadline : (oldDec.deadline || ""),
|
||||||
tags: data.tags !== undefined ? normalizeTags(data.tags) : (Array.isArray(oldDec.tags) ? oldDec.tags : []),
|
tags: data.tags !== undefined ? normalizeTags(data.tags) : (Array.isArray(oldDec.tags) ? oldDec.tags : []),
|
||||||
mapUrl: data.mapUrl !== undefined ? safeText(data.mapUrl) : (oldDec.mapUrl || ""),
|
|
||||||
author: oldDec.author || userId,
|
author: oldDec.author || userId,
|
||||||
participants: oldDec.participants || [userId],
|
participants: oldDec.participants || [userId],
|
||||||
invites: Array.isArray(oldDec.invites) ? oldDec.invites : [],
|
invites: Array.isArray(oldDec.invites) ? oldDec.invites : [],
|
||||||
|
|
@ -512,8 +495,7 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
|
||||||
const userId = ssbClient.id
|
const userId = ssbClient.id
|
||||||
const item = await new Promise((resolve, reject) => ssbClient.get(tipId, (e, it) => e ? reject(e) : resolve(it)))
|
const item = await new Promise((resolve, reject) => ssbClient.get(tipId, (e, it) => e ? reject(e) : resolve(it)))
|
||||||
if (!item || !item.content) throw new Error("Calendar not found")
|
if (!item || !item.content) throw new Error("Calendar not found")
|
||||||
const rootId = await this.resolveRootId(id)
|
const dec = await decryptIfTribe(item.content)
|
||||||
const dec = await decryptScoped(item.content, rootId)
|
|
||||||
assertReadable(dec, "Calendar")
|
assertReadable(dec, "Calendar")
|
||||||
const contentAuthor = (dec && dec.author) || (typeof item.content === 'object' && item.content.author)
|
const contentAuthor = (dec && dec.author) || (typeof item.content === 'object' && item.content.author)
|
||||||
if (contentAuthor !== userId) throw new Error("Not the author")
|
if (contentAuthor !== userId) throw new Error("Not the author")
|
||||||
|
|
@ -606,14 +588,9 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
|
||||||
const cal = await this.getCalendarById(rootId)
|
const cal = await this.getCalendarById(rootId)
|
||||||
if (!cal) throw new Error("Calendar not found")
|
if (!cal) throw new Error("Calendar not found")
|
||||||
if (cal.status === "CLOSED" && userId !== cal.author) throw new Error("Only the author can add dates to a CLOSED calendar")
|
if (cal.status === "CLOSED" && userId !== cal.author) throw new Error("Only the author can add dates to a CLOSED calendar")
|
||||||
if (!date) throw new Error("Date must be in the future")
|
if (!date || new Date(date).getTime() <= Date.now()) throw new Error("Date must be in the future")
|
||||||
const hasInterval = hasAnyInterval(intervalWeekly, intervalMonthly, intervalYearly)
|
|
||||||
assertCalendarDates({
|
|
||||||
deadline: cal.deadline || "",
|
|
||||||
date,
|
|
||||||
until: hasInterval ? intervalDeadline : ""
|
|
||||||
})
|
|
||||||
|
|
||||||
|
const hasInterval = hasAnyInterval(intervalWeekly, intervalMonthly, intervalYearly)
|
||||||
const ruleDeadline = hasInterval ? (intervalDeadline || cal.deadline || "") : ""
|
const ruleDeadline = hasInterval ? (intervalDeadline || cal.deadline || "") : ""
|
||||||
let dateContent = {
|
let dateContent = {
|
||||||
type: "calendarDate",
|
type: "calendarDate",
|
||||||
|
|
@ -771,9 +748,8 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
|
||||||
const userId = ssbClient.id
|
const userId = ssbClient.id
|
||||||
const item = await new Promise((resolve, reject) => ssbClient.get(noteId, (e, it) => e ? reject(e) : resolve(it)))
|
const item = await new Promise((resolve, reject) => ssbClient.get(noteId, (e, it) => e ? reject(e) : resolve(it)))
|
||||||
if (!item || !item.content) throw new Error("Note not found")
|
if (!item || !item.content) throw new Error("Note not found")
|
||||||
const noteRoot = item.content.calendarId || null
|
const dec = await decryptIfTribe(item.content)
|
||||||
const dec = await decryptScoped(item.content, noteRoot)
|
if ((dec.author || item.content.author) !== userId) throw new Error("Not the author")
|
||||||
if (((dec && dec.author) || item.content.author) !== userId) throw new Error("Not the author")
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
ssbClient.publish({ type: "tombstone", target: noteId, deletedAt: new Date().toISOString(), author: userId }, (e, msg) => e ? reject(e) : resolve(msg))
|
ssbClient.publish({ type: "tombstone", target: noteId, deletedAt: new Date().toISOString(), author: userId }, (e, msg) => e ? reject(e) : resolve(msg))
|
||||||
})
|
})
|
||||||
|
|
@ -801,8 +777,8 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
|
||||||
if (tombstoned.has(m.key)) continue
|
if (tombstoned.has(m.key)) continue
|
||||||
if (c.calendarId !== rootId || c.dateId !== dateId) continue
|
if (c.calendarId !== rootId || c.dateId !== dateId) continue
|
||||||
let dec = c
|
let dec = c
|
||||||
if (c.encryptedPayload) {
|
if (c.encryptedPayload && tribeCrypto && tribesModel) {
|
||||||
const r = await decryptScoped(c, rootId)
|
const r = await tribeCrypto.decryptFromTribe(c, tribesModel)
|
||||||
if (r && !r._undecryptable) dec = r
|
if (r && !r._undecryptable) dec = r
|
||||||
else continue
|
else continue
|
||||||
}
|
}
|
||||||
|
|
@ -953,7 +929,6 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
|
||||||
status: dec.status || "OPEN",
|
status: dec.status || "OPEN",
|
||||||
deadline: dec.deadline || "",
|
deadline: dec.deadline || "",
|
||||||
tags: Array.isArray(dec.tags) ? dec.tags : [],
|
tags: Array.isArray(dec.tags) ? dec.tags : [],
|
||||||
mapUrl: dec.mapUrl || "",
|
|
||||||
author: dec.author,
|
author: dec.author,
|
||||||
participants: Array.isArray(dec.participants) ? dec.participants : [userId],
|
participants: Array.isArray(dec.participants) ? dec.participants : [userId],
|
||||||
invites,
|
invites,
|
||||||
|
|
@ -1000,7 +975,6 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
|
||||||
status: dec.status || "OPEN",
|
status: dec.status || "OPEN",
|
||||||
deadline: dec.deadline || "",
|
deadline: dec.deadline || "",
|
||||||
tags: Array.isArray(dec.tags) ? dec.tags : [],
|
tags: Array.isArray(dec.tags) ? dec.tags : [],
|
||||||
mapUrl: dec.mapUrl || "",
|
|
||||||
author: dec.author,
|
author: dec.author,
|
||||||
participants: Array.isArray(dec.participants) ? dec.participants : [userId],
|
participants: Array.isArray(dec.participants) ? dec.participants : [userId],
|
||||||
invites,
|
invites,
|
||||||
|
|
@ -1035,7 +1009,7 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
|
||||||
let calKey = null
|
let calKey = null
|
||||||
if (tribeCrypto && typeof matchedInvite === "object") {
|
if (tribeCrypto && typeof matchedInvite === "object") {
|
||||||
if (matchedInvite.ekChain) {
|
if (matchedInvite.ekChain) {
|
||||||
const chain = tribeCrypto.decryptChainFromInvite(matchedInvite.ekChain, code, matchedInvite.salt, 3)
|
const chain = tribeCrypto.decryptChainFromInvite(matchedInvite.ekChain, code, matchedInvite.salt)
|
||||||
if (Array.isArray(chain) && chain.length) {
|
if (Array.isArray(chain) && chain.length) {
|
||||||
for (const entry of chain) {
|
for (const entry of chain) {
|
||||||
if (Array.isArray(entry.keys) && entry.keys.length) {
|
if (Array.isArray(entry.keys) && entry.keys.length) {
|
||||||
|
|
@ -1069,7 +1043,6 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
|
||||||
status: dec.status || "OPEN",
|
status: dec.status || "OPEN",
|
||||||
deadline: dec.deadline || "",
|
deadline: dec.deadline || "",
|
||||||
tags: Array.isArray(dec.tags) ? dec.tags : [],
|
tags: Array.isArray(dec.tags) ? dec.tags : [],
|
||||||
mapUrl: dec.mapUrl || "",
|
|
||||||
author: dec.author,
|
author: dec.author,
|
||||||
participants: [...(Array.isArray(dec.participants) ? dec.participants : []), userId],
|
participants: [...(Array.isArray(dec.participants) ? dec.participants : []), userId],
|
||||||
invites,
|
invites,
|
||||||
|
|
|
||||||
|
|
@ -17,22 +17,10 @@ const normalizeTags = (raw) => {
|
||||||
const INVITE_CODE_BYTES = 16
|
const INVITE_CODE_BYTES = 16
|
||||||
const VALID_STATUS = ["OPEN", "INVITE-ONLY", "CLOSED"]
|
const VALID_STATUS = ["OPEN", "INVITE-ONLY", "CLOSED"]
|
||||||
|
|
||||||
const DEFAULT_MESSAGES_PER_HOUR = 60
|
|
||||||
|
|
||||||
module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
|
module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
|
||||||
let ssb
|
let ssb
|
||||||
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb }
|
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb }
|
||||||
|
|
||||||
const MESSAGES_PER_HOUR = (() => {
|
|
||||||
try {
|
|
||||||
const raw = getConfig()?.chats?.messagesPerHour
|
|
||||||
const n = parseInt(raw, 10)
|
|
||||||
return Number.isFinite(n) && n > 0 ? n : DEFAULT_MESSAGES_PER_HOUR
|
|
||||||
} catch (_) {
|
|
||||||
return DEFAULT_MESSAGES_PER_HOUR
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
|
|
||||||
const ownCrypto = chatCrypto || tribeCrypto
|
const ownCrypto = chatCrypto || tribeCrypto
|
||||||
const lookupKey = (rid) => (ownCrypto && ownCrypto.getKey(rid)) || (tribeCrypto && tribeCrypto.getKey(rid)) || null
|
const lookupKey = (rid) => (ownCrypto && ownCrypto.getKey(rid)) || (tribeCrypto && tribeCrypto.getKey(rid)) || null
|
||||||
const lookupKeys = (rid) => {
|
const lookupKeys = (rid) => {
|
||||||
|
|
@ -304,9 +292,9 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
|
||||||
chatId: c.chatId || "",
|
chatId: c.chatId || "",
|
||||||
text,
|
text,
|
||||||
image: c.image || null,
|
image: c.image || null,
|
||||||
replyTo: c.replyTo || null,
|
|
||||||
author: c.author || node.author,
|
author: c.author || node.author,
|
||||||
createdAt: c.createdAt || new Date(node.ts).toISOString()
|
createdAt: c.createdAt || new Date(node.ts).toISOString(),
|
||||||
|
replyTo: c.replyTo || null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -370,15 +358,6 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
|
||||||
return {
|
return {
|
||||||
type: "chat",
|
type: "chat",
|
||||||
|
|
||||||
async encryptionKeyFor(chatRootId, tribeId = null) {
|
|
||||||
if (!tribeCrypto) return null
|
|
||||||
if (tribeId) {
|
|
||||||
const k = await getTribeFirstKeyFor(tribeId)
|
|
||||||
if (k) return k
|
|
||||||
}
|
|
||||||
return lookupKey(chatRootId) || null
|
|
||||||
},
|
|
||||||
|
|
||||||
async resolveRootId(id) {
|
async resolveRootId(id) {
|
||||||
const ssbClient = await openSsb()
|
const ssbClient = await openSsb()
|
||||||
const messages = await readAll(ssbClient)
|
const messages = await readAll(ssbClient)
|
||||||
|
|
@ -618,18 +597,6 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
|
||||||
|
|
||||||
let list = chatCollab.visibleThenCollapsed(collectChats(idx), uid)
|
let list = chatCollab.visibleThenCollapsed(collectChats(idx), uid)
|
||||||
|
|
||||||
const lastMsgAt = new Map()
|
|
||||||
const lastMineAt = new Map()
|
|
||||||
for (const node of idx.msgNodes.values()) {
|
|
||||||
const cid = node.c && node.c.chatId
|
|
||||||
if (!cid) continue
|
|
||||||
const r = idx.rawRootOf(cid) || cid
|
|
||||||
const t = node.ts || 0
|
|
||||||
if (t > (lastMsgAt.get(r) || 0)) lastMsgAt.set(r, t)
|
|
||||||
if (node.author === uid && t > (lastMineAt.get(r) || 0)) lastMineAt.set(r, t)
|
|
||||||
}
|
|
||||||
list = list.map(c => ({ ...c, lastMsgAt: lastMsgAt.get(c.rootId) || 0, lastMineAt: lastMineAt.get(c.rootId) || 0 }))
|
|
||||||
|
|
||||||
if (filter === "mine") list = list.filter(c => c.author === uid)
|
if (filter === "mine") list = list.filter(c => c.author === uid)
|
||||||
else if (filter === "recent") list = list.filter(c => new Date(c.createdAt).getTime() >= now - 86400000)
|
else if (filter === "recent") list = list.filter(c => new Date(c.createdAt).getTime() >= now - 86400000)
|
||||||
else if (filter === "open") list = list.filter(c => c.status === "OPEN" || c.status === "INVITE-ONLY")
|
else if (filter === "open") list = list.filter(c => c.status === "OPEN" || c.status === "INVITE-ONLY")
|
||||||
|
|
@ -740,7 +707,7 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
|
||||||
let chatKey = null
|
let chatKey = null
|
||||||
if (tribeCrypto && typeof matchedInvite === "object") {
|
if (tribeCrypto && typeof matchedInvite === "object") {
|
||||||
if (matchedInvite.ekChain) {
|
if (matchedInvite.ekChain) {
|
||||||
const chain = tribeCrypto.decryptChainFromInvite(matchedInvite.ekChain, code, matchedInvite.salt, 3)
|
const chain = tribeCrypto.decryptChainFromInvite(matchedInvite.ekChain, code, matchedInvite.salt)
|
||||||
if (Array.isArray(chain) && chain.length) {
|
if (Array.isArray(chain) && chain.length) {
|
||||||
for (const entry of chain) {
|
for (const entry of chain) {
|
||||||
if (Array.isArray(entry.keys) && entry.keys.length) {
|
if (Array.isArray(entry.keys) && entry.keys.length) {
|
||||||
|
|
@ -842,12 +809,7 @@ module.exports = ({ cooler, tribeCrypto, chatCrypto, tribesModel }) => {
|
||||||
const c = m.value?.content
|
const c = m.value?.content
|
||||||
return c?.type === "chatMessage" && c?.chatId === chat.rootId && m.value?.author === userId && (m.value?.timestamp || 0) >= oneHourAgo
|
return c?.type === "chatMessage" && c?.chatId === chat.rootId && m.value?.author === userId && (m.value?.timestamp || 0) >= oneHourAgo
|
||||||
}).length
|
}).length
|
||||||
if (recentCount >= MESSAGES_PER_HOUR) {
|
if (process.env.OASIS_MOBILE !== '1' && recentCount >= 3) throw new Error("Rate limit: max 3 messages per hour")
|
||||||
const err = new Error(`Rate limit: max ${MESSAGES_PER_HOUR} messages per hour`)
|
|
||||||
err.code = "CHAT_RATE_LIMIT"
|
|
||||||
err.retryAfterMinutes = 60
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
let content = {
|
let content = {
|
||||||
|
|
|
||||||
|
|
@ -223,27 +223,14 @@ module.exports = (configPath, namespace = 'tribes') => {
|
||||||
return decryptWithKey(encryptedKey, derived.toString('hex'), inviteAad(inviteCode, salt));
|
return decryptWithKey(encryptedKey, derived.toString('hex'), inviteAad(inviteCode, salt));
|
||||||
};
|
};
|
||||||
|
|
||||||
const INVITE_ENCRYPT_ATTEMPTS = 3;
|
|
||||||
|
|
||||||
const encryptChainForInvite = (ancestryRootIds, code, salt) => {
|
const encryptChainForInvite = (ancestryRootIds, code, salt) => {
|
||||||
const chain = ancestryRootIds.map(rid => ({ rootId: rid, keys: getKeys(rid), gen: getGen(rid) }));
|
const chain = ancestryRootIds.map(rid => ({ rootId: rid, keys: getKeys(rid), gen: getGen(rid) }));
|
||||||
if (chain.some(e => !Array.isArray(e.keys) || !e.keys.length)) return null;
|
if (chain.some(e => !Array.isArray(e.keys) || !e.keys.length)) return null;
|
||||||
const plain = JSON.stringify(chain);
|
|
||||||
const aad = inviteAad(code, salt);
|
|
||||||
for (let attempt = 0; attempt < INVITE_ENCRYPT_ATTEMPTS; attempt++) {
|
|
||||||
const k = deriveInviteKey(code, salt);
|
const k = deriveInviteKey(code, salt);
|
||||||
const payload = encryptWithKey(plain, k.toString('hex'), aad);
|
return encryptWithKey(JSON.stringify(chain), k.toString('hex'), inviteAad(code, salt));
|
||||||
if (!payload) continue;
|
|
||||||
const readBack = decryptChainFromInvite(payload, code, salt);
|
|
||||||
if (readBack && JSON.stringify(readBack.map(e => ({ rootId: e.rootId, keys: e.keys, gen: e.gen })))
|
|
||||||
=== JSON.stringify(chain.map(e => ({ rootId: e.rootId, keys: e.keys, gen: e.gen })))) {
|
|
||||||
return payload;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new Error('Could not produce a verifiable invite on this machine');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const decryptChainOnce = (encryptedPayload, code, salt) => {
|
const decryptChainFromInvite = (encryptedPayload, code, salt) => {
|
||||||
const k = deriveInviteKey(code, salt);
|
const k = deriveInviteKey(code, salt);
|
||||||
try {
|
try {
|
||||||
const json = decryptWithKey(encryptedPayload, k.toString('hex'), inviteAad(code, salt));
|
const json = decryptWithKey(encryptedPayload, k.toString('hex'), inviteAad(code, salt));
|
||||||
|
|
@ -260,15 +247,6 @@ module.exports = (configPath, namespace = 'tribes') => {
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const decryptChainFromInvite = (encryptedPayload, code, salt, attempts = 1) => {
|
|
||||||
const tries = Math.max(1, Number(attempts) || 1);
|
|
||||||
for (let i = 0; i < tries; i++) {
|
|
||||||
const chain = decryptChainOnce(encryptedPayload, code, salt);
|
|
||||||
if (chain) return chain;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const inviteMatchesCode = (inv, code) => {
|
const inviteMatchesCode = (inv, code) => {
|
||||||
if (!inv || typeof inv !== 'object' || !inv.codeHash) return false;
|
if (!inv || typeof inv !== 'object' || !inv.codeHash) return false;
|
||||||
return inv.codeHash === hashInviteCode(code, inv.salt);
|
return inv.codeHash === hashInviteCode(code, inv.salt);
|
||||||
|
|
|
||||||
|
|
@ -47,8 +47,6 @@ module.exports = ({ cooler }) => {
|
||||||
status: data.status || 'LOOKING FOR WORK',
|
status: data.status || 'LOOKING FOR WORK',
|
||||||
preferences: data.preferences || 'REMOTE WORKING',
|
preferences: data.preferences || 'REMOTE WORKING',
|
||||||
visibility: String(data.visibility || 'PUBLIC').toUpperCase() === 'HIDDEN' ? 'HIDDEN' : 'PUBLIC',
|
visibility: String(data.visibility || 'PUBLIC').toUpperCase() === 'HIDDEN' ? 'HIDDEN' : 'PUBLIC',
|
||||||
aiManaged: data.aiManaged === undefined ? true : (data.aiManaged === true || data.aiManaged === '1' || data.aiManaged === 'true'),
|
|
||||||
matchThreshold: Math.min(100, Math.max(0, parseInt(data.matchThreshold, 10) || 80)),
|
|
||||||
createdAt: new Date().toISOString()
|
createdAt: new Date().toISOString()
|
||||||
};
|
};
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
|
@ -87,7 +85,7 @@ module.exports = ({ cooler }) => {
|
||||||
author: userId,
|
author: userId,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
description: data.description,
|
description: data.description,
|
||||||
photo: extractBlobId(photoBlobId) || old.content.photo || null,
|
photo: extractBlobId(photoBlobId) || null,
|
||||||
contact: userId,
|
contact: userId,
|
||||||
personalSkills: parseCSV(data.personalSkills),
|
personalSkills: parseCSV(data.personalSkills),
|
||||||
personalExperiences: data.personalExperiences || '',
|
personalExperiences: data.personalExperiences || '',
|
||||||
|
|
@ -104,12 +102,6 @@ module.exports = ({ cooler }) => {
|
||||||
visibility: data.visibility !== undefined
|
visibility: data.visibility !== undefined
|
||||||
? (String(data.visibility).toUpperCase() === 'HIDDEN' ? 'HIDDEN' : 'PUBLIC')
|
? (String(data.visibility).toUpperCase() === 'HIDDEN' ? 'HIDDEN' : 'PUBLIC')
|
||||||
: (old.content.visibility || 'PUBLIC'),
|
: (old.content.visibility || 'PUBLIC'),
|
||||||
aiManaged: data.aiManaged !== undefined
|
|
||||||
? (data.aiManaged === true || data.aiManaged === '1' || data.aiManaged === 'true')
|
|
||||||
: (old.content.aiManaged !== false),
|
|
||||||
matchThreshold: data.matchThreshold !== undefined
|
|
||||||
? Math.min(100, Math.max(0, parseInt(data.matchThreshold, 10) || 80))
|
|
||||||
: (Number(old.content.matchThreshold) || 80),
|
|
||||||
createdAt: old.content.createdAt,
|
createdAt: old.content.createdAt,
|
||||||
updatedAt: new Date().toISOString()
|
updatedAt: new Date().toISOString()
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,292 +0,0 @@
|
||||||
const pull = require('../server/node_modules/pull-stream');
|
|
||||||
const { getConfig } = require('../configs/config-manager.js');
|
|
||||||
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
|
|
||||||
|
|
||||||
const logLimit = getConfig().ssbLogStream?.limit || 1000;
|
|
||||||
|
|
||||||
const norm = (v) => String(v == null ? '' : v).trim().toLowerCase();
|
|
||||||
|
|
||||||
const KINDS = {
|
|
||||||
inhabitants: { type: 'curriculum', href: (id, c) => `/author/${encodeURIComponent(c.author)}` },
|
|
||||||
jobs: { type: 'job', href: (id) => `/jobs/${encodeURIComponent(id)}` },
|
|
||||||
projects: { type: 'project', href: (id) => `/projects/${encodeURIComponent(id)}` },
|
|
||||||
events: { type: 'event', href: (id) => `/events/${encodeURIComponent(id)}` },
|
|
||||||
tribes: { type: 'tribe', href: (id) => `/tribe/${encodeURIComponent(id)}` },
|
|
||||||
market: { type: 'market', href: (id) => `/market/${encodeURIComponent(id)}` },
|
|
||||||
housing: { type: 'housing', href: (id) => `/housing/${encodeURIComponent(id)}` },
|
|
||||||
industry: { type: 'industry', href: (id) => `/industry/${encodeURIComponent(id)}` },
|
|
||||||
tasks: { type: 'task', href: (id) => `/tasks/${encodeURIComponent(id)}` },
|
|
||||||
reports: { type: 'report', href: (id) => `/reports/${encodeURIComponent(id)}` },
|
|
||||||
votes: { type: 'poll', href: (id) => `/polls/${encodeURIComponent(id)}` },
|
|
||||||
audios: { type: 'audio', href: (id) => `/audios/${encodeURIComponent(id)}` },
|
|
||||||
videos: { type: 'video', href: (id) => `/videos/${encodeURIComponent(id)}` },
|
|
||||||
images: { type: 'image', href: (id) => `/images/${encodeURIComponent(id)}` },
|
|
||||||
documents: { type: 'document', href: (id) => `/documents/${encodeURIComponent(id)}` },
|
|
||||||
bookmarks: { type: 'bookmark', href: (id) => `/bookmarks/${encodeURIComponent(id)}` },
|
|
||||||
torrents: { type: 'torrent', href: (id) => `/torrents/${encodeURIComponent(id)}` },
|
|
||||||
chats: { type: 'chat', href: (id) => `/chats/${encodeURIComponent(id)}` },
|
|
||||||
pads: { type: 'pad', href: (id) => `/pads/${encodeURIComponent(id)}` },
|
|
||||||
maps: { type: 'map', href: (id) => `/maps/${encodeURIComponent(id)}` },
|
|
||||||
calendars: { type: 'calendar', href: (id) => `/calendars/${encodeURIComponent(id)}` },
|
|
||||||
forum: { type: 'forum', href: (id) => `/forum/${encodeURIComponent(id)}` }
|
|
||||||
};
|
|
||||||
|
|
||||||
const KIND_BY_TYPE = Object.fromEntries(Object.entries(KINDS).map(([k, v]) => [v.type, k]));
|
|
||||||
|
|
||||||
const cvSkills = (c) => [
|
|
||||||
...(c.personalSkills || []),
|
|
||||||
...(c.oasisSkills || []),
|
|
||||||
...(c.educationalSkills || []),
|
|
||||||
...(c.professionalSkills || [])
|
|
||||||
];
|
|
||||||
|
|
||||||
const PLACEHOLDERS = new Set(['unknown', 'n/a', 'na', 'none', '-', 'other']);
|
|
||||||
|
|
||||||
const TITLE_STOPWORDS = new Set([
|
|
||||||
'the', 'this', 'that', 'and', 'for', 'with', 'from', 'into', 'about', 'new', 'all', 'not', 'are', 'was', 'you', 'your', 'our',
|
|
||||||
'los', 'las', 'del', 'una', 'unos', 'unas', 'este', 'esta', 'esto', 'que', 'con', 'para', 'por', 'sin', 'sobre', 'bajo', 'mas', 'más'
|
|
||||||
]);
|
|
||||||
|
|
||||||
const titleTerms = (c) => String(c.title || c.name || c.question || c.concept || '')
|
|
||||||
.toLowerCase()
|
|
||||||
.split(/[^0-9A-Za-zÀ-]+/) // sin \p{}: nodejs-mobile no lleva ICU completo
|
|
||||||
.filter(w => w.length >= 3 && !TITLE_STOPWORDS.has(w) && !/^\d+$/.test(w));
|
|
||||||
|
|
||||||
const termsOf = (kind, c) => {
|
|
||||||
const out = [];
|
|
||||||
if (kind === 'inhabitants') {
|
|
||||||
out.push(...cvSkills(c));
|
|
||||||
if (c.languages) out.push(...String(c.languages).split(/[,;]/));
|
|
||||||
} else if (kind === 'jobs') {
|
|
||||||
out.push(...(c.tasks || []), ...(c.tags || []), c.job_type, c.location);
|
|
||||||
} else if (kind === 'industry') {
|
|
||||||
out.push(...(c.tags || []), c.sector, ...(c.skills || []));
|
|
||||||
} else {
|
|
||||||
out.push(...(c.tags || []));
|
|
||||||
if (c.category) out.push(c.category);
|
|
||||||
}
|
|
||||||
return Array.from(new Set(out.map(norm).filter(t => t && !PLACEHOLDERS.has(t))));
|
|
||||||
};
|
|
||||||
|
|
||||||
const titleOf = (kind, c, author) => {
|
|
||||||
if (kind === 'inhabitants') return c.name || author || '';
|
|
||||||
return c.title || c.name || c.question || c.concept || '';
|
|
||||||
};
|
|
||||||
|
|
||||||
const jaccard = (a, b) => {
|
|
||||||
if (!a.size || !b.size) return { score: 0, common: [] };
|
|
||||||
const common = [...b].filter(x => a.has(x));
|
|
||||||
const union = a.size + b.size - common.length;
|
|
||||||
return { score: union > 0 ? common.length / union : 0, common };
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = ({ cooler }) => {
|
|
||||||
let ssb;
|
|
||||||
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
|
|
||||||
|
|
||||||
const getAllMessages = async (ssbClient) =>
|
|
||||||
new Promise((resolve, reject) => {
|
|
||||||
pull(
|
|
||||||
ssbClient.createLogStream({ limit: logLimit }),
|
|
||||||
pull.collect((err, msgs) => (err ? reject(err) : resolve(msgs)))
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const buildGraph = async () => {
|
|
||||||
const ssbClient = await openSsb();
|
|
||||||
const viewerId = ssbClient.id;
|
|
||||||
const messages = await getAllMessages(ssbClient);
|
|
||||||
const tomb = buildValidatedTombstoneSet(messages);
|
|
||||||
|
|
||||||
const latestByKey = new Map();
|
|
||||||
const replaced = new Set();
|
|
||||||
for (const m of messages) {
|
|
||||||
const v = m && m.value;
|
|
||||||
const c = v && v.content;
|
|
||||||
if (!c || typeof c !== 'object' || !c.type) continue;
|
|
||||||
if (tomb.has(m.key)) continue;
|
|
||||||
if (c.encryptedPayload || c.encryptedQuestion) continue;
|
|
||||||
if (c.tribeId && c.type !== 'tribe') continue;
|
|
||||||
const kind = KIND_BY_TYPE[c.type];
|
|
||||||
if (!kind) continue;
|
|
||||||
if (typeof c.replaces === 'string') replaced.add(c.replaces);
|
|
||||||
latestByKey.set(m.key, { key: m.key, author: v.author, ts: v.timestamp || m.timestamp || 0, kind, c });
|
|
||||||
}
|
|
||||||
|
|
||||||
const nodes = [];
|
|
||||||
const byAuthorCv = new Map();
|
|
||||||
for (const node of latestByKey.values()) {
|
|
||||||
if (replaced.has(node.key)) continue;
|
|
||||||
const coreTerms = termsOf(node.kind, node.c);
|
|
||||||
const extra = node.kind === 'inhabitants' ? [] : titleTerms(node.c).map(norm).filter(t => t && !PLACEHOLDERS.has(t));
|
|
||||||
const terms = Array.from(new Set([...coreTerms, ...extra]));
|
|
||||||
if (!terms.length) continue;
|
|
||||||
const entry = {
|
|
||||||
id: node.key,
|
|
||||||
kind: node.kind,
|
|
||||||
author: node.c.author || node.author,
|
|
||||||
title: titleOf(node.kind, node.c, node.c.author || node.author),
|
|
||||||
terms,
|
|
||||||
termSet: new Set(terms),
|
|
||||||
coreTermSet: new Set(coreTerms),
|
|
||||||
ts: node.ts,
|
|
||||||
createdAt: node.c.createdAt || new Date(node.ts).toISOString(),
|
|
||||||
href: KINDS[node.kind].href(node.key, node.c)
|
|
||||||
};
|
|
||||||
if (node.kind === 'inhabitants') {
|
|
||||||
const prev = byAuthorCv.get(entry.author);
|
|
||||||
if (prev && prev.ts >= entry.ts) continue;
|
|
||||||
byAuthorCv.set(entry.author, entry);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
nodes.push(entry);
|
|
||||||
}
|
|
||||||
for (const cv of byAuthorCv.values()) nodes.push(cv);
|
|
||||||
|
|
||||||
return { viewerId, nodes, cvByAuthor: byAuthorCv };
|
|
||||||
};
|
|
||||||
|
|
||||||
const MAX_ENTITIES = 400;
|
|
||||||
const MAX_PAIRS = 300;
|
|
||||||
|
|
||||||
const strip = (n) => ({
|
|
||||||
id: n.id, kind: n.kind, author: n.author, title: n.title,
|
|
||||||
href: n.href, createdAt: n.createdAt, ts: n.ts
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
KINDS: Object.keys(KINDS),
|
|
||||||
|
|
||||||
async listMatches(filter = 'ALL', opts = {}) {
|
|
||||||
const { viewerId, nodes, cvByAuthor } = await buildGraph();
|
|
||||||
const use = nodes.slice(0, MAX_ENTITIES);
|
|
||||||
const f = String(filter || 'ALL').toUpperCase();
|
|
||||||
|
|
||||||
const myTermSet = new Set();
|
|
||||||
const mineCv = cvByAuthor.get(viewerId);
|
|
||||||
if (mineCv) for (const t of mineCv.terms) myTermSet.add(t);
|
|
||||||
for (const n of use) {
|
|
||||||
if (String(n.author) === String(viewerId)) for (const t of n.terms) myTermSet.add(t);
|
|
||||||
}
|
|
||||||
|
|
||||||
const df = new Map();
|
|
||||||
for (const n of use) for (const t of n.termSet) df.set(t, (df.get(t) || 0) + 1);
|
|
||||||
const total = use.length || 1;
|
|
||||||
const weightOf = (t) => Math.log(1 + total / (df.get(t) || 1));
|
|
||||||
|
|
||||||
let out = [];
|
|
||||||
for (const n of use) {
|
|
||||||
if (String(n.author) === String(viewerId)) continue;
|
|
||||||
const common = [...n.termSet].filter(t => myTermSet.has(t));
|
|
||||||
if (!common.length) continue;
|
|
||||||
let commonW = 0;
|
|
||||||
for (const t of common) commonW += weightOf(t);
|
|
||||||
let itemW = 0;
|
|
||||||
for (const t of n.termSet) itemW += weightOf(t);
|
|
||||||
const score = itemW > 0 ? Math.min(1, commonW / itemW) : 0;
|
|
||||||
if (score <= 0) continue;
|
|
||||||
common.sort((x, y) => weightOf(y) - weightOf(x) || x.localeCompare(y));
|
|
||||||
out.push({ ...strip(n), score, common, connections: common.length });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (f === 'RECENT') {
|
|
||||||
const cutoff = Date.now() - 7 * 24 * 60 * 60 * 1000;
|
|
||||||
out = out.filter(s => s.ts >= cutoff);
|
|
||||||
} else if (f !== 'ALL' && f !== 'TOP') {
|
|
||||||
const kind = f.toLowerCase();
|
|
||||||
if (KINDS[kind]) out = out.filter(s => s.kind === kind);
|
|
||||||
}
|
|
||||||
|
|
||||||
const q = norm(opts.q);
|
|
||||||
if (q) out = out.filter(s => norm(s.title).includes(q) || s.common.some(t => t.includes(q)));
|
|
||||||
|
|
||||||
if (f === 'RECENT') out.sort((x, y) => y.ts - x.ts || y.score - x.score);
|
|
||||||
else out.sort((x, y) => y.score - x.score || y.ts - x.ts);
|
|
||||||
|
|
||||||
return {
|
|
||||||
matches: out.slice(0, MAX_PAIRS),
|
|
||||||
total: out.length,
|
|
||||||
hasProfile: myTermSet.size > 0,
|
|
||||||
myTerms: [...myTermSet]
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
async jobMatchesFor(viewerId, { minScore = 0.8 } = {}) {
|
|
||||||
const { nodes, cvByAuthor } = await buildGraph();
|
|
||||||
const mine = cvByAuthor.get(viewerId);
|
|
||||||
if (!mine) return [];
|
|
||||||
return nodes
|
|
||||||
.filter(n => n.kind === 'jobs' && String(n.author) !== String(viewerId))
|
|
||||||
.map(n => {
|
|
||||||
const { score, common } = jaccard(mine.termSet, n.coreTermSet || n.termSet);
|
|
||||||
return { id: n.id, title: n.title, author: n.author, href: n.href, score, common };
|
|
||||||
})
|
|
||||||
.filter(m => m.score >= minScore)
|
|
||||||
.sort((a, b) => b.score - a.score);
|
|
||||||
},
|
|
||||||
|
|
||||||
async cohesion() {
|
|
||||||
const { cvByAuthor, nodes } = await buildGraph();
|
|
||||||
const use = nodes.slice(0, MAX_ENTITIES);
|
|
||||||
let comparisons = 0;
|
|
||||||
let sum = 0;
|
|
||||||
let connectedPairs = 0;
|
|
||||||
const linkedEntities = new Set();
|
|
||||||
for (let i = 0; i < use.length; i++) {
|
|
||||||
for (let j = i + 1; j < use.length; j++) {
|
|
||||||
const a = use[i], b = use[j];
|
|
||||||
if (a.kind === 'inhabitants' && b.kind === 'inhabitants' && a.author === b.author) continue;
|
|
||||||
const { score } = jaccard(a.termSet, b.termSet);
|
|
||||||
comparisons += 1;
|
|
||||||
sum += score;
|
|
||||||
if (score > 0) { connectedPairs += 1; linkedEntities.add(a.id); linkedEntities.add(b.id); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const coefficient = comparisons > 0 ? sum / comparisons : 0;
|
|
||||||
|
|
||||||
const people = [...cvByAuthor.values()];
|
|
||||||
const linked = new Set();
|
|
||||||
let cvComparisons = 0;
|
|
||||||
let cvSum = 0;
|
|
||||||
for (let i = 0; i < people.length; i++) {
|
|
||||||
for (let j = i + 1; j < people.length; j++) {
|
|
||||||
const { score } = jaccard(people[i].termSet, people[j].termSet);
|
|
||||||
cvComparisons += 1;
|
|
||||||
cvSum += score;
|
|
||||||
if (score > 0) { linked.add(people[i].author); linked.add(people[j].author); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const cvCoefficient = cvComparisons > 0 ? cvSum / cvComparisons : 0;
|
|
||||||
|
|
||||||
const skillSet = new Set();
|
|
||||||
for (const p of people) for (const t of p.terms) skillSet.add(t);
|
|
||||||
|
|
||||||
const termCount = new Map();
|
|
||||||
for (const n of nodes) for (const t of n.terms) termCount.set(t, (termCount.get(t) || 0) + 1);
|
|
||||||
const topTerms = [...termCount.entries()]
|
|
||||||
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
||||||
.slice(0, 12)
|
|
||||||
.map(([term, count]) => ({ term, count }));
|
|
||||||
|
|
||||||
const perKind = {};
|
|
||||||
for (const n of nodes) perKind[n.kind] = (perKind[n.kind] || 0) + 1;
|
|
||||||
|
|
||||||
return {
|
|
||||||
coefficient,
|
|
||||||
percent: Math.round(coefficient * 1000) / 10,
|
|
||||||
comparisons,
|
|
||||||
pairs: connectedPairs,
|
|
||||||
entities: use.length,
|
|
||||||
distinctTerms: termCount.size,
|
|
||||||
topTerms,
|
|
||||||
perKind,
|
|
||||||
people: people.length,
|
|
||||||
skills: skillSet.size,
|
|
||||||
connected: linkedEntities.size,
|
|
||||||
isolated: Math.max(0, use.length - linkedEntities.size),
|
|
||||||
cvCoefficient,
|
|
||||||
cvPercent: Math.round(cvCoefficient * 1000) / 10
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
@ -109,9 +109,12 @@ const MODULE_ALIASES = {
|
||||||
ai: { model: 'src/AI/ai_service.mjs', view: 'src/views/AI_view.js' },
|
ai: { model: 'src/AI/ai_service.mjs', view: 'src/views/AI_view.js' },
|
||||||
aiNav: { model: 'src/AI/routes_index.js', view: 'src/views/AI_view.js', paths: ['/ai/ask'] },
|
aiNav: { model: 'src/AI/routes_index.js', view: 'src/views/AI_view.js', paths: ['/ai/ask'] },
|
||||||
docs: { paths: ['/documents'] },
|
docs: { paths: ['/documents'] },
|
||||||
blogs: { model: 'src/models/blog_model.js', view: 'src/views/blog_view.js', paths: ['/blogs'] },
|
latest: { model: 'src/models/main_models.js', view: 'src/views/main_views.js', paths: ['/public/latest'] },
|
||||||
polls: { model: 'src/models/polls_model.js', view: 'src/views/polls_view.js', paths: ['/polls'] },
|
popular: { model: 'src/models/main_models.js', view: 'src/views/main_views.js', paths: ['/public/popular'] },
|
||||||
data: { model: 'src/models/data_model.js', view: 'src/views/data_view.js', paths: ['/data'] },
|
topics: { model: 'src/models/main_models.js', view: 'src/views/main_views.js', paths: ['/public/latest/topics'] },
|
||||||
|
summaries: { model: 'src/models/main_models.js', view: 'src/views/main_views.js', paths: ['/public/latest/summaries'] },
|
||||||
|
threads: { model: 'src/models/main_models.js', view: 'src/views/main_views.js', paths: ['/public/latest/threads'] },
|
||||||
|
multiverse: { model: 'src/models/main_models.js', view: 'src/views/main_views.js', paths: ['/public/latest/extended'] },
|
||||||
invites: { model: 'src/models/main_models.js', view: 'src/views/invites_view.js' },
|
invites: { model: 'src/models/main_models.js', view: 'src/views/invites_view.js' },
|
||||||
graphos: { model: 'src/models/main_models.js', view: 'src/views/graphos_view.js' }
|
graphos: { model: 'src/models/main_models.js', view: 'src/views/graphos_view.js' }
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ const { getConfig } = require("../configs/config-manager.js");
|
||||||
const categories = require("../backend/opinion_categories");
|
const categories = require("../backend/opinion_categories");
|
||||||
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
|
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
|
||||||
const { dedupeBy, norm } = require('../backend/dedupe');
|
const { dedupeBy, norm } = require('../backend/dedupe');
|
||||||
const contentFavorites = require("../backend/content_favorites");
|
const mediaFavorites = require("../backend/media-favorites");
|
||||||
|
|
||||||
const logLimit = getConfig().ssbLogStream?.limit || 1000;
|
const logLimit = getConfig().ssbLogStream?.limit || 1000;
|
||||||
|
|
||||||
|
|
@ -154,7 +154,7 @@ module.exports = ({ cooler }) => {
|
||||||
|
|
||||||
const favoritesSetForDocuments = async () => {
|
const favoritesSetForDocuments = async () => {
|
||||||
try {
|
try {
|
||||||
return await contentFavorites.getFavoriteSet("documents");
|
return await mediaFavorites.getFavoriteSet("documents");
|
||||||
} catch {
|
} catch {
|
||||||
return new Set();
|
return new Set();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
const pull = require('../server/node_modules/pull-stream');
|
const pull = require('../server/node_modules/pull-stream');
|
||||||
const moment = require('../server/node_modules/moment');
|
const moment = require('../server/node_modules/moment');
|
||||||
const { normalizeImages, normalizeVideo } = require('./media_gallery');
|
|
||||||
const { truthy, hasAnyInterval, nextOccurrence, upcomingOccurrences } = require('./recurrence');
|
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
|
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
|
||||||
const { getConfig } = require('../configs/config-manager.js');
|
const { getConfig } = require('../configs/config-manager.js');
|
||||||
|
|
@ -74,22 +72,8 @@ module.exports = ({ cooler, tribeCrypto, eventCrypto, tribesModel }) => {
|
||||||
return m.toISOString();
|
return m.toISOString();
|
||||||
};
|
};
|
||||||
|
|
||||||
const recurrenceOf = (c) => ({
|
|
||||||
weekly: truthy(c.intervalWeekly),
|
|
||||||
monthly: truthy(c.intervalMonthly),
|
|
||||||
yearly: truthy(c.intervalYearly),
|
|
||||||
until: c.recurrenceUntil || ''
|
|
||||||
});
|
|
||||||
|
|
||||||
const effectiveDate = (c) => {
|
|
||||||
const r = recurrenceOf(c);
|
|
||||||
if (!hasAnyInterval(r.weekly, r.monthly, r.yearly) || !r.until) return c.date;
|
|
||||||
const next = nextOccurrence(c.date, r.until, r.weekly, r.monthly, r.yearly);
|
|
||||||
return next ? next.toISOString() : c.date;
|
|
||||||
};
|
|
||||||
|
|
||||||
const deriveStatus = (c) => {
|
const deriveStatus = (c) => {
|
||||||
const dateM = moment(effectiveDate(c));
|
const dateM = moment(c.date);
|
||||||
let status = String(c.status || 'OPEN').toUpperCase();
|
let status = String(c.status || 'OPEN').toUpperCase();
|
||||||
if (dateM.isValid() && dateM.isBefore(moment())) status = 'CLOSED';
|
if (dateM.isValid() && dateM.isBefore(moment())) status = 'CLOSED';
|
||||||
if (status !== 'OPEN' && status !== 'CLOSED') status = 'OPEN';
|
if (status !== 'OPEN' && status !== 'CLOSED') status = 'OPEN';
|
||||||
|
|
@ -187,7 +171,7 @@ module.exports = ({ cooler, tribeCrypto, eventCrypto, tribesModel }) => {
|
||||||
return idx.rootOf(id);
|
return idx.rootOf(id);
|
||||||
},
|
},
|
||||||
|
|
||||||
async createEvent(title, description, date, location, price = 0, url = "", attendees = [], tagsRaw = [], isPublic, mapUrl = "", clearnetPublic = false, media = {}, recurrence = {}) {
|
async createEvent(title, description, date, location, price = 0, url = "", attendees = [], tagsRaw = [], isPublic, mapUrl = "", clearnetPublic = false) {
|
||||||
const ssbClient = await openSsb();
|
const ssbClient = await openSsb();
|
||||||
const userId = await me();
|
const userId = await me();
|
||||||
|
|
||||||
|
|
@ -219,12 +203,6 @@ module.exports = ({ cooler, tribeCrypto, eventCrypto, tribesModel }) => {
|
||||||
isPublic: visibility,
|
isPublic: visibility,
|
||||||
mapUrl: String(mapUrl || "").trim(),
|
mapUrl: String(mapUrl || "").trim(),
|
||||||
clearnetPublic: clearnetPublic === true || clearnetPublic === 'true' || clearnetPublic === 'on',
|
clearnetPublic: clearnetPublic === true || clearnetPublic === 'true' || clearnetPublic === 'on',
|
||||||
images: normalizeImages(media && media.images),
|
|
||||||
video: normalizeVideo(media && media.video),
|
|
||||||
intervalWeekly: truthy(recurrence && recurrence.weekly),
|
|
||||||
intervalMonthly: truthy(recurrence && recurrence.monthly),
|
|
||||||
intervalYearly: truthy(recurrence && recurrence.yearly),
|
|
||||||
recurrenceUntil: String((recurrence && recurrence.until) || '').trim(),
|
|
||||||
opinions: {},
|
opinions: {},
|
||||||
opinions_inhabitants: []
|
opinions_inhabitants: []
|
||||||
};
|
};
|
||||||
|
|
@ -483,15 +461,6 @@ module.exports = ({ cooler, tribeCrypto, eventCrypto, tribesModel }) => {
|
||||||
status,
|
status,
|
||||||
isPublic: normalizePrivacy(c.isPublic),
|
isPublic: normalizePrivacy(c.isPublic),
|
||||||
mapUrl: c.mapUrl || "",
|
mapUrl: c.mapUrl || "",
|
||||||
images: normalizeImages(c.images),
|
|
||||||
video: normalizeVideo(c.video),
|
|
||||||
intervalWeekly: truthy(c.intervalWeekly),
|
|
||||||
intervalMonthly: truthy(c.intervalMonthly),
|
|
||||||
intervalYearly: truthy(c.intervalYearly),
|
|
||||||
recurrenceUntil: c.recurrenceUntil || '',
|
|
||||||
recurring: hasAnyInterval(truthy(c.intervalWeekly), truthy(c.intervalMonthly), truthy(c.intervalYearly)) && !!c.recurrenceUntil,
|
|
||||||
nextDate: effectiveDate(c),
|
|
||||||
occurrences: upcomingOccurrences(c.date, c.recurrenceUntil, truthy(c.intervalWeekly), truthy(c.intervalMonthly), truthy(c.intervalYearly)).map(d => d.toISOString()),
|
|
||||||
clearnetPublic: !!c.clearnetPublic,
|
clearnetPublic: !!c.clearnetPublic,
|
||||||
encrypted: normalizePrivacy(c.isPublic) === 'private',
|
encrypted: normalizePrivacy(c.isPublic) === 'private',
|
||||||
opinions: agg.opinions,
|
opinions: agg.opinions,
|
||||||
|
|
@ -534,12 +503,6 @@ module.exports = ({ cooler, tribeCrypto, eventCrypto, tribesModel }) => {
|
||||||
isPublic: updatedData.isPublic !== undefined ? normalizePrivacy(updatedData.isPublic) : normalizePrivacy(c.isPublic),
|
isPublic: updatedData.isPublic !== undefined ? normalizePrivacy(updatedData.isPublic) : normalizePrivacy(c.isPublic),
|
||||||
clearnetPublic: updatedData.clearnetPublic !== undefined ? (updatedData.clearnetPublic === true || updatedData.clearnetPublic === 'true' || updatedData.clearnetPublic === 'on') : !!c.clearnetPublic,
|
clearnetPublic: updatedData.clearnetPublic !== undefined ? (updatedData.clearnetPublic === true || updatedData.clearnetPublic === 'true' || updatedData.clearnetPublic === 'on') : !!c.clearnetPublic,
|
||||||
attendees: uniq(Array.isArray(c.attendees) ? c.attendees : []),
|
attendees: uniq(Array.isArray(c.attendees) ? c.attendees : []),
|
||||||
images: normalizeImages(updatedData.images !== undefined ? updatedData.images : c.images),
|
|
||||||
video: updatedData.video !== undefined ? normalizeVideo(updatedData.video) : normalizeVideo(c.video),
|
|
||||||
intervalWeekly: updatedData.intervalWeekly !== undefined ? truthy(updatedData.intervalWeekly) : truthy(c.intervalWeekly),
|
|
||||||
intervalMonthly: updatedData.intervalMonthly !== undefined ? truthy(updatedData.intervalMonthly) : truthy(c.intervalMonthly),
|
|
||||||
intervalYearly: updatedData.intervalYearly !== undefined ? truthy(updatedData.intervalYearly) : truthy(c.intervalYearly),
|
|
||||||
recurrenceUntil: updatedData.recurrenceUntil !== undefined ? String(updatedData.recurrenceUntil || '').trim() : (c.recurrenceUntil || ''),
|
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
replaces: eventId
|
replaces: eventId
|
||||||
};
|
};
|
||||||
|
|
@ -647,14 +610,6 @@ module.exports = ({ cooler, tribeCrypto, eventCrypto, tribesModel }) => {
|
||||||
status,
|
status,
|
||||||
isPublic: normalizePrivacy(c.isPublic),
|
isPublic: normalizePrivacy(c.isPublic),
|
||||||
mapUrl: c.mapUrl || "",
|
mapUrl: c.mapUrl || "",
|
||||||
images: normalizeImages(c.images),
|
|
||||||
video: normalizeVideo(c.video),
|
|
||||||
intervalWeekly: truthy(c.intervalWeekly),
|
|
||||||
intervalMonthly: truthy(c.intervalMonthly),
|
|
||||||
intervalYearly: truthy(c.intervalYearly),
|
|
||||||
recurrenceUntil: c.recurrenceUntil || '',
|
|
||||||
recurring: hasAnyInterval(truthy(c.intervalWeekly), truthy(c.intervalMonthly), truthy(c.intervalYearly)) && !!c.recurrenceUntil,
|
|
||||||
nextDate: effectiveDate(c),
|
|
||||||
encrypted: normalizePrivacy(c.isPublic) === 'private',
|
encrypted: normalizePrivacy(c.isPublic) === 'private',
|
||||||
opinions: agg.opinions,
|
opinions: agg.opinions,
|
||||||
opinions_inhabitants: agg.opinions_inhabitants
|
opinions_inhabitants: agg.opinions_inhabitants
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,8 @@ const archiver = require('../server/node_modules/archiver');
|
||||||
module.exports = {
|
module.exports = {
|
||||||
exportSSB: async (outputPath) => {
|
exportSSB: async (outputPath) => {
|
||||||
try {
|
try {
|
||||||
// se exporta la cuenta activa, no siempre la de por defecto
|
const homeDir = os.homedir();
|
||||||
const ssbPath = require('../server/ssb_config').path;
|
const ssbPath = path.join(homeDir, '.ssb');
|
||||||
const output = fs.createWriteStream(outputPath);
|
const output = fs.createWriteStream(outputPath);
|
||||||
const archive = archiver('zip', {
|
const archive = archiver('zip', {
|
||||||
zlib: { level: 9 }
|
zlib: { level: 9 }
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
const contentFavorites = require("../backend/content_favorites");
|
const mediaFavorites = require("../backend/media-favorites");
|
||||||
|
|
||||||
const safeArr = (v) => (Array.isArray(v) ? v : []);
|
const safeArr = (v) => (Array.isArray(v) ? v : []);
|
||||||
const safeText = (v) => String(v || "").trim();
|
const safeText = (v) => String(v || "").trim();
|
||||||
|
|
@ -15,7 +15,7 @@ const toTs = (d) => {
|
||||||
return Number.isFinite(t) ? t : 0;
|
return Number.isFinite(t) ? t : 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = ({ audiosModel, bookmarksModel, documentsModel, imagesModel, videosModel, mapsModel, padsModel, chatsModel, calendarsModel, torrentsModel, marketModel, shopsModel, eventsModel, tasksModel, reportsModel, votesModel, jobsModel, housingModel, projectsModel, transfersModel, forumModel, blogsModel, pollsModel }) => {
|
module.exports = ({ audiosModel, bookmarksModel, documentsModel, imagesModel, videosModel, mapsModel, padsModel, chatsModel, calendarsModel, torrentsModel, marketModel, shopsModel }) => {
|
||||||
const kindConfig = {
|
const kindConfig = {
|
||||||
audios: {
|
audios: {
|
||||||
base: "/audios/",
|
base: "/audios/",
|
||||||
|
|
@ -64,58 +64,10 @@ module.exports = ({ audiosModel, bookmarksModel, documentsModel, imagesModel, vi
|
||||||
shopProducts: {
|
shopProducts: {
|
||||||
base: "/shops/product/",
|
base: "/shops/product/",
|
||||||
getById: getFn(shopsModel, ["getProductById"])
|
getById: getFn(shopsModel, ["getProductById"])
|
||||||
},
|
|
||||||
events: {
|
|
||||||
base: "/events/",
|
|
||||||
getById: getFn(eventsModel, ["getEventById", "getById"])
|
|
||||||
},
|
|
||||||
tasks: {
|
|
||||||
base: "/tasks/",
|
|
||||||
getById: getFn(tasksModel, ["getTaskById", "getById"])
|
|
||||||
},
|
|
||||||
reports: {
|
|
||||||
base: "/reports/",
|
|
||||||
getById: getFn(reportsModel, ["getReportById", "getById"])
|
|
||||||
},
|
|
||||||
votes: {
|
|
||||||
base: "/votes/",
|
|
||||||
getById: getFn(votesModel, ["getVoteById", "getById"])
|
|
||||||
},
|
|
||||||
jobs: {
|
|
||||||
base: "/jobs/",
|
|
||||||
getById: getFn(jobsModel, ["getJobById", "getById"])
|
|
||||||
},
|
|
||||||
housing: {
|
|
||||||
base: "/housing/",
|
|
||||||
getById: getFn(housingModel, ["getHousingById", "getById"])
|
|
||||||
},
|
|
||||||
projects: {
|
|
||||||
base: "/projects/",
|
|
||||||
getById: getFn(projectsModel, ["getProjectById", "getById"])
|
|
||||||
},
|
|
||||||
transfers: {
|
|
||||||
base: "/transfers/",
|
|
||||||
getById: getFn(transfersModel, ["getTransferById", "getById"])
|
|
||||||
},
|
|
||||||
forum: {
|
|
||||||
base: "/forum/",
|
|
||||||
getById: getFn(forumModel, ["getForumById", "getById"])
|
|
||||||
},
|
|
||||||
blogs: {
|
|
||||||
base: "/blogs/",
|
|
||||||
getById: getFn(blogsModel, ["getBlogById", "getById"])
|
|
||||||
},
|
|
||||||
polls: {
|
|
||||||
base: "/polls/",
|
|
||||||
getById: getFn(pollsModel, ["getPollById", "getById"])
|
|
||||||
},
|
|
||||||
shops: {
|
|
||||||
base: "/shops/",
|
|
||||||
getById: getFn(shopsModel, ["getShopById", "getById"])
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const kindOrder = ["audios", "blogs", "bookmarks", "calendars", "chats", "documents", "events", "forum", "housing", "images", "jobs", "maps", "market", "pads", "polls", "projects", "reports", "shopProducts", "shops", "tasks", "torrents", "transfers", "videos", "votes"];
|
const kindOrder = ["audios", "bookmarks", "calendars", "chats", "documents", "images", "maps", "pads", "torrents", "videos", "market", "shopProducts"];
|
||||||
|
|
||||||
const hydrateKind = async (kind, ids) => {
|
const hydrateKind = async (kind, ids) => {
|
||||||
const cfg = kindConfig[kind];
|
const cfg = kindConfig[kind];
|
||||||
|
|
@ -127,11 +79,9 @@ module.exports = ({ audiosModel, bookmarksModel, documentsModel, imagesModel, vi
|
||||||
if (!id) return null;
|
if (!id) return null;
|
||||||
try {
|
try {
|
||||||
const obj = await cfg.getById(id);
|
const obj = await cfg.getById(id);
|
||||||
if (!obj || typeof obj !== "object") return null;
|
|
||||||
const viewId = safeText(obj?.key || obj?.id || id);
|
const viewId = safeText(obj?.key || obj?.id || id);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: obj,
|
|
||||||
kind,
|
kind,
|
||||||
favId: id,
|
favId: id,
|
||||||
viewHref: `${cfg.base}${encodeURIComponent(viewId)}`,
|
viewHref: `${cfg.base}${encodeURIComponent(viewId)}`,
|
||||||
|
|
@ -154,7 +104,7 @@ module.exports = ({ audiosModel, bookmarksModel, documentsModel, imagesModel, vi
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadAll = async () => {
|
const loadAll = async () => {
|
||||||
const sets = await Promise.all(kindOrder.map((k) => contentFavorites.getFavoriteSet(k)));
|
const sets = await Promise.all(kindOrder.map((k) => mediaFavorites.getFavoriteSet(k)));
|
||||||
const idsByKind = {};
|
const idsByKind = {};
|
||||||
kindOrder.forEach((k, i) => {
|
kindOrder.forEach((k, i) => {
|
||||||
idsByKind[k] = Array.from(sets[i] || []);
|
idsByKind[k] = Array.from(sets[i] || []);
|
||||||
|
|
@ -168,8 +118,21 @@ module.exports = ({ audiosModel, bookmarksModel, documentsModel, imagesModel, vi
|
||||||
|
|
||||||
const flat = kindOrder.flatMap((k) => byKind[k]);
|
const flat = kindOrder.flatMap((k) => byKind[k]);
|
||||||
|
|
||||||
const counts = { all: flat.length };
|
const counts = {
|
||||||
for (const k of kindOrder) counts[k] = (byKind[k] || []).length;
|
audios: byKind.audios.length,
|
||||||
|
bookmarks: byKind.bookmarks.length,
|
||||||
|
calendars: byKind.calendars.length,
|
||||||
|
chats: byKind.chats.length,
|
||||||
|
documents: byKind.documents.length,
|
||||||
|
images: byKind.images.length,
|
||||||
|
maps: byKind.maps.length,
|
||||||
|
pads: byKind.pads.length,
|
||||||
|
torrents: byKind.torrents.length,
|
||||||
|
videos: byKind.videos.length,
|
||||||
|
market: byKind.market.length,
|
||||||
|
shopProducts: byKind.shopProducts.length,
|
||||||
|
all: flat.length
|
||||||
|
};
|
||||||
|
|
||||||
const recentFlat = flat
|
const recentFlat = flat
|
||||||
.slice()
|
.slice()
|
||||||
|
|
@ -204,13 +167,11 @@ module.exports = ({ audiosModel, bookmarksModel, documentsModel, imagesModel, vi
|
||||||
return { items: grouped, counts };
|
return { items: grouped, counts };
|
||||||
},
|
},
|
||||||
|
|
||||||
kinds: kindOrder.slice(),
|
|
||||||
|
|
||||||
async removeFavorite(kind, id) {
|
async removeFavorite(kind, id) {
|
||||||
const k = safeText(kind);
|
const k = safeText(kind);
|
||||||
const favId = safeText(id);
|
const favId = safeText(id);
|
||||||
if (!k || !favId) return;
|
if (!k || !favId) return;
|
||||||
await contentFavorites.removeFavorite(k, favId);
|
await mediaFavorites.removeFavorite(k, favId);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -503,7 +503,8 @@ module.exports = ({ cooler, tribeCrypto, forumCrypto }) => {
|
||||||
text: c.text,
|
text: c.text,
|
||||||
author: c.author,
|
author: c.author,
|
||||||
timestamp: m.value.timestamp,
|
timestamp: m.value.timestamp,
|
||||||
parent: c.branch || null
|
parent: c.branch || null,
|
||||||
|
subtopic: c.subtopic || null
|
||||||
}));
|
}));
|
||||||
for (let r of replies) {
|
for (let r of replies) {
|
||||||
const { positives: rp, negatives: rn } = await aggregateVotes(ssbClient, r.key);
|
const { positives: rp, negatives: rn } = await aggregateVotes(ssbClient, r.key);
|
||||||
|
|
|
||||||
|
|
@ -1,554 +0,0 @@
|
||||||
const pull = require("../server/node_modules/pull-stream")
|
|
||||||
const moment = require("../server/node_modules/moment")
|
|
||||||
const categories = require("../backend/opinion_categories")
|
|
||||||
const { getConfig } = require("../configs/config-manager.js")
|
|
||||||
const { dedupeByPreferring } = require('../backend/dedupe')
|
|
||||||
const logLimit = getConfig().ssbLogStream?.limit || 1000
|
|
||||||
|
|
||||||
const HOUSING_TYPES = ["sale", "rent", "couchsurfing"]
|
|
||||||
const PROPERTY_TYPES = ["apartment", "house", "room", "land", "other"]
|
|
||||||
|
|
||||||
const norm = (s) => String(s || "").trim().toLowerCase()
|
|
||||||
const safeArr = (v) => (Array.isArray(v) ? v : [])
|
|
||||||
|
|
||||||
const toNum = (v) => {
|
|
||||||
const n = parseFloat(String(v ?? "").replace(",", "."))
|
|
||||||
return Number.isFinite(n) ? n : NaN
|
|
||||||
}
|
|
||||||
|
|
||||||
const toInt = (v, fallback = 0) => {
|
|
||||||
const n = parseInt(String(v ?? ""), 10)
|
|
||||||
return Number.isFinite(n) ? n : fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
const nonNeg = (v) => {
|
|
||||||
const n = toNum(v)
|
|
||||||
return Number.isFinite(n) && n >= 0 ? n : 0
|
|
||||||
}
|
|
||||||
|
|
||||||
const safeDate = (v) => {
|
|
||||||
const s = String(v || "").trim()
|
|
||||||
if (!s) return ""
|
|
||||||
const d = new Date(s)
|
|
||||||
return Number.isFinite(d.getTime()) ? s : ""
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizeTags = (raw) => {
|
|
||||||
if (raw === undefined || raw === null) return []
|
|
||||||
if (Array.isArray(raw)) return raw.map(t => String(t || "").trim()).filter(Boolean)
|
|
||||||
return String(raw).split(",").map(t => t.trim()).filter(Boolean)
|
|
||||||
}
|
|
||||||
|
|
||||||
const safeType = (v) => {
|
|
||||||
const t = norm(v)
|
|
||||||
return HOUSING_TYPES.includes(t) ? t : ""
|
|
||||||
}
|
|
||||||
|
|
||||||
const safeProperty = (v) => {
|
|
||||||
const t = norm(v)
|
|
||||||
return PROPERTY_TYPES.includes(t) ? t : "other"
|
|
||||||
}
|
|
||||||
|
|
||||||
const { MAX_IMAGES, normalizeImages, normalizeVideo } = require('./media_gallery')
|
|
||||||
|
|
||||||
const matchSearch = (item, q) => {
|
|
||||||
const qq = norm(q)
|
|
||||||
if (!qq) return true
|
|
||||||
const hay = [
|
|
||||||
item.title,
|
|
||||||
item.description,
|
|
||||||
item.place,
|
|
||||||
item.rules,
|
|
||||||
item.housing_type,
|
|
||||||
item.property_type,
|
|
||||||
safeArr(item.tags).join(" ")
|
|
||||||
].map(x => norm(x)).join(" ")
|
|
||||||
return hay.includes(qq)
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = ({ cooler, tribeCrypto }) => {
|
|
||||||
let ssb
|
|
||||||
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb }
|
|
||||||
|
|
||||||
const isEncrypted = (c) => !!(c && c.encryptedPayload)
|
|
||||||
|
|
||||||
const keysForRoot = (rootId) => {
|
|
||||||
if (!tribeCrypto || !rootId) return []
|
|
||||||
const ks = (tribeCrypto.getKeys && tribeCrypto.getKeys(rootId)) || []
|
|
||||||
if (ks.length) return ks
|
|
||||||
const k = tribeCrypto.getKey ? tribeCrypto.getKey(rootId) : null
|
|
||||||
return k ? [k] : []
|
|
||||||
}
|
|
||||||
|
|
||||||
const decryptHousing = (c, rootId) => {
|
|
||||||
if (!isEncrypted(c)) return c
|
|
||||||
if (!tribeCrypto) return { ...c, _undecryptable: true }
|
|
||||||
const keys = keysForRoot(rootId)
|
|
||||||
if (!keys.length) return { ...c, _undecryptable: true }
|
|
||||||
return tribeCrypto.decryptContent(c, keys.map(k => [k]))
|
|
||||||
}
|
|
||||||
|
|
||||||
const publishHousing = (ssbClient, content, rootId) => new Promise((resolve, reject) => {
|
|
||||||
const hidden = String(content.visibility || "PUBLIC").toUpperCase() === "HIDDEN"
|
|
||||||
if (!hidden || !tribeCrypto) {
|
|
||||||
ssbClient.publish(content, (err, msg) => {
|
|
||||||
if (err) return reject(err)
|
|
||||||
if (msg && msg.key && tribeCrypto && !rootId) {
|
|
||||||
try { tribeCrypto.setKey(msg.key, tribeCrypto.generateTribeKey(), 1) } catch (_) {}
|
|
||||||
}
|
|
||||||
resolve(msg)
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let key = rootId ? (keysForRoot(rootId)[0] || null) : null
|
|
||||||
if (!key) key = tribeCrypto.generateTribeKey()
|
|
||||||
let envelope
|
|
||||||
try { envelope = tribeCrypto.encryptContent(content, [key], true) } catch (err) { return reject(err) }
|
|
||||||
ssbClient.publish(envelope, (err, msg) => {
|
|
||||||
if (err) return reject(err)
|
|
||||||
try { tribeCrypto.setKey(rootId || (msg && msg.key), key, 1) } catch (_) {}
|
|
||||||
resolve(msg)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const readAll = async (ssbClient) =>
|
|
||||||
new Promise((resolve, reject) =>
|
|
||||||
pull(
|
|
||||||
ssbClient.createLogStream({ limit: logLimit }),
|
|
||||||
pull.collect((err, msgs) => err ? reject(err) : resolve(msgs))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
const buildIndex = (messages, ssbClient) => {
|
|
||||||
const tomb = new Set()
|
|
||||||
const nodes = new Map()
|
|
||||||
const parent = new Map()
|
|
||||||
const child = new Map()
|
|
||||||
const naiveReplaces = new Map()
|
|
||||||
const requestLatest = new Map()
|
|
||||||
const opinionMsgs = []
|
|
||||||
|
|
||||||
for (const m of messages) {
|
|
||||||
const key = m.key
|
|
||||||
const v = m.value || {}
|
|
||||||
const c = v.content
|
|
||||||
if (!c || typeof c !== "object") continue
|
|
||||||
|
|
||||||
if (c.type === "tombstone" && c.target) { tomb.add(c.target); continue }
|
|
||||||
|
|
||||||
if (c.type === "housing") {
|
|
||||||
nodes.set(key, { key, ts: v.timestamp || m.timestamp || 0, c, author: v.author })
|
|
||||||
if (c.replaces) naiveReplaces.set(key, c.replaces)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (c.type === "housingOpinion" && c.target) {
|
|
||||||
opinionMsgs.push({ target: c.target, author: v.author, category: c.category })
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (c.type === "housingRequest" && c.housingId) {
|
|
||||||
const author = v.author
|
|
||||||
if (!author) continue
|
|
||||||
const ts = v.timestamp || m.timestamp || 0
|
|
||||||
const k = `${c.housingId}::${author}`
|
|
||||||
const prev = requestLatest.get(k)
|
|
||||||
if (!prev || ts >= prev.ts) requestLatest.set(k, { ts, value: !!c.value, author, housingId: c.housingId })
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [key, replacesId] of naiveReplaces.entries()) {
|
|
||||||
const node = nodes.get(key)
|
|
||||||
if (!node) continue
|
|
||||||
const orig = nodes.get(replacesId)
|
|
||||||
if (!orig) continue
|
|
||||||
if (String(orig.author) !== String(node.author)) { nodes.delete(key); continue }
|
|
||||||
parent.set(key, replacesId)
|
|
||||||
child.set(replacesId, key)
|
|
||||||
}
|
|
||||||
|
|
||||||
const rootOf = (id) => {
|
|
||||||
let cur = id, guard = 0
|
|
||||||
while (parent.has(cur) && guard++ < 100000) cur = parent.get(cur)
|
|
||||||
return cur
|
|
||||||
}
|
|
||||||
|
|
||||||
const tipOf = (id) => {
|
|
||||||
let cur = id, guard = 0
|
|
||||||
while (child.has(cur) && guard++ < 100000) cur = child.get(cur)
|
|
||||||
return cur
|
|
||||||
}
|
|
||||||
|
|
||||||
const roots = new Set()
|
|
||||||
for (const id of nodes.keys()) roots.add(rootOf(id))
|
|
||||||
|
|
||||||
const tipByRoot = new Map()
|
|
||||||
for (const r of roots) tipByRoot.set(r, tipOf(r))
|
|
||||||
|
|
||||||
if (ssbClient) {
|
|
||||||
for (const m of messages) {
|
|
||||||
if (typeof m.value?.content !== 'string') continue
|
|
||||||
try {
|
|
||||||
const dec = ssbClient.private.unbox({ key: m.key, value: m.value, timestamp: m.value?.timestamp || m.timestamp || 0 })
|
|
||||||
const c = dec?.value?.content
|
|
||||||
if (!c || c.type !== 'housingRequest' || !c.housingId) continue
|
|
||||||
const author = dec.value.author
|
|
||||||
if (!author) continue
|
|
||||||
const ts = dec.value.timestamp || m.timestamp || 0
|
|
||||||
const k = `${c.housingId}::${author}`
|
|
||||||
const prev = requestLatest.get(k)
|
|
||||||
if (!prev || ts >= prev.ts) requestLatest.set(k, { ts, value: !!c.value, author, housingId: c.housingId })
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const requestsByRoot = new Map()
|
|
||||||
const everRequestedByRoot = new Map()
|
|
||||||
for (const { housingId, author, value } of requestLatest.values()) {
|
|
||||||
if (!requestsByRoot.has(housingId)) requestsByRoot.set(housingId, new Set())
|
|
||||||
if (!everRequestedByRoot.has(housingId)) everRequestedByRoot.set(housingId, new Set())
|
|
||||||
everRequestedByRoot.get(housingId).add(author)
|
|
||||||
const set = requestsByRoot.get(housingId)
|
|
||||||
if (value) set.add(author)
|
|
||||||
else set.delete(author)
|
|
||||||
}
|
|
||||||
|
|
||||||
const opinionsByRoot = new Map()
|
|
||||||
for (const op of opinionMsgs) {
|
|
||||||
if (!nodes.has(op.target)) continue
|
|
||||||
const r = rootOf(op.target)
|
|
||||||
if (!opinionsByRoot.has(r)) opinionsByRoot.set(r, [])
|
|
||||||
opinionsByRoot.get(r).push(op)
|
|
||||||
}
|
|
||||||
|
|
||||||
const aggregateFor = (rootId, content, ownerId) => {
|
|
||||||
const opinions = { ...((content && content.opinions) || {}) }
|
|
||||||
const voters = safeArr(content && content.opinions_inhabitants).slice()
|
|
||||||
const voterSet = new Set(voters)
|
|
||||||
for (const op of (opinionsByRoot.get(rootId) || [])) {
|
|
||||||
if (!op.author || op.author === ownerId) continue
|
|
||||||
if (voterSet.has(op.author)) continue
|
|
||||||
if (!categories.includes(op.category)) continue
|
|
||||||
voterSet.add(op.author); voters.push(op.author)
|
|
||||||
opinions[op.category] = (opinions[op.category] || 0) + 1
|
|
||||||
}
|
|
||||||
return { opinions, opinions_inhabitants: voters }
|
|
||||||
}
|
|
||||||
|
|
||||||
return { tomb, nodes, parent, child, rootOf, tipOf, tipByRoot, requestsByRoot, everRequestedByRoot, aggregateFor }
|
|
||||||
}
|
|
||||||
|
|
||||||
const buildObject = (node, rootId, idx, viewer) => {
|
|
||||||
const raw = node.c || {}
|
|
||||||
const c = decryptHousing(raw, rootId)
|
|
||||||
if (c._undecryptable) return null
|
|
||||||
const author = node.author || c.author
|
|
||||||
const requests = Array.from(idx.requestsByRoot.get(rootId) || [])
|
|
||||||
const visibleRequests = (author === viewer) ? requests : (requests.includes(viewer) ? [viewer] : [])
|
|
||||||
const agg = idx.aggregateFor(rootId, c, author)
|
|
||||||
return {
|
|
||||||
id: node.key,
|
|
||||||
rootId,
|
|
||||||
housing_type: safeType(c.housing_type),
|
|
||||||
property_type: safeProperty(c.property_type),
|
|
||||||
title: String(c.title || ""),
|
|
||||||
description: String(c.description || ""),
|
|
||||||
rules: String(c.rules || ""),
|
|
||||||
place: String(c.place || ""),
|
|
||||||
mapUrl: String(c.mapUrl || ""),
|
|
||||||
price: Number.isFinite(toNum(c.price)) ? toNum(c.price).toFixed(6) : "0.000000",
|
|
||||||
rooms: toInt(c.rooms, 0),
|
|
||||||
size: nonNeg(c.size),
|
|
||||||
capacity: toInt(c.capacity, 0),
|
|
||||||
availableFrom: safeDate(c.availableFrom),
|
|
||||||
availableTo: safeDate(c.availableTo),
|
|
||||||
images: normalizeImages(c.images && c.images.length ? c.images : c.image),
|
|
||||||
image: normalizeImages(c.images && c.images.length ? c.images : c.image)[0] || null,
|
|
||||||
video: normalizeVideo(c.video),
|
|
||||||
author,
|
|
||||||
createdAt: c.createdAt || new Date(node.ts).toISOString(),
|
|
||||||
updatedAt: c.updatedAt || null,
|
|
||||||
status: String(c.status || "OPEN").toUpperCase() === "CLOSED" ? "CLOSED" : "OPEN",
|
|
||||||
tags: Array.isArray(c.tags) ? c.tags : normalizeTags(c.tags),
|
|
||||||
requests: visibleRequests,
|
|
||||||
requestCount: requests.length,
|
|
||||||
opinions: agg.opinions,
|
|
||||||
opinions_inhabitants: agg.opinions_inhabitants,
|
|
||||||
requestedByViewer: requests.includes(viewer),
|
|
||||||
everRequestedByViewer: (idx.everRequestedByRoot.get(rootId) || new Set()).has(viewer),
|
|
||||||
ratedByViewer: agg.opinions_inhabitants.includes(viewer),
|
|
||||||
visibility: String(c.visibility || "PUBLIC").toUpperCase() === "HIDDEN" ? "HIDDEN" : "PUBLIC"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const buildContent = (data, opts = {}) => {
|
|
||||||
const housing_type = safeType(data.housing_type)
|
|
||||||
if (!housing_type) throw new Error("Invalid housing type")
|
|
||||||
|
|
||||||
const title = String(data.title || "").trim()
|
|
||||||
if (!title) throw new Error("Invalid title")
|
|
||||||
const description = String(data.description || "").trim()
|
|
||||||
if (!description) throw new Error("Invalid description")
|
|
||||||
|
|
||||||
const availableFrom = safeDate(data.availableFrom)
|
|
||||||
const availableTo = safeDate(data.availableTo)
|
|
||||||
if (!availableFrom) throw new Error("The start date is required")
|
|
||||||
if (opts.enforceFutureStart) {
|
|
||||||
const [y, m, d] = String(availableFrom).split("-").map(Number)
|
|
||||||
const startsAt = new Date(y, (m || 1) - 1, d || 1)
|
|
||||||
const today = new Date()
|
|
||||||
today.setHours(0, 0, 0, 0)
|
|
||||||
if (startsAt < today) throw new Error("The start date cannot be earlier than today")
|
|
||||||
}
|
|
||||||
if (availableTo && new Date(availableTo) < new Date(availableFrom)) {
|
|
||||||
throw new Error("The end date cannot be earlier than the start date")
|
|
||||||
}
|
|
||||||
|
|
||||||
const images = normalizeImages(data.images !== undefined ? data.images : data.image)
|
|
||||||
const video = normalizeVideo(data.video)
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: "housing",
|
|
||||||
housing_type,
|
|
||||||
property_type: safeProperty(data.property_type),
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
rules: String(data.rules || "").trim(),
|
|
||||||
place: String(data.place || "").trim(),
|
|
||||||
mapUrl: String(data.mapUrl || "").trim(),
|
|
||||||
price: housing_type === "couchsurfing" ? "0.000000" : nonNeg(data.price).toFixed(6),
|
|
||||||
rooms: Math.max(0, toInt(data.rooms, 0)),
|
|
||||||
size: nonNeg(data.size),
|
|
||||||
capacity: Math.max(0, toInt(data.capacity, 0)),
|
|
||||||
availableFrom,
|
|
||||||
availableTo,
|
|
||||||
images,
|
|
||||||
image: images[0] || null,
|
|
||||||
video,
|
|
||||||
tags: normalizeTags(data.tags),
|
|
||||||
visibility: String(data.visibility || "PUBLIC").toUpperCase() === "HIDDEN" ? "HIDDEN" : "PUBLIC"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: "housing",
|
|
||||||
HOUSING_TYPES,
|
|
||||||
PROPERTY_TYPES,
|
|
||||||
MAX_IMAGES,
|
|
||||||
|
|
||||||
async createHousing(data) {
|
|
||||||
const ssbClient = await openSsb()
|
|
||||||
const content = {
|
|
||||||
...buildContent(data, { enforceFutureStart: true }),
|
|
||||||
author: ssbClient.id,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
status: "OPEN",
|
|
||||||
opinions: {},
|
|
||||||
opinions_inhabitants: []
|
|
||||||
}
|
|
||||||
return publishHousing(ssbClient, content, null)
|
|
||||||
},
|
|
||||||
|
|
||||||
async resolveCurrentId(id) {
|
|
||||||
const ssbClient = await openSsb()
|
|
||||||
const idx = buildIndex(await readAll(ssbClient), ssbClient)
|
|
||||||
const tip = idx.tipOf(id)
|
|
||||||
if (idx.tomb.has(tip)) throw new Error("Housing not found")
|
|
||||||
return tip
|
|
||||||
},
|
|
||||||
|
|
||||||
async resolveRootId(id) {
|
|
||||||
const ssbClient = await openSsb()
|
|
||||||
const idx = buildIndex(await readAll(ssbClient), ssbClient)
|
|
||||||
const tip = idx.tipOf(id)
|
|
||||||
if (idx.tomb.has(tip)) throw new Error("Housing not found")
|
|
||||||
return idx.rootOf(tip)
|
|
||||||
},
|
|
||||||
|
|
||||||
async updateHousing(id, data) {
|
|
||||||
const ssbClient = await openSsb()
|
|
||||||
const idx = buildIndex(await readAll(ssbClient), ssbClient)
|
|
||||||
const tipId = idx.tipOf(id)
|
|
||||||
if (idx.tomb.has(tipId)) throw new Error("Housing not found")
|
|
||||||
const node = idx.nodes.get(tipId)
|
|
||||||
if (!node || !node.c) throw new Error("Housing not found")
|
|
||||||
if ((node.author || node.c.author) !== ssbClient.id) throw new Error("Unauthorized")
|
|
||||||
|
|
||||||
const rootId = idx.rootOf(tipId)
|
|
||||||
const current = decryptHousing(node.c, rootId)
|
|
||||||
if (current._undecryptable) throw new Error("Cannot decrypt this listing")
|
|
||||||
const startChanged = data.availableFrom !== undefined && safeDate(data.availableFrom) !== safeDate(current.availableFrom)
|
|
||||||
const merged = buildContent({
|
|
||||||
housing_type: data.housing_type === undefined ? current.housing_type : data.housing_type,
|
|
||||||
property_type: data.property_type === undefined ? current.property_type : data.property_type,
|
|
||||||
title: data.title === undefined ? current.title : data.title,
|
|
||||||
description: data.description === undefined ? current.description : data.description,
|
|
||||||
rules: data.rules === undefined ? current.rules : data.rules,
|
|
||||||
place: data.place === undefined ? current.place : data.place,
|
|
||||||
mapUrl: data.mapUrl === undefined ? current.mapUrl : data.mapUrl,
|
|
||||||
price: data.price === undefined ? current.price : data.price,
|
|
||||||
rooms: data.rooms === undefined ? current.rooms : data.rooms,
|
|
||||||
size: data.size === undefined ? current.size : data.size,
|
|
||||||
capacity: data.capacity === undefined ? current.capacity : data.capacity,
|
|
||||||
availableFrom: data.availableFrom === undefined ? current.availableFrom : data.availableFrom,
|
|
||||||
availableTo: data.availableTo === undefined ? current.availableTo : data.availableTo,
|
|
||||||
images: data.images === undefined
|
|
||||||
? (current.images && current.images.length ? current.images : current.image)
|
|
||||||
: data.images,
|
|
||||||
video: data.video === undefined ? current.video : data.video,
|
|
||||||
tags: data.tags === undefined ? current.tags : data.tags,
|
|
||||||
visibility: data.visibility === undefined ? current.visibility : data.visibility
|
|
||||||
}, { enforceFutureStart: startChanged })
|
|
||||||
|
|
||||||
let status = String(current.status || "OPEN").toUpperCase()
|
|
||||||
if (data.status !== undefined) {
|
|
||||||
const s = String(data.status || "").toUpperCase()
|
|
||||||
if (!["OPEN", "CLOSED"].includes(s)) throw new Error("Invalid status")
|
|
||||||
status = s
|
|
||||||
}
|
|
||||||
|
|
||||||
const next = {
|
|
||||||
...merged,
|
|
||||||
status,
|
|
||||||
author: current.author,
|
|
||||||
createdAt: current.createdAt,
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
opinions: current.opinions || {},
|
|
||||||
opinions_inhabitants: safeArr(current.opinions_inhabitants),
|
|
||||||
replaces: tipId
|
|
||||||
}
|
|
||||||
|
|
||||||
const tomb = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: ssbClient.id }
|
|
||||||
await new Promise((res, rej) => ssbClient.publish(tomb, (e) => e ? rej(e) : res()))
|
|
||||||
return publishHousing(ssbClient, next, rootId)
|
|
||||||
},
|
|
||||||
|
|
||||||
async updateHousingStatus(id, status) {
|
|
||||||
return this.updateHousing(id, { status: String(status || "").toUpperCase() })
|
|
||||||
},
|
|
||||||
|
|
||||||
async deleteHousing(id) {
|
|
||||||
const ssbClient = await openSsb()
|
|
||||||
const idx = buildIndex(await readAll(ssbClient), ssbClient)
|
|
||||||
const tipId = idx.tipOf(id)
|
|
||||||
if (idx.tomb.has(tipId)) throw new Error("Housing not found")
|
|
||||||
const node = idx.nodes.get(tipId)
|
|
||||||
if (!node) throw new Error("Housing not found")
|
|
||||||
if ((node.author || node.c.author) !== ssbClient.id) throw new Error("Unauthorized")
|
|
||||||
const tomb = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: ssbClient.id }
|
|
||||||
return new Promise((res, rej) => ssbClient.publish(tomb, (e, r) => e ? rej(e) : res(r)))
|
|
||||||
},
|
|
||||||
|
|
||||||
async requestHousing(id) {
|
|
||||||
const ssbClient = await openSsb()
|
|
||||||
const me = ssbClient.id
|
|
||||||
const item = await this.getHousingById(id)
|
|
||||||
if (item.author === me) throw new Error("Cannot request your own listing")
|
|
||||||
if (item.status !== "OPEN") throw new Error("This listing is closed")
|
|
||||||
if (safeArr(item.requests).includes(me)) return { alreadyRequested: true }
|
|
||||||
const msg = { type: "housingRequest", housingId: item.rootId, value: true, createdAt: new Date().toISOString() }
|
|
||||||
return new Promise((res, rej) => ssbClient.private.publish(msg, [me, item.author], (e, m) => e ? rej(e) : res(m)))
|
|
||||||
},
|
|
||||||
|
|
||||||
async cancelRequest(id) {
|
|
||||||
const ssbClient = await openSsb()
|
|
||||||
const me = ssbClient.id
|
|
||||||
const item = await this.getHousingById(id)
|
|
||||||
if (item.author === me) throw new Error("Cannot cancel a request on your own listing")
|
|
||||||
if (!safeArr(item.requests).includes(me)) return { notRequested: true }
|
|
||||||
const msg = { type: "housingRequest", housingId: item.rootId, value: false, createdAt: new Date().toISOString() }
|
|
||||||
return new Promise((res, rej) => ssbClient.private.publish(msg, [me, item.author], (e, m) => e ? rej(e) : res(m)))
|
|
||||||
},
|
|
||||||
|
|
||||||
async createOpinion(id, category) {
|
|
||||||
if (!categories.includes(category)) throw new Error("Invalid category")
|
|
||||||
const ssbClient = await openSsb()
|
|
||||||
const me = ssbClient.id
|
|
||||||
const idx = buildIndex(await readAll(ssbClient), ssbClient)
|
|
||||||
const tipId = idx.tipOf(id)
|
|
||||||
if (idx.tomb.has(tipId)) throw new Error("Housing not found")
|
|
||||||
const node = idx.nodes.get(tipId)
|
|
||||||
if (!node) throw new Error("Housing not found")
|
|
||||||
const rootId = idx.rootOf(tipId)
|
|
||||||
const owner = node.author || node.c.author
|
|
||||||
if (owner === me) throw new Error("You cannot rate your own listing")
|
|
||||||
|
|
||||||
const agg = idx.aggregateFor(rootId, decryptHousing(node.c, rootId), owner)
|
|
||||||
if (agg.opinions_inhabitants.includes(me)) throw new Error("Already voted")
|
|
||||||
|
|
||||||
const everRequested = idx.everRequestedByRoot.get(rootId) || new Set()
|
|
||||||
if (!everRequested.has(me)) throw new Error("You can rate only a place you have requested")
|
|
||||||
|
|
||||||
const content = { type: "housingOpinion", target: rootId, category, createdAt: new Date().toISOString() }
|
|
||||||
return new Promise((res, rej) => ssbClient.publish(content, (e, m) => e ? rej(e) : res(m)))
|
|
||||||
},
|
|
||||||
|
|
||||||
async listHousing(filter = "ALL", viewerId = null, query = {}) {
|
|
||||||
const ssbClient = await openSsb()
|
|
||||||
const viewer = viewerId || ssbClient.id
|
|
||||||
const idx = buildIndex(await readAll(ssbClient), ssbClient)
|
|
||||||
|
|
||||||
const items = []
|
|
||||||
for (const [rootId, tipId] of idx.tipByRoot.entries()) {
|
|
||||||
if (idx.tomb.has(tipId)) continue
|
|
||||||
const node = idx.nodes.get(tipId)
|
|
||||||
if (!node) continue
|
|
||||||
const item = buildObject(node, rootId, idx, viewer)
|
|
||||||
if (!item) continue
|
|
||||||
if (item.visibility === "HIDDEN" && item.author !== viewer) continue
|
|
||||||
items.push(item)
|
|
||||||
}
|
|
||||||
|
|
||||||
const F = String(filter || "ALL").toUpperCase()
|
|
||||||
let list = dedupeByPreferring(
|
|
||||||
items,
|
|
||||||
(h) => (h.author && h.createdAt) ? [norm(h.author), norm(h.createdAt), norm(h.title)].join("|") : null,
|
|
||||||
(h) => h.requestCount
|
|
||||||
)
|
|
||||||
|
|
||||||
if (F === "MINE") list = list.filter(h => h.author === viewer)
|
|
||||||
else if (F === "REQUESTED") list = list.filter(h => safeArr(h.requests).includes(viewer))
|
|
||||||
else if (HOUSING_TYPES.includes(norm(F))) list = list.filter(h => h.housing_type === norm(F))
|
|
||||||
else if (F === "OPEN") list = list.filter(h => h.status === "OPEN")
|
|
||||||
else if (F === "CLOSED") list = list.filter(h => h.status === "CLOSED")
|
|
||||||
else if (F === "RECENT") list = list.filter(h => moment(h.createdAt).isAfter(moment().subtract(24, "hours")))
|
|
||||||
|
|
||||||
const search = String(query.search || query.q || "").trim()
|
|
||||||
if (search) list = list.filter(h => matchSearch(h, search))
|
|
||||||
|
|
||||||
const minP = toNum(query.minPrice)
|
|
||||||
const maxP = toNum(query.maxPrice)
|
|
||||||
if (Number.isFinite(minP)) list = list.filter(h => toNum(h.price) >= minP)
|
|
||||||
if (Number.isFinite(maxP)) list = list.filter(h => toNum(h.price) <= maxP)
|
|
||||||
|
|
||||||
const place = String(query.place || "").trim()
|
|
||||||
if (place) list = list.filter(h => norm(h.place).includes(norm(place)))
|
|
||||||
|
|
||||||
const ratingOf = (h) => safeArr(h.opinions_inhabitants).length
|
|
||||||
const sort = String(query.sort || "").trim()
|
|
||||||
if (F === "TOP" || sort === "rating") list.sort((a, b) => ratingOf(b) - ratingOf(a))
|
|
||||||
else if (sort === "price") list.sort((a, b) => toNum(a.price) - toNum(b.price))
|
|
||||||
else if (sort === "requests") list.sort((a, b) => b.requestCount - a.requestCount)
|
|
||||||
else list.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
|
|
||||||
|
|
||||||
return list
|
|
||||||
},
|
|
||||||
|
|
||||||
async getHousingById(id, viewerId = null) {
|
|
||||||
const ssbClient = await openSsb()
|
|
||||||
const viewer = viewerId || ssbClient.id
|
|
||||||
const idx = buildIndex(await readAll(ssbClient), ssbClient)
|
|
||||||
const tipId = idx.tipOf(id)
|
|
||||||
if (idx.tomb.has(tipId)) throw new Error("Housing not found")
|
|
||||||
const node = idx.nodes.get(tipId)
|
|
||||||
if (!node) throw new Error("Housing not found")
|
|
||||||
const rootId = idx.rootOf(tipId)
|
|
||||||
const item = buildObject(node, rootId, idx, viewer)
|
|
||||||
if (!item) throw new Error("Housing not found")
|
|
||||||
if (item.visibility === "HIDDEN" && item.author !== viewer) throw new Error("Housing not found")
|
|
||||||
return item
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -142,6 +142,7 @@ module.exports = ({ cooler }) => {
|
||||||
title: c.title || "",
|
title: c.title || "",
|
||||||
description: c.description || "",
|
description: c.description || "",
|
||||||
mapUrl: c.mapUrl || "",
|
mapUrl: c.mapUrl || "",
|
||||||
|
meme: !!c.meme,
|
||||||
opinions: agg ? agg.opinions : (c.opinions || {}),
|
opinions: agg ? agg.opinions : (c.opinions || {}),
|
||||||
opinions_inhabitants: voters,
|
opinions_inhabitants: voters,
|
||||||
hasVoted: viewerId ? voters.includes(viewerId) : false,
|
hasVoted: viewerId ? voters.includes(viewerId) : false,
|
||||||
|
|
@ -176,7 +177,7 @@ module.exports = ({ cooler }) => {
|
||||||
return root;
|
return root;
|
||||||
},
|
},
|
||||||
|
|
||||||
async createImage(blobMarkdown, tagsRaw, title, description, mapUrl) {
|
async createImage(blobMarkdown, tagsRaw, title, description, memeBool, mapUrl) {
|
||||||
const ssbClient = await openSsb();
|
const ssbClient = await openSsb();
|
||||||
const blobId = parseBlobId(blobMarkdown);
|
const blobId = parseBlobId(blobMarkdown);
|
||||||
const tags = normalizeTags(tagsRaw) || [];
|
const tags = normalizeTags(tagsRaw) || [];
|
||||||
|
|
@ -192,6 +193,7 @@ module.exports = ({ cooler }) => {
|
||||||
title: title || "",
|
title: title || "",
|
||||||
description: description || "",
|
description: description || "",
|
||||||
mapUrl: mapUrl || "",
|
mapUrl: mapUrl || "",
|
||||||
|
meme: !!memeBool,
|
||||||
opinions: {},
|
opinions: {},
|
||||||
opinions_inhabitants: []
|
opinions_inhabitants: []
|
||||||
};
|
};
|
||||||
|
|
@ -201,7 +203,7 @@ module.exports = ({ cooler }) => {
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
async updateImageById(id, blobMarkdown, tagsRaw, title, description, mapUrl) {
|
async updateImageById(id, blobMarkdown, tagsRaw, title, description, memeBool, mapUrl) {
|
||||||
const ssbClient = await openSsb();
|
const ssbClient = await openSsb();
|
||||||
const userId = ssbClient.id;
|
const userId = ssbClient.id;
|
||||||
const tipId = await this.resolveCurrentId(id);
|
const tipId = await this.resolveCurrentId(id);
|
||||||
|
|
@ -224,6 +226,7 @@ module.exports = ({ cooler }) => {
|
||||||
title: title !== undefined ? title || "" : oldMsg.content.title || "",
|
title: title !== undefined ? title || "" : oldMsg.content.title || "",
|
||||||
description: description !== undefined ? description || "" : oldMsg.content.description || "",
|
description: description !== undefined ? description || "" : oldMsg.content.description || "",
|
||||||
mapUrl: mapUrl !== undefined ? mapUrl || "" : oldMsg.content.mapUrl || "",
|
mapUrl: mapUrl !== undefined ? mapUrl || "" : oldMsg.content.mapUrl || "",
|
||||||
|
meme: typeof memeBool === "boolean" ? memeBool : !!oldMsg.content.meme,
|
||||||
createdAt: oldMsg.content.createdAt,
|
createdAt: oldMsg.content.createdAt,
|
||||||
updatedAt: now
|
updatedAt: now
|
||||||
};
|
};
|
||||||
|
|
@ -277,6 +280,7 @@ module.exports = ({ cooler }) => {
|
||||||
|
|
||||||
if (filter === "mine") list = list.filter((im) => String(im.author) === String(viewerId));
|
if (filter === "mine") list = list.filter((im) => String(im.author) === String(viewerId));
|
||||||
else if (filter === "recent") list = list.filter((im) => new Date(im.createdAt).getTime() >= now - 86400000);
|
else if (filter === "recent") list = list.filter((im) => new Date(im.createdAt).getTime() >= now - 86400000);
|
||||||
|
else if (filter === "meme") list = list.filter((im) => im.meme === true);
|
||||||
else if (filter === "top") {
|
else if (filter === "top") {
|
||||||
list = list
|
list = list
|
||||||
.slice()
|
.slice()
|
||||||
|
|
|
||||||
|
|
@ -130,9 +130,9 @@ module.exports = ({ cooler }) => {
|
||||||
return filterInactive(users);
|
return filterInactive(users);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filter === 'all' || filter === 'TOP KARMA' || filter === 'TOP ACTIVITY' || filter === 'TOP INACTIVITY' || filter === 'TOP ECO') {
|
if (filter === 'all' || filter === 'TOP KARMA' || filter === 'TOP ACTIVITY' || filter === 'TOP ECO') {
|
||||||
let users = await listAllBase(ssbClient);
|
let users = await listAllBase(ssbClient);
|
||||||
if (filter !== 'TOP ACTIVITY' && filter !== 'TOP INACTIVITY') {
|
if (filter !== 'TOP ACTIVITY') {
|
||||||
users = filterInactive(users);
|
users = filterInactive(users);
|
||||||
}
|
}
|
||||||
if (search) {
|
if (search) {
|
||||||
|
|
@ -170,7 +170,6 @@ module.exports = ({ cooler }) => {
|
||||||
}));
|
}));
|
||||||
if (filter === 'TOP KARMA') return withMetrics.sort((a, b) => (b.karmaScore || 0) - (a.karmaScore || 0));
|
if (filter === 'TOP KARMA') return withMetrics.sort((a, b) => (b.karmaScore || 0) - (a.karmaScore || 0));
|
||||||
if (filter === 'TOP ACTIVITY') return withMetrics.sort((a, b) => (b.lastActivityTs || 0) - (a.lastActivityTs || 0));
|
if (filter === 'TOP ACTIVITY') return withMetrics.sort((a, b) => (b.lastActivityTs || 0) - (a.lastActivityTs || 0));
|
||||||
if (filter === 'TOP INACTIVITY') return withMetrics.sort((a, b) => (a.lastActivityTs || 0) - (b.lastActivityTs || 0));
|
|
||||||
if (filter === 'TOP ECO') return withMetrics.sort((a, b) => (b.ecoScore || 0) - (a.ecoScore || 0));
|
if (filter === 'TOP ECO') return withMetrics.sort((a, b) => (b.ecoScore || 0) - (a.ecoScore || 0));
|
||||||
return withMetrics;
|
return withMetrics;
|
||||||
}
|
}
|
||||||
|
|
@ -255,7 +254,7 @@ module.exports = ({ cooler }) => {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filter === 'CVs') {
|
if (filter === 'CVs' || filter === 'MATCHSKILLS') {
|
||||||
const records = await new Promise((res, rej) => {
|
const records = await new Promise((res, rej) => {
|
||||||
pull(
|
pull(
|
||||||
ssbClient.createLogStream({ limit: logLimit, reverse: true}),
|
ssbClient.createLogStream({ limit: logLimit, reverse: true}),
|
||||||
|
|
@ -296,13 +295,54 @@ module.exports = ({ cooler }) => {
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (filter === 'MATCHSKILLS') {
|
||||||
|
let base = await Promise.all(cvs.map(async c => {
|
||||||
|
const photo = await fetchUserImageUrl(c.author, 256);
|
||||||
|
const lastActivityTs = await getLastActivityTimestamp(c.author);
|
||||||
|
const { bucket, range } = bucketLastActivity(lastActivityTs);
|
||||||
|
const norm = this._normalizeCurriculum(c, photo);
|
||||||
|
const karmaScore = await getLastKarmaScore(c.author).catch(() => 0);
|
||||||
|
return { ...norm, lastActivityTs, lastActivityBucket: bucket, lastActivityRange: range, karmaScore };
|
||||||
|
}));
|
||||||
|
base = filterInactive(base);
|
||||||
|
const mecv = await this.getCVByUserId();
|
||||||
|
const userSkills = Array.from(new Set(
|
||||||
|
(mecv
|
||||||
|
? [
|
||||||
|
...(mecv.personalSkills || []),
|
||||||
|
...(mecv.oasisSkills || []),
|
||||||
|
...(mecv.educationalSkills || []),
|
||||||
|
...(mecv.professionalSkills || [])
|
||||||
|
]
|
||||||
|
: []).map(s => String(s || '').toLowerCase()).filter(Boolean)
|
||||||
|
));
|
||||||
|
if (!userSkills.length) return [];
|
||||||
|
const userSet = new Set(userSkills);
|
||||||
|
const matches = base.map(c => {
|
||||||
|
if (c.id === userId) return null;
|
||||||
|
const theirSkillsRaw = (c.skills || []).map(s => String(s || '').toLowerCase()).filter(Boolean);
|
||||||
|
const theirSet = new Set(theirSkillsRaw);
|
||||||
|
const common = Array.from(theirSet).filter(s => userSet.has(s));
|
||||||
|
if (!common.length) return null;
|
||||||
|
const unionSize = userSet.size + theirSet.size - common.length;
|
||||||
|
const matchScore = unionSize > 0 ? common.length / unionSize : 0;
|
||||||
|
const matchCoverage = userSet.size > 0 ? common.length / userSet.size : 0;
|
||||||
|
return { ...c, commonSkills: common, matchScore, matchCoverage };
|
||||||
|
}).filter(Boolean);
|
||||||
|
return matches.sort((a, b) =>
|
||||||
|
(b.matchScore - a.matchScore) ||
|
||||||
|
(b.commonSkills.length - a.commonSkills.length) ||
|
||||||
|
((b.karmaScore || 0) - (a.karmaScore || 0)) ||
|
||||||
|
((b.lastActivityTs || 0) - (a.lastActivityTs || 0))
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return [];
|
return [];
|
||||||
},
|
},
|
||||||
|
|
||||||
_normalizeCurriculum(c, photoUrl) {
|
_normalizeCurriculum(c, photoUrl) {
|
||||||
const photo = c.photo ? toImageUrl(c.photo, 256) : (photoUrl || toImageUrl(null, 256));
|
const photo = photoUrl || toImageUrl(c.photo, 256);
|
||||||
return {
|
return {
|
||||||
id: c.author,
|
id: c.author,
|
||||||
name: c.name,
|
name: c.name,
|
||||||
|
|
@ -318,8 +358,6 @@ module.exports = ({ cooler }) => {
|
||||||
languages: typeof c.languages === 'string'
|
languages: typeof c.languages === 'string'
|
||||||
? c.languages.split(',').map(x => x.trim())
|
? c.languages.split(',').map(x => x.trim())
|
||||||
: Array.isArray(c.languages) ? c.languages : [],
|
: Array.isArray(c.languages) ? c.languages : [],
|
||||||
status: c.status,
|
|
||||||
preferences: c.preferences,
|
|
||||||
createdAt: c.createdAt
|
createdAt: c.createdAt
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
@ -369,7 +407,7 @@ module.exports = ({ cooler }) => {
|
||||||
const isOwner = viewer === target;
|
const isOwner = viewer === target;
|
||||||
const arr = (v) => Array.isArray(v) ? v : [];
|
const arr = (v) => Array.isArray(v) ? v : [];
|
||||||
const up = (v) => String(v || '').toUpperCase();
|
const up = (v) => String(v || '').toUpperCase();
|
||||||
const COUNTED = new Set(['post','event','task','forum','tribe','market','job','housing','project','industry','shop','image','video','audio','document','bookmark','transfer','map']);
|
const COUNTED = new Set(['post','event','task','forum','tribe','market','job','project','industry','shop','image','video','audio','document','bookmark','transfer','map']);
|
||||||
const accessible = (type, c) => {
|
const accessible = (type, c) => {
|
||||||
if (c.encryptedPayload) return false;
|
if (c.encryptedPayload) return false;
|
||||||
switch (type) {
|
switch (type) {
|
||||||
|
|
@ -377,7 +415,6 @@ module.exports = ({ cooler }) => {
|
||||||
case 'event': return String(c.isPublic || '').toLowerCase() !== 'private' || isOwner || arr(c.attendees).includes(viewer);
|
case 'event': return String(c.isPublic || '').toLowerCase() !== 'private' || isOwner || arr(c.attendees).includes(viewer);
|
||||||
case 'forum': return c.isPrivate !== true || isOwner;
|
case 'forum': return c.isPrivate !== true || isOwner;
|
||||||
case 'job': return up(c.visibility) !== 'HIDDEN' || isOwner || arr(c.subscribers).includes(viewer);
|
case 'job': return up(c.visibility) !== 'HIDDEN' || isOwner || arr(c.subscribers).includes(viewer);
|
||||||
case 'housing': return up(c.visibility) !== 'HIDDEN' || isOwner;
|
|
||||||
case 'market': return up(c.visibility) !== 'HIDDEN' || isOwner;
|
case 'market': return up(c.visibility) !== 'HIDDEN' || isOwner;
|
||||||
case 'shop': return up(c.visibility) !== 'CLOSED' || isOwner;
|
case 'shop': return up(c.visibility) !== 'CLOSED' || isOwner;
|
||||||
case 'tribe': { const st = up(c.status); return !(st === 'PRIVATE' || st === 'INVITE-ONLY') || isOwner || arr(c.members).includes(viewer); }
|
case 'tribe': { const st = up(c.status); return !(st === 'PRIVATE' || st === 'INVITE-ONLY') || isOwner || arr(c.members).includes(viewer); }
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
|
||||||
const jobId = c.jobId
|
const jobId = c.jobId
|
||||||
const k = `${jobId}::${author}`
|
const k = `${jobId}::${author}`
|
||||||
const prev = jobSubLatest.get(k)
|
const prev = jobSubLatest.get(k)
|
||||||
if (!prev || ts >= prev.ts) jobSubLatest.set(k, { ts, value: !!c.value, author, jobId })
|
if (!prev || ts > prev.ts) jobSubLatest.set(k, { ts, value: !!c.value, author, jobId })
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -134,7 +134,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
|
||||||
const jobId = c.jobId
|
const jobId = c.jobId
|
||||||
const k = `${jobId}::${author}`
|
const k = `${jobId}::${author}`
|
||||||
const prev = jobSubLatest.get(k)
|
const prev = jobSubLatest.get(k)
|
||||||
if (!prev || ts >= prev.ts) jobSubLatest.set(k, { ts, value: !!c.value, author, jobId })
|
if (!prev || ts > prev.ts) jobSubLatest.set(k, { ts, value: !!c.value, author, jobId })
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,198 +0,0 @@
|
||||||
"use strict";
|
|
||||||
// Módulo KARVAN (self-contained) para Oasis Mobile — inspirado en karvan-protocol
|
|
||||||
// (packages/core/addons/ephemeral.js): salas EFÍMERAS en RAM que se AUTODESTRUYEN por TTL.
|
|
||||||
// - Nada se escribe a disco. Los mensajes viven solo en memoria y desaparecen con la sala.
|
|
||||||
// - TTL de inactividad (idle) que se resetea con cada actividad + TTL absoluto que nunca se resetea.
|
|
||||||
// - Mailbox RAM para señalización WebRTC (data-channel) entre pares.
|
|
||||||
// No depende de @karvan/core ni de cripto nativa: es una capa local sobre el backend de Oasis.
|
|
||||||
|
|
||||||
const crypto = require("crypto");
|
|
||||||
|
|
||||||
const DEFAULT_IDLE_TTL_MS = 30 * 60 * 1000; // 30 min sin actividad -> autodestrucción
|
|
||||||
const DEFAULT_ABS_TTL_MS = 2 * 60 * 60 * 1000; // 2 h absoluto (tope)
|
|
||||||
const MAX_ABS_TTL_MS = 24 * 60 * 60 * 1000; // nunca más de 24 h
|
|
||||||
const MAX_MSGS = 250; // anillo: solo los últimos N en RAM
|
|
||||||
const MAX_SIGNALS = 500;
|
|
||||||
const MAX_ROOMS = 100;
|
|
||||||
const MAX_MEMBERS = 50; // tope de miembros por sala (evita crecimiento sin límite del Set)
|
|
||||||
const MAX_SIGNAL_BYTES = 16384; // SDP/ICE son pequeños; rechaza payloads gigantes (anti-DoS de memoria)
|
|
||||||
|
|
||||||
module.exports = ({ idleTtlMs = DEFAULT_IDLE_TTL_MS, absTtlMs = DEFAULT_ABS_TTL_MS } = {}) => {
|
|
||||||
const rooms = new Map(); // id -> room
|
|
||||||
|
|
||||||
const publicRoom = (r) => ({
|
|
||||||
id: r.id, title: r.title, createdAt: r.createdAt, expiresAt: r.expiresAt,
|
|
||||||
count: r.messages.length, members: r.members.size,
|
|
||||||
});
|
|
||||||
|
|
||||||
const resetIdle = (room) => {
|
|
||||||
if (room.idleTimer) clearTimeout(room.idleTimer);
|
|
||||||
room.idleTimer = setTimeout(() => destroy(room.id), idleTtlMs);
|
|
||||||
if (room.idleTimer.unref) room.idleTimer.unref();
|
|
||||||
};
|
|
||||||
|
|
||||||
const destroy = (id) => {
|
|
||||||
const room = rooms.get(id);
|
|
||||||
if (!room) return;
|
|
||||||
clearTimeout(room.idleTimer);
|
|
||||||
clearTimeout(room.absTimer);
|
|
||||||
// AUTODESTRUCCIÓN: vaciar todo el material en RAM
|
|
||||||
room.messages.length = 0;
|
|
||||||
room.signals.length = 0;
|
|
||||||
room.members.clear();
|
|
||||||
room.remoteFeeds.clear();
|
|
||||||
rooms.delete(id);
|
|
||||||
};
|
|
||||||
|
|
||||||
const createRoom = ({ title = "", ttlMinutes } = {}) => {
|
|
||||||
if (rooms.size >= MAX_ROOMS) {
|
|
||||||
// desalojar la más antigua para no crecer sin límite
|
|
||||||
const oldest = [...rooms.values()].sort((a, b) => a.createdAt - b.createdAt)[0];
|
|
||||||
if (oldest) destroy(oldest.id);
|
|
||||||
}
|
|
||||||
const id = crypto.randomBytes(9).toString("hex"); // 18 hex
|
|
||||||
const createdAt = Date.now();
|
|
||||||
let absMs = absTtlMs;
|
|
||||||
const t = parseInt(ttlMinutes, 10);
|
|
||||||
if (Number.isFinite(t) && t > 0) absMs = Math.min(t * 60000, MAX_ABS_TTL_MS);
|
|
||||||
const room = {
|
|
||||||
id,
|
|
||||||
title: String(title || "").slice(0, 80),
|
|
||||||
createdAt,
|
|
||||||
expiresAt: createdAt + absMs,
|
|
||||||
messages: [],
|
|
||||||
signals: [],
|
|
||||||
members: new Set(),
|
|
||||||
remoteFeeds: new Set(), // feeds SSB de otros nodos con esta sala (para relay cross-device por privados)
|
|
||||||
seq: 0,
|
|
||||||
sigSeq: 0,
|
|
||||||
idleTimer: null,
|
|
||||||
absTimer: null,
|
|
||||||
};
|
|
||||||
rooms.set(id, room);
|
|
||||||
room.absTimer = setTimeout(() => destroy(id), absMs);
|
|
||||||
if (room.absTimer.unref) room.absTimer.unref();
|
|
||||||
resetIdle(room);
|
|
||||||
return publicRoom(room);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Fase 2 (cross-device): registra el feed SSB de otro participante (de una invitación) para relayarle.
|
|
||||||
const addRemoteFeed = (id, feed) => {
|
|
||||||
const room = rooms.get(id);
|
|
||||||
if (!room || typeof feed !== "string" || !/^@[A-Za-z0-9+/=]{20,}\.ed25519$/.test(feed)) return false;
|
|
||||||
if (room.remoteFeeds.has(feed) || room.remoteFeeds.size < MAX_MEMBERS) { room.remoteFeeds.add(feed); return true; }
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
const getRemoteFeeds = (id) => { const r = rooms.get(id); return r ? [...r.remoteFeeds] : []; };
|
|
||||||
// ¿hay ALGUNA sala con feeds remotos? (para no montar el relay si no hace falta)
|
|
||||||
const anyRemoteRooms = () => { for (const r of rooms.values()) if (r.remoteFeeds.size) return true; return false; };
|
|
||||||
|
|
||||||
// Fase 2 (invitar): unirse a una sala por su id creando un ESPEJO local (desde un enlace/invitación).
|
|
||||||
// El id es la "capacidad": quien lo tiene puede unirse. Aún NO conecta con la sala del otro nodo
|
|
||||||
// (eso es la señalización cross-device, pendiente); crea la sala local para poder abrir el enlace.
|
|
||||||
const adoptRoom = ({ id, title = "", ttlMinutes } = {}) => {
|
|
||||||
if (!id || typeof id !== "string" || !/^[0-9a-f]{6,64}$/.test(id)) return null;
|
|
||||||
const existing = rooms.get(id);
|
|
||||||
if (existing) return publicRoom(existing);
|
|
||||||
if (rooms.size >= MAX_ROOMS) {
|
|
||||||
const oldest = [...rooms.values()].sort((a, b) => a.createdAt - b.createdAt)[0];
|
|
||||||
if (oldest) destroy(oldest.id);
|
|
||||||
}
|
|
||||||
const createdAt = Date.now();
|
|
||||||
let absMs = absTtlMs;
|
|
||||||
const t = parseInt(ttlMinutes, 10);
|
|
||||||
if (Number.isFinite(t) && t > 0) absMs = Math.min(t * 60000, MAX_ABS_TTL_MS);
|
|
||||||
const room = {
|
|
||||||
id, title: String(title || "").slice(0, 80), createdAt, expiresAt: createdAt + absMs,
|
|
||||||
messages: [], signals: [], members: new Set(), remoteFeeds: new Set(), seq: 0, sigSeq: 0, idleTimer: null, absTimer: null,
|
|
||||||
};
|
|
||||||
rooms.set(id, room);
|
|
||||||
room.absTimer = setTimeout(() => destroy(id), absMs);
|
|
||||||
if (room.absTimer.unref) room.absTimer.unref();
|
|
||||||
resetIdle(room);
|
|
||||||
return publicRoom(room);
|
|
||||||
};
|
|
||||||
|
|
||||||
const listRooms = () =>
|
|
||||||
[...rooms.values()].map(publicRoom).sort((a, b) => b.createdAt - a.createdAt);
|
|
||||||
|
|
||||||
const getRoom = (id) => {
|
|
||||||
const r = rooms.get(id);
|
|
||||||
return r ? publicRoom(r) : null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const postMessage = (id, { from = "?", text = "", mid = "" } = {}) => {
|
|
||||||
const room = rooms.get(id);
|
|
||||||
if (!room) return null;
|
|
||||||
const clean = String(text || "").slice(0, 2000);
|
|
||||||
if (!clean) return null;
|
|
||||||
const msg = {
|
|
||||||
seq: ++room.seq,
|
|
||||||
mid: String(mid || "").slice(0, 40),
|
|
||||||
from: String(from || "?").slice(0, 80),
|
|
||||||
text: clean,
|
|
||||||
ts: Date.now(),
|
|
||||||
};
|
|
||||||
room.messages.push(msg);
|
|
||||||
if (room.messages.length > MAX_MSGS) room.messages.shift();
|
|
||||||
if (room.members.has(msg.from) || room.members.size < MAX_MEMBERS) room.members.add(msg.from);
|
|
||||||
resetIdle(room);
|
|
||||||
return msg;
|
|
||||||
};
|
|
||||||
|
|
||||||
const listMessages = (id, since = 0) => {
|
|
||||||
const room = rooms.get(id);
|
|
||||||
if (!room) return null;
|
|
||||||
const s = parseInt(since, 10) || 0;
|
|
||||||
return room.messages.filter((m) => m.seq > s);
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- señalización WebRTC (mailbox RAM) ---
|
|
||||||
const postSignal = (id, { from = "", to = "", payload = null } = {}) => {
|
|
||||||
const room = rooms.get(id);
|
|
||||||
if (!room) return null;
|
|
||||||
if (payload != null) { // rechaza payloads gigantes (SDP/ICE son pequeños)
|
|
||||||
try { if (JSON.stringify(payload).length > MAX_SIGNAL_BYTES) return null; }
|
|
||||||
catch (e) { return null; }
|
|
||||||
}
|
|
||||||
const sig = {
|
|
||||||
seq: ++room.sigSeq,
|
|
||||||
from: String(from || "").slice(0, 80),
|
|
||||||
to: String(to || "").slice(0, 80),
|
|
||||||
payload,
|
|
||||||
ts: Date.now(),
|
|
||||||
};
|
|
||||||
room.signals.push(sig);
|
|
||||||
if (room.signals.length > MAX_SIGNALS) room.signals.shift();
|
|
||||||
if (sig.from && (room.members.has(sig.from) || room.members.size < MAX_MEMBERS)) room.members.add(sig.from);
|
|
||||||
resetIdle(room);
|
|
||||||
return sig;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getSignals = (id, { forId = "", since = 0 } = {}) => {
|
|
||||||
const room = rooms.get(id);
|
|
||||||
if (!room) return null;
|
|
||||||
const s = parseInt(since, 10) || 0;
|
|
||||||
return {
|
|
||||||
signals: room.signals.filter(
|
|
||||||
(x) => x.seq > s && x.from !== forId && (!x.to || x.to === forId)
|
|
||||||
),
|
|
||||||
members: [...room.members].filter((m) => m !== forId),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
createRoom,
|
|
||||||
adoptRoom,
|
|
||||||
addRemoteFeed,
|
|
||||||
getRemoteFeeds,
|
|
||||||
anyRemoteRooms,
|
|
||||||
listRooms,
|
|
||||||
getRoom,
|
|
||||||
postMessage,
|
|
||||||
listMessages,
|
|
||||||
postSignal,
|
|
||||||
getSignals,
|
|
||||||
_destroy: destroy,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
@ -165,11 +165,6 @@ function getGoverningHouseKey(now = new Date()) {
|
||||||
return HOUSE_KEYS[now.getMonth() % HOUSE_KEYS.length];
|
return HOUSE_KEYS[now.getMonth() % HOUSE_KEYS.length];
|
||||||
}
|
}
|
||||||
|
|
||||||
function getGoverningPeriodId(now = new Date()) {
|
|
||||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
|
||||||
return `${getGoverningHouseKey(now)}:${now.getFullYear()}-${month}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = ({ cooler, tribesModel, tribeCrypto }) => {
|
module.exports = ({ cooler, tribesModel, tribeCrypto }) => {
|
||||||
let ssb;
|
let ssb;
|
||||||
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
|
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
|
||||||
|
|
@ -573,14 +568,10 @@ module.exports = ({ cooler, tribesModel, tribeCrypto }) => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function wallIsPublic(houseKey, { isGoverning = false } = {}) {
|
|
||||||
return houseKey === 'academia' || isGoverning === true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function listHousePosts(houseKey, { viewerHouse = null, isGoverning = false } = {}) {
|
async function listHousePosts(houseKey, { viewerHouse = null, isGoverning = false } = {}) {
|
||||||
if (!VALID_KEY(houseKey)) return [];
|
if (!VALID_KEY(houseKey)) return [];
|
||||||
const viewerIsMember = viewerHouse === houseKey;
|
const viewerIsMember = viewerHouse === houseKey;
|
||||||
if (!viewerIsMember && !wallIsPublic(houseKey, { isGoverning })) return [];
|
if (!viewerIsMember && !isGoverning) return [];
|
||||||
const client = await openSsb();
|
const client = await openSsb();
|
||||||
const memberships = await listAllMemberships();
|
const memberships = await listAllMemberships();
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
|
|
@ -921,13 +912,11 @@ module.exports = ({ cooler, tribesModel, tribeCrypto }) => {
|
||||||
return {
|
return {
|
||||||
HOUSES,
|
HOUSES,
|
||||||
HOUSE_KEYS,
|
HOUSE_KEYS,
|
||||||
wallIsPublic,
|
|
||||||
TEST_COOLDOWN_MS,
|
TEST_COOLDOWN_MS,
|
||||||
TEST_QUESTIONS_COUNT,
|
TEST_QUESTIONS_COUNT,
|
||||||
PROFILE_QUESTIONS,
|
PROFILE_QUESTIONS,
|
||||||
computeCycle,
|
computeCycle,
|
||||||
getGoverningHouseKey,
|
getGoverningHouseKey,
|
||||||
getGoverningPeriodId,
|
|
||||||
publishJoin,
|
publishJoin,
|
||||||
publishLeaveLarp,
|
publishLeaveLarp,
|
||||||
getUserHouse,
|
getUserHouse,
|
||||||
|
|
|
||||||
|
|
@ -49,8 +49,8 @@ module.exports = {
|
||||||
exportData: async (password) => {
|
exportData: async (password) => {
|
||||||
try {
|
try {
|
||||||
const pw = normalizePassword(password);
|
const pw = normalizePassword(password);
|
||||||
// identidad de la cuenta activa
|
const homeDir = os.homedir();
|
||||||
const secretFilePath = path.join(require('../server/ssb_config').path, 'secret');
|
const secretFilePath = path.join(homeDir, '.ssb', 'secret');
|
||||||
if (!fs.existsSync(secretFilePath)) {
|
if (!fs.existsSync(secretFilePath)) {
|
||||||
throw new Error(".ssb/secret file doesn't exist");
|
throw new Error(".ssb/secret file doesn't exist");
|
||||||
}
|
}
|
||||||
|
|
@ -78,8 +78,8 @@ module.exports = {
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
throw new Error('Wrong password or corrupt backup file.');
|
throw new Error('Wrong password or corrupt backup file.');
|
||||||
}
|
}
|
||||||
// identidad de la cuenta activa
|
const homeDir = os.homedir();
|
||||||
const ssbDir = require('../server/ssb_config').path;
|
const ssbDir = path.join(homeDir, '.ssb');
|
||||||
fs.mkdirSync(ssbDir, { recursive: true });
|
fs.mkdirSync(ssbDir, { recursive: true });
|
||||||
const secretPath = path.join(ssbDir, 'secret');
|
const secretPath = path.join(ssbDir, 'secret');
|
||||||
if (fs.existsSync(secretPath)) {
|
if (fs.existsSync(secretPath)) {
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ const { buildValidatedTombstoneSet } = require('./tombstone_validator');
|
||||||
|
|
||||||
const logLimit = getConfig().ssbLogStream?.limit || 1000;
|
const logLimit = getConfig().ssbLogStream?.limit || 1000;
|
||||||
|
|
||||||
|
|
||||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
const WEEK_MS = 7 * DAY_MS;
|
const WEEK_MS = 7 * DAY_MS;
|
||||||
const MONTH_MS = 30 * DAY_MS;
|
const MONTH_MS = 30 * DAY_MS;
|
||||||
|
|
@ -312,16 +311,7 @@ module.exports = ({ cooler }) => {
|
||||||
ref: c.ref || null
|
ref: c.ref || null
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const parentOf = new Map();
|
|
||||||
for (const i of items) if (i.replaces) parentOf.set(i.key, i.replaces);
|
|
||||||
const rootOf = (key) => {
|
|
||||||
let cur = key;
|
|
||||||
const seen = new Set();
|
|
||||||
while (parentOf.has(cur) && !seen.has(cur)) { seen.add(cur); cur = parentOf.get(cur); }
|
|
||||||
return cur;
|
|
||||||
};
|
|
||||||
const survivors = items.filter(i => !tombstoned.has(i.key) && !replaced.has(i.key));
|
const survivors = items.filter(i => !tombstoned.has(i.key) && !replaced.has(i.key));
|
||||||
for (const i of survivors) i.rootId = rootOf(i.key);
|
|
||||||
survivors.sort((a, b) => b.ts - a.ts);
|
survivors.sort((a, b) => b.ts - a.ts);
|
||||||
return survivors;
|
return survivors;
|
||||||
}
|
}
|
||||||
|
|
@ -342,14 +332,11 @@ module.exports = ({ cooler }) => {
|
||||||
async function updateLog(id, { text, label, mode }) {
|
async function updateLog(id, { text, label, mode }) {
|
||||||
const current = await getLogById(id);
|
const current = await getLogById(id);
|
||||||
if (!current) return { status: 'not_found' };
|
if (!current) return { status: 'not_found' };
|
||||||
const next = {
|
|
||||||
text: text !== undefined ? text : current.text,
|
|
||||||
label: label !== undefined ? label : current.label,
|
|
||||||
mode: mode || current.mode
|
|
||||||
};
|
|
||||||
await republishLog({
|
await republishLog({
|
||||||
replaces: current.key,
|
replaces: current.key,
|
||||||
...next,
|
text: text !== undefined ? text : current.text,
|
||||||
|
label: label !== undefined ? label : current.label,
|
||||||
|
mode: mode || current.mode,
|
||||||
createdAt: current.createdAt
|
createdAt: current.createdAt
|
||||||
});
|
});
|
||||||
return { status: 'ok' };
|
return { status: 'ok' };
|
||||||
|
|
|
||||||
|
|
@ -66,8 +66,8 @@ const configure = (...customOptions) =>
|
||||||
Object.assign({}, defaultOptions, ...customOptions);
|
Object.assign({}, defaultOptions, ...customOptions);
|
||||||
|
|
||||||
// PEERS
|
// PEERS
|
||||||
const ebtDir = path.join(require('../server/ssb_config').path, 'ebt');
|
const ebtDir = path.join(os.homedir(), '.ssb', 'ebt');
|
||||||
const unfollowedPath = path.join(require('../server/ssb_config').path, 'gossip_unfollowed.json');
|
const unfollowedPath = path.join(os.homedir(), '.ssb', 'gossip_unfollowed.json');
|
||||||
|
|
||||||
async function loadPeersFromEbt() {
|
async function loadPeersFromEbt() {
|
||||||
let result = [];
|
let result = [];
|
||||||
|
|
@ -501,31 +501,6 @@ async function checkLocalBlob(blobId) {
|
||||||
}
|
}
|
||||||
|
|
||||||
models.blob = {
|
models.blob = {
|
||||||
getCached: async ({ blobId }) => {
|
|
||||||
const local = await checkLocalBlob(blobId);
|
|
||||||
if (local) return local;
|
|
||||||
const ssb = await cooler.open();
|
|
||||||
const has = await new Promise((resolve) => ssb.blobs.has(blobId, (err, v) => resolve(!err && !!v)));
|
|
||||||
if (!has) {
|
|
||||||
try { ssb.blobs.want(blobId, () => {}); } catch (_) {}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
pull(
|
|
||||||
ssb.blobs.get(blobId),
|
|
||||||
pull.collect(async (err, bufs) => {
|
|
||||||
if (err || !bufs || !bufs.length) return resolve(null);
|
|
||||||
const buffer = Buffer.concat(bufs);
|
|
||||||
try {
|
|
||||||
const filePath = blobIdToHexPath(blobId);
|
|
||||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
||||||
await fs.writeFile(filePath, buffer);
|
|
||||||
} catch (e) { /* ignore */ }
|
|
||||||
resolve(buffer);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
getResolved: async ({ blobId, timeout = 30000 }) => {
|
getResolved: async ({ blobId, timeout = 30000 }) => {
|
||||||
let buf = await checkLocalBlob(blobId);
|
let buf = await checkLocalBlob(blobId);
|
||||||
if (buf) return buf;
|
if (buf) return buf;
|
||||||
|
|
@ -789,7 +764,7 @@ models.meta = {
|
||||||
discovered: async () => {
|
discovered: async () => {
|
||||||
const ssb = await cooler.open();
|
const ssb = await cooler.open();
|
||||||
const snapshot = await ssb.conn.dbPeers();
|
const snapshot = await ssb.conn.dbPeers();
|
||||||
const gossipPath = path.join(require('../server/ssb_config').path, 'gossip.json');
|
const gossipPath = path.join(os.homedir(), '.ssb', 'gossip.json');
|
||||||
let gossipMap = new Map();
|
let gossipMap = new Map();
|
||||||
try {
|
try {
|
||||||
const gossipData = JSON.parse(await fs.readFile(gossipPath, 'utf8'));
|
const gossipData = JSON.parse(await fs.readFile(gossipPath, 'utf8'));
|
||||||
|
|
|
||||||
|
|
@ -864,7 +864,7 @@ module.exports = ({ cooler, tribeCrypto, mapCrypto, tribesModel }) => {
|
||||||
let mapKey = null;
|
let mapKey = null;
|
||||||
if (tribeCrypto && typeof matchedInvite === "object") {
|
if (tribeCrypto && typeof matchedInvite === "object") {
|
||||||
if (matchedInvite.ekChain) {
|
if (matchedInvite.ekChain) {
|
||||||
const chain = tribeCrypto.decryptChainFromInvite(matchedInvite.ekChain, code, matchedInvite.salt, 3);
|
const chain = tribeCrypto.decryptChainFromInvite(matchedInvite.ekChain, code, matchedInvite.salt);
|
||||||
if (Array.isArray(chain) && chain.length) {
|
if (Array.isArray(chain) && chain.length) {
|
||||||
for (const entry of chain) {
|
for (const entry of chain) {
|
||||||
if (Array.isArray(entry.keys) && entry.keys.length) {
|
if (Array.isArray(entry.keys) && entry.keys.length) {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
const pull = require("../server/node_modules/pull-stream")
|
const pull = require("../server/node_modules/pull-stream")
|
||||||
const moment = require("../server/node_modules/moment")
|
const moment = require("../server/node_modules/moment")
|
||||||
const { getConfig } = require("../configs/config-manager.js")
|
const { getConfig } = require("../configs/config-manager.js")
|
||||||
const categories = require("../backend/opinion_categories")
|
|
||||||
const { buildValidatedTombstoneSet } = require('./tombstone_validator')
|
const { buildValidatedTombstoneSet } = require('./tombstone_validator')
|
||||||
const { dedupeByPreferring, norm } = require('../backend/dedupe')
|
const { dedupeByPreferring, norm } = require('../backend/dedupe')
|
||||||
const logLimit = getConfig().ssbLogStream?.limit || 1000
|
const logLimit = getConfig().ssbLogStream?.limit || 1000
|
||||||
|
|
@ -74,14 +73,12 @@ module.exports = ({ cooler, tribeCrypto }) => {
|
||||||
const nodes = new Map()
|
const nodes = new Map()
|
||||||
const bids = []
|
const bids = []
|
||||||
const purchases = []
|
const purchases = []
|
||||||
const opinionMsgs = []
|
|
||||||
for (const m of messages) {
|
for (const m of messages) {
|
||||||
const c = m.value && m.value.content
|
const c = m.value && m.value.content
|
||||||
if (!c) continue
|
if (!c) continue
|
||||||
if (c.type === "tombstone") continue
|
if (c.type === "tombstone") continue
|
||||||
if (c.type === "marketBid") { bids.push({ target: c.target, author: m.value.author, amount: c.amount, time: c.time, ts: (m.value && m.value.timestamp) || 0 }); continue }
|
if (c.type === "marketBid") { bids.push({ target: c.target, author: m.value.author, amount: c.amount, time: c.time, ts: (m.value && m.value.timestamp) || 0 }); continue }
|
||||||
if (c.type === "marketPurchase") { purchases.push({ target: c.target, author: m.value.author, ts: (m.value && m.value.timestamp) || 0 }); continue }
|
if (c.type === "marketPurchase") { purchases.push({ target: c.target, author: m.value.author, ts: (m.value && m.value.timestamp) || 0 }); continue }
|
||||||
if (c.type === "marketOpinion" && c.target) { opinionMsgs.push({ target: c.target, author: m.value.author, category: c.category }); continue }
|
|
||||||
if (c.type !== "market") continue
|
if (c.type !== "market") continue
|
||||||
nodes.set(m.key, { key: m.key, ts: (m.value && m.value.timestamp) || m.timestamp || 0, c, author: m.value.author })
|
nodes.set(m.key, { key: m.key, ts: (m.value && m.value.timestamp) || m.timestamp || 0, c, author: m.value.author })
|
||||||
}
|
}
|
||||||
|
|
@ -98,11 +95,11 @@ module.exports = ({ cooler, tribeCrypto }) => {
|
||||||
if (!cn || cn.author !== pn.author) { naivePrev.delete(child); nodes.delete(child); continue }
|
if (!cn || cn.author !== pn.author) { naivePrev.delete(child); nodes.delete(child); continue }
|
||||||
strictNext.set(parent, child)
|
strictNext.set(parent, child)
|
||||||
}
|
}
|
||||||
return { tomb, nodes, bids, purchases, opinionMsgs, naivePrev, strictNext }
|
return { tomb, nodes, bids, purchases, naivePrev, strictNext }
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolveGroups = (idx) => {
|
const resolveGroups = (idx) => {
|
||||||
const { tomb, nodes, bids, purchases, opinionMsgs, naivePrev, strictNext } = idx
|
const { tomb, nodes, bids, purchases, naivePrev, strictNext } = idx
|
||||||
const rootOf = (key) => { let x = key, g = 0; while (naivePrev.has(x) && nodes.has(naivePrev.get(x)) && g++ < 100000) x = naivePrev.get(x); return x }
|
const rootOf = (key) => { let x = key, g = 0; while (naivePrev.has(x) && nodes.has(naivePrev.get(x)) && g++ < 100000) x = naivePrev.get(x); return x }
|
||||||
const followStrict = (key) => { let x = key, g = 0; while (strictNext.has(x) && g++ < 100000) x = strictNext.get(x); return x }
|
const followStrict = (key) => { let x = key, g = 0; while (strictNext.has(x) && g++ < 100000) x = strictNext.get(x); return x }
|
||||||
|
|
||||||
|
|
@ -111,21 +108,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
|
||||||
const bidsByRoot = new Map()
|
const bidsByRoot = new Map()
|
||||||
for (const bd of bids) { if (!nodes.has(bd.target)) continue; const r = rootOf(bd.target); if (!bidsByRoot.has(r)) bidsByRoot.set(r, []); bidsByRoot.get(r).push(bd) }
|
for (const bd of bids) { if (!nodes.has(bd.target)) continue; const r = rootOf(bd.target); if (!bidsByRoot.has(r)) bidsByRoot.set(r, []); bidsByRoot.get(r).push(bd) }
|
||||||
const soldByRoot = new Map()
|
const soldByRoot = new Map()
|
||||||
const buyersByRoot = new Map()
|
for (const pu of purchases) { if (!nodes.has(pu.target)) continue; const r = rootOf(pu.target); soldByRoot.set(r, (soldByRoot.get(r) || 0) + 1) }
|
||||||
for (const pu of purchases) {
|
|
||||||
if (!nodes.has(pu.target)) continue
|
|
||||||
const r = rootOf(pu.target)
|
|
||||||
soldByRoot.set(r, (soldByRoot.get(r) || 0) + 1)
|
|
||||||
if (!buyersByRoot.has(r)) buyersByRoot.set(r, new Set())
|
|
||||||
buyersByRoot.get(r).add(pu.author)
|
|
||||||
}
|
|
||||||
const opinionsByRoot = new Map()
|
|
||||||
for (const op of (opinionMsgs || [])) {
|
|
||||||
if (!nodes.has(op.target)) continue
|
|
||||||
const r = rootOf(op.target)
|
|
||||||
if (!opinionsByRoot.has(r)) opinionsByRoot.set(r, [])
|
|
||||||
opinionsByRoot.get(r).push(op)
|
|
||||||
}
|
|
||||||
|
|
||||||
const out = new Map()
|
const out = new Map()
|
||||||
for (const [root, keys] of groups) {
|
for (const [root, keys] of groups) {
|
||||||
|
|
@ -148,16 +131,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
|
||||||
const addLine = (line) => { const b = parseBidEntry(line); if (!b) return; const kk = `${b.bidder}|${b.amount}|${b.time}`; if (pollSet.has(kk)) return; pollSet.add(kk); poll.push(line) }
|
const addLine = (line) => { const b = parseBidEntry(line); if (!b) return; const kk = `${b.bidder}|${b.amount}|${b.time}`; if (pollSet.has(kk)) return; pollSet.add(kk); poll.push(line) }
|
||||||
for (const k of (sellerKeys.length ? sellerKeys : keys)) { const n = nodes.get(k); if (n && Array.isArray(n.c.auctions_poll)) for (const line of n.c.auctions_poll) addLine(line) }
|
for (const k of (sellerKeys.length ? sellerKeys : keys)) { const n = nodes.get(k); if (n && Array.isArray(n.c.auctions_poll)) for (const line of n.c.auctions_poll) addLine(line) }
|
||||||
for (const bd of (bidsByRoot.get(root) || [])) { const amt = Number(bd.amount); addLine(`${bd.author}|${Number.isFinite(amt) ? amt.toFixed(6) : bd.amount}|${bd.time}`) }
|
for (const bd of (bidsByRoot.get(root) || [])) { const amt = Number(bd.amount); addLine(`${bd.author}|${Number.isFinite(amt) ? amt.toFixed(6) : bd.amount}|${bd.time}`) }
|
||||||
const opinions = {}
|
out.set(root, { tip, rootId: root, best, statusN: bestS, poll, soldCount: soldByRoot.get(root) || 0 })
|
||||||
const voters = []
|
|
||||||
for (const op of (opinionsByRoot.get(root) || [])) {
|
|
||||||
if (!op.author || op.author === sellerId) continue
|
|
||||||
if (voters.includes(op.author)) continue
|
|
||||||
if (!categories.includes(op.category)) continue
|
|
||||||
voters.push(op.author)
|
|
||||||
opinions[op.category] = (opinions[op.category] || 0) + 1
|
|
||||||
}
|
|
||||||
out.set(root, { tip, rootId: root, best, statusN: bestS, poll, soldCount: soldByRoot.get(root) || 0, buyers: Array.from(buyersByRoot.get(root) || []), opinions, voters })
|
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
@ -254,13 +228,9 @@ module.exports = ({ cooler, tribeCrypto }) => {
|
||||||
normalized.price = p.toFixed(6)
|
normalized.price = p.toFixed(6)
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentItem = await new Promise((resolve) => ssbClient.get(tipId, (e, m) => resolve(e ? null : (m && m.content) || null)))
|
|
||||||
|
|
||||||
if (normalized.deadline !== undefined && normalized.deadline !== null && normalized.deadline !== "") {
|
if (normalized.deadline !== undefined && normalized.deadline !== null && normalized.deadline !== "") {
|
||||||
const dl = moment(normalized.deadline, moment.ISO_8601, true)
|
const dl = moment(normalized.deadline, moment.ISO_8601, true)
|
||||||
if (!dl.isValid()) throw new Error("Invalid deadline")
|
if (!dl.isValid()) throw new Error("Invalid deadline")
|
||||||
const changed = !currentItem || !currentItem.deadline || !moment(currentItem.deadline).isSame(dl)
|
|
||||||
if (changed && dl.isBefore(moment(), "minute")) throw new Error("The deadline cannot be in the past")
|
|
||||||
normalized.deadline = dl.toISOString()
|
normalized.deadline = dl.toISOString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -328,7 +298,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
|
||||||
const items = []
|
const items = []
|
||||||
const now = moment()
|
const now = moment()
|
||||||
|
|
||||||
for (const { tip, rootId, best, statusN, poll, soldCount, buyers, opinions, voters } of resolveGroups(buildMarketIndex(messages)).values()) {
|
for (const { tip, rootId, best, statusN, poll, soldCount } of resolveGroups(buildMarketIndex(messages)).values()) {
|
||||||
const leaf = tip
|
const leaf = tip
|
||||||
const c = best.c
|
const c = best.c
|
||||||
let status = D(statusN)
|
let status = D(statusN)
|
||||||
|
|
@ -376,11 +346,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
|
||||||
shopProductId: c.shopProductId || "",
|
shopProductId: c.shopProductId || "",
|
||||||
shopId: c.shopId || "",
|
shopId: c.shopId || "",
|
||||||
shopTitle: c.shopTitle || "",
|
shopTitle: c.shopTitle || "",
|
||||||
industry: c.industry || "",
|
industry: c.industry || ""
|
||||||
opinions: opinions || {},
|
|
||||||
opinions_inhabitants: voters || [],
|
|
||||||
purchasedByViewer: Array.isArray(buyers) && buyers.includes(userId),
|
|
||||||
ratedByViewer: Array.isArray(voters) && voters.includes(userId)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -435,7 +401,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
|
||||||
const rootOf = (key) => { let x = key, g = 0; while (idx.naivePrev.has(x) && idx.nodes.has(idx.naivePrev.get(x)) && g++ < 100000) x = idx.naivePrev.get(x); return x }
|
const rootOf = (key) => { let x = key, g = 0; while (idx.naivePrev.has(x) && idx.nodes.has(idx.naivePrev.get(x)) && g++ < 100000) x = idx.naivePrev.get(x); return x }
|
||||||
const grp = resolveGroups(idx).get(rootOf(itemId))
|
const grp = resolveGroups(idx).get(rootOf(itemId))
|
||||||
if (!grp) return null
|
if (!grp) return null
|
||||||
const { tip, rootId, best, statusN, poll, soldCount, buyers, opinions, voters } = grp
|
const { tip, rootId, best, statusN, poll, soldCount } = grp
|
||||||
|
|
||||||
const c = best.c
|
const c = best.c
|
||||||
let status = D(statusN)
|
let status = D(statusN)
|
||||||
|
|
@ -482,11 +448,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
|
||||||
shopProductId: c.shopProductId || "",
|
shopProductId: c.shopProductId || "",
|
||||||
shopId: c.shopId || "",
|
shopId: c.shopId || "",
|
||||||
shopTitle: c.shopTitle || "",
|
shopTitle: c.shopTitle || "",
|
||||||
industry: c.industry || "",
|
industry: c.industry || ""
|
||||||
opinions: opinions || {},
|
|
||||||
opinions_inhabitants: voters || [],
|
|
||||||
purchasedByViewer: Array.isArray(buyers) && buyers.includes(userId),
|
|
||||||
ratedByViewer: Array.isArray(voters) && voters.includes(userId)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -527,24 +489,6 @@ module.exports = ({ cooler, tribeCrypto }) => {
|
||||||
return items.find((i) => i.shopProductId === shopProductId) || null
|
return items.find((i) => i.shopProductId === shopProductId) || null
|
||||||
},
|
},
|
||||||
|
|
||||||
async createOpinion(itemId, category) {
|
|
||||||
if (!categories.includes(category)) throw new Error("Invalid category")
|
|
||||||
const ssbClient = await openSsb()
|
|
||||||
const userId = ssbClient.id
|
|
||||||
const messages = await readAll(ssbClient)
|
|
||||||
const groups = resolveGroups(buildMarketIndex(messages))
|
|
||||||
let group = null
|
|
||||||
for (const g of groups.values()) {
|
|
||||||
if (g.rootId === itemId || g.tip === itemId) { group = g; break }
|
|
||||||
}
|
|
||||||
if (!group) throw new Error("Item not found")
|
|
||||||
if (group.best.author === userId) throw new Error("You cannot rate your own item")
|
|
||||||
if ((group.voters || []).includes(userId)) throw new Error("Already voted")
|
|
||||||
if (!(group.buyers || []).includes(userId)) throw new Error("You can rate only an item you have bought")
|
|
||||||
const content = { type: "marketOpinion", target: group.rootId, category, createdAt: new Date().toISOString() }
|
|
||||||
return new Promise((res, rej) => ssbClient.publish(content, (e, m) => e ? rej(e) : res(m)))
|
|
||||||
},
|
|
||||||
|
|
||||||
async setItemAsSold(itemId) {
|
async setItemAsSold(itemId) {
|
||||||
const tipId = await this.resolveCurrentId(itemId)
|
const tipId = await this.resolveCurrentId(itemId)
|
||||||
const ssbClient = await openSsb()
|
const ssbClient = await openSsb()
|
||||||
|
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
const MAX_IMAGES = 8
|
|
||||||
|
|
||||||
const MEDIA_MARKDOWN = /\[(image|video|audio):[^\]]*\]\(/
|
|
||||||
|
|
||||||
const blobOf = (value) => {
|
|
||||||
let blobId = value || null
|
|
||||||
if (blobId && /\(([^)]+)\)/.test(String(blobId))) blobId = String(blobId).match(/\(([^)]+)\)/)[1]
|
|
||||||
return blobId || null
|
|
||||||
}
|
|
||||||
|
|
||||||
const keepMarkdown = (value, id) =>
|
|
||||||
MEDIA_MARKDOWN.test(value) || value.startsWith("![") ? value : id
|
|
||||||
|
|
||||||
const normalizeImages = (raw, max = MAX_IMAGES) => {
|
|
||||||
const list = Array.isArray(raw) ? raw : (raw ? [raw] : [])
|
|
||||||
const out = []
|
|
||||||
const seen = new Set()
|
|
||||||
for (const entry of list) {
|
|
||||||
const value = String(entry || "").trim()
|
|
||||||
const id = blobOf(value)
|
|
||||||
if (!id || seen.has(id)) continue
|
|
||||||
seen.add(id)
|
|
||||||
out.push(keepMarkdown(value, id))
|
|
||||||
if (out.length >= max) break
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizeVideo = (raw) => {
|
|
||||||
const value = String(Array.isArray(raw) ? raw[0] || "" : raw || "").trim()
|
|
||||||
const id = blobOf(value)
|
|
||||||
if (!id) return ""
|
|
||||||
return keepMarkdown(value, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
const mergeGallery = (current, uploaded, removeIndex = -1, max = MAX_IMAGES) => {
|
|
||||||
let list = normalizeImages(current, max)
|
|
||||||
if (removeIndex >= 0) list = list.filter((_, i) => i !== removeIndex)
|
|
||||||
return normalizeImages([...list, ...normalizeImages(uploaded, max)], max)
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { MAX_IMAGES, blobOf, normalizeImages, normalizeVideo, mergeGallery }
|
|
||||||
|
|
@ -1,171 +0,0 @@
|
||||||
const fs = require('fs');
|
|
||||||
const pull = require('../server/node_modules/pull-stream');
|
|
||||||
|
|
||||||
const SEEN_FILE = 'oasis-mentions-seen';
|
|
||||||
|
|
||||||
const seenPath = () => {
|
|
||||||
try { return require('../server/ssb_config').statePath(SEEN_FILE); } catch (_) { return null; }
|
|
||||||
};
|
|
||||||
|
|
||||||
const MAX_SEEN_KEYS = 500;
|
|
||||||
|
|
||||||
const readSeen = () => {
|
|
||||||
const p = seenPath();
|
|
||||||
if (!p) return { ts: 0, keys: [] };
|
|
||||||
let raw = '';
|
|
||||||
try { raw = fs.readFileSync(p, 'utf8').trim(); } catch (_) { return { ts: 0, keys: [] }; }
|
|
||||||
if (!raw) return { ts: 0, keys: [] };
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(raw);
|
|
||||||
if (parsed && typeof parsed === 'object') {
|
|
||||||
return { ts: Number(parsed.ts) || 0, keys: Array.isArray(parsed.keys) ? parsed.keys : [] };
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
return { ts: Number(raw) || 0, keys: [] };
|
|
||||||
};
|
|
||||||
|
|
||||||
const writeSeen = (keys, ts) => {
|
|
||||||
const p = seenPath();
|
|
||||||
if (!p) return false;
|
|
||||||
const trimmed = (Array.isArray(keys) ? keys : []).filter(Boolean).slice(0, MAX_SEEN_KEYS);
|
|
||||||
try { fs.writeFileSync(p, JSON.stringify({ ts: Number(ts) || Date.now(), keys: trimmed })); return true; } catch (_) { return false; }
|
|
||||||
};
|
|
||||||
const { getConfig } = require('../configs/config-manager.js');
|
|
||||||
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
|
|
||||||
const logLimit = getConfig().ssbLogStream?.limit || 1000;
|
|
||||||
|
|
||||||
const TEXT_FIELDS = ['text', 'description', 'title', 'concept', 'question', 'rules', 'message', 'body', 'subject'];
|
|
||||||
|
|
||||||
const SKIP_TYPES = new Set([
|
|
||||||
'about', 'contact', 'vote', 'pub', 'tombstone', 'karmaScore',
|
|
||||||
'tribe-invite-msg', 'tribe-invite-tombstone', 'tribe-key-distrib'
|
|
||||||
]);
|
|
||||||
|
|
||||||
module.exports = ({ cooler }) => {
|
|
||||||
let ssb;
|
|
||||||
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
|
|
||||||
|
|
||||||
const getAllMessages = async (ssbClient) =>
|
|
||||||
new Promise((resolve, reject) => {
|
|
||||||
pull(
|
|
||||||
ssbClient.createLogStream({ limit: logLimit }),
|
|
||||||
pull.collect((err, msgs) => (err ? reject(err) : resolve(msgs)))
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const mentionsList = (content, feedId) => {
|
|
||||||
const m = content && content.mentions;
|
|
||||||
if (!m) return false;
|
|
||||||
const values = Array.isArray(m) ? m : (typeof m === 'object' ? Object.values(m).flat() : []);
|
|
||||||
return values.some(v => v && (v.link === feedId || v === feedId));
|
|
||||||
};
|
|
||||||
|
|
||||||
const mentionsText = (content, feedId) => {
|
|
||||||
const bare = feedId.startsWith('@') ? feedId.slice(1) : feedId;
|
|
||||||
for (const field of TEXT_FIELDS) {
|
|
||||||
const v = content[field];
|
|
||||||
if (typeof v === 'string' && (v.includes(feedId) || v.includes(bare))) return true;
|
|
||||||
}
|
|
||||||
if (Array.isArray(content.tags)) {
|
|
||||||
if (content.tags.some(t => typeof t === 'string' && (t.includes(feedId) || t.includes(bare)))) return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const snippet = (content) => {
|
|
||||||
for (const field of TEXT_FIELDS) {
|
|
||||||
const v = content[field];
|
|
||||||
if (typeof v === 'string' && v.trim()) return v.trim();
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
};
|
|
||||||
|
|
||||||
const titleOf = (content) => {
|
|
||||||
for (const field of ['title', 'concept', 'question', 'subject']) {
|
|
||||||
const v = content[field];
|
|
||||||
if (typeof v === 'string' && v.trim()) return v.trim();
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
async listMentions(filter = 'ALL', opts = {}) {
|
|
||||||
const ssbClient = await openSsb();
|
|
||||||
const myFeedId = ssbClient.id;
|
|
||||||
const messages = await getAllMessages(ssbClient);
|
|
||||||
const tomb = buildValidatedTombstoneSet(messages);
|
|
||||||
|
|
||||||
const out = [];
|
|
||||||
const seen = new Set();
|
|
||||||
const replaced = new Set();
|
|
||||||
for (const m of messages) {
|
|
||||||
const v = m && m.value;
|
|
||||||
const c = v && v.content;
|
|
||||||
if (!c || typeof c !== 'object' || !c.type) continue;
|
|
||||||
if (tomb.has(m.key)) continue;
|
|
||||||
if (SKIP_TYPES.has(c.type)) continue;
|
|
||||||
if (c.encryptedPayload || c.encryptedQuestion) continue;
|
|
||||||
if (Array.isArray(c.recps) || Array.isArray(v.content && v.content.recps)) continue;
|
|
||||||
if (c.tribeId && c.type !== 'tribe') continue;
|
|
||||||
if (String(v.author) === String(myFeedId)) continue;
|
|
||||||
if (typeof c.replaces === 'string') replaced.add(c.replaces);
|
|
||||||
if (!mentionsList(c, myFeedId) && !mentionsText(c, myFeedId)) continue;
|
|
||||||
if (seen.has(m.key)) continue;
|
|
||||||
seen.add(m.key);
|
|
||||||
out.push({
|
|
||||||
id: m.key,
|
|
||||||
key: m.key,
|
|
||||||
author: v.author,
|
|
||||||
type: c.type,
|
|
||||||
title: titleOf(c),
|
|
||||||
text: snippet(c),
|
|
||||||
content: c,
|
|
||||||
createdAt: new Date(v.timestamp || m.timestamp || 0).toISOString(),
|
|
||||||
ts: v.timestamp || m.timestamp || 0
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const fresh = out.filter(x => !replaced.has(x.key));
|
|
||||||
const q = String(opts.q || '').trim().toLowerCase();
|
|
||||||
let list = q
|
|
||||||
? fresh.filter(x => x.text.toLowerCase().includes(q) || x.title.toLowerCase().includes(q) || x.type.toLowerCase().includes(q))
|
|
||||||
: fresh;
|
|
||||||
|
|
||||||
const f = String(filter || 'ALL').toUpperCase();
|
|
||||||
if (f !== 'ALL' && f !== 'RECENT') list = list.filter(x => x.type === filter);
|
|
||||||
|
|
||||||
list.sort((a, b) => b.ts - a.ts);
|
|
||||||
return list;
|
|
||||||
},
|
|
||||||
|
|
||||||
seenAt: () => readSeen().ts,
|
|
||||||
|
|
||||||
markSeen(list) {
|
|
||||||
const items = Array.isArray(list) ? list : [];
|
|
||||||
const keys = items.map(x => x && x.key).filter(Boolean);
|
|
||||||
const newest = items.reduce((max, x) => Math.max(max, Number(x && x.ts) || 0), 0);
|
|
||||||
return writeSeen(keys, newest || Date.now());
|
|
||||||
},
|
|
||||||
|
|
||||||
unseenOf(list) {
|
|
||||||
const seen = readSeen();
|
|
||||||
const known = new Set(seen.keys);
|
|
||||||
const items = Array.isArray(list) ? list : [];
|
|
||||||
if (known.size) return items.filter(x => x && !known.has(x.key));
|
|
||||||
if (seen.ts) return items.filter(x => x && (Number(x.ts) || 0) > seen.ts);
|
|
||||||
return items;
|
|
||||||
},
|
|
||||||
|
|
||||||
async countUnseen() {
|
|
||||||
return this.unseenOf(await this.listMentions('ALL')).length;
|
|
||||||
},
|
|
||||||
|
|
||||||
async countTypes(list) {
|
|
||||||
const counts = {};
|
|
||||||
for (const item of (Array.isArray(list) ? list : [])) {
|
|
||||||
counts[item.type] = (counts[item.type] || 0) + 1;
|
|
||||||
}
|
|
||||||
return counts;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue