feat: merge Oasis 0.7.6 upstream — Graphos, E2E, peers/stats, AUTOMATIZACION

Cambios aplicados desde epsylon/oasis 3d46340 (0.7.6):

NUEVO MÓDULO Graphos (mapa interactivo de la red):
- src/views/graphos_view.js (nuevo)

LÓGICA:
- src/backend/nameCache.js (nuevo) — NameAuthor resolver
- src/models/chats_model.js — encriptación E2E
- src/models/calendars_model.js — E2E + calendar invites con códigos
- src/models/maps_model.js — E2E + CLOSED enforcement
- src/models/tribes_model.js — sub-tribe access control (PRESERVA nuestro inviteLog)
- src/models/tribe_crypto.js — soporte E2E
- src/models/main_models.js — refactor (PRESERVA nuestro pub-invite SSB msg)
- src/models/{activity,banking,pads,search,stats,tags,tribes_content}_model.js
- src/backend/backend.js — searchModel constructor + new helpers (errorView, safeRefererRedirect)
- src/backend/blobHandler.js, renderTextWithStyles.js
- src/views/main_views.js — añadido userLink/userLinkLabel + nameCache import (mantiene nuestro hamburger menu)

VISUAL:
- 31 views actualizadas con refactor a userLink helper
- src/views/peers_view.js — tabla con keys clicables
- src/views/stats_view.js — dashboard avanzado
- src/client/assets/styles/style.css — merge (preserva nuestras adiciones QR/mobile)
- Temas desktop: Clear, Dark, Matrix, Purple
- Translations 11 idiomas (ar, de, en, es, eu, fr, hi, it, pt, ru, zh)
- src/configs/{config-manager,oasis-config}, server/SSB_server.js, oasis_client.js

SKIPS (intencionalmente):
- OasisMobile.css del upstream (mantenemos NUESTRO mobile.css y theme)
- main_views.js menu reorganization (mantenemos hamburger nav)
- @xenova/transformers (LLM, no viable mobile)
- node-llama-cpp (build nativo no soportado en arm64 mobile)
- pdfjs-dist (pendiente probar luego)
- AI/embedder.js + AI/routes_index.js (dependen de las libs LLM)

server/package.json: version 0.7.5 → 0.7.6

AUTOMATIZACIÓN:
- Nueva carpeta AUTOMATIZACION/ con 10 archivos:
  - 4 opciones (cron simple, multi-agente, GitHub Actions, webhook)
  - Setup Debian completo paso a paso
  - Scripts bash listos: scout, merger, builder, notify-telegram
  - Prompts listos para los agentes
  - Sección /testing-app para 0asis.net
  - Human-in-the-loop: archivos prohibidos para auto-merge

PENDIENTE: build APK (el bash tool tuvo timeouts; usar comandos
de CONTEXT/cambio_apk_repack.txt manualmente).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
SITO 2026-05-15 19:41:45 +02:00
parent 13161b2158
commit 3a3563f2a0
84 changed files with 5842 additions and 1621 deletions

View file

@ -0,0 +1,79 @@
===============================================================
AUTOMATIZACIÓN — Oasis Mobile APK auto-update
Creado: 2026-05-09
===============================================================
OBJETIVO
--------
Automatizar la actualización de la APK de Oasis Mobile cuando se
publique una nueva versión del upstream (epsylon/oasis), sin
intervención manual semanal.
ESCENARIO IDEAL
---------------
1. Epsylon publica Oasis v0.7.7 en GitHub.
2. Un webhook / cron lo detecta.
3. Claude analiza el diff y aplica cambios seguros.
4. Construye y firma la APK.
5. La sube a 0asis.net/testing-app.
6. Te avisa por Telegram.
7. Tú compruebas, y si OK, mueves de "testing" a "release".
ARCHIVOS DE ESTA CARPETA
------------------------
00_INDICE.txt
Este archivo.
01_OPCIONES.txt
Las 4 opciones de automatización con pros/contras.
Útil para decidir cuál implementar primero.
02_OPCION_A_cron_simple.txt
Opción A: cron + claude headless. Setup más sencillo.
Pasos exactos para configurar en Debian.
03_OPCION_B_multiagente.txt
Opción B: 3 agentes (scout / merger / builder).
Recomendada — modular y a prueba de fallos.
04_OPCION_C_github_actions.txt
Opción C: GitHub Action en el fork de oasis_mobile.
Gratis, auditable, con artifacts descargables.
05_OPCION_D_webhook_reactivo.txt
Opción D: webhook reactivo a releases.
Lo más rápido en reaccionar, requiere VPS público.
06_TESTING_APP_seccion.txt
Cómo montar la sección /testing-app en 0asis.net
para distribuir builds beta automáticas.
07_HUMAN_IN_THE_LOOP.txt
Por qué no fully-auto: archivos que SIEMPRE requieren
revisión humana antes de aplicar (main_views, mobile.css).
Lista de "luces rojas" que el agente debe detectar.
08_PROMPTS_para_agentes.md
Los prompts exactos que usan los agentes.
Listos para copiar/pegar a claude -p.
09_SCRIPTS.md
Scripts bash listos para usar (cron entry, build APK,
notificación Telegram, deploy a 0asis.net).
10_DEBIAN_setup.txt
Pasos concretos para preparar el server Debian:
instalación de Claude Code, gh CLI, Android SDK, etc.
DECISIÓN RECOMENDADA
--------------------
Empezar por **Opción B + D combinadas**:
- Webhook GitHub release → dispara scout (D)
- Scout abre tarea con reporte
- Tú revisas (5 min lectura)
- Merger + Builder corren solos (B)
- Telegram notifica con APK lista
Tiempo de setup inicial: 1 día.
Tiempo manual semanal después: 5 min de revisión.

View file

@ -0,0 +1,102 @@
===============================================================
OPCIONES DE AUTOMATIZACIÓN — Comparativa
===============================================================
OPCIÓN A — CRON + CLAUDE HEADLESS (SIMPLE)
==========================================
Un cron semanal en Debian. Un único script que invoca claude
con un prompt grande pidiendo que lo haga todo de cabo a rabo.
PROS:
- Setup en 30 minutos
- Sin infra extra (solo cron)
- Cero costes adicionales
CONTRAS:
- Un único punto de fallo
- Si claude se atasca en un conflicto, todo se queda colgado
- Sin recuperación parcial
- Difícil debuggear cuándo va mal
CUÁNDO USARLA:
- Cuando solo se quiere probar el concepto
- Cuando los cambios upstream son pequeños y predecibles
OPCIÓN B — MULTI-AGENTE (RECOMENDADA)
=====================================
Tres agentes especializados en cron. Cada uno con su prompt
corto, su contexto, y su responsabilidad clara.
scout | Lun 03:00 | git fetch + analiza diff + reporta
merger | Lun 04:00 | aplica cambios safe + abre PR
builder | Lun 05:00 | build APK + sube + notifica
PROS:
- Fallo aislado (si scout falla, merger no se ejecuta)
- Prompts cortos = menos tokens = más barato y fiable
- Logs separados por agente
- Recuperable: puedes re-ejecutar solo el que falló
- Auditable: cada agente deja su reporte en disco
CONTRAS:
- Más setup inicial (3 crons, 3 prompts)
- Hay que orquestar dependencias (merger depende de scout)
CUÁNDO USARLA:
- Cuando los cambios upstream son frecuentes y variados
- Cuando quieres ver QUÉ va a aplicar antes de aplicarlo
OPCIÓN C — GITHUB ACTIONS
=========================
Workflow en .github/workflows/weekly-merge.yml del repo
oasis_mobile. Trigger en cron + manual dispatch.
PROS:
- Gratis (2000 min/mes en repo público o privado básico)
- Histórico completo de runs visible
- Artifacts descargables (la APK)
- Logs públicos si lo quieres
- Cero infraestructura propia
CONTRAS:
- Timeout máximo 6h por job
- Build APK consume bastante (10-15 min)
- El repo debe estar en GitHub (no en Gitea code.03c8.net)
- Secrets: ANTHROPIC_API_KEY visible para todos los maintainers
CUÁNDO USARLA:
- Si planeas mover el repo a GitHub
- Si no tienes VPS o no quieres gestionarlo
OPCIÓN D — WEBHOOK REACTIVO
===========================
Webhook de GitHub apuntando a un endpoint en tu VPS. Cuando
epsylon publica un release, dispara inmediatamente el pipeline.
PROS:
- Reacción casi en tiempo real (segundos)
- No esperas al cron del lunes
- El pipeline puede ser tan complejo como quieras
CONTRAS:
- Requiere VPS público con dominio + HTTPS
- Hay que mantener el endpoint
- Si el webhook falla, hay que tener un cron de respaldo
CUÁNDO USARLA:
- Cuando quieres lo más rápido posible
- Cuando tienes ya un VPS funcionando
COMBINACIÓN RECOMENDADA: B + D
==============================
- Webhook (D) dispara scout (de B)
- Cron de seguridad cada lunes 03:00 también ejecuta scout
(por si el webhook falla)
- Resto del pipeline (merger + builder) es B
Mejor de los dos mundos: rápido pero con respaldo, modular,
auditable, recuperable.

View file

@ -0,0 +1,122 @@
===============================================================
OPCIÓN A — CRON + CLAUDE HEADLESS
===============================================================
ARQUITECTURA
------------
cron (lunes 03:00) → bash script → claude -p "..." → APK lista
REQUISITOS EN EL VPS DEBIAN
---------------------------
- Claude Code instalado y autenticado (claude login)
- Git CLI
- Android SDK build-tools (zipalign, apksigner)
- Keystore copiado: /home/sito/oasis-release-key.jks
- Clon del repo oasis_mobile en /opt/oasis_mobile
- Acceso al upstream: git remote add upstream
https://github.com/epsylon/oasis.git
- ANTHROPIC_API_KEY exportada en .bashrc del usuario
ESTRUCTURA DE ARCHIVOS
----------------------
/opt/oasis_mobile/
.git/
nodejs-project/...
CONTEXT/
AUTOMATIZACION/
/opt/scripts/
oasis-auto-update.sh <- script principal
oasis-build-apk.sh <- build/sign APK
oasis-notify-telegram.sh <- enviar APK por bot
/var/log/oasis-auto/
YYYY-MM-DD.log <- logs por ejecución
CRON ENTRY
----------
0 3 * * 1 /opt/scripts/oasis-auto-update.sh \
> /var/log/oasis-auto/$(date +\%Y-\%m-\%d).log 2>&1
ESQUELETO DEL SCRIPT (oasis-auto-update.sh)
-------------------------------------------
#!/bin/bash
set -euo pipefail
cd /opt/oasis_mobile
git fetch upstream
HEAD_OLD=$(git rev-parse HEAD)
UPSTREAM_HEAD=$(git rev-parse upstream/main)
if [ "$HEAD_OLD" = "$UPSTREAM_HEAD" ]; then
echo "No hay cambios upstream. Salgo."
exit 0
fi
# Crear branch para el intento
BRANCH="auto-merge-$(date +%Y%m%d)"
git checkout -b "$BRANCH"
# Invocar claude con el prompt
claude -p "$(cat /opt/scripts/prompts/auto-merge.md)" \
--allowed-tools "Read,Write,Edit,Bash"
# Build APK si no hay conflictos
if ! git diff --name-only --diff-filter=U | grep -q .; then
bash /opt/scripts/oasis-build-apk.sh
bash /opt/scripts/oasis-notify-telegram.sh \
"APK $(date +%Y-%m-%d) lista en /opt/oasis_mobile/apk/"
else
bash /opt/scripts/oasis-notify-telegram.sh \
"Conflictos en merge automático. Revisa $BRANCH."
fi
PROMPT BASE PARA CLAUDE
-----------------------
Lee CONTEXT/cambio_apk_repack.txt y CONTEXT/tareas_usabilidad.txt
para entender el proyecto.
Tu tarea:
1. Compara HEAD con upstream/main.
2. Aplica SOLO cambios safe a la rama actual:
- views nuevos (graphos_view, markdown.js, etc)
- models nuevos (preservando inviteLog en tribes_model)
- translations (todos los idiomas)
- peers_view, stats_view (visuales)
3. NO toques:
- main_views.js
- assets/themes/OasisMobile.css
- assets/styles/mobile.css
- server/package.json (deps: @xenova/transformers, node-llama-cpp)
4. Si encuentras conflictos en archivos críticos, NO los resuelvas
automáticamente. Deja el conflicto marker y termina.
5. Commit con mensaje "feat: merge upstream vX.Y.Z (auto)"
y co-author Claude.
Reporta qué archivos copiaste, cuáles dejaste, y cualquier
conflicto encontrado.
PROS DE ESTA OPCIÓN
-------------------
- Setup en 1 hora
- Sin orquestación compleja
- Cron + log = simple de debuggear
CONTRAS
-------
- Un único prompt grande puede fallar parcialmente
- Si claude se queda sin context, no se entera de ello
- Difícil intervenir a mitad
CUÁNDO MIGRAR A OTRA OPCIÓN
---------------------------
Si al 3er intento sale algo mal automatizado, pasar a opción B
(multi-agente). El coste extra de setup vale la pena para tener
fallos aislados y reportes parciales.

View file

@ -0,0 +1,145 @@
===============================================================
OPCIÓN B — MULTI-AGENTE (RECOMENDADA)
===============================================================
ARQUITECTURA
------------
Lunes 03:00 — SCOUT
Detecta cambios upstream, genera reporte JSON con:
- Lista de archivos cambiados
- Categorización (safe / review / skip)
- Resumen de cada cambio
Output: /var/oasis-auto/reports/YYYY-MM-DD-scout.json
/var/oasis-auto/reports/YYYY-MM-DD-scout.md (humano)
Notifica Telegram: "Reporte semanal listo, revisa /scout"
Lunes 04:00 — MERGER
Lee scout.json. Si está marcado como "approved" (manual
o automático según safety level), aplica los cambios
safe + abre branch con merge para los review.
Output: /var/oasis-auto/reports/YYYY-MM-DD-merger.md
Notifica Telegram: "Branch auto-merge-X creada, lista build"
Lunes 05:00 — BUILDER
Verifica que no haya conflictos en working tree.
Hace build APK + firma + sube a /testing-app.
Output: /var/oasis-auto/apks/oasis-YYYY-MM-DD.apk
Notifica Telegram: con link a la APK
FLUJO DETALLADO
---------------
1. scout corre cron
├─ git fetch upstream
├─ git log HEAD..upstream/main --stat → lista de cambios
├─ Para cada archivo cambiado:
│ - Aplica reglas de clasificación (safe / review / skip)
│ - Genera resumen breve del cambio
├─ Escribe scout.json + scout.md
└─ Telegram bot: "Reporte listo en {URL}"
2. (HUMANO, opcional) revisa scout.md y aprueba con:
curl -X POST https://your-vps.com/api/approve/YYYY-MM-DD
o solo deja que el cron continúe (auto-aprobar si "safety_score" > 0.8)
3. merger corre cron
├─ Lee scout.json
├─ Si !approved AND auto_approve=false: salir y notificar
├─ git checkout -b auto-merge-YYYY-MM-DD
├─ Para cada archivo "safe": git checkout upstream/main -- $file
├─ Para cada archivo "review": deja conflict marker
├─ Para cada archivo "skip": ignora
├─ git commit -m "feat: auto-merge upstream v$VERSION"
└─ Telegram bot: "Branch lista, building..."
4. builder corre cron
├─ Verifica working tree limpio
├─ bash build-apk.sh
├─ Sube APK a /testing-app endpoint
└─ Telegram bot: "APK lista: {URL}"
CRON ENTRIES
------------
0 3 * * 1 /opt/scripts/oasis-scout.sh
0 4 * * 1 /opt/scripts/oasis-merger.sh
0 5 * * 1 /opt/scripts/oasis-builder.sh
SISTEMA DE CLASIFICACIÓN AUTO
-----------------------------
El scout aplica estas reglas:
SAFE (auto-aplicar):
✓ src/views/*_view.js si es nuevo file (no existía antes)
✓ src/views/markdown.js (helper nuevo)
✓ src/client/assets/translations/*.js (todos)
✓ src/client/assets/themes/Clear-SNH.css
✓ src/client/assets/themes/Dark-SNH.css
✓ src/client/assets/themes/Matrix-SNH.css
✓ src/client/assets/themes/Purple-SNH.css
REVIEW (necesita mirada humana):
⚠ src/models/* (puede conflictuar con nuestro inviteLog)
⚠ src/backend/backend.js (rutas + constructors)
⚠ src/views/main_views.js (NUESTRO menú hamburger)
⚠ src/views/blockchain_view.js (puede tener inviteLog refs)
⚠ src/views/tribes_view.js
⚠ src/client/assets/styles/style.css
⚠ src/server/package.json (puede traer deps no compatibles)
SKIP (NUNCA aplicar):
✗ src/client/assets/themes/OasisMobile.css
✗ src/client/assets/styles/mobile.css
✗ src/client/public/js/mobile-ui.js
✗ src/views/mobile_pager.js (es nuestro)
✗ dependencies: @xenova/transformers, node-llama-cpp
REPORTES (formato scout.md)
---------------------------
# Oasis upstream sync — 2026-05-09
Upstream: v0.7.5 → v0.7.6 (3 commits, +4349/-1266)
## Safe (aplicar auto): 12 archivos
- [+] src/views/graphos_view.js (nuevo, 245 líneas)
- [+] src/views/markdown.js (nuevo, 80 líneas)
- [M] src/client/assets/translations/oasis_es.js (+24 strings)
- ...
## Review (revisar manual): 6 archivos
- [M] src/models/tribes_model.js — afecta a inviteLog, revisar
- [M] src/views/main_views.js — añade ítem menu en Tools, NO aplicar
- ...
## Skip: 3 archivos
- [M] src/client/assets/themes/OasisMobile.css
- [M] src/client/assets/styles/mobile.css
- server/package.json (deps prohibidos)
## Acciones recomendadas
- Auto-merge: yes
- Conflict score: 0.2 (bajo)
- Build APK: yes
VENTAJAS CRÍTICAS
-----------------
1. Cada agente tiene su prompt corto → menos tokens → más fiable
2. Logs separados → fácil debuggear
3. Recuperable: si builder falla, re-ejecutas solo builder
4. Auditable: histórico de scouts.json en /var/oasis-auto/reports
5. Reversible: cada merge en su branch, fácil de revertir
NIVEL DE AUTOMATIZACIÓN AJUSTABLE
---------------------------------
Variable AUTO_APPROVE_THRESHOLD en /opt/scripts/config:
1.0 = nunca auto, siempre humano aprueba
0.7 = auto si scout reporta "conflict_score < 0.3"
0.0 = todo auto (no recomendado)
Empezar en 1.0, después de 4 semanas exitosas bajar a 0.7.

View file

@ -0,0 +1,115 @@
===============================================================
OPCIÓN C — GITHUB ACTIONS
===============================================================
REQUISITO PREVIO
----------------
Mover el repo oasis_mobile a GitHub (público o privado).
Actualmente está en Gitea code.03c8.net:3000.
Razón: GitHub Actions solo corre en repos de GitHub.
ARCHIVO: .github/workflows/upstream-sync.yml
--------------------------------------------
name: Upstream sync (weekly)
on:
schedule:
- cron: '0 3 * * 1' # Lunes 03:00 UTC
workflow_dispatch: {} # también manual
jobs:
sync-and-build:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Add upstream remote
run: |
git remote add upstream https://github.com/epsylon/oasis.git
git fetch upstream
- name: Check for upstream changes
id: check
run: |
BEHIND=$(git rev-list HEAD..upstream/main --count)
echo "behind=$BEHIND" >> $GITHUB_OUTPUT
- name: Setup Claude Code
if: steps.check.outputs.behind != '0'
uses: anthropics/claude-code-action@v1
with:
anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }}
prompt-file: .github/prompts/upstream-merge.md
- name: Install Android SDK
if: steps.check.outputs.behind != '0'
uses: android-actions/setup-android@v3
- name: Build APK
if: steps.check.outputs.behind != '0'
run: bash .github/scripts/build-apk.sh
env:
KEYSTORE_PASS: ${{ secrets.KEYSTORE_PASS }}
- name: Upload APK artifact
if: steps.check.outputs.behind != '0'
uses: actions/upload-artifact@v4
with:
name: oasis-mobile-apk-${{ github.run_number }}
path: /tmp/oasis-aligned.apk
retention-days: 30
- name: Create release
if: steps.check.outputs.behind != '0'
uses: softprops/action-gh-release@v1
with:
tag_name: auto-${{ github.run_number }}
files: /tmp/oasis-aligned.apk
prerelease: true
SECRETS A CONFIGURAR EN GITHUB
------------------------------
ANTHROPIC_API_KEY <- API key de Anthropic
KEYSTORE_PASS <- "oasis123"
KEYSTORE_BASE64 <- base64 del oasis-release-key.jks
TELEGRAM_BOT_TOKEN <- opcional para notificar
PROS
----
- Gratis (2000 minutos/mes en repo privado, ilimitado público)
- Histórico completo y auditable
- Artifacts descargables 30 días
- Crea release de pre-release automáticamente
- Sin VPS, sin mantenimiento de infra
CONTRAS
-------
- Hay que mover el repo a GitHub
- Build APK consume ~10-15 min, costo eficiencia OK
- Si la API key se filtra, alguien podría gastar tu cuenta
- Si Anthropic baja prices o cambia API, hay que actualizar
COSTE ESTIMADO
--------------
GitHub Actions: 0 €/mes (dentro del free tier)
Anthropic API: ~0.50€ por sync semanal x 4 = 2€/mes
Total: 2€/mes vs VPS 5-10€/mes para opción A/B/D
CUÁNDO ES MEJOR ESTA OPCIÓN
---------------------------
- Si no tienes/quieres mantener un VPS
- Si te da igual mover el repo a GitHub
- Si quieres logs públicos auditables
- Si quieres distribución automática de pre-releases

View file

@ -0,0 +1,136 @@
===============================================================
OPCIÓN D — WEBHOOK REACTIVO
===============================================================
ARQUITECTURA
------------
GitHub publica un release
Webhook POST → tu VPS:5000/api/oasis-release
El endpoint dispara el pipeline (B u otro)
APK lista en minutos
COMPONENTES NECESARIOS
----------------------
- VPS público con HTTPS (Let's Encrypt vale)
- Dominio o IP fija
- Nginx o Caddy como reverse proxy
- Servicio webhook receiver (Python Flask / Node Express / Go)
- Secret compartido para verificar firma de GitHub
WEBHOOK CONFIG EN GITHUB (en repo oasis_mobile fork)
-----------------------------------------------------
Settings → Webhooks → Add webhook
Payload URL: https://your-vps.com/api/oasis-release
Content type: application/json
Secret: <generar 32 bytes random>
Events: only "Releases"
NOTA IMPORTANTE: GitHub solo dispara webhooks de TU repo,
no del repo upstream de epsylon. Hay dos formas:
a) Hacer fork de oasis y configurar webhook ahí
- Cada push del upstream se replica si tienes
sync activo
b) Poll periódico al upstream desde tu VPS y dispara
internamente cuando detectas nuevo release
- Más simple, no requiere fork
- Recomendado
RECEIVER ENDPOINT (Flask, en Python)
-------------------------------------
/opt/oasis-webhook/app.py:
from flask import Flask, request, abort
import hmac, hashlib, subprocess
app = Flask(__name__)
SECRET = open("/opt/oasis-webhook/secret").read().strip()
@app.post("/api/oasis-release")
def release():
sig = request.headers.get("X-Hub-Signature-256", "")
mac = hmac.new(SECRET.encode(), request.data, hashlib.sha256)
expected = "sha256=" + mac.hexdigest()
if not hmac.compare_digest(sig, expected):
abort(401)
event = request.headers.get("X-GitHub-Event")
if event != "release":
return "skip", 200
payload = request.json
action = payload.get("action")
if action == "published":
tag = payload["release"]["tag_name"]
subprocess.Popen(["/opt/scripts/oasis-scout.sh", "--tag", tag])
return "ok", 200
ALTERNATIVA POLL (sin webhook, más simple)
-------------------------------------------
Cada 30 min un cron consulta:
curl -s https://api.github.com/repos/epsylon/oasis/releases/latest
Si el tag es nuevo (no está en /var/oasis-auto/seen-tags.txt):
1. Añadir a seen-tags
2. Disparar scout
SYSTEMD SERVICE (recomendado para uptime)
------------------------------------------
/etc/systemd/system/oasis-webhook.service:
[Unit]
Description=Oasis webhook receiver
After=network.target
[Service]
User=oasis
WorkingDirectory=/opt/oasis-webhook
ExecStart=/usr/bin/python3 -m gunicorn -b 127.0.0.1:5000 app:app
Restart=always
[Install]
WantedBy=multi-user.target
CADDY CONFIG (reverse proxy)
----------------------------
/etc/caddy/Caddyfile:
your-vps.com {
handle /api/oasis-release {
reverse_proxy 127.0.0.1:5000
}
handle /testing-app/* {
root * /var/oasis-auto/apks
file_server browse
}
}
PROS
----
- Reacción casi en tiempo real (segundos)
- No esperas al lunes
- Si combina con cron de respaldo, robusto
CONTRAS
-------
- Requiere VPS público con dominio
- Hay que gestionar HTTPS (Caddy/Let's Encrypt lo hace fácil)
- Si el webhook falla por red, hay que tener fallback
RECOMENDACIÓN: COMBINAR WEBHOOK + CRON DE RESPALDO
---------------------------------------------------
Webhook dispara scout inmediatamente cuando hay release.
Cron semanal lunes 03:00 también dispara scout para no
perder oportunidades si el webhook falló.
Scout debe ser idempotente: si ya procesó el commit X,
no debe procesarlo de nuevo.

View file

@ -0,0 +1,147 @@
===============================================================
SECCIÓN /testing-app EN 0asis.net
===============================================================
OBJETIVO
--------
Tener una URL pública (ej: 0asis.net/testing-app) donde
automáticamente se publican las APK beta generadas por la
automatización. Los usuarios beta-testers pueden descargar y
probar antes de que se libere oficialmente.
ESTRUCTURA RECOMENDADA
----------------------
https://0asis.net/ -- web principal pública
https://0asis.net/testing-app/ -- index de beta APKs
https://0asis.net/testing-app/latest -- siempre la última
https://0asis.net/testing-app/archive -- histórico
IMPLEMENTACIÓN
--------------
OPCIÓN A: Carpeta estática servida por Nginx/Caddy
--------------------------------------------------
Más simple. Cuando el builder termina de generar la APK:
cp /tmp/oasis-aligned.apk /var/www/0asis.net/testing-app/
oasis-v0.7.6-20260509-pruebas.apk
ln -sf oasis-v0.7.6-20260509-pruebas.apk
/var/www/0asis.net/testing-app/latest.apk
Caddyfile:
0asis.net {
handle /testing-app/* {
root * /var/www/0asis.net
file_server browse
@latest path /testing-app/latest.apk
header @latest Content-Disposition "attachment; filename=oasis-latest-pruebas.apk"
}
handle {
# web principal
}
}
OPCIÓN B: Index dinámico con metadatos
--------------------------------------
El index muestra una tabla con cada APK, fecha, changelog,
link de descarga. Más útil para usuarios.
Generar /var/www/0asis.net/testing-app/index.html
automáticamente al final de builder.sh:
<h1>Oasis Mobile - Testing builds</h1>
<table>
<tr><th>Versión</th><th>Fecha</th><th>Cambios</th><th>Download</th></tr>
<tr>
<td>v0.7.6</td>
<td>2026-05-09</td>
<td>Graphos, encriptación E2E, peers...</td>
<td><a href="oasis-v0.7.6-20260509-pruebas.apk">APK</a></td>
</tr>
...
</table>
OPCIÓN C: Página dinámica con Caddy + tag de release
----------------------------------------------------
Más profesional: una página que lee el último JSON generado
por builder y renderiza UI bonita con changelog.
CHANGELOG AUTOMÁTICO
--------------------
El scout puede generar un changelog.md amigable. Por ejemplo:
## v0.7.6 — 2026-05-09 (beta)
### Nuevas funcionalidades
- Graphos: visualización interactiva de la red
- AINav (deshabilitado en móvil, requiere LLM)
- Encriptación E2E para chats / calendarios / mapas
- Cascada al borrar tribes
### Bugfixes
- Resolución de carreras en updates concurrentes
### Cambios visuales
- Tabla de peers mejorada
- Dashboard de estadísticas
[Descargar APK](oasis-v0.7.6-20260509-pruebas.apk)
Este markdown puede renderizarse a HTML con `pandoc` o
incluirse tal cual en una página estática.
SEGURIDAD DE LA SECCIÓN /testing-app
------------------------------------
- Considera proteger /testing-app con HTTP basic auth
para que solo beta-testers tengan acceso:
Caddyfile:
basicauth /testing-app/* {
tester $2a$14$<bcrypt-hash>
}
- O usar tokens en URL: /testing-app/{token}/latest.apk
- La APK lleva tu firma — cualquiera con ella puede
instalar. Si te preocupa el leak, mantén privado.
INTEGRACIÓN CON TELEGRAM
------------------------
Al final del builder, además de subir la APK al servidor:
bash oasis-notify-telegram.sh \
"Nueva APK lista para test: \
https://0asis.net/testing-app/oasis-v0.7.6-20260509-pruebas.apk \
[Cambios](https://0asis.net/testing-app/v0.7.6-changelog.html)"
Los beta-testers en el canal de Telegram reciben el aviso al
instante y pueden instalar sin abrir email ni nada.
MIGRACIÓN A "RELEASE OFICIAL"
-----------------------------
Cuando una versión beta pasa el período de prueba (ej: 1 semana
sin reportes), tú manualmente la copias a /release/:
cp /var/www/0asis.net/testing-app/oasis-v0.7.6-20260509-pruebas.apk
/var/www/0asis.net/release/oasis-v0.7.6.apk
ln -sf oasis-v0.7.6.apk /var/www/0asis.net/release/latest.apk
Y publicas en redes / Telegram canal release.
ARCHIVO DE BUILDS HISTÓRICAS
-----------------------------
Configura una limpieza para no llenar el disco:
/etc/cron.daily/oasis-cleanup:
find /var/www/0asis.net/testing-app -name "oasis-*.apk" \
-mtime +60 -delete
Mantiene los últimos 60 días de builds beta.

View file

@ -0,0 +1,162 @@
===============================================================
HUMAN-IN-THE-LOOP — Lo que NO puede automatizarse 100%
===============================================================
POR QUÉ NO ES VIABLE EL FULL-AUTO
---------------------------------
Hay archivos donde el upstream y nuestro fork divergen
intencionadamente. Aplicar automáticamente el upstream
ROMPERÍA nuestra app móvil.
Estos archivos SIEMPRE requieren revisión humana antes de
aplicar cualquier cambio.
LISTA DE ARCHIVOS "PROHIBIDOS" PARA AUTO-MERGE
----------------------------------------------
src/client/assets/themes/OasisMobile.css
Razón: Es nuestro tema de móvil, escrito desde cero.
El upstream lo trata como tema de escritorio.
Aplicar el de upstream rompería: hamburger nav, QR
lightbox, panel-quicklinks, sidebar-panel drawer.
src/client/assets/styles/mobile.css
Razón: 100% nuestro. Contiene el paginador actpager,
safe-area iOS, header móvil de 1-fila + 2-fila, todo
el sistema mobile.
src/client/public/js/mobile-ui.js
Razón: Ya no se usa (paginador pasó a CSS-only) pero
si upstream añade un mobile-ui.js distinto, NO copiar.
src/views/mobile_pager.js
Razón: Helper nuestro, no existe en upstream.
src/views/main_views.js
Razón: Contiene nuestro menú hamburger CSS-only,
sidebar-panel drawer, panel-quicklinks. El upstream
no tiene esto. Aplicar pisaría todo nuestro layout.
EXCEPCIÓN: Si solo cambian textos / strings i18n,
se puede hacer merge cuidadoso. Pero requiere mirada.
src/views/inhabitants_view.js
Razón: Tiene nuestros QR codes integrados (qr-share
con <details>/<summary>). El upstream no.
Hay que merge manual preservando los QR.
src/views/invites_view.js
Razón: Tiene nuestros QR por cada pub + IIFE async
+ inviteLog renderInviteExtra.
src/views/tribes_view.js
Razón: QR de tribe invite + nuestros campos del form
(isAnonymous, isLARP, isSubEdit).
src/models/tribes_model.js
Razón: Tiene NUESTRO inviteLog (generateInvite +
joinByInvite). El upstream no. Si copiamos, perdemos
la trazabilidad de invites.
src/models/main_models.js
Razón: Publica SSB msg type:'pub-invite' al aceptar
pub invite. Es nuestro.
src/backend/backend.js
Razón: Tiene rutas custom (POST /settings/invite/accept,
nuestro orden de constructores). Cualquier cambio
upstream debe integrarse manualmente.
src/configs/config-manager.js
Razón: Defaults modificados (wish, pmVisibility,
economy modules on, etc).
src/server/package.json
Razón: NO añadir las deps nuevas del upstream:
- @xenova/transformers (60MB+)
- node-llama-cpp (no funciona arm64 mobile)
- pdfjs-dist (pendiente probar, anotar para luego)
src/client/middleware.js
Razón: Tiene 'unsafe-inline' en CSP necesario para
nosotros + mounts /game-assets /maptiles /mapcache
custom + frame-src 'self'.
LISTA DE ARCHIVOS "SAFE" PARA AUTO-MERGE
----------------------------------------
src/client/assets/translations/*.js
Las traducciones son aditivas. Pueden copiarse al
100% del upstream sin riesgo.
src/client/assets/themes/Clear-SNH.css
src/client/assets/themes/Dark-SNH.css
src/client/assets/themes/Matrix-SNH.css
src/client/assets/themes/Purple-SNH.css
Temas de escritorio, no afectan a móvil.
src/views/*_view.js (NUEVOS)
Si el archivo no existe en nuestro fork, copia auto.
Ej: graphos_view.js, markdown.js.
src/views/peers_view.js
src/views/stats_view.js
src/views/courts_view.js
src/views/parliament_view.js
Vistas que no tienen nuestros customizations, safe
para refresh completo.
src/models/*.js (NUEVOS)
Si es un modelo nuevo, copia auto.
docs/CHANGELOG.md
Copia auto, solo es informativo.
SEÑALES DE ALARMA QUE EL AGENTE DEBE DETECTAR
---------------------------------------------
Si el scout detecta cualquiera de estos patrones en el
diff, debe marcar la sync entera como "needs human":
- Cambios en mobile.css / OasisMobile.css del upstream
(no debería pasar, pero si pasa, alguien añadió tema
móvil al upstream y hay que decidir qué hacer)
- Nuevas dependencias en server/package.json que no
están en una whitelist conocida
- Cambios en CSP de middleware.js que reduzcan permisos
- Cambios en SSB_server.js que afecten al modo public
de nuestro APK
- Cambios en blobHandler.js (lo modificamos a veces)
DECISIÓN POR DEFECTO
--------------------
Cuando el scout no puede decidir con seguridad:
"Si tengo duda, marcar como REVIEW. Es preferible que
el humano apruebe cinco minutos a romper la APK."
PROCESO DE REVISIÓN HUMANA (5 min lunes mañana)
------------------------------------------------
1. Abrir scout.md generado el lunes 03:00
2. Lectura rápida de la lista SAFE (debe verse razonable)
3. Lectura cuidadosa de la lista REVIEW
4. Si todo OK: curl POST /api/approve o nada (auto-approve)
5. Si algo no OK: ejecutar manualmente claude con prompt
específico para ese conflicto
6. El builder corre solo a las 05:00
REGLA DE ORO
------------
NUNCA bajar AUTO_APPROVE_THRESHOLD por debajo de 0.5.
Mejor perder 5 min revisando que perder 2 horas
reconstruyendo la APK porque un cambio rompió algo.

View file

@ -0,0 +1,168 @@
# Prompts para los agentes
Copia/pega cada prompt al script correspondiente.
## SCOUT (lunes 03:00)
```
Eres el agente SCOUT de Oasis Mobile auto-update.
Contexto:
- Repo local: /opt/oasis_mobile
- Upstream: https://github.com/epsylon/oasis.git (remote `upstream`)
- Lee CONTEXT/cambio_apk_repack.txt y AUTOMATIZACION/07_HUMAN_IN_THE_LOOP.txt
para entender qué archivos son prohibidos y cuáles safe.
Tu tarea:
1. `git fetch upstream`
2. Identifica el último tag / commit del upstream/main.
3. Compara con nuestro HEAD: lista cada archivo cambiado.
4. Para cada archivo aplica las reglas de clasificación:
- SAFE = aplicar auto (translations, themes desktop, archivos
nuevos que no existen en local)
- REVIEW = requiere mirada humana (models, backend, views con
customizations nuestras)
- SKIP = nunca aplicar (mobile.css, OasisMobile.css,
server/package.json con deps prohibidas)
5. Para cada archivo SAFE y REVIEW, genera un resumen de 1-2
líneas del cambio.
6. Escribe el reporte en formato JSON Y formato Markdown legible:
- /var/oasis-auto/reports/$(date +%Y-%m-%d)-scout.json
- /var/oasis-auto/reports/$(date +%Y-%m-%d)-scout.md
7. Calcula un "conflict_score" entre 0.0 (sin conflictos) y 1.0
(muchos archivos REVIEW). Inclúyelo en el JSON.
8. NO hagas merge. Solo analiza.
9. Notifica Telegram con link al reporte.
Tu output debe ser breve y estructurado.
Si encuentras algún archivo PROHIBIDO modificado en upstream
(ej: OasisMobile.css), márcalo como FATAL en el reporte y
recomienda no auto-aprobar.
```
## MERGER (lunes 04:00)
```
Eres el agente MERGER de Oasis Mobile auto-update.
Contexto:
- Lee el último scout.json en /var/oasis-auto/reports/
- Lee CONTEXT/cambio_apk_repack.txt
- Lee AUTOMATIZACION/07_HUMAN_IN_THE_LOOP.txt
Tu tarea:
1. Verifica que existe scout.json reciente (<24h).
2. Lee approval_status del reporte:
- "approved" o conflict_score < AUTO_APPROVE_THRESHOLD
→ procede
- cualquier otra cosa → salir y notificar
3. Crea branch: `git checkout -b auto-merge-$(date +%Y%m%d)`
4. Para cada archivo "safe" del reporte:
`git checkout upstream/main -- $file`
5. Para cada archivo "review" del reporte:
- Generar diff
- Si el diff es pequeño (<30 líneas) y NO toca patrones
prohibidos (inviteLog, QR, hamburger, mobile-pager,
unsafe-inline): aplicar
- Si no: dejar el archivo intacto y marcar como pending
6. Para cada archivo "skip": ignorar
7. Verifica con `git status` que no hay archivos sin trackear
inesperados.
8. Verifica syntax con `node --check` en cada .js modificado.
9. Si todo OK:
`git commit -m "feat: merge upstream v$VERSION (auto)"`
10. Escribe reporte merger.md con qué archivos aplicaste.
11. Notifica Telegram.
NUNCA modifiques estos archivos:
- src/client/assets/themes/OasisMobile.css
- src/client/assets/styles/mobile.css
- src/client/public/js/mobile-ui.js
- src/views/mobile_pager.js
- src/views/main_views.js (excepto i18n strings sin layout)
Si tienes dudas en cualquier paso, NO procedas. Genera un
reporte explicando y termina.
```
## BUILDER (lunes 05:00)
```
Eres el agente BUILDER de Oasis Mobile auto-update.
Contexto:
- Lee CONTEXT/cambio_apk_repack.txt para los pasos exactos
de build.
- Lee AUTOMATIZACION/06_TESTING_APP_seccion.txt para saber
dónde subir la APK.
Tu tarea:
1. Verifica que estás en una branch auto-merge-*.
2. Verifica working tree limpio: `git status --porcelain`
debe estar vacío.
3. Verifica que no hay markers de merge sin resolver:
`grep -rn "<<<<<<< HEAD" src/` debe estar vacío.
4. Construye el zip del bundle:
```
cd nodejs-project/
zip -0 -r /tmp/nodejs-project-new.zip nodejs-project/ \
-x "*.apk" "*/.git/*"
```
5. Empaca + firma la APK siguiendo cambio_apk_repack.txt:
- cp oasis-v0.6.8.apk /tmp/oasis-temp.apk
- zip -d /tmp/oasis-temp.apk "META-INF/*"
- cd /tmp && zip -0 oasis-temp.apk assets/nodejs-project.zip
- zipalign + apksigner sign
6. Verifica la firma: `apksigner verify --verbose`.
7. Genera nombre con fecha:
`oasis-v$VERSION-$(date +%Y%m%d)-pruebas.apk`
8. Borra APKs de pruebas anteriores en /home/sito/
(mantener la base oasis-v0.6.8.apk).
9. Copia la APK a /var/www/0asis.net/testing-app/
10. Actualiza el symlink latest.apk.
11. Escribe builder.md con resumen del build.
12. Notifica Telegram con link de descarga.
Si zipalign o apksigner fallan: NO subas la APK. Solo
notifica el fallo y deja el archivo en /tmp para debug.
Si el build sale > 200MB (anormal): NO subas. Algo se metió
que no debería. Notifica.
```
## EJEMPLOS DE USO
```bash
# Scout manual
claude -p "$(cat /opt/scripts/prompts/scout.md)" \
--allowed-tools "Read,Write,Bash" \
--working-dir /opt/oasis_mobile
# Merger manual (después de revisar scout)
claude -p "$(cat /opt/scripts/prompts/merger.md)" \
--allowed-tools "Read,Write,Edit,Bash" \
--working-dir /opt/oasis_mobile
# Builder manual
claude -p "$(cat /opt/scripts/prompts/builder.md)" \
--allowed-tools "Read,Write,Bash" \
--working-dir /opt/oasis_mobile
```
## TIPS PARA HACER LOS PROMPTS MÁS FIABLES
1. **Prefijo de identidad**: "Eres el agente X" — fija el rol.
2. **Lista numerada**: las pasos en orden ayuda a no saltar.
3. **Reglas negativas explícitas**: "NUNCA modifiques X".
4. **Comprobaciones de éxito**: "Verifica con node --check".
5. **Fallback claro**: "Si no estás seguro, NO procedas".
6. **Output esperado**: "Escribe reporte en formato X".
7. **Notificación**: cada agente notifica al final.
## TAMAÑO DE PROMPTS
Los tres prompts juntos suman ~3000 tokens.
Con context (lectura de CONTEXT/*) + ejecución, cada agente
consume ~30-60k tokens en input + ~5-15k output.
Coste estimado total por sync: ~$0.5-1.50 USD.
```

View file

@ -0,0 +1,295 @@
# Scripts bash listos para usar
Copia a `/opt/scripts/` en el VPS Debian.
## `oasis-scout.sh`
```bash
#!/bin/bash
set -euo pipefail
REPO=/opt/oasis_mobile
REPORTS=/var/oasis-auto/reports
mkdir -p "$REPORTS"
cd "$REPO"
git fetch upstream 2>&1
DATE=$(date +%Y-%m-%d)
claude -p "$(cat /opt/scripts/prompts/scout.md)" \
--allowed-tools "Read,Write,Bash" \
> "$REPORTS/$DATE-scout-claude.log" 2>&1
# El reporte JSON y MD se generan por claude
if [ -f "$REPORTS/$DATE-scout.json" ]; then
bash /opt/scripts/notify-telegram.sh "📊 Scout listo para $DATE. Ver: https://0asis.net/admin/reports/$DATE"
else
bash /opt/scripts/notify-telegram.sh "❌ Scout falló para $DATE. Log: $REPORTS/$DATE-scout-claude.log"
exit 1
fi
```
## `oasis-merger.sh`
```bash
#!/bin/bash
set -euo pipefail
REPO=/opt/oasis_mobile
REPORTS=/var/oasis-auto/reports
DATE=$(date +%Y-%m-%d)
THRESHOLD=${AUTO_APPROVE_THRESHOLD:-1.0}
cd "$REPO"
# Verificar que existe scout reciente
if [ ! -f "$REPORTS/$DATE-scout.json" ]; then
bash /opt/scripts/notify-telegram.sh "⚠️ Merger: no hay scout.json para $DATE. Saltando."
exit 0
fi
# Leer conflict_score
SCORE=$(jq -r '.conflict_score' "$REPORTS/$DATE-scout.json")
APPROVED=$(jq -r '.approved // false' "$REPORTS/$DATE-scout.json")
if [ "$APPROVED" != "true" ] && (( $(echo "$SCORE > $THRESHOLD" | bc -l) )); then
bash /opt/scripts/notify-telegram.sh "⏸ Merger: conflict_score $SCORE > $THRESHOLD, esperando aprobación manual."
exit 0
fi
# Procede con merge
claude -p "$(cat /opt/scripts/prompts/merger.md)" \
--allowed-tools "Read,Write,Edit,Bash" \
> "$REPORTS/$DATE-merger-claude.log" 2>&1
if [ $? -eq 0 ]; then
bash /opt/scripts/notify-telegram.sh "✅ Merger OK para $DATE. Lista para build."
else
bash /opt/scripts/notify-telegram.sh "❌ Merger falló para $DATE."
fi
```
## `oasis-builder.sh`
```bash
#!/bin/bash
set -euo pipefail
REPO=/opt/oasis_mobile
APKS=/var/www/0asis.net/testing-app
DATE=$(date +%Y%m%d)
mkdir -p "$APKS"
cd "$REPO"
# Verificar working tree limpio
if [ -n "$(git status --porcelain)" ]; then
bash /opt/scripts/notify-telegram.sh "⚠️ Builder: working tree no limpio. Saltando."
exit 1
fi
# Verificar no hay markers de conflicto
if grep -rn "<<<<<<< HEAD" "$REPO/nodejs-project/" 2>/dev/null; then
bash /opt/scripts/notify-telegram.sh "❌ Builder: hay conflictos sin resolver."
exit 1
fi
# Build zip
cd "$REPO/nodejs-project"
zip -0 -r /tmp/nodejs-project-new.zip nodejs-project/ \
-x "*.apk" "*/.git/*" > /tmp/zip.log 2>&1
# Build APK
mkdir -p /tmp/assets
cp /tmp/nodejs-project-new.zip /tmp/assets/nodejs-project.zip
cp /opt/oasis-base/oasis-v0.6.8.apk /tmp/oasis-temp.apk
zip -d /tmp/oasis-temp.apk "META-INF/*" >/dev/null
cd /tmp && zip -0 oasis-temp.apk assets/nodejs-project.zip >/dev/null
SDK=/opt/android-sdk/build-tools/35.0.1
$SDK/zipalign -f -p 4 /tmp/oasis-temp.apk /tmp/oasis-aligned.apk
$SDK/apksigner sign \
--ks /opt/secrets/oasis-release-key.jks \
--ks-pass pass:oasis123 \
--key-pass pass:oasis123 \
--ks-key-alias oasis \
/tmp/oasis-aligned.apk
$SDK/apksigner verify /tmp/oasis-aligned.apk || {
bash /opt/scripts/notify-telegram.sh "❌ APK signature inválida."
exit 1
}
# Detectar versión del package.json
VERSION=$(jq -r '.version' "$REPO/nodejs-project/nodejs-project/src/server/package.json")
# Borrar pruebas anteriores y copiar
rm -f "$APKS/oasis-*-pruebas.apk"
NAME="oasis-v$VERSION-$DATE-pruebas.apk"
cp /tmp/oasis-aligned.apk "$APKS/$NAME"
ln -sf "$NAME" "$APKS/latest.apk"
SIZE=$(du -h "$APKS/$NAME" | cut -f1)
bash /opt/scripts/notify-telegram.sh "📱 APK $NAME ($SIZE) lista: https://0asis.net/testing-app/$NAME"
```
## `notify-telegram.sh`
```bash
#!/bin/bash
TOKEN="${TELEGRAM_BOT_TOKEN:-}"
CHAT="${TELEGRAM_CHAT_ID:-}"
MSG="${1:-empty}"
if [ -z "$TOKEN" ] || [ -z "$CHAT" ]; then
echo "Telegram no configurado, mensaje:"
echo "$MSG"
exit 0
fi
curl -s -X POST "https://api.telegram.org/bot$TOKEN/sendMessage" \
-d "chat_id=$CHAT" \
-d "text=$MSG" \
-d "parse_mode=Markdown" > /dev/null
```
## `/etc/cron.d/oasis-auto`
```cron
# Oasis Mobile auto-update pipeline
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
ANTHROPIC_API_KEY=sk-ant-xxxxxxxxxxxxx
TELEGRAM_BOT_TOKEN=123456789:AAxxxx
TELEGRAM_CHAT_ID=-1001234567890
AUTO_APPROVE_THRESHOLD=0.5
0 3 * * 1 oasis /opt/scripts/oasis-scout.sh
0 4 * * 1 oasis /opt/scripts/oasis-merger.sh
0 5 * * 1 oasis /opt/scripts/oasis-builder.sh
```
## Webhook receiver (Python Flask)
```python
# /opt/oasis-webhook/app.py
from flask import Flask, request, abort
import hmac, hashlib, subprocess, os
app = Flask(__name__)
SECRET = os.environ['WEBHOOK_SECRET'].encode()
@app.post("/api/oasis-release")
def release():
sig = request.headers.get("X-Hub-Signature-256", "")
mac = hmac.new(SECRET, request.data, hashlib.sha256)
expected = "sha256=" + mac.hexdigest()
if not hmac.compare_digest(sig, expected):
abort(401)
event = request.headers.get("X-GitHub-Event")
if event != "release":
return "skip", 200
payload = request.json
if payload.get("action") == "published":
tag = payload["release"]["tag_name"]
subprocess.Popen([
"sudo", "-u", "oasis",
"/opt/scripts/oasis-scout.sh",
"--tag", tag, "--reason", "webhook"
])
return "ok", 200
```
## `/etc/systemd/system/oasis-webhook.service`
```ini
[Unit]
Description=Oasis webhook receiver
After=network.target
[Service]
User=oasis
WorkingDirectory=/opt/oasis-webhook
EnvironmentFile=/opt/secrets/webhook.env
ExecStart=/usr/bin/python3 -m gunicorn -b 127.0.0.1:5000 app:app
Restart=always
[Install]
WantedBy=multi-user.target
```
## `/etc/caddy/Caddyfile`
```
0asis.net {
encode gzip zstd
handle /api/oasis-release {
reverse_proxy 127.0.0.1:5000
}
handle_path /testing-app/* {
root * /var/www/0asis.net/testing-app
file_server browse
@apk path *.apk
header @apk Content-Disposition "attachment"
}
handle {
root * /var/www/0asis.net
file_server
try_files {path} {path}/ /index.html
}
}
```
## Setup inicial — `bootstrap.sh`
```bash
#!/bin/bash
# Ejecutar UNA vez al preparar el VPS Debian
set -euo pipefail
# 1. User dedicado
useradd -m -s /bin/bash oasis || true
# 2. Directorios
mkdir -p /opt/oasis_mobile /opt/scripts /opt/secrets /opt/oasis-base
mkdir -p /var/oasis-auto/reports /var/www/0asis.net/testing-app
# 3. Dependencias del sistema
apt update
apt install -y git curl jq bc python3-pip nginx-light \
openjdk-17-jdk-headless zip unzip cron
# 4. Caddy
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/setup.deb.sh' | bash
apt install -y caddy
# 5. Android SDK build-tools
mkdir -p /opt/android-sdk
cd /opt/android-sdk
curl -L -o cmdline-tools.zip \
"https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip"
unzip cmdline-tools.zip
cmdline-tools/bin/sdkmanager --sdk_root=/opt/android-sdk \
"build-tools;35.0.1"
# 6. Claude Code
curl -fsSL https://claude.ai/install.sh | bash
# 7. Permisos
chown -R oasis:oasis /opt/oasis_mobile /var/oasis-auto /opt/scripts
# 8. Clonar repo (necesita SSH key configurada)
sudo -u oasis git clone git@code.03c8.net:s1to/oasis_mobile.git /opt/oasis_mobile
cd /opt/oasis_mobile
sudo -u oasis git remote add upstream https://github.com/epsylon/oasis.git
echo "Bootstrap completo. Configura:"
echo " - /opt/secrets/oasis-release-key.jks (keystore)"
echo " - /etc/cron.d/oasis-auto (con tu ANTHROPIC_API_KEY)"
echo " - DNS de 0asis.net apuntando a este server"
echo " - /etc/caddy/Caddyfile"
echo " - systemctl enable --now oasis-webhook"
```

View file

@ -0,0 +1,258 @@
===============================================================
DEBIAN VPS — Setup paso a paso
===============================================================
REQUISITOS MÍNIMOS DEL VPS
-------------------------
CPU: 2 cores
RAM: 4 GB (mínimo, recomendado 8 GB para AI tasks)
Disco: 30 GB (10 para sistema, 5 para Android SDK,
10 para histórico de APKs)
OS: Debian 12 (bookworm)
Red: IP pública con dominio (si vas a usar webhooks)
PASO 1 — USUARIO Y DIRECTORIOS
------------------------------
sudo adduser --system --group --home /home/oasis oasis
sudo mkdir -p /opt/oasis_mobile /opt/scripts /opt/secrets \
/opt/oasis-base /opt/oasis-webhook \
/var/oasis-auto/reports /var/oasis-auto/apks \
/var/www/0asis.net/testing-app
sudo chown -R oasis:oasis /opt/oasis_mobile /opt/scripts \
/var/oasis-auto /var/www/0asis.net
PASO 2 — DEPENDENCIAS BASE
--------------------------
sudo apt update
sudo apt install -y \
git curl wget jq bc \
python3 python3-pip python3-flask python3-gunicorn \
openjdk-17-jdk-headless \
zip unzip cron \
build-essential
PASO 3 — CADDY (REVERSE PROXY)
------------------------------
sudo apt install -y debian-keyring debian-archive-keyring \
apt-transport-https
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | \
sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | \
sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update
sudo apt install -y caddy
sudo systemctl enable caddy
PASO 4 — ANDROID SDK BUILD-TOOLS
--------------------------------
cd /tmp
wget https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
sudo mkdir -p /opt/android-sdk/cmdline-tools
sudo unzip -q commandlinetools-linux-*.zip -d /opt/android-sdk/cmdline-tools
sudo mv /opt/android-sdk/cmdline-tools/cmdline-tools \
/opt/android-sdk/cmdline-tools/latest
sudo yes | /opt/android-sdk/cmdline-tools/latest/bin/sdkmanager --licenses
sudo /opt/android-sdk/cmdline-tools/latest/bin/sdkmanager \
"build-tools;35.0.1"
# Verificar
/opt/android-sdk/build-tools/35.0.1/apksigner version
PASO 5 — CLAUDE CODE CLI
------------------------
# Como usuario oasis (no root):
sudo -u oasis -i
curl -fsSL https://claude.ai/install.sh | bash
# Loguearse o usar API key (recomendado para automatización):
export ANTHROPIC_API_KEY="sk-ant-xxxxx"
# Test
claude -p "say hello" --allowed-tools "" \
--output-format text
exit # vuelve a root
PASO 6 — REPO LOCAL
-------------------
sudo -u oasis git clone <URL del repo oasis_mobile> /opt/oasis_mobile
cd /opt/oasis_mobile
sudo -u oasis git remote add upstream https://github.com/epsylon/oasis.git
sudo -u oasis git fetch upstream
# Verificar
sudo -u oasis git log --oneline upstream/main | head -5
PASO 7 — KEYSTORE
-----------------
# COPIAR el keystore desde tu máquina:
scp ~/oasis-release-key.jks \
oasis@your-vps:/opt/secrets/oasis-release-key.jks
# Permisos restrictivos
sudo chmod 600 /opt/secrets/oasis-release-key.jks
sudo chown oasis:oasis /opt/secrets/oasis-release-key.jks
# Copia también el APK base
scp ~/oasis-v0.6.8.apk \
oasis@your-vps:/opt/oasis-base/oasis-v0.6.8.apk
PASO 8 — SCRIPTS Y PROMPTS
--------------------------
# Copia los scripts de AUTOMATIZACION/09_SCRIPTS.md a:
/opt/scripts/oasis-scout.sh
/opt/scripts/oasis-merger.sh
/opt/scripts/oasis-builder.sh
/opt/scripts/notify-telegram.sh
# Copia los prompts de AUTOMATIZACION/08_PROMPTS_para_agentes.md a:
/opt/scripts/prompts/scout.md
/opt/scripts/prompts/merger.md
/opt/scripts/prompts/builder.md
# Permisos
sudo chmod +x /opt/scripts/*.sh
sudo chown -R oasis:oasis /opt/scripts
PASO 9 — TELEGRAM BOT (opcional)
-------------------------------
# En Telegram: hablar con @BotFather, crear bot, copiar token.
# Añadir el bot a un canal/grupo donde recibir notificaciones.
# Obtener chat_id con:
curl "https://api.telegram.org/bot$TOKEN/getUpdates"
# Guardar en /opt/secrets/telegram.env:
TELEGRAM_BOT_TOKEN=123456789:AAxxxx
TELEGRAM_CHAT_ID=-1001234567890
sudo chmod 600 /opt/secrets/telegram.env
PASO 10 — CRON
--------------
sudo cp /opt/scripts/oasis-auto.cron /etc/cron.d/oasis-auto
# Verificar
systemctl restart cron
cat /etc/cron.d/oasis-auto
PASO 11 — WEBHOOK (opcional, para opción D)
-------------------------------------------
cd /opt/oasis-webhook
# Copia app.py de 09_SCRIPTS.md
# Generar secret
python3 -c "import secrets; print(secrets.token_hex(32))" \
| sudo tee /opt/secrets/webhook-secret
# Service
sudo cp oasis-webhook.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now oasis-webhook
# Verificar
curl http://127.0.0.1:5000/api/health
PASO 12 — DNS Y HTTPS
---------------------
# En tu DNS provider:
A 0asis.net -> IP-DEL-VPS
# Caddyfile:
sudo cp Caddyfile /etc/caddy/Caddyfile
sudo systemctl reload caddy
# Caddy obtiene certificado Let's Encrypt automáticamente.
# Verificar:
curl -I https://0asis.net
PASO 13 — WEBHOOK EN GITHUB
---------------------------
# En el fork de oasis_mobile en GitHub:
Settings → Webhooks → Add webhook
URL: https://0asis.net/api/oasis-release
Secret: el de /opt/secrets/webhook-secret
Events: only "Releases"
PASO 14 — TEST INICIAL
----------------------
# Como oasis user:
sudo -u oasis -i
# Forzar scout manualmente
/opt/scripts/oasis-scout.sh
# Verificar reporte
ls /var/oasis-auto/reports/
# Si OK, esperar al lunes o forzar pipeline completo
/opt/scripts/oasis-merger.sh && /opt/scripts/oasis-builder.sh
CHECKLIST FINAL
---------------
[ ] User oasis creado
[ ] /opt/oasis_mobile con remote upstream
[ ] /opt/secrets con keystore y permisos 600
[ ] /opt/oasis-base con oasis-v0.6.8.apk
[ ] Android SDK build-tools instalado
[ ] Claude Code instalado con API key
[ ] Scripts en /opt/scripts/ con permisos
[ ] Cron /etc/cron.d/oasis-auto configurado
[ ] Telegram bot (opcional)
[ ] Caddy + HTTPS para 0asis.net
[ ] Webhook receiver en systemd (opcional)
[ ] Webhook en GitHub configurado (opcional)
[ ] Test inicial OK
MONITORING
----------
# Logs cron
tail -f /var/log/syslog | grep CRON
# Logs de cada agente
tail -f /var/oasis-auto/reports/*.log
# Logs Caddy
journalctl -u caddy -f
# Logs webhook
journalctl -u oasis-webhook -f
# Espacio en disco
df -h /var/www /opt /var/oasis-auto
PROBLEMAS COMUNES
-----------------
"claude command not found":
El PATH del cron no incluye ~/.local/bin
→ en cron usa /home/oasis/.local/bin/claude full path
"git push falla":
El user oasis necesita SSH key configurada
→ ssh-keygen -t ed25519 y añadir a tu Gitea/GitHub
"apksigner: java not found":
sudo apt install openjdk-17-jdk-headless
Verifica: java --version
"permission denied en keystore":
sudo chown oasis:oasis /opt/secrets/oasis-release-key.jks
sudo chmod 600 ...

View file

@ -75,6 +75,20 @@ FICHEROS DE CAMBIOS (documentan lo que se modifico y por que)
- APK actual: /home/sito/oasis-v0.7.4-20260502-pruebas.apk (132 MB) - APK actual: /home/sito/oasis-v0.7.4-20260502-pruebas.apk (132 MB)
- Tabla de desglose de peso incluida - Tabla de desglose de peso incluida
--------------------------------------------------------------
TAREAS PENDIENTES
--------------------------------------------------------------
tareas_usabilidad.txt
Tareas de UX/usabilidad pendientes de implementar.
- Tarea 1: aplicar paginador CSS-only al resto de submenús
(inventario completo: 8 con paginador, 24 pendientes,
5 que no lo necesitan).
- Tarea 2: revisar mode-buttons-cols/row en forum/feed.
- Tarea 3: uniformar header en vistas de detalle.
- Tarea 4: revisar formularios largos en móvil.
- Tarea 5: considerar punto de quiebre 600px para tablet.
-------------------------------------------------------------- --------------------------------------------------------------
NOTA PARA EL DEVELOPER NOTA PARA EL DEVELOPER
-------------------------------------------------------------- --------------------------------------------------------------

File diff suppressed because it is too large Load diff

View file

@ -80,7 +80,8 @@ const handleBlobUpload = async function (ctx, fileFieldName) {
'.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',
'.svg': 'image/svg+xml', '.bmp': 'image/bmp' '.svg': 'image/svg+xml', '.bmp': 'image/bmp',
'.torrent': 'application/x-bittorrent'
}; };
const blob = { name: blobUpload.originalFilename || blobUpload.name || 'file' }; const blob = { name: blobUpload.originalFilename || blobUpload.name || 'file' };
@ -101,7 +102,7 @@ const handleBlobUpload = async function (ctx, fileFieldName) {
blob.mime = 'application/octet-stream'; blob.mime = 'application/octet-stream';
} }
if (blob.mime.startsWith('image/')) { if (blob.mime.startsWith('image/') && blob.mime !== 'image/gif') {
data = await stripImageMetadata(data); data = await stripImageMetadata(data);
} else if (blob.mime === 'application/pdf') { } else if (blob.mime === 'application/pdf') {
data = stripPdfMetadata(data); data = stripPdfMetadata(data);
@ -120,6 +121,7 @@ const handleBlobUpload = async function (ctx, fileFieldName) {
if (blob.mime.startsWith("audio/")) return `\n[audio:${blob.name}](${blob.id})`; if (blob.mime.startsWith("audio/")) return `\n[audio:${blob.name}](${blob.id})`;
if (blob.mime.startsWith("video/")) return `\n[video:${blob.name}](${blob.id})`; if (blob.mime.startsWith("video/")) return `\n[video:${blob.name}](${blob.id})`;
if (blob.mime === "application/pdf") return `[pdf:${blob.name}](${blob.id})`; if (blob.mime === "application/pdf") return `[pdf:${blob.name}](${blob.id})`;
if (blob.mime === "application/x-bittorrent") return `\n[torrent:${blob.name}](${blob.id})`;
return `\n[${blob.name}](${blob.id})`; return `\n[${blob.name}](${blob.id})`;
}; };
@ -213,8 +215,27 @@ const serveBlob = async function (ctx) {
if (ft && ft.mime) mime = ft.mime; if (ft && ft.mime) mime = ft.mime;
} catch {} } catch {}
if (mime === 'application/octet-stream' && buffer.length > 10 && buffer[0] === 0x64) {
const head = buffer.slice(0, 128).toString('ascii');
if (head.includes('announce') || head.includes('8:announce') || head.includes('4:info')) mime = 'application/x-bittorrent';
}
if (mime === 'application/octet-stream' || mime === 'text/plain' || mime === 'application/xml' || mime === 'text/xml') {
let head = buffer.slice(0, 512).toString('utf8');
if (head.charCodeAt(0) === 0xFEFF) head = head.slice(1);
const trimmed = head.replace(/^\s+/, '').toLowerCase();
if (trimmed.startsWith('<?xml') || trimmed.startsWith('<svg')) {
if (trimmed.includes('<svg')) 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 safeRaw = String(raw).replace(/["\r\n\\]/g, '');
const filename = qName || (mime === 'application/x-bittorrent' ? 'download.torrent' : safeRaw);
const disposition = isSvg ? 'attachment' : 'inline';
ctx.type = mime; ctx.type = mime;
ctx.set('Content-Disposition', `inline; filename="${raw}"`); ctx.set('Content-Disposition', `${disposition}; filename="${filename}"`);
ctx.set('Cache-Control', 'public, max-age=31536000, immutable'); ctx.set('Cache-Control', 'public, max-age=31536000, immutable');
const range = ctx.headers.range; const range = ctx.headers.range;

View file

@ -0,0 +1,20 @@
const cache = new Map()
const get = (feedId) => {
if (!feedId) return null
const entry = cache.get(String(feedId))
return entry ? entry.name : null
}
const set = (feedId, name, ts) => {
if (!feedId || typeof name !== 'string' || !name) return
const id = String(feedId)
const t = Number(ts) || 0
const prev = cache.get(id)
if (!prev || (prev.ts || 0) <= t) cache.set(id, { name, ts: t })
}
const has = (feedId) => !!feedId && cache.has(String(feedId))
const size = () => cache.size
module.exports = { get, set, has, size }

View file

@ -9,13 +9,57 @@ function getI18n() {
} }
} }
function renderTextPreview(text, maxLength = 220) {
if (!text) return ''
let preview = String(text)
preview = preview
.replace(/```[\s\S]*?```/g, '')
.replace(/^>.*$/gm, '')
.replace(/^#{1,6}\s*/gm, '')
.replace(/^- /gm, '')
.replace(/^\d+\. /gm, '')
.replace(/\*\*(.*?)\*\*/g, '$1')
.replace(/\*(.*?)\*/g, '$1')
.replace(/`([^`]+)`/g, '$1')
.replace(/!\[.*?\]\(.*?\)/g, '')
.replace(/\[.*?\]\(.*?\)/g, '')
.replace(/\n+/g, ' ')
.trim()
if (preview.length > maxLength) {
preview = preview.slice(0, maxLength) + '...'
}
return preview
}
function renderTextWithStyles(text) { function renderTextWithStyles(text) {
if (!text) return '' if (!text) return ''
const i18n = getI18n() const i18n = getI18n()
return String(text)
let html = String(text)
html = html
.replace(/&/g, '&amp;') .replace(/&/g, '&amp;')
.replace(/</g, '&lt;') .replace(/</g, '&lt;')
.replace(/>/g, '&gt;') .replace(/>/g, '&gt;')
html = html
.replace(/```([\s\S]*?)```/gim, '<pre><code>$1</code></pre>')
.replace(/^> (.*)$/gim, '<blockquote>$1</blockquote>')
.replace(/^---$/gim, '<hr/>')
.replace(/^### (.*)$/gim, '<h3>$1</h3>')
.replace(/^## (.*)$/gim, '<h2>$1</h2>')
.replace(/^# (.*)$/gim, '<h1>$1</h1>')
html = html
.replace(/\*\*(.*?)\*\*/gim, '<strong>$1</strong>')
.replace(/\*(.*?)\*/gim, '<em>$1</em>')
.replace(/`([^`]+)`/gim, '<code>$1</code>')
html = html
.replace(/!\[([^\]]*)\]\(\s*(&amp;[^)\s]+\.sha256)\s*\)/g, (_, alt, blob) => .replace(/!\[([^\]]*)\]\(\s*(&amp;[^)\s]+\.sha256)\s*\)/g, (_, alt, blob) =>
`<img src="/blob/${encodeURIComponent(blob.replace(/&amp;/g, '&'))}" alt="${alt}" class="post-image" />` `<img src="/blob/${encodeURIComponent(blob.replace(/&amp;/g, '&'))}" alt="${alt}" class="post-image" />`
) )
@ -28,21 +72,68 @@ function renderTextWithStyles(text) {
.replace(/\[pdf:([^\]]*)\]\(\s*(&amp;[^)\s]+\.sha256)\s*\)/g, (_, name, blob) => .replace(/\[pdf:([^\]]*)\]\(\s*(&amp;[^)\s]+\.sha256)\s*\)/g, (_, name, blob) =>
`<a class="post-pdf" href="/blob/${encodeURIComponent(blob.replace(/&amp;/g, '&'))}" target="_blank">${name || i18n.pdfFallbackLabel || 'PDF'}</a>` `<a class="post-pdf" href="/blob/${encodeURIComponent(blob.replace(/&amp;/g, '&'))}" target="_blank">${name || i18n.pdfFallbackLabel || 'PDF'}</a>`
) )
html = html
.replace(/\[@([^\]]+)\]\(@?([A-Za-z0-9+/=.\-]+\.ed25519)\)/g, (_, name, id) => .replace(/\[@([^\]]+)\]\(@?([A-Za-z0-9+/=.\-]+\.ed25519)\)/g, (_, name, id) =>
`<a href="/author/${encodeURIComponent('@' + id)}" class="mention" target="_blank">@${name}</a>` `<a href="/author/${encodeURIComponent('@' + id)}" class="mention" target="_blank">@${name}</a>`
) )
.replace(/@([A-Za-z0-9+/=.\-]+\.ed25519)/g, (_, id) => .replace(/@([A-Za-z0-9+/=.\-]+\.ed25519)/g, (_, id) =>
`<a href="/author/${encodeURIComponent('@' + id)}" class="mention" target="_blank">@${id}</a>` `<a href="/author/${encodeURIComponent('@' + id)}" class="mention" target="_blank">@${id}</a>`
) )
const escAttr = (s) => String(s).replace(/"/g, '&quot;').replace(/'/g, '&#39;')
html = html
.replace(/#(\w+)/g, (_, tag) => .replace(/#(\w+)/g, (_, tag) =>
`<a href="/hashtag/${encodeURIComponent(tag)}" class="styled-link" target="_blank">#${tag}</a>` `<a href="/hashtag/${encodeURIComponent(tag)}" class="styled-link" target="_blank">#${escAttr(tag)}</a>`
) )
.replace(/(https?:\/\/[^\s]+)/g, url => .replace(/(https?:\/\/[^\s"'<>]+)/g, url =>
`<a href="${url}" target="_blank" class="styled-link">${url}</a>` `<a href="${escAttr(url)}" target="_blank" class="styled-link">${escAttr(url)}</a>`
) )
.replace(/([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/g, email => .replace(/([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/g, email =>
`<a href="mailto:${email}" class="styled-link">${email}</a>` `<a href="mailto:${escAttr(email)}" class="styled-link">${escAttr(email)}</a>`
) )
const lines = html.split('\n')
let result = ''
let inUL = false
let inOL = false
for (let line of lines) {
if (/^- /.test(line)) {
if (!inUL) {
result += '<ul>'
inUL = true
}
result += `<li>${line.replace(/^- /, '')}</li>`
continue
}
if (/^\d+\. /.test(line)) {
if (!inOL) {
result += '<ol>'
inOL = true
}
result += `<li>${line.replace(/^\d+\. /, '')}</li>`
continue
}
if (inUL) {
result += '</ul>'
inUL = false
}
if (inOL) {
result += '</ol>'
inOL = false
}
result += line + '<br>'
}
if (inUL) result += '</ul>'
if (inOL) result += '</ol>'
return result
} }
module.exports = { renderTextWithStyles } module.exports = { renderTextWithStyles, renderTextPreview }

View file

@ -1022,30 +1022,37 @@ pre, code {
} }
.actpager { .actpager {
margin-top: 0 !important; margin: 0 !important;
} }
.actpager-frame { .actpager-frame {
gap: 8px !important; gap: 4px !important;
padding: 6px 0 !important; padding: 0 !important;
} }
.actpager-cell { .actpager-cell {
gap: 10px !important; gap: 6px !important;
}
.actpager-cell .filter-btn {
padding: 6px 8px !important;
min-height: 32px !important;
font-size: 0.78rem !important;
} }
.actpager-controls { .actpager-controls {
margin-top: 4px !important; margin: 0 !important;
} }
.actpager-arrows { .actpager-arrows {
gap: 40px !important; gap: 32px !important;
padding: 0 !important;
} }
.actpager-arrow { .actpager-arrow {
width: 32px !important; width: 28px !important;
height: 32px !important; height: 28px !important;
font-size: 1.3rem !important; font-size: 1.15rem !important;
background: transparent !important; background: transparent !important;
border: 1px solid #555 !important; border: 1px solid #555 !important;
} }

View file

@ -240,6 +240,7 @@ nav ul li a:hover {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-start; justify-content: flex-start;
gap: 0.5rem;
cursor: pointer; cursor: pointer;
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
font-weight: 600; font-weight: 600;
@ -255,6 +256,20 @@ nav ul li a:hover {
box-sizing: border-box; box-sizing: border-box;
} }
.oasis-nav-header .emoji {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.4em;
flex-shrink: 0;
text-align: center;
line-height: 1;
}
.oasis-nav-header .nav-text {
flex: 1;
}
.oasis-nav-header:hover { .oasis-nav-header:hover {
background: rgba(255, 255, 255, 0.05); background: rgba(255, 255, 255, 0.05);
color: #ffd36a; color: #ffd36a;
@ -295,6 +310,7 @@ nav ul li a:hover {
.oasis-nav-list li a { .oasis-nav-list li a {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem;
padding: 0.35rem 1.25rem 0.35rem 1.5rem; padding: 0.35rem 1.25rem 0.35rem 1.5rem;
font-size: 0.85rem; font-size: 0.85rem;
text-decoration: none; text-decoration: none;
@ -307,7 +323,18 @@ nav ul li a:hover {
} }
.oasis-nav-list .emoji { .oasis-nav-list .emoji {
margin-right: 0.4rem; display: inline-flex;
align-items: center;
justify-content: center;
width: 1.4em;
flex-shrink: 0;
text-align: center;
line-height: 1;
}
.oasis-nav-list .nav-text {
display: inline-block;
line-height: 1.2;
} }
.oasis-header-marquee { .oasis-header-marquee {
@ -459,6 +486,7 @@ nav ul li a:hover {
.top-bar-left, .top-bar-left,
.top-bar-mid, .top-bar-mid,
.top-bar-center,
.top-bar-right { .top-bar-right {
display: flex; display: flex;
align-items: center; align-items: center;
@ -474,10 +502,78 @@ nav ul li a:hover {
justify-content: center; justify-content: center;
} }
.top-bar-center {
flex: 1;
justify-content: center;
min-width: 0;
}
.top-bar-right { .top-bar-right {
justify-content: flex-end; justify-content: flex-end;
} }
.ai-ask-form {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
max-width: 720px;
}
.ai-ask-form .ai-ask-input,
.ai-ask-form .ai-ask-btn {
box-sizing: border-box;
margin: 0;
height: 32px;
border: 1px solid rgba(255, 180, 0, 0.6);
border-radius: 4px;
padding: 0 0.75rem;
font-size: 0.8rem;
font-family: inherit;
font-weight: 600;
line-height: 1;
background: transparent;
color: #ffb400;
text-transform: none;
letter-spacing: normal;
}
.ai-ask-form .ai-ask-input {
flex: 1;
min-width: 0;
outline: none;
transition: border-color 0.15s ease, background 0.15s ease;
}
.ai-ask-form .ai-ask-input::placeholder {
color: rgba(255, 180, 0, 0.55);
}
.ai-ask-form .ai-ask-input:focus {
border-color: #ffa300;
background: rgba(255, 255, 255, 0.05);
}
.ai-ask-form .ai-ask-btn {
flex-shrink: 0;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition: background 0.2s ease;
padding: 0 0.85rem;
}
.ai-ask-form .ai-ask-btn:hover {
background: rgba(255, 180, 0, 0.15);
border-color: #ffa300;
color: #ffa300;
}
@media (max-width: 768px) {
.top-bar-center { display: none; }
}
.top-bar-left nav ul, .top-bar-left nav ul,
.top-bar-mid nav ul, .top-bar-mid nav ul,
.top-bar-right nav ul { .top-bar-right nav ul {
@ -1038,6 +1134,9 @@ button.create-button:hover {
color: #ffa300; color: #ffa300;
} }
.error-page-message{margin:8px 0 16px;white-space:pre-wrap;word-break:break-word}
.error-page-actions{margin-top:16px}
.tags-container { .tags-container {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@ -3122,7 +3221,9 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
.pledge-box input[type="number"]:focus{ border-color:#3a475c; } .pledge-box input[type="number"]:focus{ border-color:#3a475c; }
.card-field{display:flex;align-items:baseline;gap:6px;margin-bottom:4px} .card-field{display:flex;align-items:baseline;gap:6px;margin-bottom:4px;flex-wrap:wrap}
.card-field>.card-label{white-space:nowrap;flex-shrink:0}
.card-field>.card-value{min-width:0;word-break:break-word;overflow-wrap:anywhere;flex:1 1 auto}
.card-field-stacked{flex-direction:column;align-items:flex-start;gap:2px} .card-field-stacked{flex-direction:column;align-items:flex-start;gap:2px}
.card-field-stacked .card-value{white-space:pre-wrap;word-break:break-word} .card-field-stacked .card-value{white-space:pre-wrap;word-break:break-word}
.card-field-rich{flex-direction:column;align-items:flex-start} .card-field-rich{flex-direction:column;align-items:flex-start}
@ -3276,8 +3377,8 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
.carbon-bar-network{background:#2ecc71 !important} .carbon-bar-network{background:#2ecc71 !important}
.carbon-bar-mine{background:#3498db !important} .carbon-bar-mine{background:#3498db !important}
.carbon-bar-max{background:#555 !important;border:none !important} .carbon-bar-max{background:#555 !important;border:none !important}
.carbon-bar-note{font-size:13px;color:#888;margin:6px 0 2px} .carbon-bar-note{font-size:13px;color:#ffd700;margin:6px 0 2px}
.carbon-bar-formula{font-size:12px;color:#999;margin:2px 0} .carbon-bar-formula{font-size:12px;color:#ffd700;margin:2px 0 10px}
/* parliament */ /* parliament */
.cycle-info { .cycle-info {
@ -3726,6 +3827,26 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
align-self: flex-end; align-self: flex-end;
} }
.tribe-parent-box {
margin-bottom: 12px;
text-align: center;
}
.tribe-parent-box h2 {
margin: 0 0 8px 0;
}
.tribe-parent-link {
display: block;
}
.tribe-parent-image {
width: 100%;
max-width: 200px;
border-radius: 8px;
border: 2px solid #444;
}
.tribe-card-meta-row { .tribe-card-meta-row {
display: flex; display: flex;
gap: .5em; gap: .5em;
@ -4123,50 +4244,6 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
align-self: flex-end; align-self: flex-end;
} }
.no-border{border:none}
.tribe-card-padded{padding:12px 16px}
.tribe-content-list-spaced{display:flex;flex-direction:column;gap:16px}
.tribe-banner{padding:12px 16px;margin-bottom:16px;text-align:center}
.bold{font-weight:bold}
.inline-form{display:inline}
.bd-type-post{border-color:#3498db}.bd-type-post .block-diagram-ruler{border-bottom-color:#3498db}
.bd-type-vote,.bd-type-votes,.bd-type-pixelia,.bd-type-parliamentTerm,.bd-type-parliamentProposal,.bd-type-parliamentLaw,.bd-type-parliamentCandidature,.bd-type-parliamentRevocation{border-color:#9b59b6}
.bd-type-vote .block-diagram-ruler,.bd-type-votes .block-diagram-ruler,.bd-type-pixelia .block-diagram-ruler,.bd-type-parliamentTerm .block-diagram-ruler,.bd-type-parliamentProposal .block-diagram-ruler,.bd-type-parliamentLaw .block-diagram-ruler,.bd-type-parliamentCandidature .block-diagram-ruler,.bd-type-parliamentRevocation .block-diagram-ruler{border-bottom-color:#9b59b6}
.bd-type-about,.bd-type-forum,.bd-type-curriculum{border-color:#1abc9c}
.bd-type-about .block-diagram-ruler,.bd-type-forum .block-diagram-ruler,.bd-type-curriculum .block-diagram-ruler{border-bottom-color:#1abc9c}
.bd-type-contact{border-color:#16a085}.bd-type-contact .block-diagram-ruler{border-bottom-color:#16a085}
.bd-type-pub,.bd-type-project,.bd-type-pad,.bd-type-map,.bd-type-mapMarker{border-color:#2ecc71}
.bd-type-pub .block-diagram-ruler,.bd-type-project .block-diagram-ruler,.bd-type-pad .block-diagram-ruler,.bd-type-map .block-diagram-ruler,.bd-type-mapMarker .block-diagram-ruler{border-bottom-color:#2ecc71}
.bd-type-tribe,.bd-type-market,.bd-type-shop,.bd-type-shopProduct{border-color:#e67e22}
.bd-type-tribe .block-diagram-ruler,.bd-type-market .block-diagram-ruler,.bd-type-shop .block-diagram-ruler,.bd-type-shopProduct .block-diagram-ruler{border-bottom-color:#e67e22}
.bd-type-event,.bd-type-transfer,.bd-type-calendar{border-color:#e74c3c}
.bd-type-event .block-diagram-ruler,.bd-type-transfer .block-diagram-ruler,.bd-type-calendar .block-diagram-ruler{border-bottom-color:#e74c3c}
.bd-type-task,.bd-type-banking,.bd-type-bankWallet,.bd-type-bankClaim,.bd-type-gameScore{border-color:#f39c12}
.bd-type-task .block-diagram-ruler,.bd-type-banking .block-diagram-ruler,.bd-type-bankWallet .block-diagram-ruler,.bd-type-bankClaim .block-diagram-ruler,.bd-type-gameScore .block-diagram-ruler{border-bottom-color:#f39c12}
.bd-type-report,.bd-type-courtsCase,.bd-type-courtsEvidence,.bd-type-courtsAnswer,.bd-type-courtsVerdict,.bd-type-courtsSettlement,.bd-type-courtsNomination{border-color:#c0392b}
.bd-type-report .block-diagram-ruler,.bd-type-courtsCase .block-diagram-ruler,.bd-type-courtsEvidence .block-diagram-ruler,.bd-type-courtsAnswer .block-diagram-ruler,.bd-type-courtsVerdict .block-diagram-ruler,.bd-type-courtsSettlement .block-diagram-ruler,.bd-type-courtsNomination .block-diagram-ruler{border-bottom-color:#c0392b}
.bd-type-image,.bd-type-job,.bd-type-aiExchange,.bd-type-chat{border-color:#3498db}
.bd-type-image .block-diagram-ruler,.bd-type-job .block-diagram-ruler,.bd-type-aiExchange .block-diagram-ruler,.bd-type-chat .block-diagram-ruler{border-bottom-color:#3498db}
.bd-type-audio{border-color:#8e44ad}.bd-type-audio .block-diagram-ruler{border-bottom-color:#8e44ad}
.bd-type-video{border-color:#d35400}.bd-type-video .block-diagram-ruler{border-bottom-color:#d35400}
.bd-type-document{border-color:#27ae60}.bd-type-document .block-diagram-ruler{border-bottom-color:#27ae60}
.bd-type-bookmark{border-color:#f1c40f}.bd-type-bookmark .block-diagram-ruler{border-bottom-color:#f1c40f}
.bd-type-feed,.bd-type-tombstone{border-color:#95a5a6}
.bd-type-feed .block-diagram-ruler,.bd-type-tombstone .block-diagram-ruler{border-bottom-color:#95a5a6}
.stats-link{color:#007bff;text-decoration:none}
.stats-link-break{color:#007bff;text-decoration:none;word-break:break-all}
.stats-muted-555{color:#555}
.stats-muted-888{color:#888}
.stats-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:16px;margin-bottom:24px}
.stats-section-h{font-size:18px;color:#555;margin:8px 0;font-weight:600}
.stats-list-reset{list-style-type:none;padding:0;margin:0}
.stats-mb-16{margin-bottom:16px}
.stats-w-100{width:100%}
.stats-table{width:100%;border-collapse:collapse}
.stats-table-mt8{width:100%;border-collapse:collapse;margin-top:8px}
.stats-h-row{font-size:18px;color:#555;margin:8px 0}
.comment-submit-btn { .comment-submit-btn {
width: auto; width: auto;
max-width: 200px; max-width: 200px;
@ -5033,89 +5110,107 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
.logs-detail-header{color:#aaa;font-family:monospace;margin-bottom:12px;word-break:break-all} .logs-detail-header{color:#aaa;font-family:monospace;margin-bottom:12px;word-break:break-all}
.logs-detail-text{white-space:pre-wrap;word-break:break-word;color:#ddd;margin:12px 0} .logs-detail-text{white-space:pre-wrap;word-break:break-word;color:#ddd;margin:12px 0}
.logs-detail-actions{display:flex;gap:10px;flex-wrap:wrap;margin-top:16px} .logs-detail-actions{display:flex;gap:10px;flex-wrap:wrap;margin-top:16px}
.no-border{border:none}
.tribe-card-padded{padding:12px 16px}
.tribe-content-list-spaced{display:flex;flex-direction:column;gap:16px}
.tribe-banner{padding:12px 16px;margin-bottom:16px;text-align:center}
.bold{font-weight:bold}
.inline-form{display:inline}
.bd-type-post{border-color:#3498db}.bd-type-post .block-diagram-ruler{border-bottom-color:#3498db}
.bd-type-vote,.bd-type-votes,.bd-type-pixelia,.bd-type-parliamentTerm,.bd-type-parliamentProposal,.bd-type-parliamentLaw,.bd-type-parliamentCandidature,.bd-type-parliamentRevocation{border-color:#9b59b6}
.bd-type-vote .block-diagram-ruler,.bd-type-votes .block-diagram-ruler,.bd-type-pixelia .block-diagram-ruler,.bd-type-parliamentTerm .block-diagram-ruler,.bd-type-parliamentProposal .block-diagram-ruler,.bd-type-parliamentLaw .block-diagram-ruler,.bd-type-parliamentCandidature .block-diagram-ruler,.bd-type-parliamentRevocation .block-diagram-ruler{border-bottom-color:#9b59b6}
.bd-type-about,.bd-type-forum,.bd-type-curriculum{border-color:#1abc9c}
.bd-type-about .block-diagram-ruler,.bd-type-forum .block-diagram-ruler,.bd-type-curriculum .block-diagram-ruler{border-bottom-color:#1abc9c}
.bd-type-contact{border-color:#16a085}.bd-type-contact .block-diagram-ruler{border-bottom-color:#16a085}
.bd-type-pub,.bd-type-project,.bd-type-pad,.bd-type-map,.bd-type-mapMarker{border-color:#2ecc71}
.bd-type-pub .block-diagram-ruler,.bd-type-project .block-diagram-ruler,.bd-type-pad .block-diagram-ruler,.bd-type-map .block-diagram-ruler,.bd-type-mapMarker .block-diagram-ruler{border-bottom-color:#2ecc71}
.bd-type-tribe,.bd-type-market,.bd-type-shop,.bd-type-shopProduct{border-color:#e67e22}
.bd-type-tribe .block-diagram-ruler,.bd-type-market .block-diagram-ruler,.bd-type-shop .block-diagram-ruler,.bd-type-shopProduct .block-diagram-ruler{border-bottom-color:#e67e22}
.bd-type-event,.bd-type-transfer,.bd-type-calendar{border-color:#e74c3c}
.bd-type-event .block-diagram-ruler,.bd-type-transfer .block-diagram-ruler,.bd-type-calendar .block-diagram-ruler{border-bottom-color:#e74c3c}
.bd-type-task,.bd-type-banking,.bd-type-bankWallet,.bd-type-bankClaim,.bd-type-gameScore{border-color:#f39c12}
.bd-type-task .block-diagram-ruler,.bd-type-banking .block-diagram-ruler,.bd-type-bankWallet .block-diagram-ruler,.bd-type-bankClaim .block-diagram-ruler,.bd-type-gameScore .block-diagram-ruler{border-bottom-color:#f39c12}
.bd-type-report,.bd-type-courtsCase,.bd-type-courtsEvidence,.bd-type-courtsAnswer,.bd-type-courtsVerdict,.bd-type-courtsSettlement,.bd-type-courtsNomination{border-color:#c0392b}
.bd-type-report .block-diagram-ruler,.bd-type-courtsCase .block-diagram-ruler,.bd-type-courtsEvidence .block-diagram-ruler,.bd-type-courtsAnswer .block-diagram-ruler,.bd-type-courtsVerdict .block-diagram-ruler,.bd-type-courtsSettlement .block-diagram-ruler,.bd-type-courtsNomination .block-diagram-ruler{border-bottom-color:#c0392b}
.bd-type-image,.bd-type-job,.bd-type-aiExchange,.bd-type-chat{border-color:#3498db}
.bd-type-image .block-diagram-ruler,.bd-type-job .block-diagram-ruler,.bd-type-aiExchange .block-diagram-ruler,.bd-type-chat .block-diagram-ruler{border-bottom-color:#3498db}
.bd-type-audio{border-color:#8e44ad}.bd-type-audio .block-diagram-ruler{border-bottom-color:#8e44ad}
.bd-type-video{border-color:#d35400}.bd-type-video .block-diagram-ruler{border-bottom-color:#d35400}
.bd-type-document{border-color:#27ae60}.bd-type-document .block-diagram-ruler{border-bottom-color:#27ae60}
.bd-type-bookmark{border-color:#f1c40f}.bd-type-bookmark .block-diagram-ruler{border-bottom-color:#f1c40f}
.bd-type-feed,.bd-type-tombstone{border-color:#95a5a6}
.bd-type-feed .block-diagram-ruler,.bd-type-tombstone .block-diagram-ruler{border-bottom-color:#95a5a6}
.stats-link{color:#ffa500;text-decoration:none}
.stats-link-break{color:#ffa500;text-decoration:none;word-break:break-all}
.stats-muted{color:#aaa}
.stats-strong{color:#ffd700;font-weight:600}
.stats-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:16px;margin-bottom:24px}
.stats-mode-row{justify-content:flex-start;margin-bottom:24px}
.stats-section-h{font-size:18px;color:#ffa500;margin:8px 0;font-weight:600}
.stats-list-reset{list-style-type:none;padding:0;margin:0}
.stats-mb-16{margin-bottom:16px}
.stats-w-100{width:100%}
.stats-table{width:100%;border-collapse:collapse}
.stats-table-mt8{width:100%;border-collapse:collapse;margin-top:8px}
.stats-h-row{font-size:15px;color:#ddd;margin:6px 0;display:flex;gap:8px;align-items:baseline}
.stats-card{background:none;padding:0;border:none;border-radius:0;margin-bottom:16px;box-shadow:none}
.stats-card .block-info-table{table-layout:fixed}
.stats-card .block-info-table .card-label{width:70%}
.stats-card .block-info-table .card-value{width:30%;text-align:right;word-break:break-word}
.invites-pubs-table{table-layout:fixed}
.invites-pubs-table col.invites-col-host,.invites-pubs-table td:nth-child(1){width:22%}
.invites-pubs-table td:nth-child(2){width:8%}
.invites-pubs-table td:nth-child(3){width:40%;word-break:break-all}
.invites-pubs-table td:nth-child(4){width:30%}
.invites-pubs-table tr:first-child td{white-space:nowrap}
.stats-block{background:none;padding:0;border:none;border-radius:0;margin-bottom:16px}
.stats-block h2{margin:0 0 10px;font-size:16px;color:#ffa500;font-weight:600}
.stats-block ul{margin:6px 0 0;padding-left:18px}
.stats-block ul li{margin:3px 0;color:#ddd;font-size:14px}
.stats-block table.stats-table th,.stats-block table.stats-table-mt8 th{background:#272727;color:#ffa500;text-align:left;padding:6px 8px;font-size:13px}
.stats-block table.stats-table td,.stats-block table.stats-table-mt8 td{padding:5px 8px;border-bottom:1px solid #2a2a2a;color:#ddd;font-size:13px}
.stats-block table.stats-table tr:last-child td,.stats-block table.stats-table-mt8 tr:last-child td{border-bottom:none}
.stats-pill{display:inline-block;padding:2px 8px;border-radius:10px;background:#2a2a2a;border:1px solid #3a3a3a;color:#ffd700;font-size:12px;margin:2px 4px 2px 0}
.stats-toplist{margin:0;padding:0;list-style:none}
.stats-toplist li{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:4px 0;border-bottom:1px solid #2a2a2a}
.stats-toplist li:last-child{border-bottom:none}
.stats-toplist .stats-bar-track{flex:1;background:#2a2a2a;border-radius:4px;height:8px;overflow:hidden}
.stats-toplist .stats-bar-fill{background:#ffa500;height:100%}
.stats-toplist .stats-toplist-name{flex:0 0 auto;color:#ddd;font-size:13px;min-width:120px}
.stats-toplist .stats-toplist-num{flex:0 0 auto;color:#ffd700;font-size:13px;min-width:36px;text-align:right}
.peer-key{word-break:break-all;font-size:12px}
.graphos-canvas{width:100%;max-width:1100px;margin:12px auto;padding:8px;background:transparent}
.graphos-svg{width:100%;height:auto;min-height:480px;display:block}
.graphos-edge{stroke:#666;stroke-width:1;opacity:.55}
.graphos-edge-online{stroke:#2ecc71;stroke-width:2;opacity:.9}
.graphos-edge-discovered{stroke:#ffd700;stroke-width:1.5;opacity:.75}
.graphos-edge-unknown{stroke:#888;stroke-width:1;stroke-dasharray:4 4;opacity:.55}
.graphos-node-circle{stroke:#1a1a1a;stroke-width:2;transition:stroke-width .15s,filter .15s}
.graphos-node-circle-me{fill:#ffa500;stroke:#cc7700;stroke-width:3}
.graphos-node-circle-online{fill:#2ecc71;stroke:#1e8449}
.graphos-node-circle-discovered{fill:#ffd700;stroke:#b8860b}
.graphos-node-circle-unknown{fill:#888;stroke:#555}
.graphos-node-link{cursor:pointer;outline:none}
.graphos-node-link:hover .graphos-node-circle{stroke-width:4;filter:brightness(1.15)}
.graphos-node-link:hover .graphos-node-label{font-weight:600}
.graphos-node-label{font-size:12px;fill:#ddd;text-anchor:middle;pointer-events:none}
.graphos-node-label-me{font-weight:600;fill:#ffa500}
.graphos-legend{display:flex;gap:18px;flex-wrap:wrap;align-items:center;font-size:13px;color:#ddd;margin:0 0 8px}
.graphos-legend-item{display:flex;align-items:center;gap:6px}
.graphos-legend-dot{display:inline-block;width:12px;height:12px;border-radius:50%;border:2px solid #1a1a1a}
.graphos-legend-dot.graphos-node-circle-me{background:#ffa500;border-color:#cc7700}
.graphos-legend-dot.graphos-node-circle-online{background:#2ecc71;border-color:#1e8449}
.graphos-legend-dot.graphos-node-circle-discovered{background:#ffd700;border-color:#b8860b}
.graphos-legend-dot.graphos-node-circle-unknown{background:#888;border-color:#555}
.stats-kpi{padding:8px 4px;display:flex;flex-direction:column;gap:4px;background:transparent;border:none}
.stats-kpi-label{color:#ffa500;font-size:12px;font-weight:600;letter-spacing:.3px}
.stats-kpi-value{color:#ffd700;font-weight:600;font-size:22px;line-height:1.1;word-break:break-word}
.stats-kpi-bar{margin-top:6px}
.stats-activity-totals{display:flex;gap:24px;flex-wrap:wrap;color:#ddd;font-size:13px;margin-top:8px}
.stats-activity-totals strong{color:#ffd700}
.stats-w-0{width:0%}.stats-w-5{width:5%}.stats-w-10{width:10%}.stats-w-15{width:15%}.stats-w-20{width:20%}.stats-w-25{width:25%}.stats-w-30{width:30%}.stats-w-35{width:35%}.stats-w-40{width:40%}.stats-w-45{width:45%}.stats-w-50{width:50%}.stats-w-55{width:55%}.stats-w-60{width:60%}.stats-w-65{width:65%}.stats-w-70{width:70%}.stats-w-75{width:75%}.stats-w-80{width:80%}.stats-w-85{width:85%}.stats-w-90{width:90%}.stats-w-95{width:95%}.stats-w-100{width:100%}
.sidebar-panel { /* === Mobile additions (QR share + mobile menu hide on desktop) === */
display: contents;
}
.sidebar-left { order: 1; }
.main-column { order: 2; }
.sidebar-right { order: 3; }
.panel-quicklinks { display: none; }
.qr-code svg {
max-width: 200px;
width: 100%;
height: auto;
display: block;
margin: 8px auto;
}
.qr-code-inline svg {
max-width: 140px;
margin: 4px 0;
}
.qr-code-profile svg {
max-width: 160px;
margin: 8px auto;
}
.user-id-qr {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
width: 100%;
margin-top: 8px;
}
.user-id-label {
font-size: 10px;
font-family: monospace;
word-break: break-all;
opacity: 0.6;
text-align: center;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.invite-code-text {
font-family: 'Courier New', Courier, monospace;
font-size: 13px;
word-break: break-all;
padding: 8px 12px;
border-radius: 6px;
letter-spacing: 0.5px;
user-select: all;
}
.invite-page {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 16px;
max-width: 480px;
margin: 0 auto;
}
.invite-log-block {
margin-top: 8px;
padding: 6px 8px;
border-left: 3px solid #27ae60;
background: rgba(39,174,96,0.06);
border-radius: 0 4px 4px 0;
}
.invite-status--pending { color: #e67e22; font-weight: bold; }
.invite-status--used { color: #27ae60; font-weight: bold; }
.invite-code-cell {
font-family: monospace;
font-size: 11px;
}
.invite-key {
font-size: 11px;
word-break: break-all;
}
.trending-container {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 20px;
}
.qr-share-details { .qr-share-details {
margin: 8px 0; margin: 8px 0;
width: 100%; width: 100%;

View file

@ -394,6 +394,17 @@ a.user-link:focus {
background: #ccc !important; background: #ccc !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; }
.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 */ /* Blockexplorer */
.blockchain-view { background-color: #F4F4F4 !important; color: #2C2C2C !important; } .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 { background: #FFFFFF !important; box-shadow: 0 2px 12px rgba(0,0,0,0.06) !important; }

View file

@ -312,6 +312,12 @@ a.user-link:focus {
} }
/* Blockexplorer */ /* 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; } .blockchain-view { background-color: #191b20 !important; color: #FFD700 !important; }
.block { background: #23242a !important; } .block { background: #23242a !important; }
.block:hover { box-shadow: 0 8px 32px rgba(35,40,50,0.18) !important; } .block:hover { box-shadow: 0 8px 32px rgba(35,40,50,0.18) !important; }

View file

@ -405,6 +405,13 @@ a.user-link:focus {
color: #00FF00 !important; 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 */ /* Blockexplorer */
.blockchain-view { background-color: #000000 !important; color: #00FF00 !important; } .blockchain-view { background-color: #000000 !important; color: #00FF00 !important; }
.block { background: #1A1A1A !important; border: 1px solid #00FF00 !important; } .block { background: #1A1A1A !important; border: 1px solid #00FF00 !important; }

View file

@ -440,6 +440,12 @@ a.user-link:focus {
color: #9B1C96 !important; 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 */ /* Blockexplorer */
.blockchain-view { background-color: #2D0B47 !important; color: #FFEEDB !important; } .blockchain-view { background-color: #2D0B47 !important; color: #FFEEDB !important; }
.block { background: #3C1360 !important; border: 1px solid #B86ADE !important; } .block { background: #3C1360 !important; border: 1px solid #B86ADE !important; }

View file

@ -38,6 +38,11 @@ module.exports = {
], ],
profile: "الصورة الرمزية", profile: "الصورة الرمزية",
inhabitants: "السكان", inhabitants: "السكان",
peersReplicatedFeeds: "الموجزات المنسوخة",
graphos: "غرافوس",
graphosDescription: "خريطة تفاعلية للشبكة من حولك.",
graphosYou: "أنت",
graphosTotalNodes: "إجمالي العقد",
manualMode: "الوضع اليدوي", manualMode: "الوضع اليدوي",
mentions: "الإشارات", mentions: "الإشارات",
mentionsDescription: [ mentionsDescription: [
@ -2148,6 +2153,7 @@ module.exports = {
bankAddressInvalid: 'عنوان غير صالح', bankAddressInvalid: 'عنوان غير صالح',
bankAddressDeleted: 'تم حذف العنوان', bankAddressDeleted: 'تم حذف العنوان',
bankAddressNotFound: 'لم يتم العثور على العنوان', bankAddressNotFound: 'لم يتم العثور على العنوان',
bankAddressForbidden: 'يمكنك فقط تعيين عنوان الدفع الخاص بك',
bankAddressTotal: 'إجمالي العناوين', bankAddressTotal: 'إجمالي العناوين',
bankAddressSearch: 'ابحث عن @ساكن أو عنوان', bankAddressSearch: 'ابحث عن @ساكن أو عنوان',
bankAddressActions: 'الإجراءات', bankAddressActions: 'الإجراءات',
@ -2753,6 +2759,11 @@ module.exports = {
fileTooLargeTitle: "الملف كبير جدًا", fileTooLargeTitle: "الملف كبير جدًا",
fileTooLargeMessage: "يتجاوز الملف الحجم الأقصى المسموح به (50 ميغابايت). يرجى اختيار ملف أصغر.", fileTooLargeMessage: "يتجاوز الملف الحجم الأقصى المسموح به (50 ميغابايت). يرجى اختيار ملف أصغر.",
goBack: "رجوع", goBack: "رجوع",
errorPageTitle: "حدث خطأ",
aiNavPlaceholder: "إلى أين تريد الذهاب؟",
aiNavDisabled: "تعطيل التنقل بالذكاء الاصطناعي.",
modulesAINavLabel: "AINav",
modulesAINavDescription: "وحدة لإجراء استعلامات بلغة طبيعية حول محتوى الشبكة.",
directConnect: "اتصال مباشر", directConnect: "اتصال مباشر",
directConnectDescription: "اتصل مباشرة بقرين عن طريق إدخال عنوان IP والمنفذ والمفتاح العام. سيتم إضافة القرين كاتصال مُتابَع.", directConnectDescription: "اتصل مباشرة بقرين عن طريق إدخال عنوان IP والمنفذ والمفتاح العام. سيتم إضافة القرين كاتصال مُتابَع.",
peerHost: "IP / اسم المضيف", peerHost: "IP / اسم المضيف",
@ -2948,6 +2959,8 @@ module.exports = {
gamesBackToGames: "العودة إلى الألعاب", gamesBackToGames: "العودة إلى الألعاب",
modulesGamesLabel: "الألعاب", modulesGamesLabel: "الألعاب",
modulesGamesDescription: "وحدة لاكتشاف وتشغيل بعض الألعاب.", modulesGamesDescription: "وحدة لاكتشاف وتشغيل بعض الألعاب.",
modulesGraphosLabel: "غرافوس",
modulesGraphosDescription: "وحدة لاستكشاف الشبكة كخريطة تفاعلية للعقد.",
gamesCocolandTitle: "Cocoland", gamesCocolandTitle: "Cocoland",
gamesCocolandDesc: "جوزة هند بعيون تقفز فوق أشجار النخيل وتجمع ECOins.", gamesCocolandDesc: "جوزة هند بعيون تقفز فوق أشجار النخيل وتجمع ECOins.",
gamesTheFlowTitle: "ECOinflow", gamesTheFlowTitle: "ECOinflow",
@ -3017,6 +3030,9 @@ module.exports = {
calendarCreated: "تم الإنشاء", calendarCreated: "تم الإنشاء",
calendarAuthor: "المؤلف", calendarAuthor: "المؤلف",
calendarJoin: "انضمام", calendarJoin: "انضمام",
calendarGenerateInvite: "إنشاء دعوة",
calendarInviteCodePlaceholder: "أدخل رمز الدعوة...",
calendarValidateInvite: "التحقق من الرمز",
calendarJoined: "منضم", calendarJoined: "منضم",
calendarAddDate: "إضافة تاريخ", calendarAddDate: "إضافة تاريخ",
calendarAddNote: "إضافة ملاحظة", calendarAddNote: "إضافة ملاحظة",

View file

@ -37,7 +37,12 @@ module.exports = {
" von Bewohnern, die du unterstützt (inkl. Multiversum), nach Aktualität sortiert.", " von Bewohnern, die du unterstützt (inkl. Multiversum), nach Aktualität sortiert.",
], ],
profile: "Avatar", profile: "Avatar",
inhabitants: "Bewohner", inhabitants: "Bewohner",
peersReplicatedFeeds: "Replizierte Feeds",
graphos: "Graphos",
graphosDescription: "Interaktive Karte des Netzwerks um dich herum.",
graphosYou: "Du",
graphosTotalNodes: "Knoten insgesamt",
manualMode: "Manueller Modus", manualMode: "Manueller Modus",
mentions: "Erwähnungen", mentions: "Erwähnungen",
mentionsDescription: [ mentionsDescription: [
@ -2147,6 +2152,7 @@ module.exports = {
bankAddressInvalid: 'Ungültige Adresse', bankAddressInvalid: 'Ungültige Adresse',
bankAddressDeleted: 'Adresse gelöscht', bankAddressDeleted: 'Adresse gelöscht',
bankAddressNotFound: 'Adresse nicht gefunden', bankAddressNotFound: 'Adresse nicht gefunden',
bankAddressForbidden: 'Du kannst nur deine eigene Auszahlungsadresse festlegen',
bankAddressTotal: 'Adressen gesamt', bankAddressTotal: 'Adressen gesamt',
bankAddressSearch: 'Nach @Bewohner oder Adresse suchen', bankAddressSearch: 'Nach @Bewohner oder Adresse suchen',
bankAddressActions: 'Aktionen', bankAddressActions: 'Aktionen',
@ -2752,6 +2758,11 @@ module.exports = {
fileTooLargeTitle: "Datei zu groß", fileTooLargeTitle: "Datei zu groß",
fileTooLargeMessage: "Die Datei überschreitet die maximal erlaubte Größe (50 MB). Bitte wähle eine kleinere Datei.", fileTooLargeMessage: "Die Datei überschreitet die maximal erlaubte Größe (50 MB). Bitte wähle eine kleinere Datei.",
goBack: "Zurück", goBack: "Zurück",
errorPageTitle: "Etwas ist schiefgelaufen",
aiNavPlaceholder: "Wohin möchtest du gehen?",
aiNavDisabled: "KI-Navigation ist deaktiviert.",
modulesAINavLabel: "AINav",
modulesAINavDescription: "Modul für natürlichsprachliche Anfragen zum Inhalt des Netzwerks.",
directConnect: "Direkte Verbindung", directConnect: "Direkte Verbindung",
directConnectDescription: "Verbinde dich direkt mit einem Peer, indem du IP-Adresse, Port und öffentlichen Schlüssel eingibst.", directConnectDescription: "Verbinde dich direkt mit einem Peer, indem du IP-Adresse, Port und öffentlichen Schlüssel eingibst.",
peerHost: "IP / Hostname", peerHost: "IP / Hostname",
@ -2891,6 +2902,8 @@ module.exports = {
gamesBackToGames: "Zurück zu Spielen", gamesBackToGames: "Zurück zu Spielen",
modulesGamesLabel: "Spiele", modulesGamesLabel: "Spiele",
modulesGamesDescription: "Modul zum Entdecken und Spielen von Spielen.", modulesGamesDescription: "Modul zum Entdecken und Spielen von Spielen.",
modulesGraphosLabel: "Graphos",
modulesGraphosDescription: "Modul, um das Netzwerk als interaktive Karte der Knoten zu erkunden.",
gamesCocolandTitle: "Cocoland", gamesCocolandTitle: "Cocoland",
gamesCocolandDesc: "Eine Kokosnuss mit Augen, die über Palmen springt und ECOins sammelt.", gamesCocolandDesc: "Eine Kokosnuss mit Augen, die über Palmen springt und ECOins sammelt.",
gamesTheFlowTitle: "ECOinflow", gamesTheFlowTitle: "ECOinflow",
@ -3013,6 +3026,9 @@ module.exports = {
calendarCreated: "Erstellt", calendarCreated: "Erstellt",
calendarAuthor: "Autor", calendarAuthor: "Autor",
calendarJoin: "Beitreten", calendarJoin: "Beitreten",
calendarGenerateInvite: "Einladung erstellen",
calendarInviteCodePlaceholder: "Einladungscode eingeben...",
calendarValidateInvite: "Code prüfen",
calendarJoined: "Beigetreten", calendarJoined: "Beigetreten",
calendarAddDate: "Datum hinzufügen", calendarAddDate: "Datum hinzufügen",
calendarAddNote: "Notiz hinzufügen", calendarAddNote: "Notiz hinzufügen",

View file

@ -37,7 +37,12 @@ module.exports = {
" from inhabitants you support (included from multiverse), sorted by recency.", " from inhabitants you support (included from multiverse), sorted by recency.",
], ],
profile: "Avatar", profile: "Avatar",
inhabitants: "Inhabitants", inhabitants: "Inhabitants",
peersReplicatedFeeds: "Replicated feeds",
graphos: "Graphos",
graphosDescription: "Interactive map of the network around you.",
graphosYou: "You",
graphosTotalNodes: "Total nodes",
manualMode: "Manual Mode", manualMode: "Manual Mode",
mentions: "Mentions", mentions: "Mentions",
mentionsDescription: [ mentionsDescription: [
@ -2153,6 +2158,7 @@ module.exports = {
bankAddressInvalid: 'Invalid address', bankAddressInvalid: 'Invalid address',
bankAddressDeleted: 'Address deleted', bankAddressDeleted: 'Address deleted',
bankAddressNotFound: 'Address not found', bankAddressNotFound: 'Address not found',
bankAddressForbidden: 'You can only set your own payout address',
bankAddressTotal: 'Total Addresses', bankAddressTotal: 'Total Addresses',
bankAddressSearch: 'Search @inhabitant or address', bankAddressSearch: 'Search @inhabitant or address',
bankAddressActions: 'Actions', bankAddressActions: 'Actions',
@ -2758,6 +2764,11 @@ module.exports = {
fileTooLargeTitle: "File too large", fileTooLargeTitle: "File too large",
fileTooLargeMessage: "The file exceeds the maximum allowed size (50 MB). Please select a smaller file.", fileTooLargeMessage: "The file exceeds the maximum allowed size (50 MB). Please select a smaller file.",
goBack: "Go back", goBack: "Go back",
errorPageTitle: "Something went wrong",
aiNavPlaceholder: "Where do you want to go?",
aiNavDisabled: "AI navigation is disabled.",
modulesAINavLabel: "AINav",
modulesAINavDescription: "Module for natural-language queries about the network's content.",
directConnect: "Direct Connect", directConnect: "Direct Connect",
directConnectDescription: "Connect directly to a peer by entering their IP address, port and public key. The peer will be added as a followed connection.", directConnectDescription: "Connect directly to a peer by entering their IP address, port and public key. The peer will be added as a followed connection.",
peerHost: "IP / Hostname", peerHost: "IP / Hostname",
@ -2921,6 +2932,9 @@ module.exports = {
calendarCreated: "Created", calendarCreated: "Created",
calendarAuthor: "Author", calendarAuthor: "Author",
calendarJoin: "Join Calendar", calendarJoin: "Join Calendar",
calendarGenerateInvite: "Generate invite",
calendarInviteCodePlaceholder: "Enter invite code...",
calendarValidateInvite: "Validate code",
calendarJoined: "Joined", calendarJoined: "Joined",
calendarAddDate: "Add Date", calendarAddDate: "Add Date",
calendarAddNote: "Add Note", calendarAddNote: "Add Note",
@ -3024,6 +3038,8 @@ module.exports = {
gamesBackToGames: "Back to Games", gamesBackToGames: "Back to Games",
modulesGamesLabel: "Games", modulesGamesLabel: "Games",
modulesGamesDescription: "Module to discover and play some games.", modulesGamesDescription: "Module to discover and play some games.",
modulesGraphosLabel: "Graphos",
modulesGraphosDescription: "Module to explore the network as an interactive map of peers.",
gamesCocolandTitle: "Cocoland", gamesCocolandTitle: "Cocoland",
gamesCocolandDesc: "A coconut with eyes jumping over palm trees and collecting ECOins. How far can you go?", gamesCocolandDesc: "A coconut with eyes jumping over palm trees and collecting ECOins. How far can you go?",
gamesTheFlowTitle: "ECOinflow", gamesTheFlowTitle: "ECOinflow",

View file

@ -38,6 +38,11 @@ module.exports = {
], ],
profile: "Avatar", profile: "Avatar",
inhabitants: "Habitantes", inhabitants: "Habitantes",
peersReplicatedFeeds: "Feeds replicados",
graphos: "Graphos",
graphosDescription: "Mapa interactivo de la red alrededor de ti.",
graphosYou: "Tú",
graphosTotalNodes: "Nodos totales",
manualMode: "Modo Manual", manualMode: "Modo Manual",
mentions: "Menciones", mentions: "Menciones",
mentionsDescription: [ mentionsDescription: [
@ -2151,6 +2156,7 @@ module.exports = {
bankAddressInvalid: 'Dirección no válida', bankAddressInvalid: 'Dirección no válida',
bankAddressDeleted: 'Dirección eliminada', bankAddressDeleted: 'Dirección eliminada',
bankAddressNotFound: 'Dirección no encontrada', bankAddressNotFound: 'Dirección no encontrada',
bankAddressForbidden: 'Solo puedes configurar tu propia dirección de cobro',
bankAddressTotal: 'Total de Direcciones', bankAddressTotal: 'Total de Direcciones',
bankAddressSearch: 'Buscar @habitante o dirección', bankAddressSearch: 'Buscar @habitante o dirección',
bankAddressActions: 'Acciones', bankAddressActions: 'Acciones',
@ -2757,6 +2763,11 @@ module.exports = {
fileTooLargeTitle: "Archivo demasiado grande", fileTooLargeTitle: "Archivo demasiado grande",
fileTooLargeMessage: "El archivo supera el tamaño máximo permitido (50 MB). Por favor, selecciona un archivo más pequeño.", fileTooLargeMessage: "El archivo supera el tamaño máximo permitido (50 MB). Por favor, selecciona un archivo más pequeño.",
goBack: "Volver", goBack: "Volver",
errorPageTitle: "Ha ocurrido un error",
aiNavPlaceholder: "¿A dónde quieres ir?",
aiNavDisabled: "La navegación con IA está desactivada.",
modulesAINavLabel: "AINav",
modulesAINavDescription: "Módulo para realizar peticiones en lenguaje natural sobre el contenido de la red.",
directConnect: "Conexión Directa", directConnect: "Conexión Directa",
directConnectDescription: "Conéctate directamente a un nodo introduciendo su dirección IP, puerto y clave pública. El nodo se añadirá como conexión seguida.", directConnectDescription: "Conéctate directamente a un nodo introduciendo su dirección IP, puerto y clave pública. El nodo se añadirá como conexión seguida.",
peerHost: "IP / Nombre de host", peerHost: "IP / Nombre de host",
@ -2922,6 +2933,9 @@ module.exports = {
calendarCreated: "Creado", calendarCreated: "Creado",
calendarAuthor: "Autor", calendarAuthor: "Autor",
calendarJoin: "Unirse al Calendario", calendarJoin: "Unirse al Calendario",
calendarGenerateInvite: "Generar invitación",
calendarInviteCodePlaceholder: "Introduce código...",
calendarValidateInvite: "Validar código",
calendarJoined: "Unido", calendarJoined: "Unido",
calendarAddDate: "Añadir Fecha", calendarAddDate: "Añadir Fecha",
calendarAddNote: "Añadir Nota", calendarAddNote: "Añadir Nota",
@ -3025,6 +3039,8 @@ module.exports = {
gamesBackToGames: "Volver a Juegos", gamesBackToGames: "Volver a Juegos",
modulesGamesLabel: "Juegos", modulesGamesLabel: "Juegos",
modulesGamesDescription: "Módulo para descubrir y jugar algunos juegos.", modulesGamesDescription: "Módulo para descubrir y jugar algunos juegos.",
modulesGraphosLabel: "Graphos",
modulesGraphosDescription: "Módulo para explorar la red como un mapa interactivo de pares.",
gamesCocolandTitle: "Cocoland", gamesCocolandTitle: "Cocoland",
gamesCocolandDesc: "Un coco con ojos saltando palmeras y coleccionando ECOins. ¿Hasta dónde puedes llegar?", gamesCocolandDesc: "Un coco con ojos saltando palmeras y coleccionando ECOins. ¿Hasta dónde puedes llegar?",
gamesTheFlowTitle: "ECOinflow", gamesTheFlowTitle: "ECOinflow",

View file

@ -37,7 +37,12 @@ module.exports = {
" eta laguntzen dituzun bizilagunenak, gaurkotasunaren arabera antolatuta. Hautatu edozein bidalketaren ordu-marka hari osoa ikusteko.", " eta laguntzen dituzun bizilagunenak, gaurkotasunaren arabera antolatuta. Hautatu edozein bidalketaren ordu-marka hari osoa ikusteko.",
], ],
profile: "Abatarra", profile: "Abatarra",
inhabitants: "Bizilagunak", inhabitants: "Bizilagunak",
peersReplicatedFeeds: "Errepikatutako jarioak",
graphos: "Graphos",
graphosDescription: "Sarearen mapa interaktiboa zure inguruan.",
graphosYou: "Zu",
graphosTotalNodes: "Nodo guztiak",
manualMode: "Eskuzko modua", manualMode: "Eskuzko modua",
mentions: "Aipamenak", mentions: "Aipamenak",
mentionsDescription: [ mentionsDescription: [
@ -2118,6 +2123,7 @@ module.exports = {
bankAddressInvalid: 'Helbide baliogabea', bankAddressInvalid: 'Helbide baliogabea',
bankAddressDeleted: 'Helbidea ezabatuta', bankAddressDeleted: 'Helbidea ezabatuta',
bankAddressNotFound: 'Helbiderik ez da aurkitu', bankAddressNotFound: 'Helbiderik ez da aurkitu',
bankAddressForbidden: 'Zure ordainketa-helbidea soilik konfigura dezakezu',
bankAddressTotal: 'Guztira', bankAddressTotal: 'Guztira',
bankAddressSearch: 'Erabiltzailea edo helbidea bilatu', bankAddressSearch: 'Erabiltzailea edo helbidea bilatu',
bankAddressActions: 'Ekintzak', bankAddressActions: 'Ekintzak',
@ -2724,6 +2730,11 @@ module.exports = {
fileTooLargeTitle: "Fitxategia handiegia", fileTooLargeTitle: "Fitxategia handiegia",
fileTooLargeMessage: "Fitxategiak onartutako gehienezko tamaina gainditzen du (50 MB). Mesedez, hautatu fitxategi txikiago bat.", fileTooLargeMessage: "Fitxategiak onartutako gehienezko tamaina gainditzen du (50 MB). Mesedez, hautatu fitxategi txikiago bat.",
goBack: "Itzuli", goBack: "Itzuli",
errorPageTitle: "Zerbait gaizki joan da",
aiNavPlaceholder: "Nora joan nahi duzu?",
aiNavDisabled: "AI nabigazioa desgaituta dago.",
modulesAINavLabel: "AINav",
modulesAINavDescription: "Sareko edukiari buruzko hizkuntza naturalezko galderak egiteko modulua.",
directConnect: "Zuzeneko Konexioa", directConnect: "Zuzeneko Konexioa",
directConnectDescription: "Konektatu zuzenean parekide batera bere IP helbidea, portua eta gako publikoa sartuz. Parekidea jarraipen-konexio gisa gehituko da.", directConnectDescription: "Konektatu zuzenean parekide batera bere IP helbidea, portua eta gako publikoa sartuz. Parekidea jarraipen-konexio gisa gehituko da.",
peerHost: "IP / Ostalari-izena", peerHost: "IP / Ostalari-izena",
@ -2918,6 +2929,8 @@ module.exports = {
gamesBackToGames: "Jokoetara itzuli", gamesBackToGames: "Jokoetara itzuli",
modulesGamesLabel: "Jokoak", modulesGamesLabel: "Jokoak",
modulesGamesDescription: "Jokoak aurkitzeko eta jolasteko modulua.", modulesGamesDescription: "Jokoak aurkitzeko eta jolasteko modulua.",
modulesGraphosLabel: "Graphos",
modulesGraphosDescription: "Sarea nodoen mapa interaktibo gisa esploratzeko modulua.",
gamesCocolandTitle: "Cocoland", gamesCocolandTitle: "Cocoland",
gamesCocolandDesc: "Begiak dituen koko bat palmondoen gainetik jauzika ECOins biltzen.", gamesCocolandDesc: "Begiak dituen koko bat palmondoen gainetik jauzika ECOins biltzen.",
gamesTheFlowTitle: "ECOinflow", gamesTheFlowTitle: "ECOinflow",
@ -2987,6 +3000,9 @@ module.exports = {
calendarCreated: "Sortua", calendarCreated: "Sortua",
calendarAuthor: "Egilea", calendarAuthor: "Egilea",
calendarJoin: "Batu", calendarJoin: "Batu",
calendarGenerateInvite: "Sortu gonbidapena",
calendarInviteCodePlaceholder: "Sartu gonbidapen-kodea...",
calendarValidateInvite: "Egiaztatu kodea",
calendarJoined: "Batuta", calendarJoined: "Batuta",
calendarAddDate: "Data gehitu", calendarAddDate: "Data gehitu",
calendarAddNote: "Oharra gehitu", calendarAddNote: "Oharra gehitu",

View file

@ -38,6 +38,11 @@ module.exports = {
], ],
profile: "Avatar", profile: "Avatar",
inhabitants: "Habitants", inhabitants: "Habitants",
peersReplicatedFeeds: "Flux répliqués",
graphos: "Graphos",
graphosDescription: "Carte interactive du réseau autour de toi.",
graphosYou: "Toi",
graphosTotalNodes: "Nœuds totaux",
manualMode: "Mode Manuel", manualMode: "Mode Manuel",
mentions: "Mentions", mentions: "Mentions",
mentionsDescription: [ mentionsDescription: [
@ -2143,6 +2148,7 @@ module.exports = {
bankAddressInvalid: 'Adresse non valide', bankAddressInvalid: 'Adresse non valide',
bankAddressDeleted: 'Adresse supprimée', bankAddressDeleted: 'Adresse supprimée',
bankAddressNotFound: 'Adresse introuvable', bankAddressNotFound: 'Adresse introuvable',
bankAddressForbidden: 'Vous ne pouvez configurer que votre propre adresse de paiement',
bankAddressTotal: 'Total des adresses', bankAddressTotal: 'Total des adresses',
bankAddressSearch: 'Rechercher @habitant ou adresse', bankAddressSearch: 'Rechercher @habitant ou adresse',
bankAddressActions: 'Actions', bankAddressActions: 'Actions',
@ -2749,6 +2755,11 @@ module.exports = {
fileTooLargeTitle: "Fichier trop volumineux", fileTooLargeTitle: "Fichier trop volumineux",
fileTooLargeMessage: "Le fichier dépasse la taille maximale autorisée (50 Mo). Veuillez sélectionner un fichier plus petit.", fileTooLargeMessage: "Le fichier dépasse la taille maximale autorisée (50 Mo). Veuillez sélectionner un fichier plus petit.",
goBack: "Retour", goBack: "Retour",
errorPageTitle: "Une erreur est survenue",
aiNavPlaceholder: "Où veux-tu aller ?",
aiNavDisabled: "La navigation par IA est désactivée.",
modulesAINavLabel: "AINav",
modulesAINavDescription: "Module pour des requêtes en langage naturel sur le contenu du réseau.",
directConnect: "Connexion Directe", directConnect: "Connexion Directe",
directConnectDescription: "Connectez-vous directement à un pair en saisissant son adresse IP, port et clé publique. Le pair sera ajouté comme connexion suivie.", directConnectDescription: "Connectez-vous directement à un pair en saisissant son adresse IP, port et clé publique. Le pair sera ajouté comme connexion suivie.",
peerHost: "IP / Nom d'hôte", peerHost: "IP / Nom d'hôte",
@ -2946,6 +2957,8 @@ module.exports = {
gamesBackToGames: "Retour aux Jeux", gamesBackToGames: "Retour aux Jeux",
modulesGamesLabel: "Jeux", modulesGamesLabel: "Jeux",
modulesGamesDescription: "Module pour découvrir et jouer à des jeux.", modulesGamesDescription: "Module pour découvrir et jouer à des jeux.",
modulesGraphosLabel: "Graphos",
modulesGraphosDescription: "Module pour explorer le réseau comme une carte interactive des nœuds.",
gamesCocolandTitle: "Cocoland", gamesCocolandTitle: "Cocoland",
gamesCocolandDesc: "Une noix de coco avec des yeux qui saute par-dessus des palmiers en collectant des ECOins.", gamesCocolandDesc: "Une noix de coco avec des yeux qui saute par-dessus des palmiers en collectant des ECOins.",
gamesTheFlowTitle: "ECOinflow", gamesTheFlowTitle: "ECOinflow",
@ -3015,6 +3028,9 @@ module.exports = {
calendarCreated: "Créé", calendarCreated: "Créé",
calendarAuthor: "Auteur", calendarAuthor: "Auteur",
calendarJoin: "Rejoindre", calendarJoin: "Rejoindre",
calendarGenerateInvite: "Générer une invitation",
calendarInviteCodePlaceholder: "Entrez le code d'invitation...",
calendarValidateInvite: "Valider le code",
calendarJoined: "Rejoint", calendarJoined: "Rejoint",
calendarAddDate: "Ajouter une date", calendarAddDate: "Ajouter une date",
calendarAddNote: "Ajouter une note", calendarAddNote: "Ajouter une note",

View file

@ -38,6 +38,11 @@ module.exports = {
], ],
profile: "अवतार", profile: "अवतार",
inhabitants: "निवासी", inhabitants: "निवासी",
peersReplicatedFeeds: "प्रतिकृत फ़ीड",
graphos: "ग्राफोस",
graphosDescription: "आपके आसपास के नेटवर्क का इंटरैक्टिव मानचित्र।",
graphosYou: "आप",
graphosTotalNodes: "कुल नोड्स",
manualMode: "मैनुअल मोड", manualMode: "मैनुअल मोड",
mentions: "उल्लेख", mentions: "उल्लेख",
mentionsDescription: [ mentionsDescription: [
@ -2148,6 +2153,7 @@ module.exports = {
bankAddressInvalid: 'अमान्य पता', bankAddressInvalid: 'अमान्य पता',
bankAddressDeleted: 'पता हटाया गया', bankAddressDeleted: 'पता हटाया गया',
bankAddressNotFound: 'पता नहीं मिला', bankAddressNotFound: 'पता नहीं मिला',
bankAddressForbidden: 'आप केवल अपना भुगतान पता सेट कर सकते हैं',
bankAddressTotal: 'कुल पते', bankAddressTotal: 'कुल पते',
bankAddressSearch: '@निवासी या पता खोजें', bankAddressSearch: '@निवासी या पता खोजें',
bankAddressActions: 'कार्रवाई', bankAddressActions: 'कार्रवाई',
@ -2753,6 +2759,11 @@ module.exports = {
fileTooLargeTitle: "फ़ाइल बहुत बड़ी", fileTooLargeTitle: "फ़ाइल बहुत बड़ी",
fileTooLargeMessage: "फ़ाइल अधिकतम अनुमत आकार (50 MB) से अधिक है। कृपया एक छोटी फ़ाइल चुनें।", fileTooLargeMessage: "फ़ाइल अधिकतम अनुमत आकार (50 MB) से अधिक है। कृपया एक छोटी फ़ाइल चुनें।",
goBack: "वापस जाएँ", goBack: "वापस जाएँ",
errorPageTitle: "कुछ गलत हो गया",
aiNavPlaceholder: "आप कहाँ जाना चाहते हैं?",
aiNavDisabled: "AI नेविगेशन अक्षम है।",
modulesAINavLabel: "AINav",
modulesAINavDescription: "नेटवर्क की सामग्री पर प्राकृतिक भाषा में प्रश्न करने का मॉड्यूल।",
directConnect: "सीधा कनेक्शन", directConnect: "सीधा कनेक्शन",
directConnectDescription: "किसी पीयर से सीधे कनेक्ट करने के लिए उनका IP पता, पोर्ट और सार्वजनिक कुंजी दर्ज करें। पीयर को एक अनुसरित कनेक्शन के रूप में जोड़ा जाएगा।", directConnectDescription: "किसी पीयर से सीधे कनेक्ट करने के लिए उनका IP पता, पोर्ट और सार्वजनिक कुंजी दर्ज करें। पीयर को एक अनुसरित कनेक्शन के रूप में जोड़ा जाएगा।",
peerHost: "IP / होस्टनाम", peerHost: "IP / होस्टनाम",
@ -2948,6 +2959,8 @@ module.exports = {
gamesBackToGames: "खेलों पर वापस जाएं", gamesBackToGames: "खेलों पर वापस जाएं",
modulesGamesLabel: "खेल", modulesGamesLabel: "खेल",
modulesGamesDescription: "कुछ खेल खोजने और खेलने का मॉड्यूल।", modulesGamesDescription: "कुछ खेल खोजने और खेलने का मॉड्यूल।",
modulesGraphosLabel: "ग्राफोस",
modulesGraphosDescription: "नोड्स के इंटरैक्टिव मानचित्र के रूप में नेटवर्क का अन्वेषण करने का मॉड्यूल।",
gamesCocolandTitle: "Cocoland", gamesCocolandTitle: "Cocoland",
gamesCocolandDesc: "आंखों वाला एक नारियल ताड़ के पेड़ों के ऊपर कूदता है और ECOins इकट्ठा करता है।", gamesCocolandDesc: "आंखों वाला एक नारियल ताड़ के पेड़ों के ऊपर कूदता है और ECOins इकट्ठा करता है।",
gamesTheFlowTitle: "ECOinflow", gamesTheFlowTitle: "ECOinflow",
@ -3017,6 +3030,9 @@ module.exports = {
calendarCreated: "बनाया गया", calendarCreated: "बनाया गया",
calendarAuthor: "लेखक", calendarAuthor: "लेखक",
calendarJoin: "जुड़ें", calendarJoin: "जुड़ें",
calendarGenerateInvite: "आमंत्रण बनाएं",
calendarInviteCodePlaceholder: "आमंत्रण कोड दर्ज करें...",
calendarValidateInvite: "कोड सत्यापित करें",
calendarJoined: "जुड़े हुए", calendarJoined: "जुड़े हुए",
calendarAddDate: "तारीख जोड़ें", calendarAddDate: "तारीख जोड़ें",
calendarAddNote: "नोट जोड़ें", calendarAddNote: "नोट जोड़ें",

View file

@ -37,7 +37,12 @@ module.exports = {
" dagli abitanti che supporti (incluso dal multiverso), ordinati per data.", " dagli abitanti che supporti (incluso dal multiverso), ordinati per data.",
], ],
profile: "Avatar", profile: "Avatar",
inhabitants: "Abitanti", inhabitants: "Abitanti",
peersReplicatedFeeds: "Feed replicati",
graphos: "Graphos",
graphosDescription: "Mappa interattiva della rete attorno a te.",
graphosYou: "Tu",
graphosTotalNodes: "Nodi totali",
manualMode: "Modalità manuale", manualMode: "Modalità manuale",
mentions: "Menzioni", mentions: "Menzioni",
mentionsDescription: [ mentionsDescription: [
@ -2148,6 +2153,7 @@ module.exports = {
bankAddressInvalid: 'Indirizzo non valido', bankAddressInvalid: 'Indirizzo non valido',
bankAddressDeleted: 'Indirizzo eliminato', bankAddressDeleted: 'Indirizzo eliminato',
bankAddressNotFound: 'Indirizzo non trovato', bankAddressNotFound: 'Indirizzo non trovato',
bankAddressForbidden: 'Puoi impostare solo il tuo indirizzo di pagamento',
bankAddressTotal: 'Indirizzi totali', bankAddressTotal: 'Indirizzi totali',
bankAddressSearch: 'Cerca @abitante o indirizzo', bankAddressSearch: 'Cerca @abitante o indirizzo',
bankAddressActions: 'Azioni', bankAddressActions: 'Azioni',
@ -2753,6 +2759,11 @@ module.exports = {
fileTooLargeTitle: "File troppo grande", fileTooLargeTitle: "File troppo grande",
fileTooLargeMessage: "Il file supera la dimensione massima consentita (50 MB). Seleziona un file più piccolo.", fileTooLargeMessage: "Il file supera la dimensione massima consentita (50 MB). Seleziona un file più piccolo.",
goBack: "Torna indietro", goBack: "Torna indietro",
errorPageTitle: "Si è verificato un errore",
aiNavPlaceholder: "Dove vuoi andare?",
aiNavDisabled: "La navigazione con IA è disattivata.",
modulesAINavLabel: "AINav",
modulesAINavDescription: "Modulo per query in linguaggio naturale sui contenuti della rete.",
directConnect: "Connessione diretta", directConnect: "Connessione diretta",
directConnectDescription: "Connettiti direttamente a un peer inserendo indirizzo IP, porta e chiave pubblica.", directConnectDescription: "Connettiti direttamente a un peer inserendo indirizzo IP, porta e chiave pubblica.",
peerHost: "IP / Nome host", peerHost: "IP / Nome host",
@ -2949,6 +2960,8 @@ module.exports = {
gamesBackToGames: "Torna ai Giochi", gamesBackToGames: "Torna ai Giochi",
modulesGamesLabel: "Giochi", modulesGamesLabel: "Giochi",
modulesGamesDescription: "Modulo per scoprire e giocare ad alcuni giochi.", modulesGamesDescription: "Modulo per scoprire e giocare ad alcuni giochi.",
modulesGraphosLabel: "Graphos",
modulesGraphosDescription: "Modulo per esplorare la rete come una mappa interattiva dei nodi.",
gamesCocolandTitle: "Cocoland", gamesCocolandTitle: "Cocoland",
gamesCocolandDesc: "Una noce di cocco con occhi che salta palme e raccoglie ECOins.", gamesCocolandDesc: "Una noce di cocco con occhi che salta palme e raccoglie ECOins.",
gamesTheFlowTitle: "ECOinflow", gamesTheFlowTitle: "ECOinflow",
@ -3018,6 +3031,9 @@ module.exports = {
calendarCreated: "Creato", calendarCreated: "Creato",
calendarAuthor: "Autore", calendarAuthor: "Autore",
calendarJoin: "Partecipa", calendarJoin: "Partecipa",
calendarGenerateInvite: "Genera invito",
calendarInviteCodePlaceholder: "Inserisci il codice di invito...",
calendarValidateInvite: "Convalida codice",
calendarJoined: "Iscritto", calendarJoined: "Iscritto",
calendarAddDate: "Aggiungi data", calendarAddDate: "Aggiungi data",
calendarAddNote: "Aggiungi nota", calendarAddNote: "Aggiungi nota",

View file

@ -37,7 +37,12 @@ module.exports = {
" dos habitantes que apoias (incluindo do multiverso), ordenadas por data.", " dos habitantes que apoias (incluindo do multiverso), ordenadas por data.",
], ],
profile: "Avatar", profile: "Avatar",
inhabitants: "Habitantes", inhabitants: "Habitantes",
peersReplicatedFeeds: "Feeds replicados",
graphos: "Graphos",
graphosDescription: "Mapa interativo da rede ao seu redor.",
graphosYou: "Você",
graphosTotalNodes: "Total de nós",
manualMode: "Modo manual", manualMode: "Modo manual",
mentions: "Menções", mentions: "Menções",
mentionsDescription: [ mentionsDescription: [
@ -2148,6 +2153,7 @@ module.exports = {
bankAddressInvalid: 'Invalid address', bankAddressInvalid: 'Invalid address',
bankAddressDeleted: 'Address deleted', bankAddressDeleted: 'Address deleted',
bankAddressNotFound: 'Address not found', bankAddressNotFound: 'Address not found',
bankAddressForbidden: 'Só podes configurar o teu próprio endereço de cobrança',
bankAddressTotal: 'Total Addresses', bankAddressTotal: 'Total Addresses',
bankAddressSearch: 'Search @inhabitant or address', bankAddressSearch: 'Search @inhabitant or address',
bankAddressActions: 'Actions', bankAddressActions: 'Actions',
@ -2753,6 +2759,11 @@ module.exports = {
fileTooLargeTitle: "Ficheiro demasiado grande", fileTooLargeTitle: "Ficheiro demasiado grande",
fileTooLargeMessage: "O ficheiro excede o tamanho máximo permitido (50 MB). Por favor, seleciona um ficheiro mais pequeno.", fileTooLargeMessage: "O ficheiro excede o tamanho máximo permitido (50 MB). Por favor, seleciona um ficheiro mais pequeno.",
goBack: "Voltar", goBack: "Voltar",
errorPageTitle: "Algo correu mal",
aiNavPlaceholder: "Para onde queres ir?",
aiNavDisabled: "A navegação por IA está desativada.",
modulesAINavLabel: "AINav",
modulesAINavDescription: "Módulo para consultas em linguagem natural sobre o conteúdo da rede.",
directConnect: "Ligação direta", directConnect: "Ligação direta",
directConnectDescription: "Conecta-te diretamente a um par inserindo o endereço IP, porta e chave pública.", directConnectDescription: "Conecta-te diretamente a um par inserindo o endereço IP, porta e chave pública.",
peerHost: "IP / Nome do anfitrião", peerHost: "IP / Nome do anfitrião",
@ -2949,6 +2960,8 @@ module.exports = {
gamesBackToGames: "Voltar aos Jogos", gamesBackToGames: "Voltar aos Jogos",
modulesGamesLabel: "Jogos", modulesGamesLabel: "Jogos",
modulesGamesDescription: "Módulo para descobrir e jogar alguns jogos.", modulesGamesDescription: "Módulo para descobrir e jogar alguns jogos.",
modulesGraphosLabel: "Graphos",
modulesGraphosDescription: "Módulo para explorar a rede como um mapa interativo de nós.",
gamesCocolandTitle: "Cocoland", gamesCocolandTitle: "Cocoland",
gamesCocolandDesc: "Um coco com olhos a saltar palmeiras e a colecionar ECOins.", gamesCocolandDesc: "Um coco com olhos a saltar palmeiras e a colecionar ECOins.",
gamesTheFlowTitle: "ECOinflow", gamesTheFlowTitle: "ECOinflow",
@ -3018,6 +3031,9 @@ module.exports = {
calendarCreated: "Criado", calendarCreated: "Criado",
calendarAuthor: "Autor", calendarAuthor: "Autor",
calendarJoin: "Participar", calendarJoin: "Participar",
calendarGenerateInvite: "Gerar convite",
calendarInviteCodePlaceholder: "Digite o código de convite...",
calendarValidateInvite: "Validar código",
calendarJoined: "Inscrito", calendarJoined: "Inscrito",
calendarAddDate: "Adicionar data", calendarAddDate: "Adicionar data",
calendarAddNote: "Adicionar nota", calendarAddNote: "Adicionar nota",

View file

@ -38,6 +38,11 @@ module.exports = {
], ],
profile: "Аватар", profile: "Аватар",
inhabitants: "Жители", inhabitants: "Жители",
peersReplicatedFeeds: "Реплицированные ленты",
graphos: "Графос",
graphosDescription: "Интерактивная карта сети вокруг вас.",
graphosYou: "Вы",
graphosTotalNodes: "Всего узлов",
manualMode: "Ручной режим", manualMode: "Ручной режим",
mentions: "Упоминания", mentions: "Упоминания",
mentionsDescription: [ mentionsDescription: [
@ -2113,6 +2118,7 @@ module.exports = {
bankAddressInvalid: "Недействительный адрес", bankAddressInvalid: "Недействительный адрес",
bankAddressDeleted: "Адрес удалён", bankAddressDeleted: "Адрес удалён",
bankAddressNotFound: "Адрес не найден", bankAddressNotFound: "Адрес не найден",
bankAddressForbidden: "Вы можете задать только свой собственный адрес для выплат",
bankAddressTotal: "Всего адресов", bankAddressTotal: "Всего адресов",
bankAddressSearch: "Поиск @жителя или адреса", bankAddressSearch: "Поиск @жителя или адреса",
bankAddressActions: "Действия", bankAddressActions: "Действия",
@ -2707,6 +2713,11 @@ module.exports = {
fileTooLargeTitle: "Файл слишком большой", fileTooLargeTitle: "Файл слишком большой",
fileTooLargeMessage: "Файл превышает максимально допустимый размер (50 МБ). Пожалуйста, выберите файл меньшего размера.", fileTooLargeMessage: "Файл превышает максимально допустимый размер (50 МБ). Пожалуйста, выберите файл меньшего размера.",
goBack: "Назад", goBack: "Назад",
errorPageTitle: "Что-то пошло не так",
aiNavPlaceholder: "Куда вы хотите перейти?",
aiNavDisabled: "Навигация с ИИ отключена.",
modulesAINavLabel: "AINav",
modulesAINavDescription: "Модуль для запросов на естественном языке к содержимому сети.",
directConnect: "Прямое подключение", directConnect: "Прямое подключение",
directConnectDescription: "Подключитесь напрямую к узлу, введя его IP-адрес, порт и публичный ключ. Узел будет добавлен как отслеживаемое соединение.", directConnectDescription: "Подключитесь напрямую к узлу, введя его IP-адрес, порт и публичный ключ. Узел будет добавлен как отслеживаемое соединение.",
peerHost: "IP / Имя хоста", peerHost: "IP / Имя хоста",
@ -2911,6 +2922,8 @@ module.exports = {
gamesBackToGames: "Назад к играм", gamesBackToGames: "Назад к играм",
modulesGamesLabel: "Игры", modulesGamesLabel: "Игры",
modulesGamesDescription: "Модуль для открытия и игры в игры.", modulesGamesDescription: "Модуль для открытия и игры в игры.",
modulesGraphosLabel: "Графос",
modulesGraphosDescription: "Модуль для исследования сети в виде интерактивной карты узлов.",
gamesCocolandTitle: "Cocoland", gamesCocolandTitle: "Cocoland",
gamesCocolandDesc: "Кокос с глазами прыгает через пальмы и собирает ECOins.", gamesCocolandDesc: "Кокос с глазами прыгает через пальмы и собирает ECOins.",
gamesTheFlowTitle: "ECOinflow", gamesTheFlowTitle: "ECOinflow",
@ -2980,6 +2993,9 @@ module.exports = {
calendarCreated: "Создан", calendarCreated: "Создан",
calendarAuthor: "Автор", calendarAuthor: "Автор",
calendarJoin: "Присоединиться", calendarJoin: "Присоединиться",
calendarGenerateInvite: "Создать приглашение",
calendarInviteCodePlaceholder: "Введите код приглашения...",
calendarValidateInvite: "Проверить код",
calendarJoined: "Участник", calendarJoined: "Участник",
calendarAddDate: "Добавить дату", calendarAddDate: "Добавить дату",
calendarAddNote: "Добавить заметку", calendarAddNote: "Добавить заметку",

View file

@ -38,6 +38,11 @@ module.exports = {
], ],
profile: "头像", profile: "头像",
inhabitants: "居民", inhabitants: "居民",
peersReplicatedFeeds: "复制的 Feed",
graphos: "Graphos",
graphosDescription: "你周围网络的互动地图。",
graphosYou: "你",
graphosTotalNodes: "节点总数",
manualMode: "手动模式", manualMode: "手动模式",
mentions: "提及", mentions: "提及",
mentionsDescription: [ mentionsDescription: [
@ -2149,6 +2154,7 @@ module.exports = {
bankAddressInvalid: '无效地址', bankAddressInvalid: '无效地址',
bankAddressDeleted: '地址已删除', bankAddressDeleted: '地址已删除',
bankAddressNotFound: '未找到地址', bankAddressNotFound: '未找到地址',
bankAddressForbidden: '您只能设置自己的收款地址',
bankAddressTotal: '地址总数', bankAddressTotal: '地址总数',
bankAddressSearch: '搜索 @居民或地址', bankAddressSearch: '搜索 @居民或地址',
bankAddressActions: '操作', bankAddressActions: '操作',
@ -2754,6 +2760,11 @@ module.exports = {
fileTooLargeTitle: "文件过大", fileTooLargeTitle: "文件过大",
fileTooLargeMessage: "文件超过了允许的最大大小50 MB。请选择较小的文件。", fileTooLargeMessage: "文件超过了允许的最大大小50 MB。请选择较小的文件。",
goBack: "返回", goBack: "返回",
errorPageTitle: "出现了问题",
aiNavPlaceholder: "你想去哪里?",
aiNavDisabled: "AI 导航已停用。",
modulesAINavLabel: "AINav",
modulesAINavDescription: "用于以自然语言查询网络内容的模块。",
directConnect: "直接连接", directConnect: "直接连接",
directConnectDescription: "通过输入对方的 IP 地址、端口和公钥直接连接到节点。该节点将被添加为已关注的连接。", directConnectDescription: "通过输入对方的 IP 地址、端口和公钥直接连接到节点。该节点将被添加为已关注的连接。",
peerHost: "IP / 主机名", peerHost: "IP / 主机名",
@ -2949,6 +2960,8 @@ module.exports = {
gamesBackToGames: "返回游戏", gamesBackToGames: "返回游戏",
modulesGamesLabel: "游戏", modulesGamesLabel: "游戏",
modulesGamesDescription: "用于发现和玩游戏的模块。", modulesGamesDescription: "用于发现和玩游戏的模块。",
modulesGraphosLabel: "Graphos",
modulesGraphosDescription: "将网络作为节点交互式地图浏览的模块。",
gamesCocolandTitle: "Cocoland", gamesCocolandTitle: "Cocoland",
gamesCocolandDesc: "一颗有眼睛的椰子跳过棕榈树收集ECOins。", gamesCocolandDesc: "一颗有眼睛的椰子跳过棕榈树收集ECOins。",
gamesTheFlowTitle: "ECOinflow", gamesTheFlowTitle: "ECOinflow",
@ -3018,6 +3031,9 @@ module.exports = {
calendarCreated: "已创建", calendarCreated: "已创建",
calendarAuthor: "作者", calendarAuthor: "作者",
calendarJoin: "加入", calendarJoin: "加入",
calendarGenerateInvite: "生成邀请",
calendarInviteCodePlaceholder: "输入邀请码...",
calendarValidateInvite: "验证邀请码",
calendarJoined: "已加入", calendarJoined: "已加入",
calendarAddDate: "添加日期", calendarAddDate: "添加日期",
calendarAddNote: "添加笔记", calendarAddNote: "添加笔记",

View file

@ -23,7 +23,7 @@ const cli = (presets, defaultConfigFile) =>
}) })
.options("offline", { .options("offline", {
describe: describe:
"Don't try to connect to scuttlebutt peers or pubs. This can be changed on the 'settings' page while Oasis is running.", "Don't try to connect to Oasis peers or pubs. This can be changed on the 'settings' page while Oasis is running.",
default: _.get(presets, "offline", false), default: _.get(presets, "offline", false),
type: "boolean", type: "boolean",
}) })
@ -54,31 +54,6 @@ const cli = (presets, defaultConfigFile) =>
default: _.get(presets, "debug", false), default: _.get(presets, "debug", false),
type: "boolean", type: "boolean",
}) })
.options("theme", {
describe: "The theme to use, if a theme hasn't been set in the cookies",
default: _.get(presets, "theme", "classic-light"),
type: "string",
})
.options("wallet-url", {
describe: "The URL of the remote ECOin wallet",
default: _.get(presets, "walletUrl", "http://localhost:7474"),
type: "string",
})
.options("wallet-user", {
describe: "The username of the remote ECOin wallet",
default: _.get(presets, "walletUser", "ecoinrpc"),
type: "string",
})
.options("wallet-pass", {
describe: "The password of the remote ECOin wallet",
default: _.get(presets, "walletPass", "ecoinrpc"),
type: "string",
})
.options("wallet-fee", {
describe: "The fee to pay for ECOin transactions",
default: _.get(presets, "walletFee", "0.01"),
type: "string",
})
.epilog(`The defaults can be configured in ${defaultConfigFile}.`).argv; .epilog(`The defaults can be configured in ${defaultConfigFile}.`).argv;
module.exports = { cli }; module.exports = { cli };

View file

@ -6,53 +6,55 @@ const configFilePath = path.join(__dirname, 'oasis-config.json');
if (!fs.existsSync(configFilePath)) { if (!fs.existsSync(configFilePath)) {
const defaultConfig = { const defaultConfig = {
"themes": { "themes": {
"current": "OasisMobile" "current": "Dark-SNH"
}, },
"modules": { "modules": {
"popularMod": "on", "popularMod": "on",
"topicsMod": "off", "topicsMod": "on",
"summariesMod": "off", "summariesMod": "on",
"latestMod": "on", "latestMod": "on",
"threadsMod": "on", "threadsMod": "on",
"multiverseMod": "on", "multiverseMod": "on",
"invitesMod": "on", "invitesMod": "on",
"walletMod": "off", "walletMod": "on",
"legacyMod": "off", "legacyMod": "on",
"cipherMod": "on", "cipherMod": "on",
"bookmarksMod": "on", "bookmarksMod": "on",
"videosMod": "on", "videosMod": "on",
"docsMod": "on", "docsMod": "on",
"audiosMod": "on", "audiosMod": "on",
"tagsMod": "on", "tagsMod": "on",
"imagesMod": "on", "imagesMod": "on",
"trendingMod": "on", "trendingMod": "on",
"eventsMod": "on", "eventsMod": "on",
"tasksMod": "off", "tasksMod": "on",
"marketMod": "on", "marketMod": "on",
"votesMod": "on", "votesMod": "on",
"tribesMod": "on", "tribesMod": "on",
"reportsMod": "off", "reportsMod": "on",
"opinionsMod": "on", "opinionsMod": "on",
"transfersMod": "on", "padsMod": "on",
"feedMod": "on", "calendarsMod": "on",
"pixeliaMod": "off", "transfersMod": "on",
"agendaMod": "on", "feedMod": "on",
"aiMod": "off", "pixeliaMod": "on",
"forumMod": "off", "agendaMod": "on",
"jobsMod": "on", "aiMod": "on",
"projectsMod": "on", "aiNavMod": "on",
"bankingMod": "on", "forumMod": "on",
"parliamentMod": "off", "gamesMod": "on",
"courtsMod": "off", "jobsMod": "on",
"favoritesMod": "off", "shopsMod": "on",
"padsMod": "on", "projectsMod": "on",
"calendarsMod": "on", "bankingMod": "on",
"gamesMod": "on", "parliamentMod": "on",
"shopsMod": "on", "courtsMod": "on",
"logsMod": "on", "favoritesMod": "on",
"mapsMod": "on", "logsMod": "on",
"chatsMod": "on", "mapsMod": "on",
"torrentsMod": "on" "chatsMod": "on",
"torrentsMod": "on",
"graphosMod": "on"
}, },
"wallet": { "wallet": {
"url": "http://localhost:7474", "url": "http://localhost:7474",

View file

@ -1,17 +1,17 @@
{ {
"themes": { "themes": {
"current": "OasisMobile" "current": "Dark-SNH"
}, },
"modules": { "modules": {
"popularMod": "on", "popularMod": "on",
"topicsMod": "off", "topicsMod": "on",
"summariesMod": "off", "summariesMod": "on",
"latestMod": "on", "latestMod": "on",
"threadsMod": "on", "threadsMod": "on",
"multiverseMod": "on", "multiverseMod": "on",
"invitesMod": "on", "invitesMod": "on",
"walletMod": "off", "walletMod": "on",
"legacyMod": "off", "legacyMod": "on",
"cipherMod": "on", "cipherMod": "on",
"bookmarksMod": "on", "bookmarksMod": "on",
"videosMod": "on", "videosMod": "on",
@ -21,32 +21,34 @@
"imagesMod": "on", "imagesMod": "on",
"trendingMod": "on", "trendingMod": "on",
"eventsMod": "on", "eventsMod": "on",
"tasksMod": "off", "tasksMod": "on",
"marketMod": "on", "marketMod": "on",
"votesMod": "on", "votesMod": "on",
"tribesMod": "on", "tribesMod": "on",
"reportsMod": "off", "reportsMod": "on",
"opinionsMod": "on", "opinionsMod": "on",
"transfersMod": "on",
"feedMod": "on",
"pixeliaMod": "off",
"agendaMod": "on",
"aiMod": "off",
"forumMod": "off",
"jobsMod": "on",
"projectsMod": "on",
"bankingMod": "on",
"parliamentMod": "off",
"courtsMod": "off",
"favoritesMod": "off",
"padsMod": "on", "padsMod": "on",
"calendarsMod": "on", "calendarsMod": "on",
"transfersMod": "on",
"feedMod": "on",
"pixeliaMod": "on",
"agendaMod": "on",
"aiMod": "on",
"aiNavMod": "on",
"forumMod": "on",
"gamesMod": "on", "gamesMod": "on",
"jobsMod": "on",
"shopsMod": "on", "shopsMod": "on",
"projectsMod": "on",
"bankingMod": "on",
"parliamentMod": "on",
"courtsMod": "on",
"favoritesMod": "on",
"logsMod": "on", "logsMod": "on",
"mapsMod": "on", "mapsMod": "on",
"chatsMod": "on", "chatsMod": "on",
"torrentsMod": "on" "torrentsMod": "on",
"graphosMod": "on"
}, },
"wallet": { "wallet": {
"url": "http://localhost:7474", "url": "http://localhost:7474",
@ -55,9 +57,7 @@
"fee": "5" "fee": "5"
}, },
"walletPub": { "walletPub": {
"url": "", "pubId": ""
"user": "",
"pass": ""
}, },
"ai": { "ai": {
"prompt": "Provide an informative and precise response." "prompt": "Provide an informative and precise response."
@ -69,4 +69,4 @@
"language": "en", "language": "en",
"wish": "whole", "wish": "whole",
"pmVisibility": "whole" "pmVisibility": "whole"
} }

View file

@ -1,7 +1,30 @@
const pull = require('../server/node_modules/pull-stream'); const pull = require('../server/node_modules/pull-stream');
const ssbRef = require('../server/node_modules/ssb-ref');
const { getConfig } = require('../configs/config-manager.js'); const { getConfig } = require('../configs/config-manager.js');
const logLimit = getConfig().ssbLogStream?.limit || 1000; const logLimit = getConfig().ssbLogStream?.limit || 1000;
const safeFeedId = (v) => {
if (typeof v === 'string' && ssbRef.isFeed(v)) return v;
if (v && typeof v === 'object' && typeof v.link === 'string' && ssbRef.isFeed(v.link)) return v.link;
return null;
};
const isContentSane = (c) => {
if (!c || typeof c !== 'object') return false;
if (c.type === 'contact') return !!safeFeedId(c.contact);
if (c.type === 'about') {
if (c.about === undefined) return true;
if (typeof c.about === 'string' && ssbRef.isFeed(c.about)) return true;
return false;
}
if (c.type === 'pub') {
const addr = c.address;
if (!addr || typeof addr !== 'object') return false;
return typeof addr.key === 'string' && ssbRef.isFeed(addr.key);
}
return true;
};
const N = s => String(s || '').toUpperCase().replace(/\s+/g, '_'); const N = s => String(s || '').toUpperCase().replace(/\s+/g, '_');
const ORDER_MARKET = ['FOR_SALE','OPEN','RESERVED','CLOSED','SOLD']; const ORDER_MARKET = ['FOR_SALE','OPEN','RESERVED','CLOSED','SOLD'];
const ORDER_PROJECT = ['CANCELLED','PAUSED','ACTIVE','COMPLETED']; const ORDER_PROJECT = ['CANCELLED','PAUSED','ACTIVE','COMPLETED'];
@ -21,6 +44,10 @@ function inferType(c = {}) {
if (c.type === 'courts_nom_vote') return 'courtsNominationVote'; if (c.type === 'courts_nom_vote') return 'courtsNominationVote';
if (c.type === 'courts_public_pref') return 'courtsPublicPref'; if (c.type === 'courts_public_pref') return 'courtsPublicPref';
if (c.type === 'courts_mediators') return 'courtsMediators'; if (c.type === 'courts_mediators') return 'courtsMediators';
if (c.type === 'map') return 'map';
if (c.type === 'mapMarker') return 'mapMarker';
if (c.type === 'chat') return 'chat';
if (c.type === 'chatMessage') return 'chatMessage';
if (c.type === 'vote' && c.vote && typeof c.vote.link === 'string') { if (c.type === 'vote' && c.vote && typeof c.vote.link === 'string') {
const br = Array.isArray(c.branch) ? c.branch : []; const br = Array.isArray(c.branch) ? c.branch : [];
if (br.includes(c.vote.link) && Number(c.vote.value) === 1) return 'spread'; if (br.includes(c.vote.link) && Number(c.vote.value) === 1) return 'spread';
@ -70,8 +97,10 @@ module.exports = ({ cooler }) => {
const c = v?.content; const c = v?.content;
if (!c?.type) continue; if (!c?.type) continue;
if (c.type === 'tombstone' && c.target) { tombstoned.add(c.target); continue } if (c.type === 'tombstone' && c.target) { tombstoned.add(c.target); continue }
if (!isContentSane(c)) continue;
const ts = v?.timestamp || Number(c?.timestamp || 0) || (c?.updatedAt ? Date.parse(c.updatedAt) : 0) || 0; const ts = v?.timestamp || Number(c?.timestamp || 0) || (c?.updatedAt ? Date.parse(c.updatedAt) : 0) || 0;
idToAction.set(k, { id: k, author: v?.author, ts, type: inferType(c), content: c }); const normalized = c.type === 'contact' ? { ...c, contact: safeFeedId(c.contact) } : c;
idToAction.set(k, { id: k, author: v?.author, ts, type: inferType(c), content: normalized });
rawById.set(k, msg); rawById.set(k, msg);
if (c.replaces) parentOf.set(k, c.replaces); if (c.replaces) parentOf.set(k, c.replaces);
} }
@ -220,148 +249,6 @@ module.exports = ({ cooler }) => {
} }
} }
if (type === 'tribe') {
const baseId = tip.id;
const baseTitle = (tip.content && tip.content.title) || '';
const isAnonymous = tip.content && typeof tip.content.isAnonymous === 'boolean' ? tip.content.isAnonymous : false;
const uniq = (xs) => Array.from(new Set((Array.isArray(xs) ? xs : []).filter(x => typeof x === 'string' && x.trim().length)));
const toSet = (xs) => new Set(uniq(xs));
const excerpt2 = (s, max = 220) => {
const t = String(s || '').replace(/\s+/g, ' ').trim();
return t.length > max ? t.slice(0, max - 1) + '…' : t;
};
const feedMap = (feed) => {
const m = new Map();
for (const it of (Array.isArray(feed) ? feed : [])) {
if (!it || typeof it !== 'object') continue;
const id = typeof it.id === 'string' || typeof it.id === 'number' ? String(it.id) : '';
if (!id) continue;
m.set(id, it);
}
return m;
};
const sorted = arr
.filter(a => a.type === 'tribe' && a.content && typeof a.content === 'object')
.sort((a, b) => (a.ts || 0) - (b.ts || 0));
let prev = null;
for (const ev of sorted) {
if (!prev) { prev = ev; continue; }
const prevMembers = toSet(prev.content.members);
const curMembers = toSet(ev.content.members);
const added = Array.from(curMembers).filter(x => !prevMembers.has(x));
const removed = Array.from(prevMembers).filter(x => !curMembers.has(x));
for (const member of added) {
const overlayId = `${ev.id}:tribeJoin:${member}`;
idToAction.set(overlayId, {
id: overlayId,
author: member,
ts: ev.ts,
type: 'tribeJoin',
content: { type: 'tribeJoin', tribeId: baseId, tribeTitle: baseTitle, isAnonymous, member }
});
idToTipId.set(overlayId, overlayId);
}
for (const member of removed) {
const overlayId = `${ev.id}:tribeLeave:${member}`;
idToAction.set(overlayId, {
id: overlayId,
author: member,
ts: ev.ts,
type: 'tribeLeave',
content: { type: 'tribeLeave', tribeId: baseId, tribeTitle: baseTitle, isAnonymous, member }
});
idToTipId.set(overlayId, overlayId);
}
const prevFeed = feedMap(prev.content.feed);
const curFeed = feedMap(ev.content.feed);
for (const [fid, item] of curFeed.entries()) {
if (prevFeed.has(fid)) continue;
const feedAuthor = (item && typeof item.author === 'string' && item.author.trim().length) ? item.author : ev.author;
const overlayId = `${ev.id}:tribeFeedPost:${fid}:${feedAuthor}`;
idToAction.set(overlayId, {
id: overlayId,
author: feedAuthor,
ts: ev.ts,
type: 'tribeFeedPost',
content: {
type: 'tribeFeedPost',
tribeId: baseId,
tribeTitle: baseTitle,
isAnonymous,
feedId: fid,
date: item.date || ev.ts,
text: excerpt2(item.message || '')
}
});
idToTipId.set(overlayId, overlayId);
}
for (const [fid, curItem] of curFeed.entries()) {
const prevItem = prevFeed.get(fid);
if (!prevItem) continue;
const pInh = toSet(prevItem.refeeds_inhabitants);
const cInh = toSet(curItem.refeeds_inhabitants);
const newInh = Array.from(cInh).filter(x => !pInh.has(x));
const curRefeeds = Number(curItem.refeeds || 0);
const prevRefeeds = Number(prevItem.refeeds || 0);
const postText = excerpt2(curItem.message || '');
if (newInh.length) {
for (const who of newInh) {
const overlayId = `${ev.id}:tribeFeedRefeed:${fid}:${who}`;
idToAction.set(overlayId, {
id: overlayId,
author: who,
ts: ev.ts,
type: 'tribeFeedRefeed',
content: {
type: 'tribeFeedRefeed',
tribeId: baseId,
tribeTitle: baseTitle,
isAnonymous,
feedId: fid,
text: postText
}
});
idToTipId.set(overlayId, overlayId);
}
} else if (curRefeeds > prevRefeeds && ev.author) {
const who = ev.author;
const overlayId = `${ev.id}:tribeFeedRefeed:${fid}:${who}`;
idToAction.set(overlayId, {
id: overlayId,
author: who,
ts: ev.ts,
type: 'tribeFeedRefeed',
content: {
type: 'tribeFeedRefeed',
tribeId: baseId,
tribeTitle: baseTitle,
isAnonymous,
feedId: fid,
text: postText
}
});
idToTipId.set(overlayId, overlayId);
}
}
prev = ev;
}
}
continue; continue;
} }
@ -429,12 +316,13 @@ module.exports = ({ cooler }) => {
a.content.rootTitle = rootAction?.content?.title || a.content.rootTitle || ''; a.content.rootTitle = rootAction?.content?.title || a.content.rootTitle || '';
a.content.rootKey = rootId || a.content.rootKey || ''; a.content.rootKey = rootId || a.content.rootKey || '';
} }
latest.push({ ...a, tipId: idToTipId.get(a.id) || a.id }); const actionRoot = rootOf(a.id);
latest.push({ ...a, tipId: idToTipId.get(a.id) || a.id, rootId: actionRoot !== a.id ? actionRoot : null });
} }
let deduped = latest.filter(a => !a.tipId || a.tipId === a.id || (a.type === 'tribe' && !parentOf.has(a.id))); let deduped = latest.filter(a => !a.tipId || a.tipId === a.id || (a.type === 'tribe' && !parentOf.has(a.id)));
const mediaTypes = new Set(['image','video','audio','document','bookmark']); const mediaTypes = new Set(['image','video','audio','document','bookmark','map']);
const perAuthorUnique = new Set(['karmaScore']); const perAuthorUnique = new Set(['karmaScore']);
const byKey = new Map(); const byKey = new Map();
const norm = s => String(s || '').trim().toLowerCase(); const norm = s => String(s || '').trim().toLowerCase();
@ -488,14 +376,34 @@ module.exports = ({ cooler }) => {
deduped = Array.from(byKey.values()).map(x => { delete x.__effTs; delete x.__hasImage; return x }); deduped = Array.from(byKey.values()).map(x => { delete x.__effTs; delete x.__hasImage; return x });
const tribeInternalTypes = new Set(['tribeLeave', 'tribeFeedPost', 'tribeFeedRefeed', 'tribe-content']); const tribeInternalTypes = new Set(['tribe-content', 'tribeParliamentCandidature', 'tribeParliamentTerm', 'tribeParliamentProposal', 'tribeParliamentRule', 'tribeParliamentLaw', 'tribeParliamentRevocation']);
const isAllowedTribeActivity = (a) => !tribeInternalTypes.has(a.type); const hiddenTypes = new Set(['padEntry', 'chatMessage', 'calendarDate', 'calendarNote', 'calendarReminderSent', 'feed-action', 'pubBalance', 'pubAvailability', 'log']);
const isAllowedTribeActivity = (a) => {
if (tribeInternalTypes.has(a.type)) return false;
const c = a.content || {};
if (c.tribeId) return false;
if (a.type === 'tribe') {
if (c.isAnonymous === true) return false;
const isInitial = !c.replaces;
if (!isInitial) return false;
}
return true;
};
const isVisible = (a) => {
if (hiddenTypes.has(a.type)) return false;
if (a.type === 'pad' && (a.content || {}).status !== 'OPEN') return false;
if (a.type === 'chat' && (a.content || {}).status !== 'OPEN') return false;
if (a.type === 'calendar' && (a.content || {}).status !== 'OPEN') return false;
if (a.type === 'event' && String((a.content || {}).isPublic || '').toLowerCase() === 'private' && (a.content || {}).organizer !== userId && !(Array.isArray((a.content || {}).attendees) && (a.content || {}).attendees.includes(userId))) return false;
if (a.type === 'task' && String((a.content || {}).isPublic || '').toUpperCase() === 'PRIVATE' && a.author !== userId && !(Array.isArray((a.content || {}).assignees) && (a.content || {}).assignees.includes(userId))) return false;
return true;
};
let out; let out;
if (filter === 'mine') out = deduped.filter(a => a.author === userId && isAllowedTribeActivity(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)) } 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(isAllowedTribeActivity); else if (filter === 'all') out = deduped.filter(a => isAllowedTribeActivity(a) && isVisible(a));
else if (filter === 'banking') out = deduped.filter(a => a.type === 'bankWallet' || a.type === 'bankClaim'); else if (filter === 'banking') out = deduped.filter(a => a.type === 'bankWallet' || a.type === 'bankClaim' || a.type === 'ubiClaim' || a.type === 'ubiclaimresult');
else if (filter === 'karma') out = deduped.filter(a => a.type === 'karmaScore'); else if (filter === 'karma') out = deduped.filter(a => a.type === 'karmaScore');
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');
@ -510,6 +418,10 @@ module.exports = ({ cooler }) => {
}); });
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 === 'gameScore') out = deduped.filter(a => a.type === 'gameScore');
else if (filter === 'pad') out = deduped.filter(a => a.type === 'pad' && (a.content || {}).status === 'OPEN');
else if (filter === 'chat') out = deduped.filter(a => a.type === 'chat' && (a.content || {}).status === 'OPEN');
else if (filter === 'calendar') out = deduped.filter(a => a.type === 'calendar' && (a.content || {}).status === 'OPEN');
else out = deduped.filter(a => a.type === filter); else out = deduped.filter(a => a.type === filter);
out.sort((a, b) => (b.ts || 0) - (a.ts || 0)); out.sort((a, b) => (b.ts || 0) - (a.ts || 0));

View file

@ -713,35 +713,12 @@ async function getLastPublishedTimestamp(userId) {
if (!pubId) throw new Error("no_pub_configured"); if (!pubId) throw new Error("no_pub_configured");
const alreadyClaimed = await hasClaimedThisMonth(uid); const alreadyClaimed = await hasClaimedThisMonth(uid);
if (alreadyClaimed) throw new Error("already_claimed"); if (alreadyClaimed) throw new Error("already_claimed");
const karmaScore = await getUserEngagementScore(uid);
const wMin = DEFAULT_RULES.caps.w_min;
const wMax = DEFAULT_RULES.caps.w_max;
const capUser = DEFAULT_RULES.caps.cap_user_epoch;
const userW = clamp(1 + karmaScore / 100, wMin, wMax);
const amount = Number(Math.max(1, Math.min(capUser * (userW / wMax), capUser)).toFixed(6));
const ssb = await openSsb(); const ssb = await openSsb();
if (!ssb || !ssb.publish) throw new Error("ssb_unavailable"); if (!ssb || !ssb.publish) throw new Error("ssb_unavailable");
const now = new Date().toISOString(); const now = new Date().toISOString();
const transferContent = { const claimContent = { type: "ubiClaim", pubId, epochId, claimedAt: now };
type: "transfer",
from: pubId,
to: uid,
concept: `UBI ${epochId} ${uid}`,
amount: String(amount),
createdAt: now,
updatedAt: now,
deadline: null,
confirmedBy: [pubId],
status: "UNCONFIRMED",
tags: ["UBI", "PENDING"],
opinions: {},
opinions_inhabitants: []
};
const transferRes = await new Promise((resolve, reject) => ssb.publish(transferContent, (err, res) => err ? reject(err) : resolve(res)));
const transferId = transferRes?.key || "";
const claimContent = { type: "ubiClaim", pubId, amount, epochId, claimedAt: now, transferId };
await new Promise((resolve, reject) => ssb.publish(claimContent, (err, res) => err ? reject(err) : resolve(res))); await new Promise((resolve, reject) => ssb.publish(claimContent, (err, res) => err ? reject(err) : resolve(res)));
return { status: "claimed_pending", amount, epochId }; return { status: "claimed_pending", epochId };
} }
async function updateAllocationStatus(allocationId, status, txid) { async function updateAllocationStatus(allocationId, status, txid) {

View file

@ -1,6 +1,8 @@
const pull = require("../server/node_modules/pull-stream") const pull = require("../server/node_modules/pull-stream")
const crypto = require("crypto")
const { getConfig } = require("../configs/config-manager.js") const { getConfig } = require("../configs/config-manager.js")
const logLimit = getConfig().ssbLogStream?.limit || 1000 const logLimit = getConfig().ssbLogStream?.limit || 1000
const INVITE_CODE_BYTES = 16
const safeText = (v) => String(v || "").trim() const safeText = (v) => String(v || "").trim()
const normalizeTags = (raw) => { const normalizeTags = (raw) => {
@ -43,7 +45,46 @@ module.exports = ({ cooler, pmModel, tribeCrypto, tribesModel }) => {
const encryptIfTribe = tribeHelpers ? tribeHelpers.encryptIfTribe : async (c) => c const encryptIfTribe = tribeHelpers ? tribeHelpers.encryptIfTribe : async (c) => c
const decryptIfTribe = tribeHelpers ? tribeHelpers.decryptIfTribe : async (c) => c const decryptIfTribe = tribeHelpers ? tribeHelpers.decryptIfTribe : async (c) => c
const assertReadable = tribeHelpers ? tribeHelpers.assertReadable : () => {} const assertReadable = tribeHelpers ? tribeHelpers.assertReadable : () => {}
const decryptIndexNodes = tribeHelpers ? tribeHelpers.decryptIndexNodes : async () => {}
const encryptStandalone = (content, rootId) => {
if (!tribeCrypto || !rootId) return content
const key = tribeCrypto.getKey(rootId)
if (!key) return content
return tribeCrypto.encryptContent(content, [key], true)
}
const decryptCalendarRoot = (content, rootId) => {
if (!content || !content.encryptedPayload) return content
if (!tribeCrypto) return content
const keys = tribeCrypto.getKeys(rootId)
if (!keys || !keys.length) return { ...content, _undecryptable: true }
return tribeCrypto.decryptContent(content, keys.map(k => [k]))
}
const decryptIndexNodes = async (idx) => {
if (!tribeCrypto) return
for (const [k, n] of idx.nodes.entries()) {
if (!n.c || !n.c.encryptedPayload) continue
let root = k
while (idx.parent.has(root)) root = idx.parent.get(root)
let dec = null
if (n.c.tribeId && tribesModel) {
try {
const r = await tribeCrypto.decryptFromTribe(n.c, tribesModel)
if (r && !r._undecryptable) dec = r
} catch (_) {}
}
if (!dec) {
const r = decryptCalendarRoot(n.c, root)
if (r && !r._undecryptable) dec = r
}
if (dec) {
idx.nodes.set(k, { ...n, c: { ...dec, _decrypted: true } })
} else {
idx.nodes.set(k, { ...n, c: { ...n.c, _decrypted: false } })
}
}
}
const buildIndex = (messages) => { const buildIndex = (messages) => {
const tomb = new Set() const tomb = new Set()
@ -95,6 +136,7 @@ module.exports = ({ cooler, pmModel, tribeCrypto, tribesModel }) => {
tags: Array.isArray(c.tags) ? c.tags : [], tags: Array.isArray(c.tags) ? c.tags : [],
author: c.author || node.author, author: c.author || node.author,
participants: Array.isArray(c.participants) ? c.participants : [], participants: Array.isArray(c.participants) ? c.participants : [],
invites: Array.isArray(c.invites) ? c.invites : [],
createdAt: c.createdAt || new Date(node.ts).toISOString(), createdAt: c.createdAt || new Date(node.ts).toISOString(),
updatedAt: c.updatedAt || null, updatedAt: c.updatedAt || null,
tribeId: c.tribeId || null, tribeId: c.tribeId || null,
@ -143,7 +185,7 @@ module.exports = ({ cooler, pmModel, tribeCrypto, tribesModel }) => {
if (deadline && new Date(deadline).getTime() <= Date.now()) throw new Error("Deadline must be in the future") if (deadline && new Date(deadline).getTime() <= Date.now()) throw new Error("Deadline must be in the future")
if (!firstDate || new Date(firstDate).getTime() <= Date.now()) throw new Error("First date must be in the future") if (!firstDate || new Date(firstDate).getTime() <= Date.now()) throw new Error("First date must be in the future")
let content = { let plainContent = {
type: "calendar", type: "calendar",
title: safeText(title), title: safeText(title),
status: validStatus, status: validStatus,
@ -151,53 +193,97 @@ module.exports = ({ cooler, pmModel, tribeCrypto, tribesModel }) => {
tags: normalizeTags(tags), tags: normalizeTags(tags),
author: userId, author: userId,
participants: [userId], participants: [userId],
invites: [],
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
...(tribeId ? { tribeId } : {}) ...(tribeId ? { tribeId } : {})
} }
content = await encryptIfTribe(content)
let calKey = null
let content = plainContent
if (tribeId) {
content = await encryptIfTribe(plainContent)
} else if (tribeCrypto) {
calKey = tribeCrypto.generateTribeKey()
content = tribeCrypto.encryptContent(plainContent, [calKey], true)
}
const calMsg = await new Promise((resolve, reject) => { const calMsg = await new Promise((resolve, reject) => {
ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg)) ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg))
}) })
const calendarId = calMsg.key const calendarId = calMsg.key
const dates = expandRecurrence(firstDate, deadline, intervalWeekly, intervalMonthly, intervalYearly)
const allDateMsgs = [] if (calKey && tribeCrypto) {
for (const d of dates) { tribeCrypto.setKey(calendarId, calKey, 1)
let dateContent = { try {
type: "calendarDate", const ssbKeys = require("../server/node_modules/ssb-keys")
const boxedKey = tribeCrypto.boxKeyForMember(calKey, userId, ssbKeys)
await new Promise((resolve) => {
ssbClient.publish({ type: "tribe-keys", tribeId: calendarId, generation: 1, memberKeys: { [userId]: boxedKey } }, () => resolve())
})
} catch (_) {}
if (validStatus === "OPEN") {
try {
const pubCode = crypto.randomBytes(INVITE_CODE_BYTES).toString("hex")
const ek = tribeCrypto.encryptForInvite(calKey, pubCode)
const tipId = await this.resolveCurrentId(calendarId)
const item = await new Promise((resolve, reject) => ssbClient.get(tipId, (e, it) => e ? reject(e) : resolve(it)))
const dec = decryptCalendarRoot(item.content, calendarId)
let updated = {
type: "calendar",
title: dec.title || "",
status: validStatus,
deadline: dec.deadline || "",
tags: Array.isArray(dec.tags) ? dec.tags : [],
author: userId,
participants: [userId],
invites: [{ code: pubCode, ek, gen: 1, public: true }],
createdAt: dec.createdAt,
updatedAt: new Date().toISOString(),
replaces: tipId
}
updated = encryptStandalone(updated, calendarId)
await new Promise((resolve, reject) => ssbClient.publish(updated, (e, res) => e ? reject(e) : resolve(res)))
await new Promise((resolve, reject) => ssbClient.publish({ type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }, e => e ? reject(e) : resolve()))
} catch (_) {}
}
}
let dateContent = {
type: "calendarDate",
calendarId,
date: new Date(firstDate).toISOString(),
label: safeText(firstDateLabel),
author: userId,
createdAt: new Date().toISOString(),
...(intervalWeekly ? { intervalWeekly: true } : {}),
...(intervalMonthly ? { intervalMonthly: true } : {}),
...(intervalYearly ? { intervalYearly: true } : {}),
...(deadline && hasAnyInterval(intervalWeekly, intervalMonthly, intervalYearly) ? { intervalDeadline: deadline } : {}),
...(tribeId ? { tribeId } : {})
}
if (tribeId) dateContent = await encryptIfTribe(dateContent)
else if (calKey) dateContent = tribeCrypto.encryptContent(dateContent, [calKey], true)
const dateMsg = await new Promise((resolve, reject) => {
ssbClient.publish(dateContent, (err, msg) => err ? reject(err) : resolve(msg))
})
if (firstNote && safeText(firstNote)) {
let noteContent = {
type: "calendarNote",
calendarId, calendarId,
date: d.toISOString(), dateId: dateMsg.key,
label: safeText(firstDateLabel), text: safeText(firstNote),
author: userId, author: userId,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
...(tribeId ? { tribeId } : {}) ...(tribeId ? { tribeId } : {})
} }
dateContent = await encryptIfTribe(dateContent) if (tribeId) noteContent = await encryptIfTribe(noteContent)
const dateMsg = await new Promise((resolve, reject) => { else if (calKey) noteContent = tribeCrypto.encryptContent(noteContent, [calKey], true)
ssbClient.publish(dateContent, (err, msg) => err ? reject(err) : resolve(msg)) await new Promise((resolve, reject) => {
ssbClient.publish(noteContent, (err, msg) => err ? reject(err) : resolve(msg))
}) })
allDateMsgs.push(dateMsg)
}
if (firstNote && safeText(firstNote) && allDateMsgs.length > 0) {
for (const dateMsg of allDateMsgs) {
let noteContent = {
type: "calendarNote",
calendarId,
dateId: dateMsg.key,
text: safeText(firstNote),
author: userId,
createdAt: new Date().toISOString(),
...(tribeId ? { tribeId } : {})
}
noteContent = await encryptIfTribe(noteContent)
await new Promise((resolve, reject) => {
ssbClient.publish(noteContent, (err, msg) => err ? reject(err) : resolve(msg))
})
}
} }
return calMsg return calMsg
@ -205,13 +291,16 @@ module.exports = ({ cooler, pmModel, tribeCrypto, tribesModel }) => {
async updateCalendarById(id, data) { async updateCalendarById(id, data) {
const tipId = await this.resolveCurrentId(id) const tipId = await this.resolveCurrentId(id)
const rootId = await this.resolveRootId(id)
const ssbClient = await openSsb() const ssbClient = await openSsb()
const userId = ssbClient.id const userId = ssbClient.id
const item = await new Promise((resolve, reject) => { const item = await new Promise((resolve, reject) => {
ssbClient.get(tipId, (err, it) => err ? reject(err) : resolve(it)) ssbClient.get(tipId, (err, it) => err ? reject(err) : resolve(it))
}) })
if (!item || !item.content) throw new Error("Calendar not found") if (!item || !item.content) throw new Error("Calendar not found")
const oldDec = await decryptIfTribe(item.content) const oldDec = item.content.tribeId
? await decryptIfTribe(item.content)
: 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")
let updated = { let updated = {
@ -222,12 +311,14 @@ module.exports = ({ cooler, pmModel, tribeCrypto, tribesModel }) => {
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 : []),
author: oldDec.author || userId, author: oldDec.author || userId,
participants: oldDec.participants || [userId], participants: oldDec.participants || [userId],
invites: Array.isArray(oldDec.invites) ? oldDec.invites : [],
...(item.content.tribeId ? { tribeId: item.content.tribeId } : {}), ...(item.content.tribeId ? { tribeId: item.content.tribeId } : {}),
createdAt: oldDec.createdAt, createdAt: oldDec.createdAt,
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
replaces: tipId replaces: tipId
} }
updated = await encryptIfTribe(updated) if (item.content.tribeId) updated = await encryptIfTribe(updated)
else updated = encryptStandalone(updated, rootId)
const result = await new Promise((resolve, reject) => ssbClient.publish(updated, (e, res) => e ? reject(e) : resolve(res))) const result = await new Promise((resolve, reject) => ssbClient.publish(updated, (e, res) => e ? reject(e) : resolve(res)))
const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId } const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
await new Promise((resolve, reject) => ssbClient.publish(tombstone, e => e ? reject(e) : resolve())) await new Promise((resolve, reject) => ssbClient.publish(tombstone, e => e ? reject(e) : resolve()))
@ -242,21 +333,29 @@ module.exports = ({ cooler, pmModel, tribeCrypto, tribesModel }) => {
if (!item || !item.content) throw new Error("Calendar not found") if (!item || !item.content) throw new Error("Calendar not found")
const dec = await decryptIfTribe(item.content) const dec = await decryptIfTribe(item.content)
assertReadable(dec, "Calendar") assertReadable(dec, "Calendar")
if ((dec.author || item.content.author) !== userId) throw new Error("Not the author") const contentAuthor = (dec && dec.author) || (typeof item.content === 'object' && item.content.author)
if (contentAuthor !== userId) throw new Error("Not the author")
const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId } const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
return new Promise((resolve, reject) => ssbClient.publish(tombstone, e => e ? reject(e) : resolve())) return new Promise((resolve, reject) => ssbClient.publish(tombstone, e => e ? reject(e) : resolve()))
}, },
async joinCalendar(calendarId) { async joinCalendar(calendarId) {
const tipId = await this.resolveCurrentId(calendarId) const tipId = await this.resolveCurrentId(calendarId)
const rootId = await this.resolveRootId(calendarId)
const ssbClient = await openSsb() const ssbClient = await openSsb()
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 dec = await decryptIfTribe(item.content) const dec = item.content.tribeId
? await decryptIfTribe(item.content)
: decryptCalendarRoot(item.content, rootId)
assertReadable(dec, "Calendar") assertReadable(dec, "Calendar")
const participants = Array.isArray(dec.participants) ? dec.participants : [] const participants = Array.isArray(dec.participants) ? dec.participants : []
if (participants.includes(userId)) return if (participants.includes(userId)) return
if (tribeCrypto && Array.isArray(dec.invites)) {
const pub = dec.invites.find(inv => typeof inv === "object" && inv.public === true && inv.code && (inv.ek || inv.ekChain))
if (pub) return await this.joinByInvite(pub.code)
}
let updated = { let updated = {
type: "calendar", type: "calendar",
title: dec.title || "", title: dec.title || "",
@ -265,12 +364,14 @@ module.exports = ({ cooler, pmModel, tribeCrypto, tribesModel }) => {
tags: Array.isArray(dec.tags) ? dec.tags : [], tags: Array.isArray(dec.tags) ? dec.tags : [],
author: dec.author, author: dec.author,
participants: [...participants, userId], participants: [...participants, userId],
invites: Array.isArray(dec.invites) ? dec.invites : [],
...(item.content.tribeId ? { tribeId: item.content.tribeId } : {}), ...(item.content.tribeId ? { tribeId: item.content.tribeId } : {}),
createdAt: dec.createdAt, createdAt: dec.createdAt,
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
replaces: tipId replaces: tipId
} }
updated = await encryptIfTribe(updated) if (item.content.tribeId) updated = await encryptIfTribe(updated)
else updated = encryptStandalone(updated, rootId)
const result = await new Promise((resolve, reject) => ssbClient.publish(updated, (e, res) => e ? reject(e) : resolve(res))) const result = await new Promise((resolve, reject) => ssbClient.publish(updated, (e, res) => e ? reject(e) : resolve(res)))
const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId } const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
await new Promise((resolve, reject) => ssbClient.publish(tombstone, e => e ? reject(e) : resolve())) await new Promise((resolve, reject) => ssbClient.publish(tombstone, e => e ? reject(e) : resolve()))
@ -279,11 +380,14 @@ module.exports = ({ cooler, pmModel, tribeCrypto, tribesModel }) => {
async leaveCalendar(calendarId) { async leaveCalendar(calendarId) {
const tipId = await this.resolveCurrentId(calendarId) const tipId = await this.resolveCurrentId(calendarId)
const rootId = await this.resolveRootId(calendarId)
const ssbClient = await openSsb() const ssbClient = await openSsb()
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 dec = await decryptIfTribe(item.content) const dec = item.content.tribeId
? await decryptIfTribe(item.content)
: decryptCalendarRoot(item.content, rootId)
assertReadable(dec, "Calendar") assertReadable(dec, "Calendar")
if ((dec.author || item.content.author) === userId) throw new Error("Author cannot leave") if ((dec.author || item.content.author) === userId) throw new Error("Author cannot leave")
const participants = Array.isArray(dec.participants) ? dec.participants : [] const participants = Array.isArray(dec.participants) ? dec.participants : []
@ -296,12 +400,14 @@ module.exports = ({ cooler, pmModel, tribeCrypto, tribesModel }) => {
tags: Array.isArray(dec.tags) ? dec.tags : [], tags: Array.isArray(dec.tags) ? dec.tags : [],
author: dec.author, author: dec.author,
participants: participants.filter(p => p !== userId), participants: participants.filter(p => p !== userId),
invites: Array.isArray(dec.invites) ? dec.invites : [],
...(item.content.tribeId ? { tribeId: item.content.tribeId } : {}), ...(item.content.tribeId ? { tribeId: item.content.tribeId } : {}),
createdAt: dec.createdAt, createdAt: dec.createdAt,
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
replaces: tipId replaces: tipId
} }
updated = await encryptIfTribe(updated) if (item.content.tribeId) updated = await encryptIfTribe(updated)
else updated = encryptStandalone(updated, rootId)
const result = await new Promise((resolve, reject) => ssbClient.publish(updated, (e, res) => e ? reject(e) : resolve(res))) const result = await new Promise((resolve, reject) => ssbClient.publish(updated, (e, res) => e ? reject(e) : resolve(res)))
const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId } const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
await new Promise((resolve, reject) => ssbClient.publish(tombstone, e => e ? reject(e) : resolve())) await new Promise((resolve, reject) => ssbClient.publish(tombstone, e => e ? reject(e) : resolve()))
@ -362,30 +468,33 @@ module.exports = ({ cooler, pmModel, tribeCrypto, tribesModel }) => {
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 || new Date(date).getTime() <= Date.now()) 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 deadlineForExpansion = (intervalDeadline && hasAnyInterval(intervalWeekly, intervalMonthly, intervalYearly)) ? intervalDeadline : cal.deadline const hasInterval = hasAnyInterval(intervalWeekly, intervalMonthly, intervalYearly)
const dates = expandRecurrence(date, deadlineForExpansion, intervalWeekly, intervalMonthly, intervalYearly) const ruleDeadline = hasInterval ? (intervalDeadline || cal.deadline || "") : ""
const allMsgs = [] let dateContent = {
for (const d of dates) { type: "calendarDate",
let dateContent = { calendarId: rootId,
type: "calendarDate", date: new Date(date).toISOString(),
calendarId: rootId, label: safeText(label),
date: d.toISOString(), author: userId,
label: safeText(label), createdAt: new Date().toISOString(),
author: userId, ...(intervalWeekly ? { intervalWeekly: true } : {}),
createdAt: new Date().toISOString(), ...(intervalMonthly ? { intervalMonthly: true } : {}),
...(cal.tribeId ? { tribeId: cal.tribeId } : {}) ...(intervalYearly ? { intervalYearly: true } : {}),
} ...(ruleDeadline ? { intervalDeadline: ruleDeadline } : {}),
dateContent = await encryptIfTribe(dateContent) ...(cal.tribeId ? { tribeId: cal.tribeId } : {})
const msg = await new Promise((resolve, reject) => {
ssbClient.publish(dateContent, (err, m) => err ? reject(err) : resolve(m))
})
allMsgs.push(msg)
} }
return allMsgs if (cal.tribeId) dateContent = await encryptIfTribe(dateContent)
else dateContent = encryptStandalone(dateContent, rootId)
const msg = await new Promise((resolve, reject) => {
ssbClient.publish(dateContent, (err, m) => err ? reject(err) : resolve(m))
})
return [msg]
}, },
async getDatesForCalendar(calendarId) { async getDatesForCalendar(calendarId) {
const rootId = await this.resolveRootId(calendarId) const rootId = await this.resolveRootId(calendarId)
const cal = await this.getCalendarById(rootId)
const calDeadline = cal && cal.deadline ? cal.deadline : ""
const ssbClient = await openSsb() const ssbClient = await openSsb()
const messages = await readAll(ssbClient) const messages = await readAll(ssbClient)
const authorByKey = new Map() const authorByKey = new Map()
@ -411,14 +520,23 @@ module.exports = ({ cooler, pmModel, tribeCrypto, tribesModel }) => {
dec = r && !r._undecryptable ? r : c dec = r && !r._undecryptable ? r : c
if (r && r._undecryptable) continue if (r && r._undecryptable) continue
} }
dates.push({ const baseEntry = {
key: m.key, key: m.key,
calendarId: dec.calendarId || c.calendarId, calendarId: dec.calendarId || c.calendarId,
date: dec.date,
label: dec.label || "", label: dec.label || "",
author: dec.author || v.author, author: dec.author || v.author,
createdAt: dec.createdAt || new Date(v.timestamp || 0).toISOString() createdAt: dec.createdAt || new Date(v.timestamp || 0).toISOString()
}) }
const hasInterval = !!(dec.intervalWeekly || dec.intervalMonthly || dec.intervalYearly)
const ruleDeadline = dec.intervalDeadline || calDeadline
if (hasInterval && ruleDeadline) {
const occurrences = expandRecurrence(dec.date, ruleDeadline, dec.intervalWeekly, dec.intervalMonthly, dec.intervalYearly)
for (const occ of occurrences) {
dates.push({ ...baseEntry, date: occ.toISOString() })
}
} else {
dates.push({ ...baseEntry, date: dec.date })
}
} }
dates.sort((a, b) => new Date(a.date) - new Date(b.date)) dates.sort((a, b) => new Date(a.date) - new Date(b.date))
return dates return dates
@ -487,7 +605,8 @@ module.exports = ({ cooler, pmModel, tribeCrypto, tribesModel }) => {
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
...(cal.tribeId ? { tribeId: cal.tribeId } : {}) ...(cal.tribeId ? { tribeId: cal.tribeId } : {})
} }
noteContent = await encryptIfTribe(noteContent) if (cal.tribeId) noteContent = await encryptIfTribe(noteContent)
else noteContent = encryptStandalone(noteContent, rootId)
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
ssbClient.publish(noteContent, (err, msg) => err ? reject(err) : resolve(msg)) ssbClient.publish(noteContent, (err, msg) => err ? reject(err) : resolve(msg))
}) })
@ -555,7 +674,8 @@ module.exports = ({ cooler, pmModel, tribeCrypto, tribesModel }) => {
for (const m of messages) { for (const m of messages) {
const c = (m.value || {}).content const c = (m.value || {}).content
if (!c || c.type !== "calendarReminderSent") continue if (!c || c.type !== "calendarReminderSent") continue
sentMarkers.add(`${c.calendarId}::${c.dateId}`) const sig = c.occurrence ? `${c.calendarId}::${c.dateId}::${c.occurrence}` : `${c.calendarId}::${c.dateId}`
sentMarkers.add(sig)
} }
const authorByKey = new Map() const authorByKey = new Map()
@ -569,6 +689,7 @@ module.exports = ({ cooler, pmModel, tribeCrypto, tribesModel }) => {
} }
} }
const calendarDeadlines = new Map()
const dueByCalendar = new Map() const dueByCalendar = new Map()
for (const m of messages) { for (const m of messages) {
if (tombstoned.has(m.key)) continue if (tombstoned.has(m.key)) continue
@ -581,21 +702,42 @@ module.exports = ({ cooler, pmModel, tribeCrypto, tribesModel }) => {
if (!r || r._undecryptable) continue if (!r || r._undecryptable) continue
dec = r dec = r
} }
if (!dec.date || new Date(dec.date).getTime() > now) continue if (!dec.date) continue
if (sentMarkers.has(`${c.calendarId}::${m.key}`)) continue const calId = c.calendarId
const entry = { key: m.key, calendarId: c.calendarId, date: dec.date, label: dec.label || "" } let calDeadline = calendarDeadlines.get(calId)
const list = dueByCalendar.get(c.calendarId) || [] if (calDeadline === undefined) {
list.push(entry) try {
dueByCalendar.set(c.calendarId, list) const cc = await this.getCalendarById(calId)
calDeadline = (cc && cc.deadline) || ""
} catch (_) { calDeadline = "" }
calendarDeadlines.set(calId, calDeadline)
}
const hasInterval = !!(dec.intervalWeekly || dec.intervalMonthly || dec.intervalYearly)
const ruleDeadline = dec.intervalDeadline || calDeadline
const occurrences = (hasInterval && ruleDeadline)
? expandRecurrence(dec.date, ruleDeadline, dec.intervalWeekly, dec.intervalMonthly, dec.intervalYearly)
: [new Date(dec.date)]
for (const occ of occurrences) {
if (occ.getTime() > now) continue
const occIso = occ.toISOString()
const sig = hasInterval ? `${calId}::${m.key}::${occIso}` : `${calId}::${m.key}`
if (sentMarkers.has(sig)) continue
const entry = { key: m.key, calendarId: calId, date: occIso, label: dec.label || "", recurring: hasInterval }
const list = dueByCalendar.get(calId) || []
list.push(entry)
dueByCalendar.set(calId, list)
}
} }
const publishMarker = (calendarId, dateId) => new Promise((resolve, reject) => { const publishMarker = (calendarId, dateId, occurrence) => new Promise((resolve, reject) => {
ssbClient.publish({ const payload = {
type: "calendarReminderSent", type: "calendarReminderSent",
calendarId, calendarId,
dateId, dateId,
sentAt: new Date().toISOString() sentAt: new Date().toISOString()
}, (err) => err ? reject(err) : resolve()) }
if (occurrence) payload.occurrence = occurrence
ssbClient.publish(payload, (err) => err ? reject(err) : resolve())
}) })
for (const [calendarId, list] of dueByCalendar.entries()) { for (const [calendarId, list] of dueByCalendar.entries()) {
@ -623,10 +765,134 @@ module.exports = ({ cooler, pmModel, tribeCrypto, tribesModel }) => {
} }
} }
for (const dd of list) { for (const dd of list) {
try { await publishMarker(calendarId, dd.key) } catch (_) {} try { await publishMarker(calendarId, dd.key, dd.recurring ? dd.date : null) } catch (_) {}
} }
} catch (_) {} } catch (_) {}
} }
},
async generateInvite(calendarId) {
const ssbClient = await openSsb()
const userId = ssbClient.id
const cal = await this.getCalendarById(calendarId)
if (!cal) throw new Error("Calendar not found")
if (cal.author !== userId) throw new Error("Only the author can generate invites")
const code = crypto.randomBytes(INVITE_CODE_BYTES).toString("hex")
let invite = code
if (tribeCrypto && !cal.tribeId) {
const ekChain = tribeCrypto.encryptChainForInvite([cal.rootId], code)
if (ekChain) invite = { code, ekChain, gen: tribeCrypto.getGen(cal.rootId) }
}
const tipId = await this.resolveCurrentId(calendarId)
const item = await new Promise((resolve, reject) => ssbClient.get(tipId, (e, it) => e ? reject(e) : resolve(it)))
const dec = item.content.tribeId
? await decryptIfTribe(item.content)
: decryptCalendarRoot(item.content, cal.rootId)
const invites = [...(Array.isArray(dec.invites) ? dec.invites : []), invite]
let updated = {
type: "calendar",
title: dec.title || "",
status: dec.status || "OPEN",
deadline: dec.deadline || "",
tags: Array.isArray(dec.tags) ? dec.tags : [],
author: dec.author,
participants: Array.isArray(dec.participants) ? dec.participants : [userId],
invites,
...(item.content.tribeId ? { tribeId: item.content.tribeId } : {}),
createdAt: dec.createdAt,
updatedAt: new Date().toISOString(),
replaces: tipId
}
if (item.content.tribeId) updated = await encryptIfTribe(updated)
else updated = encryptStandalone(updated, cal.rootId)
await new Promise((resolve, reject) => ssbClient.publish(updated, (e, res) => e ? reject(e) : resolve(res)))
const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
await new Promise((resolve, reject) => ssbClient.publish(tombstone, e => e ? reject(e) : resolve()))
return code
},
async joinByInvite(code) {
const ssbClient = await openSsb()
const userId = ssbClient.id
const calendars = await this.listAll()
let matched = null
let matchedInvite = null
for (const cal of calendars) {
const invs = Array.isArray(cal.invites) ? cal.invites : []
for (const inv of invs) {
if (typeof inv === "string" && inv === code) { matched = cal; matchedInvite = inv; break }
if (typeof inv === "object" && inv.code === code) { matched = cal; matchedInvite = inv; break }
}
if (matched) break
}
if (!matched) throw new Error("Invalid or expired invite code")
if (matched.participants.includes(userId)) throw new Error("Already a participant")
let calKey = null
if (tribeCrypto && typeof matchedInvite === "object") {
if (matchedInvite.ekChain) {
const chain = tribeCrypto.decryptChainFromInvite(matchedInvite.ekChain, code)
if (Array.isArray(chain) && chain.length) {
for (const entry of chain) {
if (Array.isArray(entry.keys) && entry.keys.length) {
tribeCrypto.setKeys(entry.rootId, entry.keys, entry.gen || entry.keys.length)
} else if (entry.key) {
tribeCrypto.setKey(entry.rootId, entry.key, entry.gen || 1)
}
}
calKey = chain[0].key
}
} else if (matchedInvite.ek) {
calKey = tribeCrypto.decryptFromInvite(matchedInvite.ek, code)
tribeCrypto.setKey(matched.rootId, calKey, matchedInvite.gen || 1)
}
}
const tipId = await this.resolveCurrentId(matched.rootId)
const item = await new Promise((resolve, reject) => ssbClient.get(tipId, (e, it) => e ? reject(e) : resolve(it)))
const dec = item.content.tribeId
? await decryptIfTribe(item.content)
: decryptCalendarRoot(item.content, matched.rootId)
const isPublicInvite = typeof matchedInvite === "object" && matchedInvite.public === true
const invites = isPublicInvite
? (Array.isArray(dec.invites) ? dec.invites : [])
: (Array.isArray(dec.invites) ? dec.invites : []).filter(inv => {
if (typeof inv === "string") return inv !== code
return inv.code !== code
})
let updated = {
type: "calendar",
title: dec.title || "",
status: dec.status || "OPEN",
deadline: dec.deadline || "",
tags: Array.isArray(dec.tags) ? dec.tags : [],
author: dec.author,
participants: [...(Array.isArray(dec.participants) ? dec.participants : []), userId],
invites,
...(item.content.tribeId ? { tribeId: item.content.tribeId } : {}),
createdAt: dec.createdAt,
updatedAt: new Date().toISOString(),
replaces: tipId
}
if (item.content.tribeId) updated = await encryptIfTribe(updated)
else updated = encryptStandalone(updated, matched.rootId)
await new Promise((resolve, reject) => ssbClient.publish(updated, (e, res) => e ? reject(e) : resolve(res)))
const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
await new Promise((resolve, reject) => ssbClient.publish(tombstone, e => e ? reject(e) : resolve()))
if (tribeCrypto && calKey) {
try {
const ssbKeys = require("../server/node_modules/ssb-keys")
const memberKeys = {}
try { memberKeys[userId] = tribeCrypto.boxKeyForMember(calKey, userId, ssbKeys) } catch (_) {}
if (matched.author && matched.author !== userId) {
try { memberKeys[matched.author] = tribeCrypto.boxKeyForMember(calKey, matched.author, ssbKeys) } catch (_) {}
}
if (Object.keys(memberKeys).length) {
await new Promise((resolve) => {
ssbClient.publish({ type: "tribe-keys", tribeId: matched.rootId, generation: tribeCrypto.getGen(matched.rootId) || 1, memberKeys }, () => resolve())
})
}
} catch (_) {}
}
return matched.rootId
} }
} }
} }

View file

@ -192,18 +192,47 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
...(tribeId ? { tribeId } : {}) ...(tribeId ? { tribeId } : {})
} }
if (tribeCrypto && !tribeId) { if (!tribeCrypto) {
const chatKey = tribeCrypto.generateTribeKey() return new Promise((resolve, reject) => {
const result = await new Promise((resolve, reject) => {
ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg)) ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg))
}) })
tribeCrypto.setKey(result.key, chatKey, 1)
return result
} }
return new Promise((resolve, reject) => { if (tribeId) {
try {
const ancestryIds = await tribesModel.getAncestryChain(tribeId)
const chain = []
for (const rid of ancestryIds || []) {
const k = tribeCrypto.getKey(rid)
if (!k) { chain.length = 0; break }
chain.push(k)
}
if (chain.length) content = tribeCrypto.encryptContent(content, chain, true)
} catch (_) {}
return new Promise((resolve, reject) => {
ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg))
})
}
const chatKey = tribeCrypto.generateTribeKey()
if (st === "OPEN") {
const code = crypto.randomBytes(INVITE_CODE_BYTES).toString("hex")
const ek = tribeCrypto.encryptForInvite(chatKey, code)
content.invites = [{ code, ek, gen: 1, public: true }]
}
content = tribeCrypto.encryptContent(content, [chatKey], true)
const result = await new Promise((resolve, reject) => {
ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg)) ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg))
}) })
tribeCrypto.setKey(result.key, chatKey, 1)
try {
const ssbKeys = require("../server/node_modules/ssb-keys")
const boxedKey = tribeCrypto.boxKeyForMember(chatKey, userId, ssbKeys)
await new Promise((resolve) => {
ssbClient.publish({ type: "tribe-keys", tribeId: result.key, generation: 1, memberKeys: { [userId]: boxedKey } }, () => resolve())
})
} catch (_) {}
return result
}, },
async updateChatById(id, data, { skipAuthorCheck = false } = {}) { async updateChatById(id, data, { skipAuthorCheck = false } = {}) {
@ -211,40 +240,60 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
const ssbClient = await openSsb() const ssbClient = await openSsb()
const userId = ssbClient.id const userId = ssbClient.id
const item = await new Promise((resolve, reject) => {
ssbClient.get(tipId, (err, item) => err || !item?.content ? reject(new Error("Chat not found")) : resolve(item))
})
const c = item.content
const rawAuthor = c.author || (c.encryptedPayload ? null : undefined)
if (!skipAuthorCheck && rawAuthor && rawAuthor !== userId) throw new Error("Not the author")
const messages = await readAll(ssbClient)
const idx = buildIndex(messages)
let rootId = tipId
while (idx.parent.has(rootId)) rootId = idx.parent.get(rootId)
const node = { key: tipId, c, author: item.author, ts: item.timestamp || 0 }
const chat = buildChat(node, rootId)
if (!chat) throw new Error("Invalid chat")
let updated = {
type: "chat",
replaces: tipId,
title: data.title !== undefined ? safeText(data.title) : chat.title,
description: data.description !== undefined ? safeText(data.description) : chat.description,
image: data.image !== undefined ? (data.image ? String(data.image).trim() || null : chat.image) : chat.image,
category: data.category !== undefined ? safeText(data.category) : chat.category,
status: data.status !== undefined ? (VALID_STATUS.includes(String(data.status).toUpperCase()) ? String(data.status).toUpperCase() : chat.status) : chat.status,
tags: data.tags !== undefined ? normalizeTags(data.tags) : chat.tags,
members: data.members !== undefined ? safeArr(data.members) : chat.members,
invites: data.invites !== undefined ? safeArr(data.invites) : chat.invites,
author: chat.author,
createdAt: chat.createdAt,
updatedAt: new Date().toISOString(),
...(chat.tribeId ? { tribeId: chat.tribeId } : {})
}
if (tribeCrypto) {
if (chat.tribeId) {
try {
const ancestryIds = await tribesModel.getAncestryChain(chat.tribeId)
const chain = []
for (const rid of ancestryIds || []) {
const k = tribeCrypto.getKey(rid)
if (!k) { chain.length = 0; break }
chain.push(k)
}
if (chain.length) updated = tribeCrypto.encryptContent(updated, chain, true)
} catch (_) {}
} else {
const chatKey = tribeCrypto.getKey(rootId)
if (chatKey) updated = tribeCrypto.encryptContent(updated, [chatKey], true)
}
}
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
ssbClient.get(tipId, (err, item) => { ssbClient.publish({ type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }, (e1) => {
if (err || !item?.content) return reject(new Error("Chat not found")) if (e1) return reject(e1)
const c = item.content ssbClient.publish(updated, (e2, res) => e2 ? reject(e2) : resolve(res))
const rawAuthor = c.author || (c.encryptedPayload ? null : undefined)
if (!skipAuthorCheck && rawAuthor && rawAuthor !== userId) return reject(new Error("Not the author"))
const rootId = tipId
const messages = []
const node = { key: tipId, c, author: item.author, ts: item.timestamp || 0 }
const chat = buildChat(node, rootId)
if (!chat) return reject(new Error("Invalid chat"))
const updated = {
type: "chat",
replaces: tipId,
title: data.title !== undefined ? safeText(data.title) : chat.title,
description: data.description !== undefined ? safeText(data.description) : chat.description,
image: data.image !== undefined ? (data.image ? String(data.image).trim() || null : chat.image) : chat.image,
category: data.category !== undefined ? safeText(data.category) : chat.category,
status: data.status !== undefined ? (VALID_STATUS.includes(String(data.status).toUpperCase()) ? String(data.status).toUpperCase() : chat.status) : chat.status,
tags: data.tags !== undefined ? normalizeTags(data.tags) : chat.tags,
members: data.members !== undefined ? safeArr(data.members) : chat.members,
invites: data.invites !== undefined ? safeArr(data.invites) : chat.invites,
author: chat.author,
createdAt: chat.createdAt,
updatedAt: new Date().toISOString()
}
ssbClient.publish({ type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }, (e1) => {
if (e1) return reject(e1)
ssbClient.publish(updated, (e2, res) => e2 ? reject(e2) : resolve(res))
})
}) })
}) })
}, },
@ -372,10 +421,15 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
let invite = code let invite = code
if (tribeCrypto) { if (tribeCrypto) {
const chatKey = tribeCrypto.getKey(chat.rootId) const ekChain = tribeCrypto.encryptChainForInvite([chat.rootId], code)
if (chatKey) { if (ekChain) {
const ek = tribeCrypto.encryptForInvite(chatKey, code) invite = { code, ekChain, gen: tribeCrypto.getGen(chat.rootId) }
invite = { code, ek, gen: tribeCrypto.getGen(chat.rootId) } } else {
const chatKey = tribeCrypto.getKey(chat.rootId)
if (chatKey) {
const ek = tribeCrypto.encryptForInvite(chatKey, code)
invite = { code, ek, gen: tribeCrypto.getGen(chat.rootId) }
}
} }
} }
@ -414,18 +468,51 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
if (!matchedChat) throw new Error("Invalid or expired invite code") if (!matchedChat) throw new Error("Invalid or expired invite code")
if (matchedChat.members.includes(userId)) throw new Error("Already a participant") if (matchedChat.members.includes(userId)) throw new Error("Already a participant")
if (tribeCrypto && typeof matchedInvite === "object" && matchedInvite.ek) { let chatKey = null
const chatKey = tribeCrypto.decryptFromInvite(matchedInvite.ek, code) if (tribeCrypto && typeof matchedInvite === "object") {
tribeCrypto.setKey(matchedChat.rootId, chatKey, matchedInvite.gen || 1) if (matchedInvite.ekChain) {
const chain = tribeCrypto.decryptChainFromInvite(matchedInvite.ekChain, code)
if (Array.isArray(chain) && chain.length) {
for (const entry of chain) {
if (Array.isArray(entry.keys) && entry.keys.length) {
tribeCrypto.setKeys(entry.rootId, entry.keys, entry.gen || entry.keys.length)
} else if (entry.key) {
tribeCrypto.setKey(entry.rootId, entry.key, entry.gen || 1)
}
}
chatKey = chain[0].key
}
} else if (matchedInvite.ek) {
chatKey = tribeCrypto.decryptFromInvite(matchedInvite.ek, code)
tribeCrypto.setKey(matchedChat.rootId, chatKey, matchedInvite.gen || 1)
}
} }
const members = [...matchedChat.members, userId] const members = [...matchedChat.members, userId]
const invites = matchedChat.invites.filter(inv => { const isPublicInvite = typeof matchedInvite === "object" && matchedInvite.public === true
const invites = isPublicInvite ? matchedChat.invites : matchedChat.invites.filter(inv => {
if (typeof inv === "string") return inv !== code if (typeof inv === "string") return inv !== code
return inv.code !== code return inv.code !== code
}) })
await this.updateChatById(matchedChat.key, { members, invites, status: matchedChat.status, title: matchedChat.title, description: matchedChat.description, image: matchedChat.image, category: matchedChat.category, tags: matchedChat.tags }, { skipAuthorCheck: true }) await this.updateChatById(matchedChat.key, { members, invites, status: matchedChat.status, title: matchedChat.title, description: matchedChat.description, image: matchedChat.image, category: matchedChat.category, tags: matchedChat.tags }, { skipAuthorCheck: true })
if (tribeCrypto && chatKey) {
try {
const ssbKeys = require("../server/node_modules/ssb-keys")
const memberKeys = {}
try { memberKeys[userId] = tribeCrypto.boxKeyForMember(chatKey, userId, ssbKeys) } catch (_) {}
if (matchedChat.author && matchedChat.author !== userId) {
try { memberKeys[matchedChat.author] = tribeCrypto.boxKeyForMember(chatKey, matchedChat.author, ssbKeys) } catch (_) {}
}
if (Object.keys(memberKeys).length) {
await new Promise((resolve) => {
ssbClient.publish({ type: "tribe-keys", tribeId: matchedChat.rootId, generation: tribeCrypto.getGen(matchedChat.rootId) || 1, memberKeys }, () => resolve())
})
}
} catch (_) {}
}
return matchedChat.key return matchedChat.key
}, },
@ -437,17 +524,12 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
if (chat.status === "CLOSED") throw new Error("Chat is closed") if (chat.status === "CLOSED") throw new Error("Chat is closed")
if (chat.members.includes(userId)) return chat.key if (chat.members.includes(userId)) return chat.key
const members = [...chat.members, userId] if (tribeCrypto && Array.isArray(chat.invites)) {
const pub = chat.invites.find(inv => typeof inv === "object" && inv.public === true && inv.code && (inv.ek || inv.ekChain))
if (tribeCrypto) { if (pub) return await this.joinByInvite(pub.code)
const chatKey = tribeCrypto.getKey(chat.rootId)
if (chatKey && ssbClient.keys) {
try {
tribeCrypto.boxKeyForMember(chatKey, userId, ssbClient.keys)
} catch (_) {}
}
} }
const members = [...chat.members, userId]
await this.updateChatById(chatId, { members, invites: chat.invites, status: chat.status, title: chat.title, description: chat.description, image: chat.image, category: chat.category, tags: chat.tags }, { skipAuthorCheck: true }) await this.updateChatById(chatId, { members, invites: chat.invites, status: chat.status, title: chat.title, description: chat.description, image: chat.image, category: chat.category, tags: chat.tags }, { skipAuthorCheck: true })
return chat.key return chat.key
}, },

View file

@ -14,6 +14,7 @@ const fs = require('fs/promises');
const os = require('os'); const os = require('os');
const ssbRef = require("../server/node_modules/ssb-ref"); const ssbRef = require("../server/node_modules/ssb-ref");
const nameCache = require('../backend/nameCache');
const { getConfig } = require('../configs/config-manager.js'); const { getConfig } = require('../configs/config-manager.js');
const logLimit = getConfig().ssbLogStream?.limit || 1000; const logLimit = getConfig().ssbLogStream?.limit || 1000;
@ -63,7 +64,7 @@ const publicOnlyFilter = pull.filter(isNotPrivate);
const configure = (...customOptions) => const configure = (...customOptions) =>
Object.assign({}, defaultOptions, ...customOptions); Object.assign({}, defaultOptions, ...customOptions);
// peers // PEERS
const ebtDir = path.join(os.homedir(), '.ssb', 'ebt'); const ebtDir = path.join(os.homedir(), '.ssb', 'ebt');
const unfollowedPath = path.join(os.homedir(), '.ssb', 'gossip_unfollowed.json'); const unfollowedPath = path.join(os.homedir(), '.ssb', 'gossip_unfollowed.json');
@ -117,13 +118,10 @@ const canonicalizePubId = (s) => {
}; };
const parseRemote = (remote) => { const parseRemote = (remote) => {
// net: format (TCP)
let m = /^net:([^:]+):\d+~shs:([^=]+)=/.exec(remote); let m = /^net:([^:]+):\d+~shs:([^=]+)=/.exec(remote);
if (m) return { host: m[1], pubId: canonicalizePubId(m[2]) }; if (m) return { host: m[1], pubId: canonicalizePubId(m[2]) };
// ws/wss format (WebSocket)
m = /^wss?:\/\/([^:/]+)(?::\d+)?.*~shs:([^=]+)=/.exec(remote); m = /^wss?:\/\/([^:/]+)(?::\d+)?.*~shs:([^=]+)=/.exec(remote);
if (m) return { host: m[1], pubId: canonicalizePubId(m[2]) }; if (m) return { host: m[1], pubId: canonicalizePubId(m[2]) };
// Generic: extract ~shs: part from any format
m = /~shs:([^=]+)=/.exec(remote); m = /~shs:([^=]+)=/.exec(remote);
if (m) return { host: null, pubId: canonicalizePubId(m[1]) }; if (m) return { host: null, pubId: canonicalizePubId(m[1]) };
return { host: null, pubId: null }; return { host: null, pubId: null };
@ -161,7 +159,7 @@ function toLegacyInvite(s) {
return `${m[1]}:${m[2]}:@${key}~${m[4]}`; return `${m[1]}:${m[2]}:@${key}~${m[4]}`;
} }
// core modules // CORE MODEL
module.exports = ({ cooler, isPublic }) => { module.exports = ({ cooler, isPublic }) => {
const models = {}; const models = {};
const getAbout = async ({ key, feedId }) => { const getAbout = async ({ key, feedId }) => {
@ -290,7 +288,7 @@ module.exports = ({ cooler, isPublic }) => {
); );
}; };
//ABOUT MODEL // ABOUT MODEL
models.about = { models.about = {
publicWebHosting: async (feedId) => { publicWebHosting: async (feedId) => {
const result = await getAbout({ const result = await getAbout({
@ -303,12 +301,16 @@ models.about = {
if (isPublic && (await models.about.publicWebHosting(feedId)) === false) { if (isPublic && (await models.about.publicWebHosting(feedId)) === false) {
return "Redacted"; return "Redacted";
} }
return ( const resolved = await getAbout({ key: "name", feedId });
(await getAbout({ if (resolved) nameCache.set(feedId, resolved, Date.now());
key: "name", return resolved || feedId.slice(1, 1 + 8);
feedId, },
})) || feedId.slice(1, 1 + 8) nameSync: (feedId) => {
); if (!feedId) return null;
const cached = nameCache.get(feedId);
if (cached) return cached;
const local = feeds_to_name[feedId];
return local && local.name ? local.name : null;
}, },
named: (name) => { named: (name) => {
let found = []; let found = [];
@ -397,9 +399,11 @@ models.about = {
if (typeof currentEntry == "undefined") { if (typeof currentEntry == "undefined") {
dirty = true; dirty = true;
feeds_to_name[feed] = newEntry; feeds_to_name[feed] = newEntry;
nameCache.set(feed, name, ts);
} else if (currentEntry.ts < ts) { } else if (currentEntry.ts < ts) {
dirty = true; dirty = true;
feeds_to_name[feed] = newEntry; feeds_to_name[feed] = newEntry;
nameCache.set(feed, name, ts);
} }
}, (err) => { }, (err) => {
console.error(err); console.error(err);
@ -476,7 +480,7 @@ models.blob = {
} }
}; };
//FRIENDS MODEL // FRIENDS MODEL
models.friend = { models.friend = {
setRelationship: async ({ feedId, following, blocking }) => { setRelationship: async ({ feedId, following, blocking }) => {
if (following && blocking) { if (following && blocking) {
@ -566,7 +570,7 @@ models.friend = {
}, },
}; };
//META MODEL // META MODEL
models.meta = { models.meta = {
myFeedId: async () => { myFeedId: async () => {
const ssb = await cooler.open(); const ssb = await cooler.open();
@ -601,14 +605,97 @@ models.meta = {
pull.take(1), pull.take(1),
pull.collect((err, [entries]) => { pull.collect((err, [entries]) => {
if (err) return reject(err); if (err) return reject(err);
resolve(entries); resolve(entries || []);
}) })
); );
}); });
}, },
connectedPeers: async () => { connectedPeers: async () => {
const peers = await models.meta.peers(); const ssb = await cooler.open();
return peers.filter(([_, data]) => data.state === "connected"); const connEntries = await models.meta.peers();
const seen = new Set();
const result = [];
const lookupAddr = (key) => {
for (const [addr, data] of connEntries) {
if (data && data.key === key) return addr;
}
return null;
};
const lookupConnData = (key) => {
for (const [, data] of connEntries) {
if (data && data.key === key) return data;
}
return null;
};
try {
const livePeers = ssb && ssb.peers && typeof ssb.peers === "object" ? ssb.peers : {};
for (const rawKey of Object.keys(livePeers)) {
if (!rawKey || rawKey === ssb.id) continue;
const rpcs = livePeers[rawKey];
if (!Array.isArray(rpcs) || rpcs.length === 0) continue;
const key = canonicalizePubId(rawKey);
if (seen.has(key)) continue;
seen.add(key);
const existing = lookupConnData(key) || {};
const addr = (rpcs[0] && rpcs[0].stream && rpcs[0].stream.address) || lookupAddr(key) || `live:${key}`;
result.push([addr, { ...existing, key, state: "connected", source: "rpc" }]);
}
} catch {}
for (const [addr, data] of connEntries) {
if (!data || data.state !== "connected" || !data.key || seen.has(data.key)) continue;
seen.add(data.key);
result.push([addr, data]);
}
try {
const gp = ssb.gossip && typeof ssb.gossip.peers === "function" ? ssb.gossip.peers() : [];
const RECENT_MS = 30 * 60 * 1000;
const now = Date.now();
for (const p of (gp || [])) {
if (!p || !p.key) continue;
const key = canonicalizePubId(p.key);
if (seen.has(key)) continue;
const isConnected = p.state === "connected";
const recentlyConnected =
!isConnected &&
(p.failure === 0 || p.failure === undefined || p.failure === null) &&
typeof p.stateChange === "number" &&
(now - p.stateChange) < RECENT_MS;
if (!isConnected && !recentlyConnected) continue;
let addr = p.address;
if (!addr && p.host && p.port) {
const core = String(p.key).replace(/^@/, "").replace(/\.ed25519$/, "");
addr = `net:${p.host}:${p.port}~shs:${core}`;
}
if (!addr) continue;
seen.add(key);
result.push([addr, { ...p, key, state: "connected", source: isConnected ? "gossip" : "recent" }]);
}
} catch {}
try {
const myId = ssb.id;
const status = ssb.ebt && typeof ssb.ebt.peerStatus === "function" ? ssb.ebt.peerStatus(myId) : null;
const ebtPeers = (status && status.peers) ? Object.keys(status.peers) : [];
for (const rawKey of ebtPeers) {
if (!rawKey) continue;
const key = canonicalizePubId(rawKey);
if (seen.has(key)) continue;
let addr = lookupAddr(key);
if (!addr) {
const core = String(key).replace(/^@/, "").replace(/\.ed25519$/, "");
addr = `ebt:${core}`;
}
seen.add(key);
result.push([addr, { key, state: "connected", source: "ebt" }]);
}
} catch {}
return result;
}, },
onlinePeers: async () => { onlinePeers: async () => {
const entries = await models.meta.connectedPeers(); const entries = await models.meta.connectedPeers();
@ -617,7 +704,6 @@ 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();
// Read gossip.json to merge announcers data
const gossipPath = path.join(os.homedir(), '.ssb', 'gossip.json'); const gossipPath = path.join(os.homedir(), '.ssb', 'gossip.json');
let gossipMap = new Map(); let gossipMap = new Map();
try { try {
@ -629,7 +715,6 @@ models.meta = {
} }
} catch {} } catch {}
const allDbPeers = await enrichEntries(snapshot); const allDbPeers = await enrichEntries(snapshot);
// Merge announcers from gossip.json into enriched peers
for (const [, peerData] of allDbPeers) { for (const [, peerData] of allDbPeers) {
if ((!peerData.announcers || peerData.announcers === 0) && gossipMap.has(peerData.key)) { if ((!peerData.announcers || peerData.announcers === 0) && gossipMap.has(peerData.key)) {
const gossipEntry = gossipMap.get(peerData.key); const gossipEntry = gossipMap.get(peerData.key);
@ -637,15 +722,13 @@ models.meta = {
} }
} }
const connectedEntries = await models.meta.connectedPeers(); const connectedEntries = await models.meta.connectedPeers();
const onlineKeys = new Set(connectedEntries.map(([remote]) => { const onlineKeys = new Set(
const m = /~shs:([^=]+)=/.exec(remote); connectedEntries
if (!m) return null; .map(([, d]) => d && d.key ? canonicalizePubId(d.key) : null)
let core = m[1].replace(/-/g, '+').replace(/_/g, '/'); .filter(Boolean)
if (!core.endsWith('=')) core += '='; );
return `@${core}.ed25519`; const discoveredPeers = allDbPeers.filter(([, d]) => !onlineKeys.has(canonicalizePubId(d.key)));
}).filter(Boolean)); const discoveredIds = new Set(allDbPeers.map(([, d]) => canonicalizePubId(d.key)));
const discoveredPeers = allDbPeers.filter(([, d]) => !onlineKeys.has(d.key));
const discoveredIds = new Set(allDbPeers.map(([, d]) => d.key));
const ebtList = await loadPeersFromEbt(); const ebtList = await loadPeersFromEbt();
const ebtMap = new Map(ebtList.map(e => [e.pub, e.users])); const ebtMap = new Map(ebtList.map(e => [e.pub, e.users]));
const unknownPeers = []; const unknownPeers = [];
@ -723,9 +806,7 @@ models.meta = {
const ssb = await cooler.open(); const ssb = await cooler.open();
const code = toLegacyInvite(String(invite || '')); const code = toLegacyInvite(String(invite || ''));
const result = await new Promise((resolve, reject) => { const result = await new Promise((resolve, reject) => {
ssb.invite.accept(code, (err, res) => { ssb.invite.accept(code, (err, res) => err ? reject(err) : resolve(res));
err ? reject(err) : resolve(res);
});
}); });
const { host, pubId } = parseRemote(code); const { host, pubId } = parseRemote(code);
await new Promise((resolve) => { await new Promise((resolve) => {
@ -734,7 +815,7 @@ models.meta = {
pubHost: host || null, pubHost: host || null,
pubKey: pubId || null, pubKey: pubId || null,
acceptedAt: new Date().toISOString(), acceptedAt: new Date().toISOString(),
}, (err) => resolve()); }, (_err) => resolve());
}); });
return result; return result;
}, },
@ -1954,7 +2035,5 @@ models.vote = {
}); });
}, },
}; };
//return models
return models; return models;
}; };

View file

@ -1,7 +1,9 @@
const pull = require("../server/node_modules/pull-stream"); const pull = require("../server/node_modules/pull-stream");
const crypto = require("crypto");
const { getConfig } = require("../configs/config-manager.js"); const { getConfig } = require("../configs/config-manager.js");
const logLimit = getConfig().ssbLogStream?.limit || 1000; const logLimit = getConfig().ssbLogStream?.limit || 1000;
const INVITE_CODE_BYTES = 16;
const safeArr = (v) => (Array.isArray(v) ? v : []); const safeArr = (v) => (Array.isArray(v) ? v : []);
@ -25,7 +27,47 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
const encryptIfTribe = tribeHelpers ? tribeHelpers.encryptIfTribe : async (c) => c; const encryptIfTribe = tribeHelpers ? tribeHelpers.encryptIfTribe : async (c) => c;
const decryptIfTribe = tribeHelpers ? tribeHelpers.decryptIfTribe : async (c) => c; const decryptIfTribe = tribeHelpers ? tribeHelpers.decryptIfTribe : async (c) => c;
const assertReadable = tribeHelpers ? tribeHelpers.assertReadable : () => {}; const assertReadable = tribeHelpers ? tribeHelpers.assertReadable : () => {};
const decryptIndexNodes = tribeHelpers ? tribeHelpers.decryptIndexNodes : async () => {};
const encryptStandalone = (content, rootId) => {
if (!tribeCrypto || !rootId) return content;
const key = tribeCrypto.getKey(rootId);
if (!key) return content;
return tribeCrypto.encryptContent(content, [key], true);
};
const decryptMapRoot = (content, rootId) => {
if (!content || !content.encryptedPayload) return content;
if (!tribeCrypto) return content;
const keys = tribeCrypto.getKeys(rootId);
if (!keys || !keys.length) return { ...content, _undecryptable: true };
return tribeCrypto.decryptContent(content, keys.map(k => [k]));
};
const decryptIndexNodes = async (idx) => {
if (!tribeCrypto) return;
for (const [k, n] of (idx.nodes ? idx.nodes.entries() : [])) {
if (!n.c || !n.c.encryptedPayload) continue;
let root = k;
if (idx.parent) { while (idx.parent.has(root)) root = idx.parent.get(root); }
else if (idx.backward) { while (idx.backward.has(root)) root = idx.backward.get(root); }
let dec = null;
if (n.c.tribeId && tribesModel) {
try {
const r = await tribeCrypto.decryptFromTribe(n.c, tribesModel);
if (r && !r._undecryptable) dec = r;
} catch (_) {}
}
if (!dec) {
const r = decryptMapRoot(n.c, root);
if (r && !r._undecryptable) dec = r;
}
if (dec) {
idx.nodes.set(k, { ...n, c: { ...dec, _decrypted: true } });
} else {
idx.nodes.set(k, { ...n, c: { ...n.c, _decrypted: false } });
}
}
};
const getAllMessages = async (ssbClient) => const getAllMessages = async (ssbClient) =>
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
@ -157,6 +199,8 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
mapType: ALLOWED_MAP_TYPES.has(c.mapType) ? c.mapType : "SINGLE", mapType: ALLOWED_MAP_TYPES.has(c.mapType) ? c.mapType : "SINGLE",
tags: safeArr(c.tags), tags: safeArr(c.tags),
author: c.author, author: c.author,
members: Array.isArray(c.members) ? c.members : [],
invites: Array.isArray(c.invites) ? c.invites : [],
tribeId: c.tribeId || null, tribeId: c.tribeId || null,
encrypted: !!undec, encrypted: !!undec,
createdAt: c.createdAt || new Date(node.ts).toISOString(), createdAt: c.createdAt || new Date(node.ts).toISOString(),
@ -195,11 +239,12 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
async createMap(lat, lng, description, mapType, tagsRaw, title, tribeId, markerLabel, image) { async createMap(lat, lng, description, mapType, tagsRaw, title, tribeId, markerLabel, image) {
const ssbClient = await openSsb(); const ssbClient = await openSsb();
const userId = ssbClient.id;
const tags = normalizeTags(tagsRaw) || []; const tags = normalizeTags(tagsRaw) || [];
const now = new Date().toISOString(); const now = new Date().toISOString();
const mType = ALLOWED_MAP_TYPES.has(mapType) ? mapType : "SINGLE"; const mType = ALLOWED_MAP_TYPES.has(mapType) ? mapType : "SINGLE";
let content = { let plainContent = {
type: "map", type: "map",
title: title || "", title: title || "",
lat: parseFloat(lat) || 0, lat: parseFloat(lat) || 0,
@ -207,7 +252,9 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
description: description || "", description: description || "",
markerLabel: markerLabel || "", markerLabel: markerLabel || "",
mapType: mType, mapType: mType,
author: ssbClient.id, author: userId,
members: [userId],
invites: [],
tags, tags,
...(tribeId ? { tribeId } : {}), ...(tribeId ? { tribeId } : {}),
...(image ? { image } : {}), ...(image ? { image } : {}),
@ -215,21 +262,71 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
updatedAt: now updatedAt: now
}; };
content = await encryptIfTribe(content); const shouldEncryptStandalone = !tribeId && tribeCrypto && (mType === "OPEN" || mType === "CLOSED");
let mapKey = null;
let content = plainContent;
if (tribeId) {
content = await encryptIfTribe(plainContent);
} else if (shouldEncryptStandalone) {
mapKey = tribeCrypto.generateTribeKey();
content = tribeCrypto.encryptContent(plainContent, [mapKey], true);
}
return new Promise((resolve, reject) => { const result = await new Promise((resolve, reject) => {
ssbClient.publish(content, (err, res) => (err ? reject(err) : resolve(res))); ssbClient.publish(content, (err, res) => (err ? reject(err) : resolve(res)));
}); });
if (mapKey) {
tribeCrypto.setKey(result.key, mapKey, 1);
try {
const ssbKeys = require("../server/node_modules/ssb-keys");
const boxedKey = tribeCrypto.boxKeyForMember(mapKey, userId, ssbKeys);
await new Promise((resolve) => {
ssbClient.publish({ type: "tribe-keys", tribeId: result.key, generation: 1, memberKeys: { [userId]: boxedKey } }, () => resolve());
});
} catch (_) {}
if (mType === "OPEN") {
try {
const pubCode = crypto.randomBytes(INVITE_CODE_BYTES).toString("hex");
const ek = tribeCrypto.encryptForInvite(mapKey, pubCode);
let updated = {
type: "map",
replaces: result.key,
title: plainContent.title,
lat: plainContent.lat,
lng: plainContent.lng,
description: plainContent.description,
markerLabel: plainContent.markerLabel,
mapType: mType,
author: userId,
members: [userId],
invites: [{ code: pubCode, ek, gen: 1, public: true }],
tags,
...(image ? { image } : {}),
createdAt: now,
updatedAt: new Date().toISOString()
};
updated = encryptStandalone(updated, result.key);
await new Promise((resolve, reject) => ssbClient.publish(updated, (e, r) => e ? reject(e) : resolve(r)));
await new Promise((resolve, reject) => ssbClient.publish({ type: "tombstone", target: result.key, deletedAt: new Date().toISOString(), author: userId }, e => e ? reject(e) : resolve()));
} catch (_) {}
}
}
return result;
}, },
async updateMapById(id, lat, lng, description, mapType, tagsRaw, title, image) { async updateMapById(id, lat, lng, description, mapType, tagsRaw, title, image) {
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);
const rootId = await this.resolveRootId(id);
const oldMsg = await getMsg(ssbClient, tipId); const oldMsg = await getMsg(ssbClient, tipId);
if (!oldMsg || oldMsg.content?.type !== "map") throw new Error("Map not found"); if (!oldMsg || oldMsg.content?.type !== "map") throw new Error("Map not found");
const oldDecrypted = await decryptIfTribe(oldMsg.content); const oldDecrypted = oldMsg.content.tribeId
? await decryptIfTribe(oldMsg.content)
: decryptMapRoot(oldMsg.content, rootId);
assertReadable(oldDecrypted, "Map"); assertReadable(oldDecrypted, "Map");
if ((oldDecrypted.author || oldMsg.content.author) !== userId) throw new Error("Not the author"); if ((oldDecrypted.author || oldMsg.content.author) !== userId) throw new Error("Not the author");
@ -248,13 +345,19 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
mapType: mType, mapType: mType,
tags, tags,
author: oldDecrypted.author || userId, author: oldDecrypted.author || userId,
members: Array.isArray(oldDecrypted.members) ? oldDecrypted.members : [userId],
invites: Array.isArray(oldDecrypted.invites) ? oldDecrypted.invites : [],
...(oldMsg.content.tribeId ? { tribeId: oldMsg.content.tribeId } : {}), ...(oldMsg.content.tribeId ? { tribeId: oldMsg.content.tribeId } : {}),
...(image ? { image } : (oldDecrypted.image ? { image: oldDecrypted.image } : {})), ...(image ? { image } : (oldDecrypted.image ? { image: oldDecrypted.image } : {})),
createdAt: oldDecrypted.createdAt, createdAt: oldDecrypted.createdAt,
updatedAt: now updatedAt: now
}; };
updated = await encryptIfTribe(updated); if (oldMsg.content.tribeId) {
updated = await encryptIfTribe(updated);
} else if (mType !== "SINGLE") {
updated = encryptStandalone(updated, rootId);
}
const result = await new Promise((resolve, reject) => { const result = await new Promise((resolve, reject) => {
ssbClient.publish(updated, (err, res) => (err ? reject(err) : resolve(res))); ssbClient.publish(updated, (err, res) => (err ? reject(err) : resolve(res)));
@ -303,9 +406,11 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
if (mapType === "CLOSED" && mapAuthor !== userId) throw new Error("Only the map creator can add markers"); if (mapType === "CLOSED" && mapAuthor !== userId) throw new Error("Only the map creator can add markers");
const now = new Date().toISOString(); const now = new Date().toISOString();
let rootId = tipId;
while (idx.backward && idx.backward.has(rootId)) rootId = idx.backward.get(rootId);
let content = { let content = {
type: "mapMarker", type: "mapMarker",
mapId: tipId, mapId: rootId,
lat: parseFloat(lat) || 0, lat: parseFloat(lat) || 0,
lng: parseFloat(lng) || 0, lng: parseFloat(lng) || 0,
label: label || "", label: label || "",
@ -315,7 +420,12 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
}; };
if (image) content.image = image; if (image) content.image = image;
content = await encryptIfTribe(content); if (node.c.tribeId) {
content = await encryptIfTribe(content);
} else if (tribeCrypto) {
const mapKey = tribeCrypto.getKey(rootId);
if (mapKey) content = tribeCrypto.encryptContent(content, [mapKey], true);
}
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
ssbClient.publish(content, (err, res) => (err ? reject(err) : resolve(res))); ssbClient.publish(content, (err, res) => (err ? reject(err) : resolve(res)));
@ -395,6 +505,150 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
const markerList = safeArr(idx.markers.get(tip)).concat(safeArr(idx.markers.get(root))); const markerList = safeArr(idx.markers.get(tip)).concat(safeArr(idx.markers.get(root)));
return buildMap(node, root, viewer, markerList); return buildMap(node, root, viewer, markerList);
},
async generateInvite(mapId) {
const ssbClient = await openSsb();
const userId = ssbClient.id;
const map = await this.getMapById(mapId, userId);
if (!map) throw new Error("Map not found");
if (map.author !== userId) throw new Error("Only the author can generate invites");
const code = crypto.randomBytes(INVITE_CODE_BYTES).toString("hex");
let invite = code;
if (tribeCrypto && !map.tribeId) {
const ekChain = tribeCrypto.encryptChainForInvite([map.rootId || map.key], code);
if (ekChain) invite = { code, ekChain, gen: tribeCrypto.getGen(map.rootId || map.key) || 1 };
}
const tipId = await this.resolveCurrentId(mapId);
const rootId = await this.resolveRootId(mapId);
const oldMsg = await getMsg(ssbClient, tipId);
const oldDecrypted = oldMsg.content.tribeId
? await decryptIfTribe(oldMsg.content)
: decryptMapRoot(oldMsg.content, rootId);
const invites = [...(Array.isArray(oldDecrypted.invites) ? oldDecrypted.invites : []), invite];
let updated = {
type: "map",
replaces: tipId,
title: oldDecrypted.title || "",
lat: oldDecrypted.lat,
lng: oldDecrypted.lng,
description: oldDecrypted.description || "",
markerLabel: oldDecrypted.markerLabel || "",
mapType: oldDecrypted.mapType,
tags: Array.isArray(oldDecrypted.tags) ? oldDecrypted.tags : [],
author: oldDecrypted.author,
members: Array.isArray(oldDecrypted.members) ? oldDecrypted.members : [userId],
invites,
...(oldMsg.content.tribeId ? { tribeId: oldMsg.content.tribeId } : {}),
...(oldDecrypted.image ? { image: oldDecrypted.image } : {}),
createdAt: oldDecrypted.createdAt,
updatedAt: new Date().toISOString()
};
if (oldMsg.content.tribeId) updated = await encryptIfTribe(updated);
else if (oldDecrypted.mapType !== "SINGLE") updated = encryptStandalone(updated, rootId);
await new Promise((resolve, reject) => ssbClient.publish(updated, (e, r) => e ? reject(e) : resolve(r)));
await new Promise((resolve, reject) => ssbClient.publish({ type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }, e => e ? reject(e) : resolve()));
return code;
},
async joinByInvite(code) {
const ssbClient = await openSsb();
const userId = ssbClient.id;
const maps = await this.listAll({ filter: "all", viewerId: userId });
let matched = null;
let matchedInvite = null;
for (const m of maps) {
const invs = Array.isArray(m.invites) ? m.invites : [];
for (const inv of invs) {
if (typeof inv === "string" && inv === code) { matched = m; matchedInvite = inv; break; }
if (typeof inv === "object" && inv.code === code) { matched = m; matchedInvite = inv; break; }
}
if (matched) break;
}
if (!matched) throw new Error("Invalid or expired invite code");
if (Array.isArray(matched.members) && matched.members.includes(userId)) throw new Error("Already a member");
let mapKey = null;
if (tribeCrypto && typeof matchedInvite === "object") {
if (matchedInvite.ekChain) {
const chain = tribeCrypto.decryptChainFromInvite(matchedInvite.ekChain, code);
if (Array.isArray(chain) && chain.length) {
for (const entry of chain) {
if (Array.isArray(entry.keys) && entry.keys.length) {
tribeCrypto.setKeys(entry.rootId, entry.keys, entry.gen || entry.keys.length);
} else if (entry.key) {
tribeCrypto.setKey(entry.rootId, entry.key, entry.gen || 1);
}
}
mapKey = chain[0].key;
}
} else if (matchedInvite.ek) {
mapKey = tribeCrypto.decryptFromInvite(matchedInvite.ek, code);
tribeCrypto.setKey(matched.rootId || matched.key, mapKey, matchedInvite.gen || 1);
}
}
const tipId = await this.resolveCurrentId(matched.rootId || matched.key);
const rootId = await this.resolveRootId(matched.rootId || matched.key);
const oldMsg = await getMsg(ssbClient, tipId);
const oldDecrypted = oldMsg.content.tribeId
? await decryptIfTribe(oldMsg.content)
: decryptMapRoot(oldMsg.content, rootId);
const isPublicInvite = typeof matchedInvite === "object" && matchedInvite.public === true;
const invites = isPublicInvite
? (Array.isArray(oldDecrypted.invites) ? oldDecrypted.invites : [])
: (Array.isArray(oldDecrypted.invites) ? oldDecrypted.invites : []).filter(inv => {
if (typeof inv === "string") return inv !== code;
return inv.code !== code;
});
let updated = {
type: "map",
replaces: tipId,
title: oldDecrypted.title || "",
lat: oldDecrypted.lat,
lng: oldDecrypted.lng,
description: oldDecrypted.description || "",
markerLabel: oldDecrypted.markerLabel || "",
mapType: oldDecrypted.mapType,
tags: Array.isArray(oldDecrypted.tags) ? oldDecrypted.tags : [],
author: oldDecrypted.author,
members: [...(Array.isArray(oldDecrypted.members) ? oldDecrypted.members : []), userId],
invites,
...(oldMsg.content.tribeId ? { tribeId: oldMsg.content.tribeId } : {}),
...(oldDecrypted.image ? { image: oldDecrypted.image } : {}),
createdAt: oldDecrypted.createdAt,
updatedAt: new Date().toISOString()
};
if (oldMsg.content.tribeId) updated = await encryptIfTribe(updated);
else if (oldDecrypted.mapType !== "SINGLE") updated = encryptStandalone(updated, rootId);
await new Promise((resolve, reject) => ssbClient.publish(updated, (e, r) => e ? reject(e) : resolve(r)));
await new Promise((resolve, reject) => ssbClient.publish({ type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }, e => e ? reject(e) : resolve()));
if (tribeCrypto && mapKey) {
try {
const ssbKeys = require("../server/node_modules/ssb-keys");
const memberKeys = {};
try { memberKeys[userId] = tribeCrypto.boxKeyForMember(mapKey, userId, ssbKeys); } catch (_) {}
if (matched.author && matched.author !== userId) {
try { memberKeys[matched.author] = tribeCrypto.boxKeyForMember(mapKey, matched.author, ssbKeys); } catch (_) {}
}
if (Object.keys(memberKeys).length) {
await new Promise((resolve) => {
ssbClient.publish({ type: "tribe-keys", tribeId: rootId, generation: tribeCrypto.getGen(rootId) || 1, memberKeys }, () => resolve());
});
}
} catch (_) {}
}
return rootId;
},
async joinMap(mapId) {
const userId = (await openSsb()).id;
const map = await this.getMapById(mapId, userId);
if (!map) throw new Error("Map not found");
if (Array.isArray(map.members) && map.members.includes(userId)) return map.rootId || map.key;
if (tribeCrypto && Array.isArray(map.invites)) {
const pub = map.invites.find(inv => typeof inv === "object" && inv.public === true && inv.code && (inv.ek || inv.ekChain));
if (pub) return await this.joinByInvite(pub.code);
}
throw new Error("This map requires an invite code to join");
} }
}; };
}; };

View file

@ -20,16 +20,41 @@ module.exports = ({ cooler, cipherModel, tribeCrypto, tribesModel }) => {
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb } const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb }
let keyringPath = null let keyringPath = null
const getKeyring = () => { let migratedToTribeCrypto = false
const getLegacyKeyringPath = () => {
if (!keyringPath) { if (!keyringPath) {
const ssbConfig = require("../server/node_modules/ssb-config/inject")() const ssbConfig = require("../server/node_modules/ssb-config/inject")()
keyringPath = path.join(ssbConfig.path, "pad-keys.json") keyringPath = path.join(ssbConfig.path, "pad-keys.json")
} }
try { return JSON.parse(fs.readFileSync(keyringPath, "utf8")) } catch (e) { return {} } return keyringPath
}
const migrateLegacyKeyring = () => {
if (migratedToTribeCrypto || !tribeCrypto) { migratedToTribeCrypto = true; return }
migratedToTribeCrypto = true
try {
const p = getLegacyKeyringPath()
if (!fs.existsSync(p)) return
const legacy = JSON.parse(fs.readFileSync(p, "utf8")) || {}
for (const [rootId, keyHex] of Object.entries(legacy)) {
if (rootId && keyHex && !tribeCrypto.getKey(rootId)) {
tribeCrypto.setKey(rootId, keyHex, 1)
}
}
} catch (_) {}
}
const getPadKey = (rootId) => {
migrateLegacyKeyring()
if (tribeCrypto) return tribeCrypto.getKey(rootId) || null
try { return JSON.parse(fs.readFileSync(getLegacyKeyringPath(), "utf8"))[rootId] || null } catch (_) { return null }
}
const setPadKey = (rootId, keyHex) => {
migrateLegacyKeyring()
if (tribeCrypto) { tribeCrypto.setKey(rootId, keyHex, 1); return }
let kr = {}
try { kr = JSON.parse(fs.readFileSync(getLegacyKeyringPath(), "utf8")) } catch (_) {}
kr[rootId] = keyHex
fs.writeFileSync(getLegacyKeyringPath(), JSON.stringify(kr, null, 2), "utf8")
} }
const saveKeyring = (kr) => fs.writeFileSync(keyringPath, JSON.stringify(kr, null, 2), "utf8")
const getPadKey = (rootId) => { const kr = getKeyring(); return kr[rootId] || null }
const setPadKey = (rootId, keyHex) => { const kr = getKeyring(); kr[rootId] = keyHex; saveKeyring(kr) }
const encryptField = (text, keyHex) => { const encryptField = (text, keyHex) => {
const key = Buffer.from(keyHex, "hex") const key = Buffer.from(keyHex, "hex")
@ -225,6 +250,13 @@ module.exports = ({ cooler, cipherModel, tribeCrypto, tribesModel }) => {
if (!keyHex) keyHex = crypto.randomBytes(32).toString("hex") if (!keyHex) keyHex = crypto.randomBytes(32).toString("hex")
const enc = (text) => encryptField(text, keyHex) const enc = (text) => encryptField(text, keyHex)
const initialInvites = []
if (validStatus === "OPEN" && !usesTribeKey) {
const pubCode = crypto.randomBytes(INVITE_BYTES).toString("hex")
const ek = encryptForInvite(keyHex, pubCode)
initialInvites.push({ code: pubCode, ek, gen: 1, public: true })
}
const content = { const content = {
type: "pad", type: "pad",
title: enc(safeText(title)), title: enc(safeText(title)),
@ -233,17 +265,28 @@ module.exports = ({ cooler, cipherModel, tribeCrypto, tribesModel }) => {
tags: enc(normalizeTags(tagsRaw).join(",")), tags: enc(normalizeTags(tagsRaw).join(",")),
author: ssbClient.id, author: ssbClient.id,
members: [ssbClient.id], members: [ssbClient.id],
invites: [], invites: initialInvites,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
encrypted: true, encrypted: true,
...(tribeId ? { tribeId } : {}) ...(tribeId ? { tribeId } : {})
} }
const userId = ssbClient.id
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
ssbClient.publish(content, (err, msg) => { ssbClient.publish(content, (err, msg) => {
if (err) return reject(err) if (err) return reject(err)
if (!usesTribeKey) setPadKey(msg.key, keyHex) if (!usesTribeKey) {
setPadKey(msg.key, keyHex)
if (tribeCrypto) {
try {
const ssbKeys = require("../server/node_modules/ssb-keys")
const boxedKey = tribeCrypto.boxKeyForMember(keyHex, userId, ssbKeys)
ssbClient.publish({ type: "tribe-keys", tribeId: msg.key, generation: 1, memberKeys: { [userId]: boxedKey } }, () => resolve(msg))
return
} catch (_) {}
}
}
resolve(msg) resolve(msg)
}) })
}) })
@ -452,13 +495,31 @@ module.exports = ({ cooler, cipherModel, tribeCrypto, tribesModel }) => {
} }
if (!matchedPad) throw new Error("Invalid or expired invite code") if (!matchedPad) throw new Error("Invalid or expired invite code")
if (matchedPad.members.includes(userId)) throw new Error("Already a member") if (matchedPad.members.includes(userId)) throw new Error("Already a member")
let padKey = null
let resolvedRootId = null
if (typeof matchedInvite === "object" && matchedInvite.ek) { if (typeof matchedInvite === "object" && matchedInvite.ek) {
const padKey = decryptFromInvite(matchedInvite.ek, code) padKey = decryptFromInvite(matchedInvite.ek, code)
const rootId = await this.resolveRootId(matchedPad.rootId) resolvedRootId = await this.resolveRootId(matchedPad.rootId)
setPadKey(rootId, padKey) setPadKey(resolvedRootId, padKey)
} }
await this.addMemberToPad(matchedPad.rootId, userId) await this.addMemberToPad(matchedPad.rootId, userId)
const invites = matchedPad.invites.filter(inv => { if (tribeCrypto && padKey && resolvedRootId) {
try {
const ssbKeys = require("../server/node_modules/ssb-keys")
const memberKeys = {}
try { memberKeys[userId] = tribeCrypto.boxKeyForMember(padKey, userId, ssbKeys) } catch (_) {}
if (matchedPad.author && matchedPad.author !== userId) {
try { memberKeys[matchedPad.author] = tribeCrypto.boxKeyForMember(padKey, matchedPad.author, ssbKeys) } catch (_) {}
}
if (Object.keys(memberKeys).length) {
await new Promise((resolve) => {
ssbClient.publish({ type: "tribe-keys", tribeId: resolvedRootId, generation: 1, memberKeys }, () => resolve())
})
}
} catch (_) {}
}
const isPublicInvite = typeof matchedInvite === "object" && matchedInvite.public === true
const invites = isPublicInvite ? matchedPad.invites : matchedPad.invites.filter(inv => {
if (typeof inv === "string") return inv !== code if (typeof inv === "string") return inv !== code
return inv.code !== code return inv.code !== code
}) })

View file

@ -3,13 +3,38 @@ const moment = require('../server/node_modules/moment');
const { getConfig } = require('../configs/config-manager.js'); const { getConfig } = require('../configs/config-manager.js');
const logLimit = getConfig().ssbLogStream?.limit || 1000; const logLimit = getConfig().ssbLogStream?.limit || 1000;
module.exports = ({ cooler, padsModel }) => { module.exports = ({ cooler, padsModel, tribeCrypto, tribesModel }) => {
let ssb; let ssb;
const openSsb = async () => { const openSsb = async () => {
if (!ssb) ssb = await cooler.open(); if (!ssb) ssb = await cooler.open();
return ssb; return ssb;
}; };
const STANDALONE_ENCRYPTED_TYPES = new Set(['chat', 'pad', 'map', 'calendar']);
const tryDecryptStandalone = (content) => {
if (!tribeCrypto || !content || !content.encryptedPayload) return null;
const rootCandidates = [content.calendarId, content.chatId, content.padId, content.mapId, content.roomId, content.parentId, content.dateId, content.rootId].filter(Boolean);
for (const cand of rootCandidates) {
const keys = tribeCrypto.getKeys(cand);
if (!keys || !keys.length) continue;
try {
const dec = tribeCrypto.decryptContent(content, keys.map(k => [k]));
if (dec && !dec._undecryptable) return dec;
} catch (_) {}
}
return null;
};
const tryDecryptTribe = async (content) => {
if (!tribeCrypto || !tribesModel || !content || !content.encryptedPayload || !content.tribeId) return null;
try {
const dec = await tribeCrypto.decryptFromTribe(content, tribesModel);
if (dec && !dec._undecryptable) return dec;
} catch (_) {}
return null;
};
const searchableTypes = [ const searchableTypes = [
'post', 'about', 'curriculum', 'tribe', 'transfer', 'feed', 'post', 'about', 'curriculum', 'tribe', 'transfer', 'feed',
'votes', 'report', 'task', 'event', 'bookmark', 'document', 'votes', 'report', 'task', 'event', 'bookmark', 'document',
@ -250,6 +275,7 @@ module.exports = ({ cooler, padsModel }) => {
const search = async ({ query, types = [], resultsPerPage = "10" }) => { const search = async ({ query, types = [], resultsPerPage = "10" }) => {
const ssbClient = await openSsb(); const ssbClient = await openSsb();
const viewerId = ssbClient.id;
const queryLower = String(query || '').toLowerCase(); const queryLower = String(query || '').toLowerCase();
const messages = await new Promise((res, rej) => { const messages = await new Promise((res, rej) => {
@ -277,6 +303,39 @@ module.exports = ({ cooler, padsModel }) => {
latestByKey.delete(oldId); latestByKey.delete(oldId);
} }
const viewerTribeIds = new Set();
if (tribesModel && typeof tribesModel.listTribesForViewer === 'function') {
try {
const myTribes = await tribesModel.listTribesForViewer(viewerId);
for (const tr of (myTribes || [])) viewerTribeIds.add(String(tr.rootId || tr.id || tr.key));
} catch (_) {}
}
for (const [k, msg] of Array.from(latestByKey.entries())) {
const c = msg?.value?.content;
if (!c) { latestByKey.delete(k); continue; }
if (c.tribeId && !viewerTribeIds.has(String(c.tribeId))) {
latestByKey.delete(k);
continue;
}
if (c.encryptedPayload) {
let dec = null;
if (c.tribeId) dec = await tryDecryptTribe(c);
if (!dec && STANDALONE_ENCRYPTED_TYPES.has(c.type)) {
const keys = tribeCrypto && tribeCrypto.getKeys ? tribeCrypto.getKeys(k) : [];
if (keys && keys.length) {
try {
const out = tribeCrypto.decryptContent(c, keys.map(kk => [kk]));
if (out && !out._undecryptable) dec = out;
} catch (_) {}
}
}
if (!dec) dec = tryDecryptStandalone(c);
if (!dec) { latestByKey.delete(k); continue; }
msg.value.content = { ...c, ...dec, encryptedPayload: undefined };
}
}
if (padsModel) { if (padsModel) {
for (const msg of latestByKey.values()) { for (const msg of latestByKey.values()) {
const c = msg?.value?.content; const c = msg?.value?.content;

View file

@ -319,17 +319,17 @@ module.exports = ({ cooler }) => {
const allTribesPublic = tribeDedupNodes const allTribesPublic = tribeDedupNodes
.filter(n => n.content?.isAnonymous === false) .filter(n => n.content?.isAnonymous === false)
.map(n => ({ id: n.key, name: n.content.name || n.content.title || n.key })); .map(n => ({ id: findRoot('tribe', n.key), name: n.content.name || n.content.title || n.key }));
const allTribes = allTribesPublic.map(t => t.name); const allTribes = allTribesPublic.map(t => t.name);
const memberTribesDetailed = tribeDedupNodes const memberTribesDetailed = tribeDedupNodes
.filter(n => Array.isArray(n.content?.members) && n.content.members.includes(userId)) .filter(n => Array.isArray(n.content?.members) && n.content.members.includes(userId))
.map(n => ({ id: n.key, name: n.content.name || n.content.title || n.key })); .map(n => ({ id: findRoot('tribe', n.key), name: n.content.name || n.content.title || n.key }));
const myPrivateTribesDetailed = tribeDedupNodes const myPrivateTribesDetailed = tribeDedupNodes
.filter(n => n.content?.isAnonymous !== false && Array.isArray(n.content?.members) && n.content.members.includes(userId)) .filter(n => n.content?.isAnonymous !== false && Array.isArray(n.content?.members) && n.content.members.includes(userId))
.map(n => ({ id: n.key, name: n.content.name || n.content.title || n.key })); .map(n => ({ id: findRoot('tribe', n.key), name: n.content.name || n.content.title || n.key }));
const content = {}; const content = {};
const opinions = {}; const opinions = {};
@ -446,6 +446,40 @@ module.exports = ({ cooler }) => {
.slice(0, 5) .slice(0, 5)
.map(([id, count]) => ({ id, count })); .map(([id, count]) => ({ id, count }));
const tagCount = new Map();
for (const t of types) {
for (const v of Array.from(tipOf[t].values())) {
const tags = v.content && Array.isArray(v.content.tags) ? v.content.tags : [];
for (const tg of tags) {
const key = String(tg || '').trim();
if (!key) continue;
tagCount.set(key, (tagCount.get(key) || 0) + 1);
}
}
}
const topTags = Array.from(tagCount.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([tag, count]) => ({ tag, count }));
const totalTypeCount = types.reduce((s, t) => s + (Array.from(tipOf[t].values()).length || 0), 0);
const topTypeBlacklist = new Set(['shopProduct','chatMessage','padEntry','calendarDate','calendarNote','log']);
const topTypes = types
.filter(t => !topTypeBlacklist.has(t))
.map(t => ({ type: t, count: Array.from(tipOf[t].values()).length || 0 }))
.filter(o => o.count > 0)
.sort((a, b) => b.count - a.count)
.slice(0, 10);
const myMsgsAll = allMsgs.filter(m => m.value.author === userId);
const myShare = allMsgs.length ? (myMsgsAll.length / allMsgs.length) * 100 : 0;
const avgMsgsPerInhabitant = inhabitants ? allMsgs.length / inhabitants : 0;
const tombstoneRatio = allMsgs.length ? (allMsgs.filter(m => m.value.content.type === 'tombstone').length / allMsgs.length) * 100 : 0;
const totalsTs = allMsgs.map(m => m.value.timestamp || 0).filter(Boolean).sort((a, b) => a - b);
const networkSpanDays = totalsTs.length >= 2 ? (totalsTs[totalsTs.length - 1] - totalsTs[0]) / 86400000 : 0;
const networkMsgsPerDay = networkSpanDays > 0 ? (allMsgs.length / networkSpanDays) : 0;
const addrMap = readAddrMap(); const addrMap = readAddrMap();
const myAddress = addrMap[userId] || null; const myAddress = addrMap[userId] || null;
const banking = { const banking = {
@ -507,8 +541,19 @@ module.exports = ({ cooler }) => {
}, },
tombstoneKPIs: { tombstoneKPIs: {
networkTombstoneCount: allMsgs.filter(m => m.value.content.type === 'tombstone').length, networkTombstoneCount: allMsgs.filter(m => m.value.content.type === 'tombstone').length,
ratio: allMsgs.length ? (allMsgs.filter(m => m.value.content.type === 'tombstone').length / allMsgs.length) * 100 : 0 ratio: tombstoneRatio
}, },
networkKPIs: {
totalMsgs: allMsgs.length,
myMsgs: myMsgsAll.length,
myShare,
avgMsgsPerInhabitant,
networkSpanDays,
networkMsgsPerDay
},
topTags,
topTypes,
totalTypeCount,
banking banking
}; };

View file

@ -130,23 +130,23 @@ module.exports = ({ cooler, padsModel, tribesModel }) => {
for (const oldId of replacesMap.keys()) latestByKey.delete(oldId); for (const oldId of replacesMap.keys()) latestByKey.delete(oldId);
const anonTribeIds = new Set(); const viewerId = ssbClient.id;
const viewerVisibleTribeIds = new Set();
if (tribesModel) { if (tribesModel) {
const allTribes = await tribesModel.listAll().catch(() => []); const visibleTribes = await tribesModel.listTribesForViewer(viewerId).catch(() => []);
for (const tribe of allTribes) { for (const tribe of visibleTribes) viewerVisibleTribeIds.add(tribe.id);
if (tribe.isAnonymous === true) anonTribeIds.add(tribe.id);
}
} }
let filtered = Array.from(latestByKey.values()).filter(msg => { let filtered = Array.from(latestByKey.values()).filter(msg => {
const c = msg?.value?.content; const c = msg?.value?.content;
if (!c || c.type === 'tombstone') return false; if (!c || c.type === 'tombstone') return false;
if (tombstoned.has(msg.key)) return false; if (tombstoned.has(msg.key)) return false;
if (c.encryptedPayload) return false;
if (!Array.isArray(c.tags) || !c.tags.filter(Boolean).length) return false; if (!Array.isArray(c.tags) || !c.tags.filter(Boolean).length) return false;
if (c.tribeId && anonTribeIds.has(c.tribeId)) return false; if (c.tribeId && !viewerVisibleTribeIds.has(c.tribeId)) return false;
if (c.type === 'event' && c.isPublic === 'private') return false; if (c.type === 'event' && c.isPublic === 'private') return false;
if (c.type === 'task' && String(c.isPublic).toUpperCase() === 'PRIVATE') return false; if (c.type === 'task' && String(c.isPublic).toUpperCase() === 'PRIVATE') return false;
if ((c.type === 'chat' || c.type === 'pad') && c.status === 'INVITE-ONLY') return false; if ((c.type === 'chat' || c.type === 'pad' || c.type === 'map' || c.type === 'calendar') && c.status === 'INVITE-ONLY' && c.author !== viewerId && !(Array.isArray(c.members) && c.members.includes(viewerId))) return false;
if (c.type === 'shop' && c.visibility === 'CLOSED') return false; if (c.type === 'shop' && c.visibility === 'CLOSED') return false;
return true; return true;
}); });

View file

@ -13,10 +13,12 @@ const ENVELOPE_PRESERVE = new Set([
'type', 'tribeId', 'contentType', 'replaces', 'target', 'author', 'type', 'tribeId', 'contentType', 'replaces', 'target', 'author',
'createdAt', 'updatedAt', 'encryptedPayload', 'createdAt', 'updatedAt', 'encryptedPayload',
'mapId', 'calendarId', 'dateId', 'padId', 'roomId', 'parentId', 'mapId', 'calendarId', 'dateId', 'padId', 'roomId', 'parentId',
'members', 'invites', 'participants',
'_decrypted', '_undecryptable' '_decrypted', '_undecryptable'
]); ]);
const INVITE_SALT = 'SolarNET.HuB'; const INVITE_SALT_LEGACY = 'SolarNET.HuB';
const INVITE_SCRYPT = { N: 131072, r: 8, p: 1, maxmem: 256 * 1024 * 1024 };
module.exports = (configPath) => { module.exports = (configPath) => {
const keyringPath = path.join(configPath, 'tribe-keys.json'); const keyringPath = path.join(configPath, 'tribe-keys.json');
@ -25,6 +27,7 @@ module.exports = (configPath) => {
const loadKeyring = () => { const loadKeyring = () => {
try { try {
keyring = JSON.parse(fs.readFileSync(keyringPath, 'utf8')); keyring = JSON.parse(fs.readFileSync(keyringPath, 'utf8'));
try { fs.chmodSync(keyringPath, 0o600); } catch (_) {}
} catch (e) { } catch (e) {
if (e.code !== 'ENOENT') throw e; if (e.code !== 'ENOENT') throw e;
keyring = {}; keyring = {};
@ -34,8 +37,9 @@ module.exports = (configPath) => {
const saveKeyring = () => { const saveKeyring = () => {
const tmp = keyringPath + '.tmp.' + process.pid + '.' + Date.now(); const tmp = keyringPath + '.tmp.' + process.pid + '.' + Date.now();
fs.writeFileSync(tmp, JSON.stringify(keyring, null, 2), 'utf8'); fs.writeFileSync(tmp, JSON.stringify(keyring, null, 2), { encoding: 'utf8', mode: 0o600 });
fs.renameSync(tmp, keyringPath); fs.renameSync(tmp, keyringPath);
try { fs.chmodSync(keyringPath, 0o600); } catch (_) {}
}; };
const generateTribeKey = () => crypto.randomBytes(32).toString('hex'); const generateTribeKey = () => crypto.randomBytes(32).toString('hex');
@ -60,8 +64,30 @@ module.exports = (configPath) => {
saveKeyring(); saveKeyring();
}; };
const setKeys = (tribeRootId, keysHex, topGen) => {
if (!Array.isArray(keysHex) || !keysHex.length) return;
const seen = new Set();
const dedup = [];
for (const k of keysHex) { if (k && !seen.has(k)) { seen.add(k); dedup.push(k); } }
keyring[tribeRootId] = { keys: dedup, gen: topGen || dedup.length };
saveKeyring();
};
const mergeKeys = (tribeRootId, incomingKeys, topGen) => {
const entry = keyring[tribeRootId] || { keys: [], gen: 0 };
const seen = new Set(entry.keys);
const merged = [...entry.keys];
for (const k of incomingKeys) {
if (k && !seen.has(k)) { seen.add(k); merged.push(k); }
}
keyring[tribeRootId] = { keys: merged, gen: Math.max(entry.gen || 0, topGen || merged.length) };
saveKeyring();
return keyring[tribeRootId].gen;
};
const addNewKey = (tribeRootId, newKeyHex) => { const addNewKey = (tribeRootId, newKeyHex) => {
const entry = keyring[tribeRootId] || { keys: [], gen: 0 }; const entry = keyring[tribeRootId] || { keys: [], gen: 0 };
if (entry.keys.includes(newKeyHex)) return entry.gen;
entry.keys.unshift(newKeyHex); entry.keys.unshift(newKeyHex);
entry.gen = (entry.gen || 0) + 1; entry.gen = (entry.gen || 0) + 1;
keyring[tribeRootId] = entry; keyring[tribeRootId] = entry;
@ -69,65 +95,110 @@ module.exports = (configPath) => {
return entry.gen; return entry.gen;
}; };
const encryptWithKey = (plaintext, keyHex) => { const canonicalAad = (envelope) => {
if (!envelope) return null;
const fields = ['type', 'tribeId', 'contentType', 'replaces', 'author', 'createdAt'];
const obj = {};
for (const f of fields) if (envelope[f] !== undefined && envelope[f] !== null) obj[f] = envelope[f];
return Buffer.from(JSON.stringify(obj), 'utf8');
};
const encryptWithKey = (plaintext, keyHex, aad) => {
const key = Buffer.from(keyHex, 'hex'); const key = Buffer.from(keyHex, 'hex');
const iv = crypto.randomBytes(12); const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv); const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
if (aad) cipher.setAAD(aad);
const enc = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); const enc = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
const authTag = cipher.getAuthTag(); const authTag = cipher.getAuthTag();
return iv.toString('hex') + authTag.toString('hex') + enc.toString('hex'); return iv.toString('hex') + authTag.toString('hex') + enc.toString('hex');
}; };
const decryptWithKey = (encrypted, keyHex) => { const decryptWithKey = (encrypted, keyHex, aad) => {
const key = Buffer.from(keyHex, 'hex'); const key = Buffer.from(keyHex, 'hex');
const iv = Buffer.from(encrypted.slice(0, 24), 'hex'); const iv = Buffer.from(encrypted.slice(0, 24), 'hex');
const authTag = Buffer.from(encrypted.slice(24, 56), 'hex'); const authTag = Buffer.from(encrypted.slice(24, 56), 'hex');
const ciphertext = Buffer.from(encrypted.slice(56), 'hex'); const ciphertext = Buffer.from(encrypted.slice(56), 'hex');
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv); const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
if (aad) decipher.setAAD(aad);
decipher.setAuthTag(authTag); decipher.setAuthTag(authTag);
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8'); return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8');
}; };
const encryptForInvite = (tribeKeyHex, inviteCode) => { const generateInviteSalt = () => crypto.randomBytes(16).toString('hex');
const derived = crypto.scryptSync(inviteCode, INVITE_SALT, 32);
const deriveInviteKey = (inviteCode, salt) => {
if (salt === undefined || salt === null || salt === '') {
return crypto.scryptSync(inviteCode, INVITE_SALT_LEGACY, 32);
}
return crypto.scryptSync(inviteCode, salt, 32, INVITE_SCRYPT);
};
const hashInviteCode = (inviteCode, salt) => {
const s = salt === undefined || salt === null || salt === '' ? INVITE_SALT_LEGACY : salt;
return crypto.createHmac('sha256', s).update(String(inviteCode), 'utf8').digest('hex');
};
const encryptForInvite = (tribeKeyHex, inviteCode, salt) => {
const derived = deriveInviteKey(inviteCode, salt);
return encryptWithKey(tribeKeyHex, derived.toString('hex')); return encryptWithKey(tribeKeyHex, derived.toString('hex'));
}; };
const decryptFromInvite = (encryptedKey, inviteCode) => { const decryptFromInvite = (encryptedKey, inviteCode, salt) => {
const derived = crypto.scryptSync(inviteCode, INVITE_SALT, 32); const derived = deriveInviteKey(inviteCode, salt);
return decryptWithKey(encryptedKey, derived.toString('hex')); return decryptWithKey(encryptedKey, derived.toString('hex'));
}; };
const encryptChainForInvite = (ancestryRootIds, inviteCode) => { const encryptChainForInvite = (ancestryRootIds, inviteCode, salt) => {
const chain = ancestryRootIds.map(rootId => ({ rootId, key: getKey(rootId), gen: getGen(rootId) })); const chain = ancestryRootIds.map(rootId => ({
if (chain.some(e => !e.key)) return null; rootId,
const derived = crypto.scryptSync(inviteCode, INVITE_SALT, 32); key: getKey(rootId),
keys: getKeys(rootId),
gen: getGen(rootId)
}));
if (chain.some(e => !e.key || !Array.isArray(e.keys) || !e.keys.length)) return null;
const derived = deriveInviteKey(inviteCode, salt);
return encryptWithKey(JSON.stringify(chain), derived.toString('hex')); return encryptWithKey(JSON.stringify(chain), derived.toString('hex'));
}; };
const decryptChainFromInvite = (encryptedPayload, inviteCode) => { const decryptChainFromInvite = (encryptedPayload, inviteCode, salt) => {
const derived = crypto.scryptSync(inviteCode, INVITE_SALT, 32); const derived = deriveInviteKey(inviteCode, salt);
try { try {
const json = decryptWithKey(encryptedPayload, derived.toString('hex')); const json = decryptWithKey(encryptedPayload, derived.toString('hex'));
const parsed = JSON.parse(json); const parsed = JSON.parse(json);
if (Array.isArray(parsed) && parsed.every(e => e && e.rootId && e.key)) return parsed; if (Array.isArray(parsed) && parsed.every(e => e && e.rootId && (e.key || (Array.isArray(e.keys) && e.keys.length)))) {
return parsed.map(e => ({
rootId: e.rootId,
key: e.key || (Array.isArray(e.keys) ? e.keys[0] : null),
keys: Array.isArray(e.keys) && e.keys.length ? e.keys : (e.key ? [e.key] : []),
gen: e.gen || 1
}));
}
} catch (_) {} } catch (_) {}
return null; return null;
}; };
const encryptChain = (plaintext, keyChain) => { const inviteMatchesCode = (inv, code) => {
if (typeof inv === 'string') return inv === code;
if (!inv || typeof inv !== 'object') return false;
if (inv.codeHash) return inv.codeHash === hashInviteCode(code, inv.salt);
if (inv.code) return inv.code === code;
return false;
};
const encryptChain = (plaintext, keyChain, aad) => {
let data = plaintext; let data = plaintext;
for (const keyHex of keyChain) { const last = keyChain.length - 1;
data = encryptWithKey(data, keyHex); for (let i = 0; i < keyChain.length; i++) {
data = encryptWithKey(data, keyChain[i], i === last ? aad : undefined);
} }
return data; return data;
}; };
const decryptChain = (encrypted, keyChain) => { const decryptChain = (encrypted, keyChain, aad) => {
const reversed = [...keyChain].reverse(); const reversed = [...keyChain].reverse();
let data = encrypted; let data = encrypted;
for (const keyHex of reversed) { for (let i = 0; i < reversed.length; i++) {
data = decryptWithKey(data, keyHex); data = decryptWithKey(data, reversed[i], i === 0 ? aad : undefined);
} }
return data; return data;
}; };
@ -145,20 +216,32 @@ module.exports = (configPath) => {
} }
} }
const plaintext = JSON.stringify(payload); const plaintext = JSON.stringify(payload);
const encryptedPayload = encryptChain(plaintext, keyChain);
const result = {}; const result = {};
for (const [k, v] of Object.entries(content)) { for (const [k, v] of Object.entries(content)) {
if (customFields ? ENVELOPE_PRESERVE.has(k) : !SENSITIVE_FIELDS.includes(k)) { if (customFields ? ENVELOPE_PRESERVE.has(k) : !SENSITIVE_FIELDS.includes(k)) {
result[k] = v; result[k] = v;
} }
} }
const aad = canonicalAad(result);
const encryptedPayload = encryptChain(plaintext, keyChain, aad);
result.encryptedPayload = encryptedPayload; result.encryptedPayload = encryptedPayload;
return result; return result;
}; };
const decryptContent = (content, keyChainSets) => { const decryptContent = (content, keyChainSets) => {
if (!content.encryptedPayload) return content; if (!content.encryptedPayload) return content;
const envelope = { ...content };
delete envelope.encryptedPayload;
const aad = canonicalAad(envelope);
for (const keyChain of keyChainSets) { for (const keyChain of keyChainSets) {
try {
const plaintext = decryptChain(content.encryptedPayload, keyChain, aad);
const payload = JSON.parse(plaintext);
const result = { ...content };
delete result.encryptedPayload;
Object.assign(result, payload);
return result;
} catch (e) {}
try { try {
const plaintext = decryptChain(content.encryptedPayload, keyChain); const plaintext = decryptChain(content.encryptedPayload, keyChain);
const payload = JSON.parse(plaintext); const payload = JSON.parse(plaintext);
@ -229,10 +312,31 @@ module.exports = (configPath) => {
const decryptFromTribe = async (content, tribesModel) => { const decryptFromTribe = async (content, tribesModel) => {
if (!content || !content.encryptedPayload) return content; if (!content || !content.encryptedPayload) return content;
const tid = content.tribeId; const tid = content.tribeId;
if (!tid) return content; if (tid) {
const sets = await resolveKeyChainSets(tid, tribesModel); let sets = null;
if (!sets || !sets.length) return { ...content, _undecryptable: true }; try { sets = await resolveKeyChainSets(tid, tribesModel); } catch (_) {}
return decryptContent(content, sets); if (sets && sets.length) {
const r = decryptContent(content, sets);
if (r && !r._undecryptable) return r;
}
const directKeys = getKeys(tid);
if (directKeys && directKeys.length) {
const r = decryptContent(content, directKeys.map(k => [k]));
if (r && !r._undecryptable) return r;
}
}
const candidateRoots = [
content.calendarId, content.chatId, content.padId,
content.mapId, content.roomId, content.parentId, content.dateId
].filter(Boolean);
for (const rid of candidateRoots) {
const keys = getKeys(rid);
if (keys && keys.length) {
const r = decryptContent(content, keys.map(k => [k]));
if (r && !r._undecryptable) return r;
}
}
return { ...content, _undecryptable: true };
}; };
const createHelpers = (tribesModel) => ({ const createHelpers = (tribesModel) => ({
@ -267,10 +371,11 @@ module.exports = (configPath) => {
SENSITIVE_FIELDS, SENSITIVE_FIELDS,
ENVELOPE_PRESERVE, ENVELOPE_PRESERVE,
loadKeyring, saveKeyring, loadKeyring, saveKeyring,
generateTribeKey, getKey, getKeys, getGen, setKey, addNewKey, generateTribeKey, getKey, getKeys, getGen, setKey, setKeys, mergeKeys, addNewKey,
encryptWithKey, decryptWithKey, encryptWithKey, decryptWithKey,
encryptForInvite, decryptFromInvite, encryptForInvite, decryptFromInvite,
encryptChainForInvite, decryptChainFromInvite, encryptChainForInvite, decryptChainFromInvite,
generateInviteSalt, hashInviteCode, inviteMatchesCode,
encryptChain, decryptChain, encryptChain, decryptChain,
encryptContent, decryptContent, encryptContent, decryptContent,
boxKeyForMember, unboxKeyFromMember, boxKeyForMember, unboxKeyFromMember,

View file

@ -1,6 +1,7 @@
const pull = require('../server/node_modules/pull-stream'); const pull = require('../server/node_modules/pull-stream');
const { getConfig } = require('../configs/config-manager.js'); const { getConfig } = require('../configs/config-manager.js');
const logLimit = getConfig().ssbLogStream?.limit || 1000; const logLimit = getConfig().ssbLogStream?.limit || 1000;
const tribeLogLimit = Math.max(logLimit, 100000);
const VALID_CONTENT_TYPES = ['event', 'task', 'report', 'votation', 'forum', 'forum-reply', 'market', 'job', 'project', 'media', 'feed', 'pixelia']; const VALID_CONTENT_TYPES = ['event', 'task', 'report', 'votation', 'forum', 'forum-reply', 'market', 'job', 'project', 'media', 'feed', 'pixelia'];
const categories = require('../backend/opinion_categories'); const categories = require('../backend/opinion_categories');
@ -30,7 +31,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
const ssbClient = await openSsb(); const ssbClient = await openSsb();
return new Promise((resolve, reject) => return new Promise((resolve, reject) =>
pull( pull(
ssbClient.createLogStream({ limit: logLimit }), ssbClient.createLogStream({ limit: tribeLogLimit }),
pull.collect((err, msgs) => err ? reject(err) : resolve(msgs)) pull.collect((err, msgs) => err ? reject(err) : resolve(msgs))
) )
); );

View file

@ -2,6 +2,7 @@ const pull = require('../server/node_modules/pull-stream');
const crypto = require('crypto'); const crypto = require('crypto');
const { getConfig } = require('../configs/config-manager.js'); const { getConfig } = require('../configs/config-manager.js');
const logLimit = getConfig().ssbLogStream?.limit || 1000; const logLimit = getConfig().ssbLogStream?.limit || 1000;
const tribeLogLimit = Math.max(logLimit, 100000);
const INVITE_CODE_BYTES = 16; const INVITE_CODE_BYTES = 16;
const VALID_INVITE_MODES = ['strict', 'open']; const VALID_INVITE_MODES = ['strict', 'open'];
@ -57,7 +58,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
const client = await openSsb(); const client = await openSsb();
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
pull( pull(
client.createLogStream({ limit: logLimit }), client.createLogStream({ limit: tribeLogLimit }),
pull.collect((err, msgs) => { pull.collect((err, msgs) => {
if (err) return reject(err); if (err) return reject(err);
const tombstones = new Map(); const tombstones = new Map();
@ -88,6 +89,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
let progress = true; let progress = true;
while (progress) { while (progress) {
progress = false; progress = false;
const candidatesByReplaces = new Map();
for (const [k, entry] of tribeMsgs.entries()) { for (const [k, entry] of tribeMsgs.entries()) {
if (tribes.has(k)) continue; if (tribes.has(k)) continue;
const replaces = entry.content.replaces; const replaces = entry.content.replaces;
@ -106,10 +108,25 @@ module.exports = ({ cooler, tribeCrypto }) => {
if (!validInvitesDelta(parentEntry.content.invites, entry.content.invites, entry.author, rootAuthor)) continue; if (!validInvitesDelta(parentEntry.content.invites, entry.content.invites, entry.author, rootAuthor)) continue;
if (!structuralFieldsEqual(parentEntry.content, entry.content)) continue; if (!structuralFieldsEqual(parentEntry.content, entry.content)) continue;
} }
parent.set(k, replaces); if (!candidatesByReplaces.has(replaces)) candidatesByReplaces.set(replaces, []);
child.set(replaces, k); candidatesByReplaces.get(replaces).push({ k, entry, isRootAuthor, root });
tribes.set(k, entry); }
rootByTip.set(k, root); for (const [replaces, candidates] of candidatesByReplaces.entries()) {
if (child.has(replaces)) continue;
let winner = candidates[0];
for (let i = 1; i < candidates.length; i++) {
const c = candidates[i];
if (c.isRootAuthor && !winner.isRootAuthor) { winner = c; continue; }
if (winner.isRootAuthor && !c.isRootAuthor) continue;
const wt = winner.entry._ts || 0;
const ct = c.entry._ts || 0;
if (ct < wt) winner = c;
else if (ct === wt && c.k < winner.k) winner = c;
}
parent.set(winner.k, replaces);
child.set(replaces, winner.k);
tribes.set(winner.k, winner.entry);
rootByTip.set(winner.k, winner.root);
progress = true; progress = true;
} }
} }
@ -129,7 +146,25 @@ module.exports = ({ cooler, tribeCrypto }) => {
const tip = tipOf(root); const tip = tipOf(root);
tipByRoot.set(root, tip); tipByRoot.set(root, tip);
} }
tribeIndex = { tribes, tombstoned, parent, child, tipByRoot, rootByTip }; const effectivelyTombstoned = new Set(tombstoned);
let cascadeProgress = true;
while (cascadeProgress) {
cascadeProgress = false;
for (const k of tribes.keys()) {
if (effectivelyTombstoned.has(k)) continue;
const root = rootOf(k);
if (effectivelyTombstoned.has(root)) { effectivelyTombstoned.add(k); cascadeProgress = true; continue; }
const entry = tribes.get(k);
const pid = entry?.content?.parentTribeId;
if (!pid) continue;
const parentRoot = rootOf(pid);
if (effectivelyTombstoned.has(parentRoot) || effectivelyTombstoned.has(pid)) {
effectivelyTombstoned.add(k);
cascadeProgress = true;
}
}
}
tribeIndex = { tribes, tombstoned, effectivelyTombstoned, parent, child, tipByRoot, rootByTip };
tribeIndexTs = Date.now(); tribeIndexTs = Date.now();
resolve(tribeIndex); resolve(tribeIndex);
}) })
@ -196,8 +231,16 @@ module.exports = ({ cooler, tribeCrypto }) => {
if (tribeCrypto) { if (tribeCrypto) {
const ancestryIds = await this.getAncestryChain(tribeId).catch(() => null); const ancestryIds = await this.getAncestryChain(tribeId).catch(() => null);
if (Array.isArray(ancestryIds) && ancestryIds.length) { if (Array.isArray(ancestryIds) && ancestryIds.length) {
const ekChain = tribeCrypto.encryptChainForInvite(ancestryIds, code); const salt = tribeCrypto.generateInviteSalt();
if (ekChain) invite = { code, ekChain, gen: tribeCrypto.getGen(ancestryIds[0]) }; const ekChain = tribeCrypto.encryptChainForInvite(ancestryIds, code, salt);
if (ekChain) {
invite = {
codeHash: tribeCrypto.hashInviteCode(code, salt),
ekChain,
salt,
gen: tribeCrypto.getGen(ancestryIds[0])
};
}
} }
} }
const invites = Array.isArray(tribe.invites) ? [...tribe.invites, invite] : [invite]; const invites = Array.isArray(tribe.invites) ? [...tribe.invites, invite] : [invite];
@ -236,10 +279,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
for (const t of tribes) { for (const t of tribes) {
if (!t.invites) continue; if (!t.invites) continue;
for (const inv of t.invites) { for (const inv of t.invites) {
if (typeof inv === 'string' && inv === code) { if (tribeCrypto ? tribeCrypto.inviteMatchesCode(inv, code) : (inv === code || (inv && inv.code === code))) {
matchedTribe = t; matchedInvite = inv; break;
}
if (typeof inv === 'object' && inv.code === code) {
matchedTribe = t; matchedInvite = inv; break; matchedTribe = t; matchedInvite = inv; break;
} }
} }
@ -253,16 +293,23 @@ module.exports = ({ cooler, tribeCrypto }) => {
let storedGen = 1; let storedGen = 1;
let storedRootId = null; let storedRootId = null;
if (tribeCrypto && typeof matchedInvite === 'object') { if (tribeCrypto && typeof matchedInvite === 'object') {
const salt = matchedInvite.salt;
if (matchedInvite.ekChain) { if (matchedInvite.ekChain) {
const chain = tribeCrypto.decryptChainFromInvite(matchedInvite.ekChain, code); const chain = tribeCrypto.decryptChainFromInvite(matchedInvite.ekChain, code, salt);
if (Array.isArray(chain) && chain.length) { if (Array.isArray(chain) && chain.length) {
for (const entry of chain) tribeCrypto.setKey(entry.rootId, entry.key, entry.gen || 1); for (const entry of chain) {
if (Array.isArray(entry.keys) && entry.keys.length) {
tribeCrypto.setKeys(entry.rootId, entry.keys, entry.gen || entry.keys.length);
} else if (entry.key) {
tribeCrypto.setKey(entry.rootId, entry.key, entry.gen || 1);
}
}
storedRootId = chain[0].rootId; storedRootId = chain[0].rootId;
storedTribeKey = chain[0].key; storedTribeKey = chain[0].key;
storedGen = chain[0].gen || 1; storedGen = chain[0].gen || 1;
} }
} else if (matchedInvite.ek) { } else if (matchedInvite.ek) {
storedTribeKey = tribeCrypto.decryptFromInvite(matchedInvite.ek, code); storedTribeKey = tribeCrypto.decryptFromInvite(matchedInvite.ek, code, salt);
storedRootId = await this.getRootId(matchedTribe.id); storedRootId = await this.getRootId(matchedTribe.id);
storedGen = matchedInvite.gen || 1; storedGen = matchedInvite.gen || 1;
tribeCrypto.setKey(storedRootId, storedTribeKey, storedGen); tribeCrypto.setKey(storedRootId, storedTribeKey, storedGen);
@ -270,8 +317,9 @@ module.exports = ({ cooler, tribeCrypto }) => {
} }
const members = [...matchedTribe.members, userId]; const members = [...matchedTribe.members, userId];
const invites = matchedTribe.invites.filter(inv => { const invites = matchedTribe.invites.filter(inv => {
if (tribeCrypto) return !tribeCrypto.inviteMatchesCode(inv, code);
if (typeof inv === 'string') return inv !== code; if (typeof inv === 'string') return inv !== code;
return inv.code !== code; return inv && inv.code !== code;
}); });
const inviteLog = Array.isArray(matchedTribe.inviteLog) ? matchedTribe.inviteLog.map(entry => const inviteLog = Array.isArray(matchedTribe.inviteLog) ? matchedTribe.inviteLog.map(entry =>
entry.code === code ? { ...entry, status: 'used', usedBy: userId, usedAt: new Date().toISOString() } : entry entry.code === code ? { ...entry, status: 'used', usedBy: userId, usedAt: new Date().toISOString() } : entry
@ -318,14 +366,18 @@ module.exports = ({ cooler, tribeCrypto }) => {
const rootId = await this.getRootId(tribeId); const rootId = await this.getRootId(tribeId);
const currentKey = tribeCrypto.getKey(rootId); const currentKey = tribeCrypto.getKey(rootId);
if (!currentKey) return; if (!currentKey) return;
const allKeys = tribeCrypto.getKeys(rootId);
const gen = tribeCrypto.getGen(rootId); const gen = tribeCrypto.getGen(rootId);
const payload = JSON.stringify({ keys: allKeys, gen });
const memberKeys = {}; const memberKeys = {};
const memberKeysFull = {};
for (const memberId of toMembers) { for (const memberId of toMembers) {
try { memberKeys[memberId] = tribeCrypto.boxKeyForMember(currentKey, memberId, ssbKeys); } catch (_) {} try { memberKeys[memberId] = tribeCrypto.boxKeyForMember(currentKey, memberId, ssbKeys); } catch (_) {}
try { memberKeysFull[memberId] = tribeCrypto.boxKeyForMember(payload, memberId, ssbKeys); } catch (_) {}
} }
if (!Object.keys(memberKeys).length) return; if (!Object.keys(memberKeys).length && !Object.keys(memberKeysFull).length) return;
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
ssb.publish({ type: 'tribe-keys', tribeId: rootId, generation: gen, memberKeys }, (err, res) => err ? reject(err) : resolve(res)); ssb.publish({ type: 'tribe-keys', tribeId: rootId, generation: gen, memberKeys, memberKeysFull }, (err, res) => err ? reject(err) : resolve(res));
}); });
await this.ensureFollowTribeMembers(tribeId).catch(() => {}); await this.ensureFollowTribeMembers(tribeId).catch(() => {});
}, },
@ -341,7 +393,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
if (!currentKey) return; if (!currentKey) return;
const gen = tribeCrypto.getGen(rootId); const gen = tribeCrypto.getGen(rootId);
const msgs = await new Promise((resolve, reject) => { const msgs = await new Promise((resolve, reject) => {
pull(ssb.createLogStream({ limit: logLimit }), pull.collect((err, m) => err ? reject(err) : resolve(m))); pull(ssb.createLogStream({ limit: tribeLogLimit }), pull.collect((err, m) => err ? reject(err) : resolve(m)));
}); });
const distributed = new Set(); const distributed = new Set();
for (const m of msgs) { for (const m of msgs) {
@ -386,10 +438,10 @@ module.exports = ({ cooler, tribeCrypto }) => {
}, },
async getTribeById(tribeId) { async getTribeById(tribeId) {
const { tribes, tombstoned, child } = await buildTribeIndex(); const { tribes, tombstoned, effectivelyTombstoned, child } = await buildTribeIndex();
let latestId = tribeId; let latestId = tribeId;
while (child.has(latestId)) latestId = child.get(latestId); while (child.has(latestId)) latestId = child.get(latestId);
if (tombstoned.has(latestId)) throw new Error('Tribe not found'); if (tombstoned.has(latestId) || effectivelyTombstoned.has(latestId)) throw new Error('Tribe not found');
const tribe = tribes.get(latestId); const tribe = tribes.get(latestId);
if (!tribe) throw new Error('Tribe not found'); if (!tribe) throw new Error('Tribe not found');
return { return {
@ -403,6 +455,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
isAnonymous: tribe.content.isAnonymous, isAnonymous: tribe.content.isAnonymous,
members: Array.isArray(tribe.content.members) ? tribe.content.members : [], members: Array.isArray(tribe.content.members) ? tribe.content.members : [],
invites: Array.isArray(tribe.content.invites) ? tribe.content.invites : [], invites: Array.isArray(tribe.content.invites) ? tribe.content.invites : [],
inviteLog: Array.isArray(tribe.content.inviteLog) ? tribe.content.inviteLog : [],
inviteMode: tribe.content.inviteMode || 'strict', inviteMode: tribe.content.inviteMode || 'strict',
status: tribe.content.status || 'OPEN', status: tribe.content.status || 'OPEN',
parentTribeId: tribe.content.parentTribeId || null, parentTribeId: tribe.content.parentTribeId || null,
@ -410,12 +463,11 @@ module.exports = ({ cooler, tribeCrypto }) => {
createdAt: tribe.content.createdAt, createdAt: tribe.content.createdAt,
updatedAt: tribe.content.updatedAt, updatedAt: tribe.content.updatedAt,
author: tribe.content.author, author: tribe.content.author,
inviteLog: Array.isArray(tribe.content.inviteLog) ? tribe.content.inviteLog : [],
}; };
}, },
async listAll() { async listAll() {
const { tribes, tombstoned, tipByRoot, rootByTip } = await buildTribeIndex(); const { tribes, tombstoned, effectivelyTombstoned, tipByRoot, rootByTip } = await buildTribeIndex();
const resolveParent = (pid) => { const resolveParent = (pid) => {
if (!pid) return null; if (!pid) return null;
const root = rootByTip.get(pid) || pid; const root = rootByTip.get(pid) || pid;
@ -424,6 +476,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
const items = []; const items = [];
for (const [root, tip] of tipByRoot) { for (const [root, tip] of tipByRoot) {
if (tombstoned.has(root) || tombstoned.has(tip)) continue; if (tombstoned.has(root) || tombstoned.has(tip)) continue;
if (effectivelyTombstoned.has(root) || effectivelyTombstoned.has(tip)) continue;
const entry = tribes.get(tip); const entry = tribes.get(tip);
if (!entry) continue; if (!entry) continue;
const c = entry.content; const c = entry.content;
@ -438,6 +491,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
isAnonymous: c.isAnonymous !== false, isAnonymous: c.isAnonymous !== false,
members: Array.isArray(c.members) ? c.members : [], members: Array.isArray(c.members) ? c.members : [],
invites: Array.isArray(c.invites) ? c.invites : [], invites: Array.isArray(c.invites) ? c.invites : [],
inviteLog: Array.isArray(c.inviteLog) ? c.inviteLog : [],
inviteMode: c.inviteMode || 'strict', inviteMode: c.inviteMode || 'strict',
status: c.status || 'OPEN', status: c.status || 'OPEN',
parentTribeId: resolveParent(c.parentTribeId), parentTribeId: resolveParent(c.parentTribeId),
@ -445,7 +499,6 @@ module.exports = ({ cooler, tribeCrypto }) => {
createdAt: c.createdAt, createdAt: c.createdAt,
updatedAt: c.updatedAt, updatedAt: c.updatedAt,
author: c.author, author: c.author,
inviteLog: Array.isArray(c.inviteLog) ? c.inviteLog : [],
_ts: entry._ts _ts: entry._ts
}); });
} }
@ -495,31 +548,38 @@ module.exports = ({ cooler, tribeCrypto }) => {
if (!oldKey) return; if (!oldKey) return;
const newKey = tribeCrypto.generateTribeKey(); const newKey = tribeCrypto.generateTribeKey();
const newGen = tribeCrypto.addNewKey(rootId, newKey); const newGen = tribeCrypto.addNewKey(rootId, newKey);
const allKeys = tribeCrypto.getKeys(rootId);
const fullPayload = JSON.stringify({ keys: allKeys, gen: newGen });
const memberKeys = {}; const memberKeys = {};
const memberKeysFull = {};
for (const memberId of remainingMembers) { for (const memberId of remainingMembers) {
memberKeys[memberId] = tribeCrypto.boxKeyForMember(newKey, memberId, ssbKeys); try { memberKeys[memberId] = tribeCrypto.boxKeyForMember(newKey, memberId, ssbKeys); } catch (_) {}
try { memberKeysFull[memberId] = tribeCrypto.boxKeyForMember(fullPayload, memberId, ssbKeys); } catch (_) {}
} }
const entries = Object.entries(memberKeys); const entries = Object.entries(memberKeys);
const BATCH_SIZE = 20; const BATCH_SIZE = 20;
for (let i = 0; i < entries.length; i += BATCH_SIZE) { for (let i = 0; i < entries.length; i += BATCH_SIZE) {
const batch = Object.fromEntries(entries.slice(i, i + BATCH_SIZE)); const batchSingle = Object.fromEntries(entries.slice(i, i + BATCH_SIZE));
const batchFull = {};
for (const id of Object.keys(batchSingle)) {
if (memberKeysFull[id]) batchFull[id] = memberKeysFull[id];
}
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
ssb.publish({ type: 'tribe-keys', tribeId: rootId, generation: newGen, memberKeys: batch }, ssb.publish({ type: 'tribe-keys', tribeId: rootId, generation: newGen, memberKeys: batchSingle, memberKeysFull: batchFull },
(err, res) => err ? reject(err) : resolve(res)); (err, res) => err ? reject(err) : resolve(res));
}); });
} }
const tribe = await this.getTribeById(tribeId); const tribe = await this.getTribeById(tribeId);
if (Array.isArray(tribe.invites) && tribe.invites.length > 0) { if (Array.isArray(tribe.invites) && tribe.invites.length > 0) {
const ancestryIds = await this.getAncestryChain(tribeId).catch(() => [rootId]); const survivingInvites = tribe.invites.map(inv => {
const updatedInvites = tribe.invites.map(inv => { if (typeof inv === 'string') return inv;
if (typeof inv === 'object' && inv.code) { if (!inv || typeof inv !== 'object') return inv;
const ekChain = tribeCrypto.encryptChainForInvite(ancestryIds, inv.code); const next = { ...inv, gen: newGen };
if (ekChain) return { code: inv.code, ekChain, gen: newGen }; delete next.ekChain;
return { code: inv.code, ek: tribeCrypto.encryptForInvite(newKey, inv.code), gen: newGen }; delete next.ek;
} return next;
return inv;
}); });
await this.updateTribeInvites(tribeId, updatedInvites); await this.updateTribeInvites(tribeId, survivingInvites);
} }
}, },
@ -530,20 +590,38 @@ module.exports = ({ cooler, tribeCrypto }) => {
const config = require('../server/ssb_config'); const config = require('../server/ssb_config');
const msgs = await new Promise((resolve, reject) => { const msgs = await new Promise((resolve, reject) => {
pull( pull(
ssb.createLogStream({ limit: logLimit }), ssb.createLogStream({ limit: tribeLogLimit }),
pull.collect((err, msgs) => err ? reject(err) : resolve(msgs)) pull.collect((err, msgs) => err ? reject(err) : resolve(msgs))
); );
}); });
const byTribe = new Map();
for (const m of msgs) { for (const m of msgs) {
const c = m.value?.content; const c = m.value?.content;
if (!c || c.type !== 'tribe-keys') continue; if (!c || c.type !== 'tribe-keys' || !c.tribeId) continue;
const myEntry = c.memberKeys && c.memberKeys[ssb.id]; const fullEntry = c.memberKeysFull && c.memberKeysFull[ssb.id];
if (!myEntry) continue; const singleEntry = c.memberKeys && c.memberKeys[ssb.id];
const currentGen = tribeCrypto.getGen(c.tribeId); if (!fullEntry && !singleEntry) continue;
if (c.generation <= currentGen) continue; const list = byTribe.get(c.tribeId) || [];
const newKey = tribeCrypto.unboxKeyFromMember(myEntry, config.keys, ssbKeys); list.push({ generation: c.generation || 0, fullEntry, singleEntry });
if (newKey) { byTribe.set(c.tribeId, list);
tribeCrypto.addNewKey(c.tribeId, newKey); }
for (const [tribeId, entries] of byTribe.entries()) {
entries.sort((a, b) => b.generation - a.generation);
const top = entries[0];
const knownGen = tribeCrypto.getGen(tribeId);
if (top.fullEntry) {
try {
const text = tribeCrypto.unboxKeyFromMember(top.fullEntry, config.keys, ssbKeys);
const parsed = text ? JSON.parse(text) : null;
if (parsed && Array.isArray(parsed.keys) && parsed.keys.length) {
tribeCrypto.mergeKeys(tribeId, parsed.keys, parsed.gen || top.generation || knownGen);
continue;
}
} catch (_) {}
}
if (top.singleEntry && top.generation > knownGen) {
const newKey = tribeCrypto.unboxKeyFromMember(top.singleEntry, config.keys, ssbKeys);
if (newKey) tribeCrypto.addNewKey(tribeId, newKey);
} }
} }
}, },
@ -562,7 +640,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
const myFollows = new Map(); const myFollows = new Map();
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
pull( pull(
ssb.createLogStream({ limit: logLimit }), ssb.createLogStream({ limit: tribeLogLimit }),
pull.collect((err, msgs) => { pull.collect((err, msgs) => {
if (err) return reject(err); if (err) return reject(err);
for (const m of msgs) { for (const m of msgs) {
@ -636,12 +714,19 @@ module.exports = ({ cooler, tribeCrypto }) => {
tribeIndex = null; tribeIndex = null;
}, },
async listSubTribes(parentId) { async listSubTribes(parentId, userId) {
const idx = await buildTribeIndex(); const idx = await buildTribeIndex();
const rootOf = (id) => { let cur = id; while (idx.parent.has(cur)) cur = idx.parent.get(cur); return cur; }; const rootOf = (id) => { let cur = id; while (idx.parent.has(cur)) cur = idx.parent.get(cur); return cur; };
const parentRoot = rootOf(parentId); const parentRoot = rootOf(parentId);
const all = await this.listAll(); const all = await this.listAll();
return all.filter(t => t.parentTribeId && rootOf(t.parentTribeId) === parentRoot); const subs = all.filter(t => t.parentTribeId && rootOf(t.parentTribeId) === parentRoot);
if (!userId) return subs;
const out = [];
for (const sub of subs) {
const ok = await this.canAccessTribe(userId, sub.id).catch(() => false);
if (ok) out.push(sub);
}
return out;
}, },
async isTribeMember(userId, tribeId) { async isTribeMember(userId, tribeId) {
@ -661,6 +746,10 @@ module.exports = ({ cooler, tribeCrypto }) => {
try { try {
const tribe = await this.getTribeById(tribeId); const tribe = await this.getTribeById(tribeId);
if (!tribe) return false; if (!tribe) return false;
if (tribe.parentTribeId) {
const parentOk = await this.canAccessTribe(userId, tribe.parentTribeId).catch(() => false);
if (!parentOk) return false;
}
if (tribe.author === userId) return true; if (tribe.author === userId) return true;
if (Array.isArray(tribe.members) && tribe.members.includes(userId)) return true; if (Array.isArray(tribe.members) && tribe.members.includes(userId)) return true;
const effective = await this.getEffectiveStatus(tribeId); const effective = await this.getEffectiveStatus(tribeId);

View file

@ -48,8 +48,31 @@ const manifestFile = path.join(config.path, 'manifest.json');
let server; let server;
const argv = process.argv.slice(2); const argv = process.argv.slice(2);
const isLockError = (err) => {
if (!err) return false;
if (err.name === 'OpenError') return true;
const msg = String(err.message || '');
return /Resource temporarily unavailable/i.test(msg) && /\.ssb\/.*LOCK/i.test(msg);
};
const handleFatal = (err) => {
if (isLockError(err)) {
console.log('');
console.log('Another Oasis instance is already running on this device. Close the other instance (or kill the process) and try again.');
console.log('');
process.exit(1);
}
throw err;
};
process.on('uncaughtException', handleFatal);
if (argv[0] === 'start') { if (argv[0] === 'start') {
server = Server(config); try {
server = Server(config);
} catch (err) {
handleFatal(err);
}
fs.writeFileSync(manifestFile, JSON.stringify(server.getManifest(), null, 2)); fs.writeFileSync(manifestFile, JSON.stringify(server.getManifest(), null, 2));
const { cmdAliases } = require('../client/cli-cmd-aliases'); const { cmdAliases } = require('../client/cli-cmd-aliases');
@ -91,7 +114,14 @@ if (argv[0] === 'start') {
} }
const { printMetadata, colors } = require('./ssb_metadata'); const { printMetadata, colors } = require('./ssb_metadata');
printMetadata('OASIS Server Only', colors.cyan); printMetadata('OASIS Server Only', colors.cyan, null);
setTimeout(async () => {
try {
const bankingModel = require('../models/banking_model.js')({});
await bankingModel.ensureSelfAddressPublished();
} catch (_) {}
}, 5000);
} }

View file

@ -1,6 +1,6 @@
{ {
"name": "@krakenslab/oasis", "name": "@krakenslab/oasis",
"version": "0.7.5", "version": "0.7.6",
"description": "Oasis Social Networking Project Utopia", "description": "Oasis Social Networking Project Utopia",
"repository": { "repository": {
"type": "git", "type": "git",

View file

@ -16,7 +16,7 @@ const {
option option
} = require("../server/node_modules/hyperaxe"); } = require("../server/node_modules/hyperaxe");
const { template, i18n } = require("./main_views"); const { template, i18n, userLink} = require("./main_views");
const moment = require("../server/node_modules/moment"); const moment = require("../server/node_modules/moment");
const { config } = require("../server/SSB_server.js"); const { config } = require("../server/SSB_server.js");
const { renderUrl } = require("../backend/renderUrl") const { renderUrl } = require("../backend/renderUrl")
@ -100,7 +100,10 @@ const renderAudioOwnerActions = (filter, audioObj, params = {}) => {
}; };
const renderAudioCommentsSection = (audioId, comments = [], returnTo = null) => { const renderAudioCommentsSection = (audioId, comments = [], returnTo = null) => {
const list = safeArr(comments); const list = safeArr(comments).filter(c => {
const t = c && c.value && c.value.content && c.value.content.text;
return t && String(t).trim();
});
const commentsCount = list.length; const commentsCount = list.length;
return div( return div(
@ -221,7 +224,7 @@ const renderAudioList = (audios, filter, params = {}) => {
return p( return p(
{ class: "card-footer" }, { class: "card-footer" },
span({ class: "date-link" }, `${moment(audioObj.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `), span({ class: "date-link" }, `${moment(audioObj.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
a({ href: `/author/${encodeURIComponent(audioObj.author)}`, class: "user-link" }, `${audioObj.author}`), userLink(audioObj.author),
showUpdated showUpdated
? span( ? span(
{ class: "votations-comment-date" }, { class: "votations-comment-date" },
@ -416,7 +419,7 @@ exports.singleAudioView = async (audioObj, filter = "all", comments = [], params
return p( return p(
{ class: "card-footer" }, { class: "card-footer" },
span({ class: "date-link" }, `${moment(audioObj.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `), span({ class: "date-link" }, `${moment(audioObj.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
a({ href: `/author/${encodeURIComponent(audioObj.author)}`, class: "user-link" }, `${audioObj.author}`), userLink(audioObj.author),
showUpdated showUpdated
? span( ? span(
{ class: "votations-comment-date" }, { class: "votations-comment-date" },

View file

@ -1,7 +1,7 @@
const { form, button, div, h2, p, section, input, label, textarea, br, a, span, select, option } = const { form, button, div, h2, p, section, input, label, textarea, br, a, span, select, option } =
require("../server/node_modules/hyperaxe"); require("../server/node_modules/hyperaxe");
const { template, i18n } = require("./main_views"); const { template, i18n, userLink} = require("./main_views");
const moment = require("../server/node_modules/moment"); const moment = require("../server/node_modules/moment");
const { config } = require("../server/SSB_server.js"); const { config } = require("../server/SSB_server.js");
const { renderUrl } = require("../backend/renderUrl"); const { renderUrl } = require("../backend/renderUrl");
@ -59,7 +59,10 @@ const renderBookmarkActions = (filter, bookmark, params = {}) => {
}; };
const renderBookmarkCommentsSection = (bookmarkId, rootId, comments = [], returnTo = null) => { const renderBookmarkCommentsSection = (bookmarkId, rootId, comments = [], returnTo = null) => {
const list = safeArr(comments); const list = safeArr(comments).filter(c => {
const t = c && c.value && c.value.content && c.value.content.text;
return t && String(t).trim();
});
const commentsCount = list.length; const commentsCount = list.length;
return div( return div(
@ -220,7 +223,7 @@ const renderBookmarkList = (filteredBookmarks, filter, params = {}) => {
return p( return p(
{ class: "card-footer" }, { class: "card-footer" },
span({ class: "date-link" }, `${moment(bookmark.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `), span({ class: "date-link" }, `${moment(bookmark.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
a({ href: `/author/${encodeURIComponent(bookmark.author)}`, class: "user-link" }, `${bookmark.author}`), userLink(bookmark.author),
showUpdated showUpdated
? span( ? span(
{ class: "votations-comment-date" }, { class: "votations-comment-date" },
@ -453,7 +456,7 @@ exports.singleBookmarkView = async (bookmark, filter = "all", comments = [], par
return p( return p(
{ class: "card-footer" }, { class: "card-footer" },
span({ class: "date-link" }, `${moment(bookmark.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `), span({ class: "date-link" }, `${moment(bookmark.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
a({ href: `/author/${encodeURIComponent(bookmark.author)}`, class: "user-link" }, `${bookmark.author}`), userLink(bookmark.author),
showUpdated showUpdated
? span( ? span(
{ class: "votations-comment-date" }, { class: "votations-comment-date" },

View file

@ -1,5 +1,5 @@
const { div, h2, p, section, button, form, a, span, textarea, br, input, label, select, option, img, table, tr, td, ul, li } = require("../server/node_modules/hyperaxe") const { div, h2, p, section, button, form, a, span, textarea, br, input, label, select, option, img, table, tr, td, ul, li } = require("../server/node_modules/hyperaxe")
const { template, i18n } = require("./main_views") const { template, i18n, userLink} = require("./main_views")
const moment = require("../server/node_modules/moment") const moment = require("../server/node_modules/moment")
const { config } = require("../server/SSB_server.js") const { config } = require("../server/SSB_server.js")
const { renderUrl } = require("../backend/renderUrl") const { renderUrl } = require("../backend/renderUrl")
@ -131,9 +131,7 @@ const renderMessage = (msg, chatAuthor) => {
const isSelf = String(msg.author) === String(userId) const isSelf = String(msg.author) === String(userId)
const dateStr = moment(msg.createdAt).format("YYYY/MM/DD HH:mm") const dateStr = moment(msg.createdAt).format("YYYY/MM/DD HH:mm")
const shortId = msg.author ? "@" + msg.author.slice(1, 9) + "\u2026" : "?" const shortId = msg.author ? "@" + msg.author.slice(1, 9) + "\u2026" : "?"
const authorLink = msg.author const authorLink = msg.author ? userLink(msg.author) : span("?")
? a({ href: `/author/${encodeURIComponent(msg.author)}`, class: "user-link" }, shortId)
: span("?")
const imageNode = msg.image ? renderMediaBlob(msg.image, null, { class: "chat-message-image" }) : null const imageNode = msg.image ? renderMediaBlob(msg.image, null, { class: "chat-message-image" }) : null
@ -237,7 +235,7 @@ exports.singleChatView = async (chat, filter, messages = [], params = {}) => {
), ),
isRestrictedInviteOnly ? null : tr( isRestrictedInviteOnly ? null : tr(
td({ class: "tribe-info-value", colspan: "4" }, td({ class: "tribe-info-value", colspan: "4" },
a({ href: `/author/${encodeURIComponent(chat.author)}`, class: "user-link" }, chat.author) userLink(chat.author)
) )
), ),
tr( tr(
@ -328,9 +326,12 @@ exports.singleChatView = async (chat, filter, messages = [], params = {}) => {
) )
: null, : null,
div({ class: "chat-messages-list" }, div({ class: "chat-messages-list" },
msgList.length (() => {
? msgList.map(msg => renderMessage(msg, chat.author)) const visible = msgList.filter(msg => (msg.text && String(msg.text).trim()) || msg.image)
: p({ class: "chat-no-messages" }, i18n.chatNoMessages) return visible.length
? visible.map(msg => renderMessage(msg, chat.author))
: p({ class: "chat-no-messages" }, i18n.chatNoMessages)
})()
) )
) )

View file

@ -1,6 +1,6 @@
const { form, button, div, h2, p, section, input, label, br, a, span, table, thead, tbody, tr, th, td, textarea, select, option, ul, li, img } = require('../server/node_modules/hyperaxe'); const { form, button, div, h2, p, section, input, label, br, a, span, table, thead, tbody, tr, th, td, textarea, select, option, ul, li, img } = require('../server/node_modules/hyperaxe');
const moment = require('../server/node_modules/moment'); const moment = require('../server/node_modules/moment');
const { template, i18n } = require('./main_views'); const { template, i18n, userLink } = require('./main_views');
const fmt = (d) => moment(d).format('YYYY-MM-DD HH:mm:ss'); const fmt = (d) => moment(d).format('YYYY-MM-DD HH:mm:ss');
@ -341,21 +341,8 @@ const shortId = (id) => {
return `${s.slice(0, 6)}${s.slice(-4)}`; return `${s.slice(0, 6)}${s.slice(-4)}`;
}; };
const UserLinkCompact = (id) => const UserLinkCompact = (id) => id ? userLink(id) : span('');
id const UserLinkFull = (id) => id ? userLink(id) : span('');
? a(
{ class: 'user-link', href: `/author/${encodeURIComponent(id)}` },
shortId(id)
)
: span('');
const UserLinkFull = (id) =>
id
? a(
{ class: 'user-link', href: `/author/${encodeURIComponent(id)}` },
id
)
: span('');
const renderRichTextNodes = (raw) => { const renderRichTextNodes = (raw) => {
const text = String(raw || ''); const text = String(raw || '');
@ -438,20 +425,14 @@ const CaseCard = (c) => {
const mediatorLinksAccuser = mediatorsAccuser.map((mId, idx) => const mediatorLinksAccuser = mediatorsAccuser.map((mId, idx) =>
span( span(
{ class: 'mediator' }, { class: 'mediator' },
a( userLink(mId),
{ class: 'user-link', href: `/author/${encodeURIComponent(mId)}` },
shortId(mId)
),
idx < mediatorsAccuser.length - 1 ? span(', ') : null idx < mediatorsAccuser.length - 1 ? span(', ') : null
) )
); );
const mediatorLinksRespondent = mediatorsRespondent.map((mId, idx) => const mediatorLinksRespondent = mediatorsRespondent.map((mId, idx) =>
span( span(
{ class: 'mediator' }, { class: 'mediator' },
a( userLink(mId),
{ class: 'user-link', href: `/author/${encodeURIComponent(mId)}` },
shortId(mId)
),
idx < mediatorsRespondent.length - 1 ? span(', ') : null idx < mediatorsRespondent.length - 1 ? span(', ') : null
) )
); );
@ -637,10 +618,7 @@ const MyCaseCard = (c) => {
const mediatorLinksAccuser = mediatorsAccuser.map((mId, idx) => const mediatorLinksAccuser = mediatorsAccuser.map((mId, idx) =>
span( span(
{}, {},
a( userLink(mId),
{ class: 'user-link', href: `/author/${encodeURIComponent(mId)}` },
mId
),
idx < mediatorsAccuser.length - 1 ? span(', ') : null idx < mediatorsAccuser.length - 1 ? span(', ') : null
) )
); );
@ -648,10 +626,7 @@ const MyCaseCard = (c) => {
const mediatorLinksRespondent = mediatorsRespondent.map((mId, idx) => const mediatorLinksRespondent = mediatorsRespondent.map((mId, idx) =>
span( span(
{}, {},
a( userLink(mId),
{ class: 'user-link', href: `/author/${encodeURIComponent(mId)}` },
mId
),
idx < mediatorsRespondent.length - 1 ? span(', ') : null idx < mediatorsRespondent.length - 1 ? span(', ') : null
) )
); );
@ -945,12 +920,7 @@ const NominationsTable = (nominations = [], currentUserId = '') => {
currentUserId && currentUserId &&
String(n.judgeId || '') === String(currentUserId || ''); String(n.judgeId || '') === String(currentUserId || '');
return tr( return tr(
td( td(userLink(n.judgeId)),
a(
{ class: 'user-link', href: `/author/${encodeURIComponent(n.judgeId)}` },
n.judgeId
)
),
td(String(n.supports || 0)), td(String(n.supports || 0)),
td(fmt(n.createdAt)), td(fmt(n.createdAt)),
td( td(
@ -1081,20 +1051,14 @@ const CaseDetailsBlock = (c) => {
const mediatorLinksAccuser = mediatorsAccuser.map((mId, idx) => const mediatorLinksAccuser = mediatorsAccuser.map((mId, idx) =>
span( span(
{ class: 'mediator' }, { class: 'mediator' },
a( userLink(mId),
{ class: 'user-link', href: `/author/${encodeURIComponent(mId)}` },
shortId(mId)
),
idx < mediatorsAccuser.length - 1 ? span(', ') : null idx < mediatorsAccuser.length - 1 ? span(', ') : null
) )
); );
const mediatorLinksRespondent = mediatorsRespondent.map((mId, idx) => const mediatorLinksRespondent = mediatorsRespondent.map((mId, idx) =>
span( span(
{ class: 'mediator' }, { class: 'mediator' },
a( userLink(mId),
{ class: 'user-link', href: `/author/${encodeURIComponent(mId)}` },
shortId(mId)
),
idx < mediatorsRespondent.length - 1 ? span(', ') : null idx < mediatorsRespondent.length - 1 ? span(', ') : null
) )
); );

View file

@ -1,5 +1,5 @@
const { form, button, div, h2, p, section, textarea, label, input, br, img, a, select, option } = require("../server/node_modules/hyperaxe"); const { form, button, div, h2, p, section, textarea, label, input, br, img, a, select, option } = require("../server/node_modules/hyperaxe");
const { template, i18n } = require('./main_views'); const { template, i18n, userLink} = require('./main_views');
const { renderUrl } = require('../backend/renderUrl'); const { renderUrl } = require('../backend/renderUrl');
const generateCVBox = (label, content, className) => { const generateCVBox = (label, content, className) => {
@ -156,7 +156,7 @@ exports.cvView = async (cv) => {
}) })
: null, : null,
cv.name ? h2(`${cv.name}`) : null, cv.name ? h2(`${cv.name}`) : null,
cv.contact ? p(a({ class: "user-link", href: `/author/${encodeURIComponent(cv.contact)}` }, cv.contact)) : null, cv.contact ? p(userLink(cv.contact)) : null,
cv.description ? p(...renderUrl(`${cv.description}`)) : null, cv.description ? p(...renderUrl(`${cv.description}`)) : null,
(cv.personalSkills && cv.personalSkills.length) (cv.personalSkills && cv.personalSkills.length)
? div( ? div(

View file

@ -2,7 +2,7 @@ const { form, button, div, h2, p, section, input, label, br, a, span, textarea,
require("../server/node_modules/hyperaxe"); require("../server/node_modules/hyperaxe");
const moment = require("../server/node_modules/moment"); const moment = require("../server/node_modules/moment");
const { template, i18n } = require("./main_views"); const { template, i18n, userLink} = require("./main_views");
const { config } = require("../server/SSB_server.js"); const { config } = require("../server/SSB_server.js");
const { renderUrl } = require("../backend/renderUrl"); const { renderUrl } = require("../backend/renderUrl");
const opinionCategories = require("../backend/opinion_categories"); const opinionCategories = require("../backend/opinion_categories");
@ -74,7 +74,10 @@ const renderDocumentActions = (filter, doc, params = {}) => {
}; };
const renderDocumentCommentsSection = (documentKey, rootId, comments = [], returnTo = null) => { const renderDocumentCommentsSection = (documentKey, rootId, comments = [], returnTo = null) => {
const list = safeArr(comments); const list = safeArr(comments).filter(c => {
const t = c && c.value && c.value.content && c.value.content.text;
return t && String(t).trim();
});
const commentsCount = list.length; const commentsCount = list.length;
return div( return div(
@ -203,7 +206,7 @@ const renderDocumentList = (documents, filter, params = {}) => {
return p( return p(
{ class: "card-footer" }, { class: "card-footer" },
span({ class: "date-link" }, `${moment(doc.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `), span({ class: "date-link" }, `${moment(doc.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
a({ href: `/author/${encodeURIComponent(doc.author)}`, class: "user-link" }, `${doc.author}`), userLink(doc.author),
showUpdated showUpdated
? span( ? span(
{ class: "votations-comment-date" }, { class: "votations-comment-date" },
@ -408,7 +411,7 @@ exports.singleDocumentView = async (doc, filter = "all", comments = [], params =
return p( return p(
{ class: "card-footer" }, { class: "card-footer" },
span({ class: "date-link" }, `${moment(doc.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `), span({ class: "date-link" }, `${moment(doc.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
a({ href: `/author/${encodeURIComponent(doc.author)}`, class: "user-link" }, `${doc.author}`), userLink(doc.author),
showUpdated showUpdated
? span( ? span(
{ class: "votations-comment-date" }, { class: "votations-comment-date" },

View file

@ -1,5 +1,5 @@
const { div, h2, p, section, button, form, a, span, textarea, br, input, label, select, option } = require("../server/node_modules/hyperaxe"); const { div, h2, p, section, button, form, a, span, textarea, br, input, label, select, option } = require("../server/node_modules/hyperaxe");
const { template, i18n } = require("./main_views"); const { template, i18n, userLink} = require("./main_views");
const moment = require("../server/node_modules/moment"); const moment = require("../server/node_modules/moment");
const { config } = require("../server/SSB_server.js"); const { config } = require("../server/SSB_server.js");
const { renderUrl } = require("../backend/renderUrl"); const { renderUrl } = require("../backend/renderUrl");
@ -163,10 +163,15 @@ const renderEventCommentsSection = (eventId, comments = [], currentFilter = "all
button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton) button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton)
) )
), ),
comments && comments.length (() => {
const visibleComments = (comments || []).filter(c => {
const t = c && c.value && c.value.content && c.value.content.text;
return t && String(t).trim();
});
return visibleComments.length
? div( ? div(
{ class: "comments-list" }, { class: "comments-list" },
comments.map((c) => { visibleComments.map((c) => {
const author = c.value && c.value.author ? c.value.author : ""; const author = c.value && c.value.author ? c.value.author : "";
const ts = c.value && c.value.timestamp ? c.value.timestamp : c.timestamp; const ts = c.value && c.value.timestamp ? c.value.timestamp : c.timestamp;
const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : ""; const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : "";
@ -192,7 +197,8 @@ const renderEventCommentsSection = (eventId, comments = [], currentFilter = "all
); );
}) })
) )
: p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet) : p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet);
})()
); );
}; };
@ -235,7 +241,7 @@ const renderEventItem = (e, filter) => {
p( p(
{ class: "card-footer" }, { class: "card-footer" },
span({ class: "date-link" }, `${moment(e.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `), span({ class: "date-link" }, `${moment(e.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
a({ href: `/author/${encodeURIComponent(e.organizer)}`, class: "user-link" }, `${e.organizer}`) userLink(e.organizer)
) )
); );
}; };
@ -472,7 +478,7 @@ exports.singleEventView = async (event, filter, comments = [], params = {}) => {
attendees.length attendees.length
? attendees ? attendees
.filter(Boolean) .filter(Boolean)
.map((id, i) => [i > 0 ? ", " : "", a({ class: "user-link", href: `/author/${encodeURIComponent(id)}` }, id)]) .map((id, i) => [i > 0 ? ", " : "", userLink(id)])
.flat() .flat()
: i18n.noAttendees : i18n.noAttendees
) )
@ -481,7 +487,7 @@ exports.singleEventView = async (event, filter, comments = [], params = {}) => {
p( p(
{ class: "card-footer" }, { class: "card-footer" },
span({ class: "date-link" }, `${moment(event.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `), span({ class: "date-link" }, `${moment(event.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
a({ href: `/author/${encodeURIComponent(event.organizer)}`, class: "user-link" }, `${event.organizer}`) userLink(event.organizer)
) )
), ),
renderEventCommentsSection(event.id, comments, currentFilter) renderEventCommentsSection(event.id, comments, currentFilter)

View file

@ -1,6 +1,6 @@
const { form, button, div, h2, p, section, input, a, span, img } = require("../server/node_modules/hyperaxe"); const { form, button, div, h2, p, section, input, a, span, img } = require("../server/node_modules/hyperaxe");
const { template, i18n } = require("./main_views"); const { template, i18n, userLink} = require("./main_views");
const moment = require("../server/node_modules/moment"); const moment = require("../server/node_modules/moment");
const { renderUrl } = require("../backend/renderUrl"); const { renderUrl } = require("../backend/renderUrl");
@ -90,7 +90,7 @@ const renderFavoriteCard = (item, filter) => {
p( p(
{ class: "card-footer" }, { class: "card-footer" },
absDate ? span({ class: "date-link" }, `${absDate} ${i18n.performed} `) : "", absDate ? span({ class: "date-link" }, `${absDate} ${i18n.performed} `) : "",
item.author ? a({ href: `/author/${encodeURIComponent(item.author)}`, class: "user-link" }, `${item.author}`) : "" item.author ? userLink(item.author) : ""
) )
); );
}; };

View file

@ -1,5 +1,5 @@
const { div, h2, p, section, button, form, a, span, textarea, br, input, h1, label } = require("../server/node_modules/hyperaxe"); const { div, h2, p, section, button, form, a, span, textarea, br, input, h1, label } = require("../server/node_modules/hyperaxe");
const { template, i18n } = require("./main_views"); const { template, i18n, userLink } = require("./main_views");
const { config } = require("../server/SSB_server.js"); const { config } = require("../server/SSB_server.js");
const { renderTextWithStyles } = require("../backend/renderTextWithStyles"); const { renderTextWithStyles } = require("../backend/renderTextWithStyles");
const opinionCategories = require("../backend/opinion_categories"); const opinionCategories = require("../backend/opinion_categories");
@ -193,7 +193,7 @@ const renderFeedCard = (feed) => {
p( p(
{ class: "card-footer" }, { class: "card-footer" },
span({ class: "date-link" }, `${createdAt} ${i18n.performed} `), span({ class: "date-link" }, `${createdAt} ${i18n.performed} `),
a({ href: `/author/${encodeURIComponent(authorId)}`, class: "user-link" }, `${authorId}`), userLink(authorId),
content._textEdited ? span({ class: "edited-badge" }, ` · ${i18n.edited || "edited"}`) : null content._textEdited ? span({ class: "edited-badge" }, ` · ${i18n.edited || "edited"}`) : null
) )
) )
@ -361,7 +361,7 @@ exports.singleFeedView = (feed, comments = []) => {
p( p(
{ class: "card-footer" }, { class: "card-footer" },
span({ class: "date-link" }, `${createdAt} ${i18n.performed} `), span({ class: "date-link" }, `${createdAt} ${i18n.performed} `),
a({ href: `/author/${encodeURIComponent(authorId)}`, class: "user-link" }, authorId), userLink(authorId),
content._textEdited ? span({ class: "edited-badge" }, ` · ${i18n.edited || "edited"}`) : null content._textEdited ? span({ class: "edited-badge" }, ` · ${i18n.edited || "edited"}`) : null
) )
) )

View file

@ -3,7 +3,7 @@ const {
input, label, br, select, option, h2, textarea input, label, br, select, option, h2, textarea
} = require("../server/node_modules/hyperaxe"); } = require("../server/node_modules/hyperaxe");
const moment = require("../server/node_modules/moment"); const moment = require("../server/node_modules/moment");
const { template, i18n } = require('./main_views'); const { template, i18n, userLink } = require('./main_views');
const { config } = require('../server/SSB_server.js'); const { config } = require('../server/SSB_server.js');
const { renderUrl } = require('../backend/renderUrl'); const { renderUrl } = require('../backend/renderUrl');
const { renderTextWithStyles } = require('../backend/renderTextWithStyles'); const { renderTextWithStyles } = require('../backend/renderTextWithStyles');
@ -100,6 +100,7 @@ const renderForumForm = () =>
const renderThread = (nodes, level = 0, forumId) => { const renderThread = (nodes, level = 0, forumId) => {
if (!Array.isArray(nodes)) return []; if (!Array.isArray(nodes)) return [];
return [...nodes] return [...nodes]
.filter(m => (m && m.text && String(m.text).trim()) || (m && Array.isArray(m.children) && m.children.length))
.sort((a, b) => .sort((a, b) =>
wilsonScore(b.positiveVotes, b.negativeVotes) wilsonScore(b.positiveVotes, b.negativeVotes)
- wilsonScore(a.positiveVotes, a.negativeVotes) - wilsonScore(a.positiveVotes, a.negativeVotes)
@ -117,11 +118,7 @@ const renderThread = (nodes, level = 0, forumId) => {
div({ class: 'comment-header' }, div({ class: 'comment-header' },
span({ class: 'date-link' }, span({ class: 'date-link' },
`${moment(m.timestamp).format('YYYY/MM/DD HH:mm:ss')} ${i18n.performed}`), `${moment(m.timestamp).format('YYYY/MM/DD HH:mm:ss')} ${i18n.performed}`),
a({ userLink(m.author),
href: `/author/${encodeURIComponent(m.author)}`,
class: 'user-link',
style: 'margin-left:12px;'
}, m.author),
div({ class: 'comment-votes' }, div({ class: 'comment-votes' },
span({ class: 'votes-count' }, `▲: ${m.positiveVotes || 0}`), span({ class: 'votes-count' }, `▲: ${m.positiveVotes || 0}`),
span({ class: 'votes-count', style: 'margin-left:12px;' }, span({ class: 'votes-count', style: 'margin-left:12px;' },
@ -164,10 +161,13 @@ const renderThread = (nodes, level = 0, forumId) => {
}); });
}; };
const renderForumList = (forums, currentFilter) => const renderForumList = (forums, currentFilter) => {
div({ class: 'forum-list' }, const visibleForums = (Array.isArray(forums) ? forums : []).filter(f =>
Array.isArray(forums) && forums.length (f && f.title && String(f.title).trim()) || (f && f.text && String(f.text).trim())
? forums.map(f => )
return div({ class: 'forum-list' },
visibleForums.length
? visibleForums.map(f =>
div({ class: 'forum-card' }, div({ class: 'forum-card' },
div({ class: 'forum-score-col' }, div({ class: 'forum-score-col' },
renderVotes(f.key, f.score, f.key) renderVotes(f.key, f.score, f.key)
@ -203,11 +203,7 @@ const renderForumList = (forums, currentFilter) =>
div({ class: 'forum-footer' }, div({ class: 'forum-footer' },
span({ class: 'date-link' }, span({ class: 'date-link' },
`${moment(f.createdAt).format('YYYY/MM/DD HH:mm:ss')} ${i18n.performed}`), `${moment(f.createdAt).format('YYYY/MM/DD HH:mm:ss')} ${i18n.performed}`),
a({ userLink(f.author)
href: `/author/${encodeURIComponent(f.author)}`,
class: 'user-link',
style: 'margin-left:12px;'
}, f.author)
), ),
currentFilter === 'mine' && f.author === userId currentFilter === 'mine' && f.author === userId
? div({ class: 'forum-owner-actions' }, ? div({ class: 'forum-owner-actions' },
@ -226,6 +222,7 @@ const renderForumList = (forums, currentFilter) =>
) )
: p(i18n.noForums) : p(i18n.noForums)
); );
}
exports.forumView = async (forums, currentFilter) => { exports.forumView = async (forums, currentFilter) => {
const CAT_I18N_MAP_UP = ALL_CATS.reduce((m,c)=>{ m[c]=(catLabel(c)||c).toUpperCase(); return m; },{}); const CAT_I18N_MAP_UP = ALL_CATS.reduce((m,c)=>{ m[c]=(catLabel(c)||c).toUpperCase(); return m; },{});
@ -312,11 +309,7 @@ exports.singleForumView = async (forum, messagesData, currentFilter) => {
div({ class: 'forum-footer' }, div({ class: 'forum-footer' },
span({ class: 'date-link' }, span({ class: 'date-link' },
`${moment(forum.createdAt).format('YYYY/MM/DD HH:mm:ss')} ${i18n.performed}`), `${moment(forum.createdAt).format('YYYY/MM/DD HH:mm:ss')} ${i18n.performed}`),
a({ userLink(forum.author)
href: `/author/${encodeURIComponent(forum.author)}`,
class: 'user-link',
style: 'margin-left:12px;'
}, forum.author)
), ),
div({ div({
class: 'forum-body', class: 'forum-body',

View file

@ -1,5 +1,5 @@
const { div, h2, h3, p, section, form, input, button, a, img, table, tr, td, th, span, iframe } = require("../server/node_modules/hyperaxe"); const { div, h2, h3, p, section, form, input, button, a, img, table, tr, td, th, span, iframe } = require("../server/node_modules/hyperaxe");
const { template, i18n } = require('./main_views'); const { template, i18n, userLink} = require('./main_views');
const moment = require("../server/node_modules/moment"); const moment = require("../server/node_modules/moment");
const getGames = () => [ const getGames = () => [
@ -44,7 +44,7 @@ const renderHallOfFame = (hall) => {
...hall[game.id].map((entry, idx) => ...hall[game.id].map((entry, idx) =>
tr( tr(
td(String(idx + 1)), td(String(idx + 1)),
td(a({ href: `/author/${encodeURIComponent(entry.author)}`, class: 'user-link' }, entry.author)), td(userLink(entry.author)),
td({ class: idx === 0 ? 'score-first' : '' }, String(entry.score)), td({ class: idx === 0 ? 'score-first' : '' }, String(entry.score)),
td(entry.ts ? moment(entry.ts).format('YYYY-MM-DD') : '\u2014') td(entry.ts ? moment(entry.ts).format('YYYY-MM-DD') : '\u2014')
) )
@ -124,7 +124,7 @@ exports.gamesView = (filter = 'all', hall = null) => {
p({ class: 'game-card-desc game-desc-yellow' }, game.desc()), p({ class: 'game-card-desc game-desc-yellow' }, game.desc()),
topScore topScore
? p({ class: 'game-top-score' }, ? p({ class: 'game-top-score' },
a({ href: `/author/${encodeURIComponent(topScore.author)}`, class: 'user-link' }, topScore.author), userLink(topScore.author),
span({ class: 'game-new-record-label' }, ' - ' + (i18n.gamesNewRecord || 'New Record') + ': '), span({ class: 'game-new-record-label' }, ' - ' + (i18n.gamesNewRecord || 'New Record') + ': '),
String(topScore.score) String(topScore.score)
) )

View file

@ -0,0 +1,129 @@
const h = require("../server/node_modules/hyperaxe");
const { div, h2, p, section, button, form, input, span } = h;
const { template, i18n } = require('./main_views');
const TAU = Math.PI * 2;
const escAttr = (s) => String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
const escText = (s) => String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
const adaptiveLayout = (n) => {
if (n <= 1) return { rRatio: 0.22, nodeR: 30, labelGap: 18 };
if (n <= 3) return { rRatio: 0.28, nodeR: 26, labelGap: 16 };
if (n <= 6) return { rRatio: 0.34, nodeR: 22, labelGap: 14 };
if (n <= 12) return { rRatio: 0.40, nodeR: 18, labelGap: 14 };
return { rRatio: 0.44, nodeR: 14, labelGap: 12 };
};
const buildGraphSvg = (me, peers) => {
const W = 900;
const H = 600;
const cx = W / 2;
const cy = H / 2;
const N = Math.max(1, peers.length);
const { rRatio, nodeR, labelGap } = adaptiveLayout(N);
const r = Math.min(W, H) * rRatio;
const meR = nodeR + 6;
const positions = peers.map((peer, i) => {
const angle = (i / N) * TAU - Math.PI / 2;
return {
peer,
x: cx + r * Math.cos(angle),
y: cy + r * Math.sin(angle)
};
});
const edges = positions.map(({ peer, x, y }) =>
`<line x1="${cx}" y1="${cy}" x2="${x.toFixed(2)}" y2="${y.toFixed(2)}" class="graphos-edge graphos-edge-${peer.kind}" />`
).join('');
const nodes = positions.map(({ peer, x, y }) => {
const xs = x.toFixed(2);
const ys = y.toFixed(2);
const labelY = (y + nodeR + labelGap).toFixed(2);
const href = `/author/${escAttr(encodeURIComponent(peer.key))}`;
const name = escText(peer.name);
return `<a href="${href}" class="graphos-node-link">`
+ `<g class="graphos-node graphos-node-${peer.kind}">`
+ `<title>${name} (${peer.kind})</title>`
+ `<circle cx="${xs}" cy="${ys}" r="${nodeR}" class="graphos-node-circle graphos-node-circle-${peer.kind}" />`
+ `<text x="${xs}" y="${labelY}" text-anchor="middle" class="graphos-node-label">${name}</text>`
+ `</g></a>`;
}).join('');
const meLabelY = (cy + meR + labelGap + 2).toFixed(2);
const meHref = `/author/${escAttr(encodeURIComponent(me.key))}`;
const meName = escText(me.name);
const center = `<a href="${meHref}" class="graphos-node-link">`
+ `<g class="graphos-node graphos-node-me">`
+ `<title>${meName} (you)</title>`
+ `<circle cx="${cx}" cy="${cy}" r="${meR}" class="graphos-node-circle graphos-node-circle-me" />`
+ `<text x="${cx}" y="${meLabelY}" text-anchor="middle" class="graphos-node-label graphos-node-label-me">${meName}</text>`
+ `</g></a>`;
return `<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="xMidYMid meet" xmlns="http://www.w3.org/2000/svg" class="graphos-svg">`
+ edges + nodes + center
+ `</svg>`;
};
const kpi = (label, value) => div({ class: 'stats-kpi' },
div({ class: 'stats-kpi-label' }, label),
div({ class: 'stats-kpi-value' }, String(value))
);
const legendItem = (kind, label) =>
span({ class: 'graphos-legend-item' },
span({ class: `graphos-legend-dot graphos-node-circle-${kind}` }),
span(label)
);
exports.graphosView = ({ filter, me, peers, kpis }) => {
const title = i18n.graphos || 'Graphos';
const description = i18n.graphosDescription || 'Interactive map of the network around you.';
const modes = ['ALL', 'MINE'];
return template(
title,
section(
div({ class: 'tags-header' },
h2(title),
p(description)
),
div({ class: 'mode-buttons stats-mode-row' },
modes.map(m =>
form({ method: 'GET', action: '/graphos' },
input({ type: 'hidden', name: 'filter', value: m }),
button({ type: 'submit', class: filter === m ? 'filter-btn active' : 'filter-btn' }, i18n[m + 'Button'])
)
)
),
div({ class: 'graphos-legend' },
legendItem('me', i18n.graphosYou || 'You'),
legendItem('online', i18n.online || 'Online'),
filter !== 'MINE' ? legendItem('discovered', i18n.discovered || 'Discovered') : null,
filter !== 'MINE' ? legendItem('unknown', i18n.unknown || 'Unknown') : null
),
div({ class: 'graphos-canvas', innerHTML: buildGraphSvg(me, peers) }),
div({ class: 'stats-block' },
div({ class: 'stats-grid' },
kpi(i18n.graphosTotalNodes || 'Total nodes', kpis.total),
kpi(i18n.online || 'Online', kpis.online),
filter !== 'MINE' ? kpi(i18n.discovered || 'Discovered', kpis.discovered) : null,
filter !== 'MINE' ? kpi(i18n.unknown || 'Unknown', kpis.unknown) : null
)
)
)
);
};

View file

@ -2,7 +2,7 @@ const { form, button, div, h2, p, section, input, label, br, a, img, span, texta
require("../server/node_modules/hyperaxe"); require("../server/node_modules/hyperaxe");
const moment = require("../server/node_modules/moment"); const moment = require("../server/node_modules/moment");
const { template, i18n } = require("./main_views"); const { template, i18n, userLink} = require("./main_views");
const { config } = require("../server/SSB_server.js"); const { config } = require("../server/SSB_server.js");
const { renderUrl } = require("../backend/renderUrl") const { renderUrl } = require("../backend/renderUrl")
const { renderMapLocationVisitLabel } = require("./maps_view"); const { renderMapLocationVisitLabel } = require("./maps_view");
@ -160,7 +160,7 @@ const renderImageList = (images, filter, params = {}) => {
return p( return p(
{ class: "card-footer" }, { class: "card-footer" },
span({ class: "date-link" }, `${moment(imgObj.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `), span({ class: "date-link" }, `${moment(imgObj.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
a({ href: `/author/${encodeURIComponent(imgObj.author)}`, class: "user-link" }, `${imgObj.author}`), userLink(imgObj.author),
showUpdated showUpdated
? span( ? span(
{ class: "votations-comment-date" }, { class: "votations-comment-date" },
@ -255,7 +255,10 @@ const renderLightbox = (images) =>
}); });
const renderImageCommentsSection = (imageKey, comments = [], returnTo = null) => { const renderImageCommentsSection = (imageKey, comments = [], returnTo = null) => {
const list = safeArr(comments); const list = safeArr(comments).filter(c => {
const t = c && c.value && c.value.content && c.value.content.text;
return t && String(t).trim();
});
const commentsCount = list.length; const commentsCount = list.length;
return div( return div(
@ -475,7 +478,7 @@ exports.singleImageView = async (imageObj, filter = "all", comments = [], params
return p( return p(
{ class: "card-footer" }, { class: "card-footer" },
span({ class: "date-link" }, `${moment(imageObj.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `), span({ class: "date-link" }, `${moment(imageObj.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
a({ href: `/author/${encodeURIComponent(imageObj.author)}`, class: "user-link" }, `${imageObj.author}`), userLink(imageObj.author),
showUpdated showUpdated
? span( ? span(
{ class: "votations-comment-date" }, { class: "votations-comment-date" },

View file

@ -1,5 +1,5 @@
const { form, button, div, h2, p, section, input, label, textarea, br, a, span, select, option, img, progress, video, audio } = require("../server/node_modules/hyperaxe") const { form, button, div, h2, p, section, input, label, textarea, br, a, span, select, option, img, progress, video, audio } = require("../server/node_modules/hyperaxe")
const { template, i18n } = require("./main_views") const { template, i18n, userLink} = require("./main_views")
const moment = require("../server/node_modules/moment") const moment = require("../server/node_modules/moment")
const { config } = require("../server/SSB_server.js") const { config } = require("../server/SSB_server.js")
const { renderUrl } = require("../backend/renderUrl") const { renderUrl } = require("../backend/renderUrl")
@ -268,7 +268,7 @@ const renderJobList = (jobs, filter, params = {}) => {
p( p(
{ class: "card-footer" }, { class: "card-footer" },
span({ class: "date-link" }, `${moment(job.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `), span({ class: "date-link" }, `${moment(job.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
a({ href: `/author/${encodeURIComponent(job.author)}`, class: "user-link" }, job.author), userLink(job.author),
renderUpdatedLabel(job.createdAt, job.updatedAt) renderUpdatedLabel(job.createdAt, job.updatedAt)
) )
) )
@ -388,7 +388,7 @@ const renderCVList = (inhabitants) =>
div( div(
{ class: "inhabitant-details" }, { class: "inhabitant-details" },
user.description ? p(...renderUrl(user.description)) : null, user.description ? p(...renderUrl(user.description)) : null,
p(a({ class: "user-link", href: `/author/${encodeURIComponent(user.id)}` }, user.id)), p(userLink(user.id)),
div( div(
{ class: "cv-actions" }, { class: "cv-actions" },
form({ method: "GET", action: `/inhabitant/${encodeURIComponent(user.id)}` }, button({ type: "submit", class: "filter-btn" }, i18n.inhabitantviewDetails)), form({ method: "GET", action: `/inhabitant/${encodeURIComponent(user.id)}` }, button({ type: "submit", class: "filter-btn" }, i18n.inhabitantviewDetails)),
@ -481,7 +481,10 @@ exports.jobsView = async (jobsOrCVs, filter = "ALL", params = {}) => {
} }
const renderJobCommentsSection = (jobId, returnTo, comments = []) => { const renderJobCommentsSection = (jobId, returnTo, comments = []) => {
const list = safeArr(comments) const list = safeArr(comments).filter(c => {
const t = c && c.value && c.value.content && c.value.content.text
return t && String(t).trim()
})
const commentsCount = list.length const commentsCount = list.length
return div( return div(
@ -582,7 +585,7 @@ exports.singleJobsView = async (job, filter = "ALL", comments = [], params = {})
p( p(
{ class: "card-footer" }, { class: "card-footer" },
span({ class: "date-link" }, `${moment(job.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `), span({ class: "date-link" }, `${moment(job.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
a({ href: `/author/${encodeURIComponent(job.author)}`, class: "user-link" }, job.author), userLink(job.author),
renderUpdatedLabel(job.createdAt, job.updatedAt) renderUpdatedLabel(job.createdAt, job.updatedAt)
) )
), ),

View file

@ -28,6 +28,23 @@ const { a, article, br, body, button, details, div, em, footer, form, h1, h2, h3
const lodash = require("../server/node_modules/lodash"); const lodash = require("../server/node_modules/lodash");
const markdown = require("./markdown"); const markdown = require("./markdown");
const { sanitizeHtml } = require('../backend/sanitizeHtml'); const { sanitizeHtml } = require('../backend/sanitizeHtml');
const nameCache = require('../backend/nameCache');
const userLinkLabel = (feedId, knownName) => {
const id = String(feedId || '');
if (!id) return '';
const name = (knownName && String(knownName).trim()) || nameCache.get(id);
if (name && name.length) return '@' + name;
return id;
};
const userLink = (feedId, knownName) => {
if (!feedId) return null;
return a({ class: 'user-link', href: `/author/${encodeURIComponent(feedId)}` }, userLinkLabel(feedId, knownName));
};
exports.userLink = userLink;
exports.userLinkLabel = userLinkLabel;
const i18nBase = require("../client/assets/translations/i18n"); const i18nBase = require("../client/assets/translations/i18n");
let selectedLanguage = "en"; let selectedLanguage = "en";

View file

@ -2,7 +2,7 @@ const { form, button, div, h2, h3, p, section, input, label, br, a, span, textar
require("../server/node_modules/hyperaxe"); require("../server/node_modules/hyperaxe");
const moment = require("../server/node_modules/moment"); const moment = require("../server/node_modules/moment");
const { template, i18n } = require("./main_views"); const { template, i18n, userLink} = require("./main_views");
const { config } = require("../server/SSB_server.js"); const { config } = require("../server/SSB_server.js");
const { renderMapWithPins, renderZoomedMapWithPins, getViewportBounds, latLngToPx, pxToLatLng, MAP_W, MAP_H, getMaxTileZoom } = require("../maps/map_renderer"); const { renderMapWithPins, renderZoomedMapWithPins, getViewportBounds, latLngToPx, pxToLatLng, MAP_W, MAP_H, getMaxTileZoom } = require("../maps/map_renderer");
const { sanitizeHtml } = require('../backend/sanitizeHtml'); const { sanitizeHtml } = require('../backend/sanitizeHtml');
@ -313,7 +313,7 @@ const renderMarkersList = (markers, mapObj) => {
span({ class: "map-marker-dot" }, "ꔌ"), span({ class: "map-marker-dot" }, "ꔌ"),
span({ class: "map-marker-coords" }, `${(typeof mk.lat === 'number' ? mk.lat : 0).toFixed(4)}, ${(typeof mk.lng === 'number' ? mk.lng : 0).toFixed(4)}`), span({ class: "map-marker-coords" }, `${(typeof mk.lat === 'number' ? mk.lat : 0).toFixed(4)}, ${(typeof mk.lng === 'number' ? mk.lng : 0).toFixed(4)}`),
span({ class: "map-marker-meta" }, span({ class: "map-marker-meta" },
a({ href: `/author/${encodeURIComponent(mk.author)}`, class: "user-link" }, mk.author), userLink(mk.author),
` · ${moment(mk.createdAt).fromNow()}`)) ` · ${moment(mk.createdAt).fromNow()}`))
]))); ])));
}; };
@ -351,7 +351,7 @@ const renderMapCard = (mapObj, filter, params = {}) => {
p({ class: "card-footer" }, p({ class: "card-footer" },
span({ class: "date-link" }, moment(mapObj.createdAt).fromNow()), span({ class: "date-link" }, moment(mapObj.createdAt).fromNow()),
span(" · "), span(" · "),
a({ href: `/author/${encodeURIComponent(mapObj.author)}`, class: "user-link" }, mapObj.author)))); userLink(mapObj.author))));
}; };
const renderMapList = (maps, filter, params = {}) => const renderMapList = (maps, filter, params = {}) =>
@ -433,7 +433,7 @@ exports.singleMapView = async (mapObj, filter = "all", params = {}) => {
br(), br(),
p({ class: "card-footer" }, p({ class: "card-footer" },
span({ class: "date-link" }, `${moment(mapObj.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `), span({ class: "date-link" }, `${moment(mapObj.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
a({ href: `/author/${encodeURIComponent(mapObj.author)}`, class: "user-link" }, mapObj.author), userLink(mapObj.author),
mapObj.updatedAt && mapObj.updatedAt !== mapObj.createdAt mapObj.updatedAt && mapObj.updatedAt !== mapObj.createdAt
? span({ class: "votations-comment-date" }, ` · ${i18n.mapUpdatedAt}: ${moment(mapObj.updatedAt).format("YYYY/MM/DD HH:mm:ss")}`) ? span({ class: "votations-comment-date" }, ` · ${i18n.mapUpdatedAt}: ${moment(mapObj.updatedAt).format("YYYY/MM/DD HH:mm:ss")}`)
: null), : null),

View file

@ -1,5 +1,5 @@
const { div, h2, p, section, button, form, a, span, textarea, br, input, label, select, option, img, table, tr, th, td, progress, video, audio } = require("../server/node_modules/hyperaxe") const { div, h2, p, section, button, form, a, span, textarea, br, input, label, select, option, img, table, tr, th, td, progress, video, audio } = require("../server/node_modules/hyperaxe")
const { template, i18n } = require("./main_views") const { template, i18n, userLink} = require("./main_views")
const moment = require("../server/node_modules/moment") const moment = require("../server/node_modules/moment")
const { config } = require("../server/SSB_server.js") const { config } = require("../server/SSB_server.js")
const { renderUrl } = require("../backend/renderUrl") const { renderUrl } = require("../backend/renderUrl")
@ -138,10 +138,15 @@ const renderMarketCommentsSection = (itemId, returnTo, comments = []) => {
button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton) button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton)
) )
), ),
comments && comments.length (() => {
const visibleComments = (comments || []).filter(c => {
const t = c && c.value && c.value.content && c.value.content.text
return t && String(t).trim()
})
return visibleComments.length
? div( ? div(
{ class: "comments-list" }, { class: "comments-list" },
comments.map((c) => { visibleComments.map((c) => {
const author = c.value && c.value.author ? c.value.author : "" const author = c.value && c.value.author ? c.value.author : ""
const ts = c.value && c.value.timestamp ? c.value.timestamp : c.timestamp const ts = c.value && c.value.timestamp ? c.value.timestamp : c.timestamp
const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : "" const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : ""
@ -166,6 +171,7 @@ const renderMarketCommentsSection = (itemId, returnTo, comments = []) => {
}) })
) )
: p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet) : p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet)
})()
) )
} }
@ -678,7 +684,7 @@ exports.singleMarketView = async (item, filter, comments = [], params = {}) => {
renderCardField(`${i18n.marketItemIncludesShipping}:`, `${item.includesShipping ? i18n.YESLabel : i18n.NOLabel}`), renderCardField(`${i18n.marketItemIncludesShipping}:`, `${item.includesShipping ? i18n.YESLabel : i18n.NOLabel}`),
renderMapEmbedWithZoom(params.mapData, item.mapUrl, `/market/${encodeURIComponent(item.id)}`, params.zoom), renderMapEmbedWithZoom(params.mapData, item.mapUrl, `/market/${encodeURIComponent(item.id)}`, params.zoom),
item.deadline ? renderCardField(`${i18n.marketItemAvailable}:`, `${moment(item.deadline).format("YYYY/MM/DD HH:mm:ss")}`) : null, item.deadline ? renderCardField(`${i18n.marketItemAvailable}:`, `${moment(item.deadline).format("YYYY/MM/DD HH:mm:ss")}`) : null,
renderCardFieldRich(`${i18n.marketItemSeller}:`, [a({ class: "user-link", href: `/author/${encodeURIComponent(item.seller)}` }, item.seller)]) renderCardFieldRich(`${i18n.marketItemSeller}:`, [userLink(item.seller)])
), ),
item.item_type === "auction" item.item_type === "auction"
? div( ? div(

View file

@ -7,6 +7,7 @@ const modulesView = () => {
const modules = [ const modules = [
{ name: 'agenda', label: i18n.modulesAgendaLabel, description: i18n.modulesAgendaDescription }, { name: 'agenda', label: i18n.modulesAgendaLabel, description: i18n.modulesAgendaDescription },
{ name: 'ai', label: i18n.modulesAILabel, description: i18n.modulesAIDescription }, { name: 'ai', label: i18n.modulesAILabel, description: i18n.modulesAIDescription },
{ name: 'aiNav', label: i18n.modulesAINavLabel, description: i18n.modulesAINavDescription },
{ name: 'audios', label: i18n.modulesAudiosLabel, description: i18n.modulesAudiosDescription }, { name: 'audios', label: i18n.modulesAudiosLabel, description: i18n.modulesAudiosDescription },
{ name: 'banking', label: i18n.modulesBankingLabel, description: i18n.modulesBankingDescription }, { name: 'banking', label: i18n.modulesBankingLabel, description: i18n.modulesBankingDescription },
{ name: 'bookmarks', label: i18n.modulesBookmarksLabel, description: i18n.modulesBookmarksDescription }, { name: 'bookmarks', label: i18n.modulesBookmarksLabel, description: i18n.modulesBookmarksDescription },
@ -20,6 +21,7 @@ const modulesView = () => {
{ name: 'feed', label: i18n.modulesFeedLabel, description: i18n.modulesFeedDescription }, { name: 'feed', label: i18n.modulesFeedLabel, description: i18n.modulesFeedDescription },
{ name: 'forum', label: i18n.modulesForumLabel, description: i18n.modulesForumDescription }, { name: 'forum', label: i18n.modulesForumLabel, description: i18n.modulesForumDescription },
{ name: 'games', label: i18n.modulesGamesLabel, description: i18n.modulesGamesDescription }, { name: 'games', label: i18n.modulesGamesLabel, description: i18n.modulesGamesDescription },
{ name: 'graphos', label: i18n.modulesGraphosLabel, description: i18n.modulesGraphosDescription },
{ name: 'images', label: i18n.modulesImagesLabel, description: i18n.modulesImagesDescription }, { name: 'images', label: i18n.modulesImagesLabel, description: i18n.modulesImagesDescription },
{ name: 'invites', label: i18n.modulesInvitesLabel, description: i18n.modulesInvitesDescription }, { name: 'invites', label: i18n.modulesInvitesLabel, description: i18n.modulesInvitesDescription },
{ name: 'jobs', label: i18n.modulesJobsLabel, description: i18n.modulesJobsDescription }, { name: 'jobs', label: i18n.modulesJobsLabel, description: i18n.modulesJobsDescription },
@ -86,7 +88,7 @@ const modulesView = () => {
full: modules.map(m => m.name) full: modules.map(m => m.name)
}; };
const presetButtons = div({ class: 'preset-group mode-buttons', style: 'margin-bottom:16px;' }, const presetButtons = div({ class: 'preset-group', style: 'display:flex;gap:8px;flex-wrap:nowrap;margin-bottom:16px;' },
Object.entries(PRESETS).map(([key, mods]) => { Object.entries(PRESETS).map(([key, mods]) => {
const presetLabel = (i18n[`modulesPreset_${key}`] || key).toUpperCase(); const presetLabel = (i18n[`modulesPreset_${key}`] || key).toUpperCase();
const isActive = modules.every(m => mods.includes(m.name) === (moduleStates[`${m.name}Mod`] === 'on')); const isActive = modules.every(m => mods.includes(m.name) === (moduleStates[`${m.name}Mod`] === 'on'));

View file

@ -1,5 +1,5 @@
const { div, h2, p, section, button, form, a, img, video: videoHyperaxe, audio: audioHyperaxe, input, table, tr, th, td, br, span } = require("../server/node_modules/hyperaxe"); const { div, h2, p, section, button, form, a, img, video: videoHyperaxe, audio: audioHyperaxe, input, table, tr, th, td, br, span } = require("../server/node_modules/hyperaxe");
const { template, i18n } = require('./main_views'); const { template, i18n, userLink} = require('./main_views');
const { config } = require('../server/SSB_server.js'); const { config } = require('../server/SSB_server.js');
const { renderTextWithStyles } = require('../backend/renderTextWithStyles'); const { renderTextWithStyles } = require('../backend/renderTextWithStyles');
const { renderUrl } = require('../backend/renderUrl'); const { renderUrl } = require('../backend/renderUrl');
@ -215,11 +215,11 @@ const renderContentHtml = (content, key) => {
), ),
div({ class: 'card-field' }, div({ class: 'card-field' },
span({ class: 'card-label' }, i18n.from + ':'), span({ class: 'card-label' }, i18n.from + ':'),
span({ class: 'card-value' }, a({ class: 'user-link', href: `/author/${encodeURIComponent(content.from)}`, target: "_blank" }, content.from)) span({ class: 'card-value' }, userLink(content.from))
), ),
div({ class: 'card-field' }, div({ class: 'card-field' },
span({ class: 'card-label' }, i18n.to + ':'), span({ class: 'card-label' }, i18n.to + ':'),
span({ class: 'card-value' }, a({ class: 'user-link', href: `/author/${encodeURIComponent(content.to)}`, target: "_blank" }, content.to)) span({ class: 'card-value' }, userLink(content.to))
), ),
h2({ class: 'card-field' }, h2({ class: 'card-field' },
span({ class: 'card-label' }, `${i18n.transfersConfirmations}: `), span({ class: 'card-label' }, `${i18n.transfersConfirmations}: `),
@ -273,7 +273,7 @@ exports.opinionsView = (items, filter) => {
contentHtml, contentHtml,
p({ class: 'card-footer' }, p({ class: 'card-footer' },
span({ class: 'date-link' }, `${created} ${i18n.performed} `), span({ class: 'date-link' }, `${created} ${i18n.performed} `),
a({ href: `/author/${encodeURIComponent(item.value.author)}`, class: 'user-link' }, item.value.author) userLink(item.value.author)
), ),
(() => { (() => {
const entries = voteEntries.filter(([, v]) => v > 0); const entries = voteEntries.filter(([, v]) => v > 0);

View file

@ -1,5 +1,5 @@
const { div, h2, h3, h4, p, section, button, form, a, span, br, textarea, input, label, select, option, table, tr, td } = require("../server/node_modules/hyperaxe") const { div, h2, h3, h4, p, section, button, form, a, span, br, textarea, input, label, select, option, table, tr, td } = require("../server/node_modules/hyperaxe")
const { template, i18n } = require("./main_views") const { template, i18n, userLink} = require("./main_views")
const moment = require("../server/node_modules/moment") const moment = require("../server/node_modules/moment")
const { config } = require("../server/SSB_server.js") const { config } = require("../server/SSB_server.js")
@ -226,7 +226,7 @@ exports.singlePadView = async (pad, entries, params) => {
), ),
table({ class: "tribe-info-table" }, table({ class: "tribe-info-table" },
tr(td({ class: "tribe-info-label" }, i18n.padCreated || "Created"), td({ class: "tribe-info-value", colspan: "3" }, moment(pad.createdAt).format("YYYY-MM-DD"))), tr(td({ class: "tribe-info-label" }, i18n.padCreated || "Created"), td({ class: "tribe-info-value", colspan: "3" }, moment(pad.createdAt).format("YYYY-MM-DD"))),
isRestrictedInviteOnly ? null : tr(td({ class: "tribe-info-value", colspan: "4" }, a({ href: `/author/${encodeURIComponent(pad.author)}`, class: "user-link" }, pad.author))), isRestrictedInviteOnly ? null : tr(td({ class: "tribe-info-value", colspan: "4" }, userLink(pad.author))),
tr(td({ class: "tribe-info-label" }, i18n.padStatusLabel || "Status"), td({ class: "tribe-info-value", colspan: "3" }, renderStatus(pad.status, padClosed))), tr(td({ class: "tribe-info-label" }, i18n.padStatusLabel || "Status"), td({ class: "tribe-info-value", colspan: "3" }, renderStatus(pad.status, padClosed))),
isRestrictedInviteOnly ? null : tr(td({ class: "tribe-info-label" }, i18n.padDeadlineLabel || "Deadline"), td({ class: "tribe-info-value", colspan: "3" }, pad.deadline ? moment(pad.deadline).format("YYYY-MM-DD HH:mm") : "\u2014")) isRestrictedInviteOnly ? null : tr(td({ class: "tribe-info-label" }, i18n.padDeadlineLabel || "Deadline"), td({ class: "tribe-info-value", colspan: "3" }, pad.deadline ? moment(pad.deadline).format("YYYY-MM-DD HH:mm") : "\u2014"))
), ),
@ -296,15 +296,16 @@ exports.singlePadView = async (pad, entries, params) => {
) )
: p(i18n.padNoEntries || "No entries yet.") : p(i18n.padNoEntries || "No entries yet.")
const versionList = entries.length > 0 const visibleEntries = entries.filter(e => e.text && String(e.text).trim())
const versionList = visibleEntries.length > 0
? div({ class: "pad-version-list" }, ? div({ class: "pad-version-list" },
h4(i18n.padVersionHistory || "Version History"), h4(i18n.padVersionHistory || "Version History"),
...entries.slice().reverse().map((e, idx) => ...visibleEntries.slice().reverse().map((e, idx) =>
div({ class: "pad-version-item" }, div({ class: "pad-version-item" },
span({ class: "pad-version-date" }, moment(e.createdAt).format("YYYY-MM-DD HH:mm")), span({ class: "pad-version-date" }, moment(e.createdAt).format("YYYY-MM-DD HH:mm")),
span({ class: "pad-version-author" }, span({ class: "pad-version-author" },
span({ class: "pad-author-swatch " + memberColorClass(pad.members, e.author) }), span({ class: "pad-author-swatch " + memberColorClass(pad.members, e.author) }),
a({ href: `/author/${encodeURIComponent(e.author)}`, class: "user-link" }, "@" + e.author.slice(1, 9) + "\u2026") userLink(e.author)
), ),
a({ href: `/pads/${encodeURIComponent(pad.rootId)}?version=${encodeURIComponent(e.key || idx)}`, class: "pad-version-link" }, i18n.padVersionView || "View") a({ href: `/pads/${encodeURIComponent(pad.rootId)}?version=${encodeURIComponent(e.key || idx)}`, class: "pad-version-link" }, i18n.padVersionView || "View")
) )

View file

@ -1,6 +1,6 @@
const { form, button, div, h2, p, section, input, label, br, a, span, table, thead, tbody, tr, th, td, textarea, select, option, ul, li, img } = require('../server/node_modules/hyperaxe'); const { form, button, div, h2, p, section, input, label, br, a, span, table, thead, tbody, tr, th, td, textarea, select, option, ul, li, img } = require('../server/node_modules/hyperaxe');
const moment = require("../server/node_modules/moment"); const moment = require("../server/node_modules/moment");
const { template, i18n } = require('./main_views'); const { template, i18n, userLink} = require('./main_views');
const TERM_DAYS = 60; const TERM_DAYS = 60;
@ -131,7 +131,7 @@ const GovernmentCard = (g, meta) => {
const actorLink = const actorLink =
g.powerType === 'tribe' g.powerType === 'tribe'
? a({ class: 'user-link', href: `/tribe/${encodeURIComponent(g.powerId)}` }, g.powerTitle || g.powerId) ? a({ class: 'user-link', href: `/tribe/${encodeURIComponent(g.powerId)}` }, g.powerTitle || g.powerId)
: a({ class: 'user-link', href: `/author/${encodeURIComponent(g.powerId)}` }, g.powerTitle || g.powerId); : userLink(g.powerId, g.powerTitle);
const actorBio = meta && meta.bio ? meta.bio : ''; const actorBio = meta && meta.bio ? meta.bio : '';
const memberIds = Array.isArray(g.membersList) ? g.membersList : (Array.isArray(g.members) ? g.members : []); const memberIds = Array.isArray(g.membersList) ? g.membersList : (Array.isArray(g.members) ? g.members : []);
const membersRow = const membersRow =
@ -143,7 +143,7 @@ const GovernmentCard = (g, meta) => {
div( div(
span({ class: 'card-label' }, (i18n.parliamentMembers + ': ').toUpperCase()), span({ class: 'card-label' }, (i18n.parliamentMembers + ': ').toUpperCase()),
memberIds && memberIds.length memberIds && memberIds.length
? ul({ class: 'parliament-members-list' }, ...memberIds.map(id => li(a({ class: 'user-link', href: `/author/${encodeURIComponent(id)}` }, id)))) ? ul({ class: 'parliament-members-list' }, ...memberIds.map(id => li(userLink(id))))
: span({ class: 'card-value' }, String(g.members || 0)) : span({ class: 'card-value' }, String(g.members || 0))
) )
) )
@ -247,7 +247,7 @@ const CandidatureStats = (cands, govCard, leaderMeta) => {
const winLbl = (i18n.parliamentWinningCandidature || i18n.parliamentCurrentLeader || 'WINNING CANDIDATURE').toUpperCase(); const winLbl = (i18n.parliamentWinningCandidature || i18n.parliamentCurrentLeader || 'WINNING CANDIDATURE').toUpperCase();
const idLink = leader const idLink = leader
? (leader.targetType === 'inhabitant' ? (leader.targetType === 'inhabitant'
? a({ class: 'user-link', href: `/author/${encodeURIComponent(leader.targetId)}` }, leader.targetId) ? userLink(leader.targetId)
: a({ class: 'tag-link', href: `/tribe/${encodeURIComponent(leader.targetId)}?` }, leader.targetTitle || leader.targetId)) : a({ class: 'tag-link', href: `/tribe/${encodeURIComponent(leader.targetId)}?` }, leader.targetTitle || leader.targetId))
: null; : null;
return div( return div(
@ -287,7 +287,7 @@ const CandidaturesTable = (candidatures) => {
const rows = (candidatures || []).map(c => { const rows = (candidatures || []).map(c => {
const idLink = const idLink =
c.targetType === 'inhabitant' c.targetType === 'inhabitant'
? p(a({ class: 'user-link break-all', href: `/author/${encodeURIComponent(c.targetId)}` }, c.targetId)) ? p(userLink(c.targetId))
: p(a({ class: 'tag-link', href: `/tribe/${encodeURIComponent(c.targetId)}?` }, c.targetTitle || c.targetId)); : p(a({ class: 'tag-link', href: `/tribe/${encodeURIComponent(c.targetId)}?` }, c.targetTitle || c.targetId));
return tr( return tr(
td(idLink), td(idLink),
@ -349,7 +349,7 @@ const ProposalsList = (proposals) => {
div( div(
{ class: 'card-field' }, { class: 'card-field' },
span({ class: 'card-label' }, i18n.parliamentLawProposer.toUpperCase() + ': '), span({ class: 'card-label' }, i18n.parliamentLawProposer.toUpperCase() + ': '),
span({ class: 'card-value' }, a({ class: 'user-link', href: `/author/${encodeURIComponent(pItem.proposer)}` }, pItem.proposer)) span({ class: 'card-value' }, userLink(pItem.proposer))
), ),
div( div(
{ class: 'card-field' }, { class: 'card-field' },
@ -419,7 +419,7 @@ const FutureLawsList = (rows) => {
br(), br(),
div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.parliamentGovMethod.toUpperCase() + ': '), span({ class: 'card-value' }, pItem.method)), div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.parliamentGovMethod.toUpperCase() + ': '), span({ class: 'card-value' }, pItem.method)),
div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.parliamentThProposalDate.toUpperCase() + ': '), span({ class: 'card-value' }, fmt(pItem.createdAt))), div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.parliamentThProposalDate.toUpperCase() + ': '), span({ class: 'card-value' }, fmt(pItem.createdAt))),
div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.parliamentLawProposer.toUpperCase() + ': '), span({ class: 'card-value' }, a({ class: 'user-link', href: `/author/${encodeURIComponent(pItem.proposer)}` }, pItem.proposer))), div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.parliamentLawProposer.toUpperCase() + ': '), span({ class: 'card-value' }, userLink(pItem.proposer))),
h2(pItem.title || ''), h2(pItem.title || ''),
p(pItem.description || '') p(pItem.description || '')
) )
@ -480,7 +480,7 @@ const RevocationsList = (revocations) => {
span({ class: 'card-label' }, i18n.parliamentLawProposer.toUpperCase() + ': '), span({ class: 'card-label' }, i18n.parliamentLawProposer.toUpperCase() + ': '),
span( span(
{ class: 'card-value' }, { class: 'card-value' },
a({ class: 'user-link', href: `/author/${encodeURIComponent(pItem.proposer)}` }, pItem.proposer) userLink(pItem.proposer)
) )
), ),
div( div(
@ -551,7 +551,7 @@ const FutureRevocationsList = (rows) => {
br(), br(),
pItem.method ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.parliamentGovMethod.toUpperCase() + ': '), span({ class: 'card-value' }, pItem.method)) : null, pItem.method ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.parliamentGovMethod.toUpperCase() + ': '), span({ class: 'card-value' }, pItem.method)) : null,
div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.parliamentThProposalDate.toUpperCase() + ': '), span({ class: 'card-value' }, fmt(pItem.createdAt))), div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.parliamentThProposalDate.toUpperCase() + ': '), span({ class: 'card-value' }, fmt(pItem.createdAt))),
div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.parliamentLawProposer.toUpperCase() + ': '), span({ class: 'card-value' }, a({ class: 'user-link', href: `/author/${encodeURIComponent(pItem.proposer)}` }, pItem.proposer))), div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.parliamentLawProposer.toUpperCase() + ': '), span({ class: 'card-value' }, userLink(pItem.proposer))),
h2(pItem.title || pItem.lawTitle || ''), h2(pItem.title || pItem.lawTitle || ''),
p(pItem.reasons || '') p(pItem.reasons || '')
) )
@ -605,7 +605,7 @@ const LawsList = (laws) => {
br(), br(),
div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.parliamentGovMethod + ': ').toUpperCase()), span({ class: 'card-value' }, l.method)), div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.parliamentGovMethod + ': ').toUpperCase()), span({ class: 'card-value' }, l.method)),
div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.parliamentLawEnacted + ': ').toUpperCase()), span({ class: 'card-value' }, fmt(l.enactedAt))), div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.parliamentLawEnacted + ': ').toUpperCase()), span({ class: 'card-value' }, fmt(l.enactedAt))),
div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.parliamentLawProposer.toUpperCase() + ': '), span({ class: 'card-value' }, a({ class: 'user-link', href: `/author/${encodeURIComponent(l.proposer)}` }, l.proposer))), div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.parliamentLawProposer.toUpperCase() + ': '), span({ class: 'card-value' }, userLink(l.proposer))),
h2(l.question || ''), h2(l.question || ''),
p(l.description || ''), p(l.description || ''),
showMetricsFlag ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.parliamentVotesNeeded + ': ').toUpperCase()), span({ class: 'card-value' }, String(needed))) : null, showMetricsFlag ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.parliamentVotesNeeded + ': ').toUpperCase()), span({ class: 'card-value' }, String(needed))) : null,
@ -666,7 +666,7 @@ const HistoricalList = (rows, metasByKey = {}) => {
span({ class: 'card-value' }, span({ class: 'card-value' },
g.powerType === 'tribe' g.powerType === 'tribe'
? a({ class: 'user-link', href: `/tribe/${encodeURIComponent(g.powerId)}` }, g.powerTitle || g.powerId) ? a({ class: 'user-link', href: `/tribe/${encodeURIComponent(g.powerId)}` }, g.powerTitle || g.powerId)
: a({ class: 'user-link', href: `/author/${encodeURIComponent(g.powerId)}` }, g.powerTitle || g.powerId) : userLink(g.powerId, g.powerTitle)
) )
) : null, ) : null,
(g.method !== 'ANARCHY') (g.method !== 'ANARCHY')
@ -785,7 +785,7 @@ const LeadersList = (leaders, metas = {}, candidatures = []) => {
const avatar = meta.avatarUrl ? img({ src: meta.avatarUrl, alt: '', class: 'leader-table__avatar' }) : null; const avatar = meta.avatarUrl ? img({ src: meta.avatarUrl, alt: '', class: 'leader-table__avatar' }) : null;
const link = l.powerType === 'tribe' const link = l.powerType === 'tribe'
? a({ class: 'user-link', href: `/tribe/${encodeURIComponent(l.powerId)}` }, l.powerTitle || l.powerId) ? a({ class: 'user-link', href: `/tribe/${encodeURIComponent(l.powerId)}` }, l.powerTitle || l.powerId)
: a({ class: 'user-link', href: `/author/${encodeURIComponent(l.powerId)}` }, l.powerTitle || l.powerId); : userLink(l.powerId, l.powerTitle);
const leaderCell = div({ class: 'leader-cell' }, avatar, link); const leaderCell = div({ class: 'leader-cell' }, avatar, link);
return tr( return tr(
td(leaderCell), td(leaderCell),

View file

@ -23,10 +23,10 @@ const peersView = async ({ onlinePeers, discoveredPeers, unknownPeers }) => {
const { name, users, key } = peer; const { name, users, key } = peer;
const peerUrl = `/author/${encodeURIComponent(key)}`; const peerUrl = `/author/${encodeURIComponent(key)}`;
const filteredUsers = (users || []).filter(u => u.id !== key); const filteredUsers = (users || []).filter(u => u.id !== key);
const userCount = filteredUsers.length || peer.announcers || 0; const userCount = filteredUsers.length;
return tr( return tr(
td(a({ href: peerUrl, class: "user-link" }, name || key.slice(0, 20) + '…')), td(a({ href: peerUrl, class: "user-link" }, name || key.slice(0, 20) + '…')),
td(span({ style: 'word-break:break-all;font-size:12px;color:#888;' }, key)), td(a({ href: peerUrl, class: 'user-link peer-key' }, key)),
td(String(userCount)) td(String(userCount))
); );
}; };
@ -35,19 +35,9 @@ const peersView = async ({ onlinePeers, discoveredPeers, unknownPeers }) => {
const dedupDiscovered = deduplicatePeers(discoveredPeers); const dedupDiscovered = deduplicatePeers(discoveredPeers);
const dedupUnknown = deduplicatePeers(unknownPeers); const dedupUnknown = deduplicatePeers(unknownPeers);
const countPeers = (list) => { const onlineCount = dedupOnline.length;
let usersTotal = 0; const discoveredCount = dedupDiscovered.length;
for (const item of list) { const unknownCount = dedupUnknown.length;
const peerKey = item[1].key;
const users = (item[1].users || []).filter(u => u.id !== peerKey);
usersTotal += users.length || item[1].announcers || 0;
}
return list.length + usersTotal;
};
const onlineCount = countPeers(dedupOnline);
const discoveredCount = countPeers(dedupDiscovered);
const unknownCount = countPeers(dedupUnknown);
const renderPeerTable = (peers) => { const renderPeerTable = (peers) => {
if (peers.length === 0) return p(i18n.noConnections || i18n.noDiscovered); if (peers.length === 0) return p(i18n.noConnections || i18n.noDiscovered);
@ -55,7 +45,7 @@ const peersView = async ({ onlinePeers, discoveredPeers, unknownPeers }) => {
tr( tr(
td({ class: 'card-label' }, i18n.peerHost || 'Pub'), td({ class: 'card-label' }, i18n.peerHost || 'Pub'),
td({ class: 'card-label' }, 'Key'), td({ class: 'card-label' }, 'Key'),
td({ class: 'card-label' }, i18n.inhabitants || 'Inhabitants') td({ class: 'card-label' }, i18n.peersReplicatedFeeds || 'Replicated feeds')
), ),
...peers.map(renderPeerRow) ...peers.map(renderPeerRow)
); );

View file

@ -1,5 +1,5 @@
const { div, h2, p, section, form, input, label, select, option, button, table, tr, td, hr, ul, li, a, br } = require("../server/node_modules/hyperaxe"); const { div, h2, p, section, form, input, label, select, option, button, table, tr, td, hr, ul, li, a, br } = require("../server/node_modules/hyperaxe");
const { template, i18n } = require('./main_views'); const { template, i18n, userLink} = require('./main_views');
exports.pixeliaView = (pixelArt, errorMessage) => { exports.pixeliaView = (pixelArt, errorMessage) => {
const title = i18n.pixeliaTitle; const title = i18n.pixeliaTitle;
@ -81,7 +81,7 @@ exports.pixeliaView = (pixelArt, errorMessage) => {
h2(i18n.contributorsTitle), h2(i18n.contributorsTitle),
ul( ul(
...contributors.map(author => ...contributors.map(author =>
li(a({ class: 'user-link', href: `/author/${encodeURIComponent(author)}` }, author)) li(userLink(author))
) )
) )
) : null ) : null

View file

@ -1,5 +1,5 @@
const { form, button, div, h2, p, section, input, label, textarea, br, a, span, select, option, img, ul, li, table, thead, tbody, tr, th, td, progress, video, audio } = require("../server/node_modules/hyperaxe") const { form, button, div, h2, p, section, input, label, textarea, br, a, span, select, option, img, ul, li, table, thead, tbody, tr, th, td, progress, video, audio } = require("../server/node_modules/hyperaxe")
const { template, i18n } = require("./main_views") const { template, i18n, userLink} = require("./main_views")
const moment = require("../server/node_modules/moment") const moment = require("../server/node_modules/moment")
const { config } = require("../server/SSB_server.js") const { config } = require("../server/SSB_server.js")
const { renderUrl } = require("../backend/renderUrl") const { renderUrl } = require("../backend/renderUrl")
@ -142,7 +142,7 @@ const renderFollowers = (project) => {
return div( return div(
{ class: "followers-block" }, { class: "followers-block" },
h2(i18n.projectFollowersTitle), h2(i18n.projectFollowersTitle),
ul(show.map((uid) => li(a({ href: `/author/${encodeURIComponent(uid)}`, class: "user-link" }, uid)))), ul(show.map((uid) => li(userLink(uid)))),
followers.length > show.length ? p(`+${followers.length - show.length} ${i18n.projectMore}`) : null followers.length > show.length ? p(`+${followers.length - show.length} ${i18n.projectMore}`) : null
) )
} }
@ -171,7 +171,7 @@ const renderBackers = (project, filter) => {
...backers.slice(0, 8).map((b) => ...backers.slice(0, 8).map((b) =>
tr( tr(
td(b.at ? moment(b.at).format("YYYY/MM/DD HH:mm") : ""), td(b.at ? moment(b.at).format("YYYY/MM/DD HH:mm") : ""),
td(a({ href: `/author/${encodeURIComponent(b.userId)}`, class: "user-link" }, b.userId)), td(userLink(b.userId)),
td(`${b.amount} ECO`) td(`${b.amount} ECO`)
) )
) )
@ -285,7 +285,7 @@ const renderMilestonesAndBounties = (project, filter, editable) => {
), ),
safeText(b.description) ? p(...renderUrl(b.description)) : null, safeText(b.description) ? p(...renderUrl(b.description)) : null,
renderCardField(i18n.projectBountyStatus + ":", statusText), renderCardField(i18n.projectBountyStatus + ":", statusText),
b.claimedBy ? renderCardField(i18n.projectBountyClaimedBy + ":", a({ href: `/author/${encodeURIComponent(b.claimedBy)}`, class: "user-link" }, b.claimedBy)) : null, b.claimedBy ? renderCardField(i18n.projectBountyClaimedBy + ":", userLink(b.claimedBy)) : null,
!editable && !b.done && !b.claimedBy && project.author !== userId !editable && !b.done && !b.claimedBy && project.author !== userId
? form( ? form(
{ method: "POST", action: `/projects/bounties/claim/${encodeURIComponent(project.id)}/${globalIndex}` }, { method: "POST", action: `/projects/bounties/claim/${encodeURIComponent(project.id)}/${globalIndex}` },
@ -344,7 +344,7 @@ const renderMilestonesAndBounties = (project, filter, editable) => {
), ),
safeText(b.description) ? p(...renderUrl(b.description)) : null, safeText(b.description) ? p(...renderUrl(b.description)) : null,
renderCardField(i18n.projectBountyStatus + ":", statusText), renderCardField(i18n.projectBountyStatus + ":", statusText),
b.claimedBy ? renderCardField(i18n.projectBountyClaimedBy + ":", a({ href: `/author/${encodeURIComponent(b.claimedBy)}`, class: "user-link" }, b.claimedBy)) : null, b.claimedBy ? renderCardField(i18n.projectBountyClaimedBy + ":", userLink(b.claimedBy)) : null,
!editable && !b.done && !b.claimedBy && project.author !== userId !editable && !b.done && !b.claimedBy && project.author !== userId
? form( ? form(
{ method: "POST", action: `/projects/bounties/claim/${encodeURIComponent(project.id)}/${globalIndex}` }, { method: "POST", action: `/projects/bounties/claim/${encodeURIComponent(project.id)}/${globalIndex}` },
@ -588,7 +588,7 @@ const renderProjectList = (projects, filter) => {
div( div(
{ class: "card-footer" }, { class: "card-footer" },
span({ class: "date-link" }, `${moment(pr.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `), span({ class: "date-link" }, `${moment(pr.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
a({ href: `/author/${encodeURIComponent(pr.author)}`, class: "user-link" }, pr.author) userLink(pr.author)
) )
) )
}) })
@ -745,7 +745,7 @@ exports.singleProjectView = async (project, filter, comments, params = {}) => {
div( div(
{ class: "card-footer" }, { class: "card-footer" },
span({ class: "date-link" }, `${moment(pr.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `), span({ class: "date-link" }, `${moment(pr.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
a({ href: `/author/${encodeURIComponent(pr.author)}`, class: "user-link" }, pr.author) userLink(pr.author)
) )
), ),
div( div(
@ -760,23 +760,29 @@ exports.singleProjectView = async (project, filter, comments, params = {}) => {
button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton) button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton)
) )
), ),
comments && comments.length (() => {
const visibleComments = (comments || []).filter(c => {
const t = c && c.value && c.value.content && c.value.content.text
return t && String(t).trim()
})
return visibleComments.length
? div( ? div(
{ class: "comments-list" }, { class: "comments-list" },
comments.map((c) => { visibleComments.map((c) => {
const author = c?.value?.author || "" const author = c?.value?.author || ""
const ts = c?.value?.timestamp || c?.timestamp const ts = c?.value?.timestamp || c?.timestamp
const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : "" const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : ""
const relDate = ts ? moment(ts).fromNow() : "" const relDate = ts ? moment(ts).fromNow() : ""
return div( return div(
{ class: "comment-card" }, { class: "comment-card" },
div({ class: "comment-header" }, a({ href: `/author/${encodeURIComponent(author)}`, class: "user-link" }, author)), div({ class: "comment-header" }, userLink(author)),
div({ class: "comment-date" }, span({ title: absDate }, relDate)), div({ class: "comment-date" }, span({ title: absDate }, relDate)),
div({ class: "comment-body" }, ...renderUrl(c?.value?.content?.text || "")) div({ class: "comment-body" }, ...renderUrl(c?.value?.content?.text || ""))
) )
}) })
) )
: p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet) : p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet)
})()
) )
) )
} }

View file

@ -1,5 +1,5 @@
const { div, h2, p, section, button, form, a, textarea, br, input, img, span, label, select, option, video, audio } = require("../server/node_modules/hyperaxe"); const { div, h2, p, section, button, form, a, textarea, br, input, img, span, label, select, option, video, audio } = require("../server/node_modules/hyperaxe");
const { template, i18n } = require("./main_views"); const { template, i18n, userLink} = require("./main_views");
const { config } = require("../server/SSB_server.js"); const { config } = require("../server/SSB_server.js");
const moment = require("../server/node_modules/moment"); const moment = require("../server/node_modules/moment");
const { renderUrl } = require("../backend/renderUrl"); const { renderUrl } = require("../backend/renderUrl");
@ -215,10 +215,15 @@ const renderReportCommentsSection = (reportId, comments = []) => {
button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton) button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton)
) )
), ),
comments && comments.length (() => {
const visibleComments = (comments || []).filter(c => {
const t = c && c.value && c.value.content && c.value.content.text;
return t && String(t).trim();
});
return visibleComments.length
? div( ? div(
{ class: "comments-list" }, { class: "comments-list" },
comments.map((c) => { visibleComments.map((c) => {
const author = c.value && c.value.author ? c.value.author : ""; const author = c.value && c.value.author ? c.value.author : "";
const ts = c.value && c.value.timestamp ? c.value.timestamp : c.timestamp; const ts = c.value && c.value.timestamp ? c.value.timestamp : c.timestamp;
const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : ""; const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : "";
@ -244,7 +249,8 @@ const renderReportCommentsSection = (reportId, comments = []) => {
); );
}) })
) )
: p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet) : p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet);
})()
); );
}; };
@ -404,7 +410,7 @@ const renderReportCard = (report, userId, currentFilter = "all") => {
p( p(
{ class: "card-footer" }, { class: "card-footer" },
span({ class: "date-link" }, `${moment(report.createdAt).format("YYYY-MM-DD HH:mm")} ${i18n.performed} `), span({ class: "date-link" }, `${moment(report.createdAt).format("YYYY-MM-DD HH:mm")} ${i18n.performed} `),
a({ class: "user-link", href: `/author/${encodeURIComponent(report.author)}` }, report.author) userLink(report.author)
) )
); );
}; };
@ -659,7 +665,7 @@ exports.singleReportView = async (report, filter, comments = []) => {
p( p(
{ class: "card-footer" }, { class: "card-footer" },
span({ class: "date-link" }, `${moment(report.createdAt).format("YYYY-MM-DD HH:mm")} ${i18n.performed} `), span({ class: "date-link" }, `${moment(report.createdAt).format("YYYY-MM-DD HH:mm")} ${i18n.performed} `),
a({ class: "user-link", href: `/author/${encodeURIComponent(report.author)}` }, report.author) userLink(report.author)
) )
), ),
renderReportCommentsSection(report.id, comments) renderReportCommentsSection(report.id, comments)

View file

@ -1,5 +1,5 @@
const { form, button, div, h2, p, section, input, select, option, img, audio: audioHyperaxe, video: videoHyperaxe, table, hr, hd, br, td, tr, th, a, span } = require("../server/node_modules/hyperaxe"); const { form, button, div, h2, p, section, input, select, option, img, audio: audioHyperaxe, video: videoHyperaxe, table, hr, hd, br, td, tr, th, a, span } = require("../server/node_modules/hyperaxe");
const { template, i18n } = require('./main_views'); const { template, i18n, userLink} = require('./main_views');
const moment = require("../server/node_modules/moment"); const moment = require("../server/node_modules/moment");
const { renderTextWithStyles } = require('../backend/renderTextWithStyles'); const { renderTextWithStyles } = require('../backend/renderTextWithStyles');
const { renderUrl } = require('../backend/renderUrl'); const { renderUrl } = require('../backend/renderUrl');
@ -120,7 +120,7 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
case 'about': case 'about':
return div({ class: 'search-about' }, return div({ class: 'search-about' },
content.name ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.name + ':'), span({ class: 'card-value' }, content.name)) : null, content.name ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.name + ':'), span({ class: 'card-value' }, content.name)) : null,
content.description ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.description + ':'), span({ class: 'card-value' }, content.description)) : null, content.description ? div({ class: 'card-field card-field-stacked' }, span({ class: 'card-label' }, i18n.description + ':'), span({ class: 'card-value' }, content.description)) : null,
content.image ? img({ src: `/image/64/${encodeURIComponent(content.image)}` }) : null content.image ? img({ src: `/image/64/${encodeURIComponent(content.image)}` }) : null
); );
case 'feed': { case 'feed': {
@ -190,23 +190,15 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
); );
case 'tribe': case 'tribe':
return div({ class: 'search-tribe' }, return div({ class: 'search-tribe' },
content.title ? h2(content.title) : null, content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.title + ':'), span({ class: 'card-value' }, content.title)) : null,
(() => { const s = String(content.image || '').trim().replace(/&amp;/g, '&'); const m = s.match(/!\[[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/); const src = m ? m[1] : s; return src.startsWith('&') ? img({ src: `/blob/${encodeURIComponent(src)}`, class: 'feed-image' }) : img({ src: '/assets/images/default-tribe.png', class: 'feed-image' }); })(), (() => { const s = String(content.image || '').trim().replace(/&amp;/g, '&'); const m = s.match(/!\[[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/); const src = m ? m[1] : s; return src.startsWith('&') ? img({ src: `/blob/${encodeURIComponent(src)}`, class: 'feed-image' }) : img({ src: '/assets/images/default-tribe.png', class: 'feed-image' }); })(),
br(), content.description ? div({ class: 'card-field card-field-stacked' }, span({ class: 'card-label' }, i18n.searchDescription + ':'), span({ class: 'card-value' }, ...renderUrl(content.description))) : null,
content.description ? content.description : null, content.location ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.tribeLocationLabel + ':'), span({ class: 'card-value' }, ...renderUrl(content.location))) : null,
br(),br(), div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.tribeIsAnonymousLabel + ':'), span({ class: 'card-value' }, content.isAnonymous ? i18n.tribePrivate : i18n.tribePublic)),
div({ style: 'display:flex; gap:.6em; flex-wrap:wrap;' }, content.inviteMode ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.tribeModeLabel + ':'), span({ class: 'card-value' }, String(content.inviteMode).toUpperCase())) : null,
content.location ? p({ style: 'color:#9aa3b2;' }, `${i18n.tribeLocationLabel.toUpperCase()}: `, ...renderUrl(content.location)) : null, div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.tribeLARPLabel + ':'), span({ class: 'card-value' }, content.isLARP ? i18n.tribeYes : i18n.tribeNo)),
p({ style: 'color:#9aa3b2;' }, `${i18n.tribeIsAnonymousLabel}: ${content.isAnonymous ? i18n.tribePrivate : i18n.tribePublic}`),
content.inviteMode ? p({ style: 'color:#9aa3b2;' }, `${i18n.tribeModeLabel}: ${content.inviteMode.toUpperCase()}`) : null,
p({ style: 'color:#9aa3b2;' }, `${i18n.tribeLARPLabel}: ${content.isLARP ? i18n.tribeYes : i18n.tribeNo}`)
),
Array.isArray(content.members) Array.isArray(content.members)
? div({}, ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.tribeMembersCount + ':'), span({ class: 'card-value' }, String(content.members.length)))
div({ class: 'card-field' },
h2(`${i18n.tribeMembersCount}: ${content.members.length}`),
)
)
: null, : null,
content.tags && content.tags.length content.tags && content.tags.length
? div({ class: 'card-tags' }, content.tags.map(tag => ? div({ class: 'card-tags' }, content.tags.map(tag =>
@ -274,8 +266,9 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
); );
case 'torrent': case 'torrent':
return div({ class: 'search-torrent' }, return div({ class: 'search-torrent' },
content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.torrentTitleLabel || 'Title') + ':'), span({ class: 'card-value' }, content.title)) : null, content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.torrentTitleLabel || i18n.title) + ':'), span({ class: 'card-value' }, content.title)) : null,
content.description ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.torrentDescriptionLabel || 'Description') + ':'), span({ class: 'card-value' }, content.description)) : null, content.description ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.torrentDescriptionLabel || i18n.searchDescription) + ':'), span({ class: 'card-value' }, content.description)) : null,
content.size ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.torrentSizeLabel || 'Size') + ':'), span({ class: 'card-value' }, String(content.size))) : null,
content.tags && content.tags.length content.tags && content.tags.length
? div({ class: 'card-tags' }, content.tags.map(tag => ? div({ class: 'card-tags' }, content.tags.map(tag =>
a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`) a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`)
@ -292,7 +285,7 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
br(), br(),
blobImg(content.image), blobImg(content.image),
br(), br(),
content.seller ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.marketItemSeller + ':'), span({ class: 'card-value' }, a({ class: "user-link", href: `/author/${encodeURIComponent(content.seller)}` }, content.seller))) : null, content.seller ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.marketItemSeller + ':'), span({ class: 'card-value' }, userLink(content.seller))) : null,
content.stock ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.marketItemStock + ':'), span({ class: 'card-value' }, content.stock || 'N/A')) : null, content.stock ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.marketItemStock + ':'), span({ class: 'card-value' }, content.stock || 'N/A')) : null,
content.price ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchPriceLabel + ':'), span({ class: 'card-value' }, `${content.price} ECO`)) : null, content.price ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchPriceLabel + ':'), span({ class: 'card-value' }, `${content.price} ECO`)) : null,
content.condition ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchDescription + ':'), span({ class: 'card-value' }, content.condition)) : null, content.condition ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchDescription + ':'), span({ class: 'card-value' }, content.condition)) : null,
@ -325,8 +318,8 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
); );
case 'bookmark': case 'bookmark':
return div({ class: 'search-bookmark' }, return div({ class: 'search-bookmark' },
content.description ? div({ class: 'card-field' }, span({ class: 'card-value' }, content.description)) : null, br(), content.url ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.bookmarkUrlLabel + ':'), span({ class: 'card-value' }, a({ href: content.url, target: '_blank' }, content.url))) : null,
content.url ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.bookmarkUrlLabel + ':'), span({ class: 'card-value' }, a({ href: content.url, target: '_blank' }, content.url))) : null,br(), content.description ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchDescription + ':'), span({ class: 'card-value' }, content.description)) : null,
content.category ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.bookmarkCategory + ':'), span({ class: 'card-value' }, content.category)) : null, content.category ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.bookmarkCategory + ':'), span({ class: 'card-value' }, content.category)) : null,
content.lastVisit ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.bookmarkLastVisit + ':'), span({ class: 'card-value' }, new Date(content.lastVisit).toLocaleString())) : null, content.lastVisit ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.bookmarkLastVisit + ':'), span({ class: 'card-value' }, new Date(content.lastVisit).toLocaleString())) : null,
content.tags && content.tags.length content.tags && content.tags.length
@ -376,8 +369,8 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
content.status ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.transfersStatus + ':'), span({ class: 'card-value' }, content.status)) : null, content.status ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.transfersStatus + ':'), span({ class: 'card-value' }, content.status)) : null,
content.amount ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.transfersAmount + ':'), span({ class: 'card-value' }, content.amount)) : null, content.amount ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.transfersAmount + ':'), span({ class: 'card-value' }, content.amount)) : null,
br(), br(),
content.from ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.transfersFrom + ':'), span({ class: 'card-value' }, a({ class: "user-link", href: `/author/${encodeURIComponent(content.from)}` }, content.from))) : null, content.from ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.transfersFrom + ':'), span({ class: 'card-value' }, userLink(content.from))) : null,
content.to ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.transfersTo + ':'), span({ class: 'card-value' }, a({ class: "user-link", href: `/author/${encodeURIComponent(content.to)}` }, content.to))) : null, content.to ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.transfersTo + ':'), span({ class: 'card-value' }, userLink(content.to))) : null,
br(), br(),
content.confirmedBy && content.confirmedBy.length content.confirmedBy && content.confirmedBy.length
? h2({ class: 'card-field' }, span({ class: 'card-label' }, i18n.transfersConfirmations + ':'), span({ class: 'card-value' }, content.confirmedBy.length)) ? h2({ class: 'card-field' }, span({ class: 'card-label' }, i18n.transfersConfirmations + ':'), span({ class: 'card-value' }, content.confirmedBy.length))
@ -457,8 +450,9 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
); );
case 'forum': case 'forum':
return div({ class: 'search-forum' }, return div({ class: 'search-forum' },
content.root ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.title + ':'), span({ class: 'card-value' }, content.title || '')) : div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.title + ':'), span({ class: 'card-value' }, content.title || '')), content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.title + ':'), span({ class: 'card-value' }, content.title)) : null,
content.text ? div({ class: 'card-field' }, span({ class: 'card-value' }, content.text)) : null content.category ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchCategoryLabel + ':'), span({ class: 'card-value' }, content.category)) : null,
content.text ? div({ class: 'card-field card-field-stacked' }, span({ class: 'card-label' }, i18n.searchDescription + ':'), span({ class: 'card-value' }, content.text)) : null
); );
case 'vote': case 'vote':
return div({ class: 'search-vote-link' }, return div({ class: 'search-vote-link' },
@ -527,16 +521,6 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
) )
) : null ) : null
); );
case 'torrent':
return div({ class: 'search-torrent' },
content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.torrentTitleLabel || 'Title') + ':'), span({ class: 'card-value' }, content.title)) : null,
content.size ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.torrentSizeLabel || 'Size') + ':'), span({ class: 'card-value' }, String(content.size))) : null,
content.tags && content.tags.length
? div({ class: 'card-tags' }, content.tags.map(tag =>
a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`)
))
: null
);
case 'map': case 'map':
return div({ class: 'search-map' }, return div({ class: 'search-map' },
content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.title + ':'), span({ class: 'card-value' }, content.title)) : null, content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.title + ':'), span({ class: 'card-value' }, content.title)) : null,
@ -604,7 +588,9 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
author author
? p({ class: 'card-footer' }, ? p({ class: 'card-footer' },
span({ class: 'date-link' }, `${created} ${i18n.performed} `), span({ class: 'date-link' }, `${created} ${i18n.performed} `),
a({ href: authorUrl, class: 'user-link' }, `${author}`) (authorUrl && authorUrl !== '#' && authorUrl.startsWith('/author/'))
? userLink(decodeURIComponent(authorUrl.replace(/^\/author\//, '')))
: a({ href: authorUrl, class: 'user-link' }, `${author}`)
): null, ): null,
]); ]);
}) })

View file

@ -1,5 +1,5 @@
const { div, h2, p, section, button, form, a, span, textarea, br, input, label, select, option, img, progress, video, table, tr, td } = require("../server/node_modules/hyperaxe") const { div, h2, p, section, button, form, a, span, textarea, br, input, label, select, option, img, progress, video, table, tr, td } = require("../server/node_modules/hyperaxe")
const { template, i18n } = require("./main_views") const { template, i18n, userLink} = require("./main_views")
const moment = require("../server/node_modules/moment") const moment = require("../server/node_modules/moment")
const { config } = require("../server/SSB_server.js") const { config } = require("../server/SSB_server.js")
const { renderUrl } = require("../backend/renderUrl") const { renderUrl } = require("../backend/renderUrl")
@ -327,7 +327,7 @@ exports.singleShopView = async (shop, filter, products = [], comments = [], para
td({ class: "tribe-info-value", colspan: "3" }, new Date(shop.createdAt).toLocaleString()) td({ class: "tribe-info-value", colspan: "3" }, new Date(shop.createdAt).toLocaleString())
), ),
tr( tr(
td({ class: "tribe-info-value", colspan: "4" }, a({ class: "user-link", href: `/author/${encodeURIComponent(shop.author)}` }, shop.author)) td({ class: "tribe-info-value", colspan: "4" }, userLink(shop.author))
), ),
shop.location ? tr( shop.location ? tr(
td({ class: "tribe-info-label" }, i18n.shopLocation), td({ class: "tribe-info-label" }, i18n.shopLocation),
@ -458,7 +458,7 @@ exports.singleProductView = async (product, shop, comments = [], params = {}) =>
p({ class: "card-footer" }, p({ class: "card-footer" },
span({ class: "date-link" }, moment(product.createdAt).format("YYYY-MM-DD HH:mm")), span({ class: "date-link" }, moment(product.createdAt).format("YYYY-MM-DD HH:mm")),
" ", " ",
a({ href: `/author/${encodeURIComponent(product.author)}`, class: "user-link" }, product.author) userLink(product.author)
), ),
!isAuthor && safeArr(product.buyers).includes(userId) && !safeArr(product.opinions_inhabitants).includes(userId) !isAuthor && safeArr(product.buyers).includes(userId) && !safeArr(product.opinions_inhabitants).includes(userId)
? div({ class: "voting-buttons transfer-voting-buttons" }, ? div({ class: "voting-buttons transfer-voting-buttons" },

View file

@ -1,5 +1,5 @@
const { div, h2, p, section, button, form, input, ul, li, a, h3, span, strong, table, tr, td, th } = require("../server/node_modules/hyperaxe"); const { div, h2, p, section, button, form, input, ul, li, a, h3, span, strong, table, tr, td, th } = require("../server/node_modules/hyperaxe");
const { template, i18n } = require('./main_views'); const { template, i18n, userLink } = require('./main_views');
Object.assign(i18n, { Object.assign(i18n, {
statsChat: "Chats", statsChat: "Chats",
@ -26,6 +26,11 @@ Object.assign(i18n, {
const C = (stats, t) => Number((stats && stats.content && stats.content[t]) || 0); const C = (stats, t) => Number((stats && stats.content && stats.content[t]) || 0);
const O = (stats, t) => Number((stats && stats.opinions && stats.opinions[t]) || 0); const O = (stats, t) => Number((stats && stats.opinions && stats.opinions[t]) || 0);
const wClass = (pct) => {
const n = Math.max(0, Math.min(100, Math.round((pct || 0) / 5) * 5));
return `stats-w-${n}`;
};
exports.statsView = (stats, filter) => { exports.statsView = (stats, filter) => {
const title = i18n.statsTitle; const title = i18n.statsTitle;
const description = i18n.statsDescription; const description = i18n.statsDescription;
@ -86,8 +91,369 @@ exports.statsView = (stats, filter) => {
}; };
const totalContent = types.filter(t => t !== 'karmaScore').reduce((sum, t) => sum + C(stats, t), 0); const totalContent = types.filter(t => t !== 'karmaScore').reduce((sum, t) => sum + C(stats, t), 0);
const totalOpinions = types.reduce((sum, t) => sum + O(stats, t), 0); const totalOpinions = types.reduce((sum, t) => sum + O(stats, t), 0);
const blockStyle = 'padding:16px;border:1px solid #ddd;border-radius:8px;margin-bottom:24px;';
const headerStyle = 'background-color:#f8f9fa; padding:24px; border-radius:8px; border:1px solid #e0e0e0; box-shadow:0 2px 8px rgba(0,0,0,0.1);'; const fmtNum = (n) => {
if (typeof n !== 'number' || !isFinite(n)) return '0';
if (Math.abs(n) >= 100) return n.toFixed(0);
if (Math.abs(n) >= 10) return n.toFixed(1);
return n.toFixed(2);
};
const kpi = (label, value) => div({ class: 'stats-kpi' },
div({ class: 'stats-kpi-label' }, label),
div({ class: 'stats-kpi-value' }, String(value))
);
const kpiBar = (label, value, pct) => {
const n = Math.max(0, Math.min(100, Number(pct) || 0));
return div({ class: 'stats-kpi' },
div({ class: 'stats-kpi-label' }, label),
div({ class: 'stats-kpi-value' }, String(value)),
n > 0
? div({ class: 'stats-bar-track stats-kpi-bar' },
div({ class: `stats-bar-fill ${wClass(n)}` })
)
: null
);
};
const kpiGrid = (...tiles) => div({ class: 'stats-grid' }, tiles.filter(Boolean));
const renderTopList = (items, getName, getCount, max) => {
if (!items || !items.length) return p({ class: 'no-content' }, i18n.no_results || 'No data');
const m = Math.max(1, max || items[0] && getCount(items[0]) || 1);
return ul({ class: 'stats-toplist' },
...items.map(it => {
const cnt = getCount(it);
const pct = (cnt / m) * 100;
return li(
span({ class: 'stats-toplist-name' }, getName(it)),
div({ class: 'stats-bar-track' },
div({ class: `stats-bar-fill ${wClass(pct)}` })
),
span({ class: 'stats-toplist-num' }, String(cnt))
);
})
);
};
const carbonChart = (() => {
const parseSize = (s) => {
if (!s) return 0;
const m = String(s).match(/([\d.]+)\s*(GB|MB|KB|B)/i);
if (!m) return 0;
const v = parseFloat(m[1]);
const u = m[2].toUpperCase();
if (u === 'GB') return v * 1024;
if (u === 'MB') return v;
if (u === 'KB') return v / 1024;
return v / (1024 * 1024);
};
const blobsMB = parseSize(stats.statsBlobsSize);
const chainMB = parseSize(stats.statsBlockchainSize);
const totalMB = blobsMB + chainMB;
const kWhPerMB = 0.0002;
const gCO2PerKWh = 475;
const networkCO2 = parseFloat((totalMB * kWhPerMB * gCO2PerKWh).toFixed(2));
const inhabitants = stats.usersKPIs?.totalInhabitants || stats.inhabitants || 1;
const userCO2 = parseFloat((networkCO2 / Math.max(1, inhabitants)).toFixed(2));
const maxAnnualCO2 = 500;
if (filter === 'MINE') {
const pct = networkCO2 > 0 ? Math.min(100, (userCO2 / networkCO2) * 100) : 0;
return div({ class: 'carbon-chart' },
div({ class: 'carbon-bar-label' },
span(i18n.statsCarbonUser || 'Your footprint'),
span(`${userCO2} g CO₂`)
),
div({ class: 'carbon-bar-track' },
div({ class: `carbon-bar-fill carbon-bar-mine ${wClass(pct)}` })
),
div({ class: 'carbon-bar-label' },
span(i18n.statsCarbonNetwork || 'Network total'),
span(`${networkCO2} g CO₂`)
),
div({ class: 'carbon-bar-track' },
div({ class: 'carbon-bar-fill carbon-bar-network stats-w-100' })
),
p({ class: 'carbon-bar-note' }, strong(`${pct.toFixed(1)}%`), ` ${i18n.statsCarbonOfNetwork || 'of network total'}`),
p({ class: 'carbon-bar-formula' }, 'Based on local data storage weight ', strong('(0.0002 kWh/MB × 475 g CO₂/kWh)'))
);
}
if (filter === 'TOMBSTONE') {
const tombCount = stats.tombstoneKPIs?.networkTombstoneCount || 0;
const avgTombBytes = 500;
const tombMB = (tombCount * avgTombBytes) / (1024 * 1024);
const tombCO2 = parseFloat((tombMB * kWhPerMB * gCO2PerKWh).toFixed(4));
const tombPct = networkCO2 > 0 ? Math.min(100, (tombCO2 / networkCO2) * 100) : 0;
return div({ class: 'carbon-chart' },
div({ class: 'carbon-bar-label' },
span(i18n.statsCarbonTombstone || 'Tombstoning footprint'),
span(`${tombCO2} g CO₂`)
),
div({ class: 'carbon-bar-track' },
div({ class: `carbon-bar-fill carbon-bar-mine ${wClass(tombPct)}` })
),
div({ class: 'carbon-bar-label' },
span(i18n.statsCarbonNetwork || 'Network total'),
span(`${networkCO2} g CO₂`)
),
div({ class: 'carbon-bar-track' },
div({ class: 'carbon-bar-fill carbon-bar-network stats-w-100' })
),
p({ class: 'carbon-bar-note' }, strong(`${tombPct.toFixed(1)}%`), ` ${i18n.statsCarbonOfNetwork || 'of network total'} (${tombCount} tombstones × ~${avgTombBytes} bytes)`),
p({ class: 'carbon-bar-formula' }, 'Based on estimated tombstone message size ', strong('(0.0002 kWh/MB × 475 g CO₂/kWh)'))
);
}
const pct = Math.min(100, (networkCO2 / maxAnnualCO2) * 100);
return div({ class: 'carbon-chart' },
div({ class: 'carbon-bar-label' },
span(i18n.statsCarbonNetwork || 'Network footprint'),
span(`${networkCO2} g CO₂`)
),
div({ class: 'carbon-bar-track' },
div({ class: `carbon-bar-fill carbon-bar-network ${wClass(pct)}` })
),
div({ class: 'carbon-bar-label' },
span(i18n.statsCarbonMaxAnnual || 'Annual max estimate'),
span(`${maxAnnualCO2} g CO₂`)
),
div({ class: 'carbon-bar-track' },
div({ class: 'carbon-bar-fill carbon-bar-max stats-w-100' })
),
p({ class: 'carbon-bar-note' }, strong(`${pct.toFixed(1)}%`), ` ${i18n.statsCarbonOfEstMax || 'of estimated max capacity'}`),
p({ class: 'carbon-bar-formula' }, 'Based on local data storage weight ', strong('(0.0002 kWh/MB × 475 g CO₂/kWh)'))
);
})();
const headerCard = div({ class: 'stats-card' },
table({ class: 'block-info-table' },
tr(td({ class: 'card-label' }, i18n.statsCreatedAt), td({ class: 'card-value' }, stats.createdAt)),
tr(td({ class: 'card-label' }, 'ID'), td({ class: 'card-value' }, userLink(stats.id))),
tr(td({ class: 'card-label' }, i18n.statsBlobsSize), td({ class: 'card-value' }, stats.statsBlobsSize)),
tr(td({ class: 'card-label' }, i18n.statsBlockchainSize), td({ class: 'card-value' }, stats.statsBlockchainSize)),
tr(td({ class: 'card-label' }, i18n.statsSize), td({ class: 'card-value' }, stats.folderSize))
)
);
const totalInhabitants = stats.usersKPIs?.totalInhabitants || stats.inhabitants || 0;
const networkKPIs = stats.networkKPIs || {};
const topStrip = div({ class: 'stats-block' },
kpiGrid(
kpi(i18n.bankingUserEngagementScore, C(stats, 'karmaScore')),
kpi(i18n.statsUsersTitle, totalInhabitants),
kpi(i18n.statsTotalMsgs || 'Total messages', networkKPIs.totalMsgs || 0),
kpi(i18n.statsLogsTitle || 'Logs', stats?.logsCount || 0),
kpi(i18n.statsAITraining, C(stats, 'aiExchange') || 0),
kpi(i18n.statsPUBs, stats.pubsCount || 0)
)
);
const carbonCard = div({ class: 'stats-card' },
h3({ class: 'stats-section-h' }, i18n.statsCarbonFootprintTitle || 'Carbon Footprint'),
carbonChart
);
const bankingCard = div({ class: 'stats-card' },
h3({ class: 'stats-section-h' }, i18n.statsBankingTitle),
table({ class: 'block-info-table' },
tr(td({ class: 'card-label' }, i18n.statsEcoWalletLabel), td({ class: 'card-value' }, a({ href: '/wallet', class: 'stats-link-break' }, stats?.banking?.myAddress || i18n.statsEcoWalletNotConfigured))),
tr(td({ class: 'card-label' }, i18n.statsTotalEcoAddresses), td({ class: 'card-value' }, String(stats?.banking?.totalAddresses || 0)))
)
);
const networkBlock = div({ class: 'stats-block' },
h2(i18n.statsNetworkKPIsTitle || 'Network KPIs'),
kpiGrid(
filter === 'MINE'
? kpi(i18n.statsMyShare || 'Your share of the network', `${fmtNum(networkKPIs.myShare || 0)}%`)
: null,
kpi(i18n.statsAvgPerInhabitant || 'Avg per inhabitant', fmtNum(networkKPIs.avgMsgsPerInhabitant || 0)),
kpi(i18n.statsMsgsPerDay || 'Messages/day (lifetime)', fmtNum(networkKPIs.networkMsgsPerDay || 0)),
kpi(i18n.statsNetworkSpan || 'Network span', `${fmtNum(networkKPIs.networkSpanDays || 0)} d`),
kpi(i18n.statsTombstoneRatioLabel || 'Tombstone ratio', `${fmtNum(stats.tombstoneKPIs?.ratio || 0)}%`)
)
);
const activityBlock = (() => {
const rows = Array.isArray(stats.activity?.daily7) ? stats.activity.daily7 : [];
const max = Math.max(1, ...rows.map(r => Number(r.count) || 0));
return div({ class: 'stats-block' },
h2(i18n.statsActivity7d),
rows.length
? ul({ class: 'stats-toplist' },
...rows.map(row => {
const cnt = Number(row.count) || 0;
const pct = (cnt / max) * 100;
return li(
span({ class: 'stats-toplist-name' }, row.day),
div({ class: 'stats-bar-track' },
div({ class: `stats-bar-fill ${wClass(pct)}` })
),
span({ class: 'stats-toplist-num' }, String(cnt))
);
})
)
: p({ class: 'no-content' }, i18n.no_results || 'No data'),
div({ class: 'stats-activity-totals' },
span(`${i18n.statsActivity7dTotal}: `, strong(String(stats.activity?.daily7Total || 0))),
span(`${i18n.statsActivity30dTotal}: `, strong(String(stats.activity?.daily30Total || 0)))
)
);
})();
const topTypes = Array.isArray(stats.topTypes) ? stats.topTypes : [];
const topTypesBlock = topTypes.length ? div({ class: 'stats-block' },
h2(i18n.statsTopTypesTitle || 'Top Content Types'),
renderTopList(
topTypes,
it => labels[it.type] || it.type,
it => it.count,
topTypes[0] ? topTypes[0].count : 1
)
) : null;
const topTags = Array.isArray(stats.topTags) ? stats.topTags : [];
const topTagsBlock = topTags.length ? div({ class: 'stats-block' },
h2(i18n.statsTopTagsTitle || 'Top Tags'),
div({ class: 'stats-mb-16' },
topTags.map(t => a({ class: 'stats-pill', href: `/search?query=%23${encodeURIComponent(t.tag)}` }, `#${t.tag} (${t.count})`))
)
) : null;
const marketBlock = div({ class: 'stats-block' },
h2(i18n.statsMarketTitle),
kpiGrid(
kpi(i18n.statsMarketTotal, stats.marketKPIs?.total || 0),
kpi(i18n.statsMarketForSale, stats.marketKPIs?.forSale || 0),
kpi(i18n.statsMarketReserved, stats.marketKPIs?.reserved || 0),
kpi(i18n.statsMarketClosed, stats.marketKPIs?.closed || 0),
kpi(i18n.statsMarketSold, stats.marketKPIs?.sold || 0)
)
);
const projectsBlock = div({ class: 'stats-block' },
h2(i18n.statsProjectsTitle),
kpiGrid(
kpi(i18n.statsProjectsTotal, stats.projectsKPIs?.total || 0),
kpi(i18n.statsProjectsActive, stats.projectsKPIs?.active || 0),
kpi(i18n.statsProjectsCompleted, stats.projectsKPIs?.completed || 0),
kpi(i18n.statsProjectsPaused, stats.projectsKPIs?.paused || 0),
kpi(i18n.statsProjectsCancelled, stats.projectsKPIs?.cancelled || 0),
kpi(i18n.statsProjectsGoalTotal, `${stats.projectsKPIs?.ecoGoalTotal || 0} ECO`),
kpi(i18n.statsProjectsPledgedTotal, `${stats.projectsKPIs?.ecoPledgedTotal || 0} ECO`)
)
);
const allTribesPublic = Array.isArray(stats.allTribesPublic) ? stats.allTribesPublic : [];
const memberTribesDetailed = Array.isArray(stats.memberTribesDetailed) ? stats.memberTribesDetailed : [];
const myPrivateTribesDetailed = Array.isArray(stats.myPrivateTribesDetailed) ? stats.myPrivateTribesDetailed : [];
const buildContentTiles = () => {
const tiles = [];
types.filter(t => t !== 'karmaScore' && t !== 'shopProduct' && t !== 'padEntry' && t !== 'chatMessage' && t !== 'calendarDate' && t !== 'calendarNote').forEach(t => {
const cnt = C(stats, t);
if (cnt <= 0) return;
tiles.push(kpi(labels[t], cnt));
if (t === 'shop') tiles.push(kpi(labels.shopProduct, C(stats, 'shopProduct')));
else if (t === 'pad') tiles.push(kpi(labels.padEntry, C(stats, 'padEntry')));
else if (t === 'chat') tiles.push(kpi(labels.chatMessage, C(stats, 'chatMessage')));
else if (t === 'calendar') {
tiles.push(kpi(labels.calendarDate, C(stats, 'calendarDate')));
tiles.push(kpi(labels.calendarNote, C(stats, 'calendarNote')));
} else if (t === 'tribe') {
tiles.push(kpi(i18n.statsPublic, stats.tribePublicCount || 0));
tiles.push(kpi(i18n.statsPrivate, stats.tribePrivateCount || 0));
}
});
return tiles;
};
const buildOpinionTiles = () =>
types.map(t => O(stats, t) > 0 ? kpi(labels[t], O(stats, t)) : null).filter(Boolean);
const tribeListBlock = (label, list) => div({ class: 'stats-block' },
h2(`${label}: ${list.length}`),
list.length
? table({ class: 'stats-table-mt8' },
...list.map(t => tr(td(a({ href: `/tribe/${encodeURIComponent(t.id)}`, class: 'tribe-link' }, t.name))))
)
: p({ class: 'no-content' }, i18n.no_results || 'No data')
);
const allMode = filter === 'ALL'
? div({ class: 'stats-container' }, [
networkBlock,
activityBlock,
topTypesBlock,
topTagsBlock,
div({ class: 'stats-block' },
h2(i18n.statsNetworkContent),
kpiGrid(
kpi(i18n.statsDiscoveredTribes, allTribesPublic.length),
kpi(i18n.statsPrivateDiscoveredTribes, stats.tribePrivateCount || 0),
kpi(i18n.statsDiscoveredForum, C(stats, 'forum')),
kpi(i18n.statsDiscoveredTransfer, C(stats, 'transfer'))
)
),
tribeListBlock(i18n.statsDiscoveredTribes, allTribesPublic),
marketBlock,
projectsBlock,
div({ class: 'stats-block' },
h2(`${i18n.statsNetworkOpinions}: ${totalOpinions}`),
kpiGrid(...buildOpinionTiles())
),
div({ class: 'stats-block' },
h2(`${i18n.statsNetworkContent}: ${totalContent}`),
kpiGrid(...buildContentTiles())
)
])
: null;
const mineMode = filter === 'MINE'
? div({ class: 'stats-container' }, [
networkBlock,
activityBlock,
topTypesBlock,
topTagsBlock,
div({ class: 'stats-block' },
h2(i18n.statsYourContent || i18n.statsNetworkContent),
kpiGrid(
kpi(i18n.statsDiscoveredTribes, memberTribesDetailed.length),
kpi(i18n.statsPrivateDiscoveredTribes, myPrivateTribesDetailed.length),
kpi(i18n.statsYourForum, C(stats, 'forum')),
kpi(i18n.statsYourTransfer, C(stats, 'transfer'))
)
),
tribeListBlock(i18n.statsDiscoveredTribes, memberTribesDetailed),
myPrivateTribesDetailed.length
? tribeListBlock(i18n.statsPrivateDiscoveredTribes, myPrivateTribesDetailed)
: null,
marketBlock,
projectsBlock,
div({ class: 'stats-block' },
h2(`${i18n.statsYourOpinions}: ${totalOpinions}`),
kpiGrid(...buildOpinionTiles())
),
div({ class: 'stats-block' },
h2(`${i18n.statsYourContent}: ${totalContent}`),
kpiGrid(...buildContentTiles())
)
])
: null;
const tombMode = filter === 'TOMBSTONE'
? div({ class: 'stats-container' }, [
div({ class: 'stats-block' },
kpiGrid(
kpi(i18n.TOMBSTONEButton, stats.userTombstoneCount || 0),
kpi(i18n.statsTombstoneRatio, `${(stats.tombstoneKPIs?.ratio || 0).toFixed(2)}%`)
)
)
])
: null;
return template( return template(
title, title,
section( section(
@ -95,7 +461,7 @@ exports.statsView = (stats, filter) => {
h2(title), h2(title),
p(description) p(description)
), ),
div({ class: 'mode-buttons stats-grid' }, div({ class: 'mode-buttons stats-mode-row' },
modes.map(m => modes.map(m =>
form({ method: 'GET', action: '/stats' }, form({ method: 'GET', action: '/stats' },
input({ type: 'hidden', name: 'filter', value: m }), input({ type: 'hidden', name: 'filter', value: m }),
@ -104,311 +470,14 @@ exports.statsView = (stats, filter) => {
) )
), ),
section( section(
div({ style: headerStyle }, topStrip,
h3({ class: 'stats-h-row' }, `${i18n.statsCreatedAt}: `, span({ class: 'stats-muted-888' }, stats.createdAt)), headerCard,
h3({ class: 'stats-section-h' }, bankingCard,
a({ class: "user-link", href: `/author/${encodeURIComponent(stats.id)}`, class: 'stats-link' }, stats.id) carbonCard,
), allMode,
div({ class: 'stats-mb-16' }, mineMode,
ul({ class: 'stats-list-reset' }, tombMode
li({ class: 'stats-h-row' }, `${i18n.statsBlobsSize}: `, span({ class: 'stats-muted-888' }, stats.statsBlobsSize)),
li({ class: 'stats-h-row' }, `${i18n.statsBlockchainSize}: `, span({ class: 'stats-muted-888' }, stats.statsBlockchainSize)),
li({ class: 'stats-h-row' }, strong(`${i18n.statsSize}: `, span({ class: 'stats-muted-888' }, span({ class: 'stats-muted-555' }, stats.folderSize))))
)
)
),
div({ class: "stats-karma-block" }, h3(`${i18n.bankingUserEngagementScore}: ${C(stats, 'karmaScore')}`)),
div({ style: headerStyle },
h3(i18n.statsCarbonFootprintTitle || 'Carbon Footprint'),
(() => {
const parseSize = (s) => {
if (!s) return 0;
const m = String(s).match(/([\d.]+)\s*(GB|MB|KB|B)/i);
if (!m) return 0;
const v = parseFloat(m[1]);
const u = m[2].toUpperCase();
if (u === 'GB') return v * 1024;
if (u === 'MB') return v;
if (u === 'KB') return v / 1024;
return v / (1024 * 1024);
};
const blobsMB = parseSize(stats.statsBlobsSize);
const chainMB = parseSize(stats.statsBlockchainSize);
const totalMB = blobsMB + chainMB;
const kWhPerMB = 0.0002;
const gCO2PerKWh = 475;
const networkCO2 = parseFloat((totalMB * kWhPerMB * gCO2PerKWh).toFixed(2));
const inhabitants = stats.usersKPIs?.totalInhabitants || stats.inhabitants || 1;
const userCO2 = parseFloat((networkCO2 / Math.max(1, inhabitants)).toFixed(2));
const maxAnnualCO2 = 500;
if (filter === 'MINE') {
const pct = networkCO2 > 0 ? Math.min(100, (userCO2 / networkCO2) * 100).toFixed(1) : '0.0';
return div({ class: 'carbon-chart' },
div({ class: 'carbon-bar-label' },
span(i18n.statsCarbonUser || 'Your footprint'),
span(`${userCO2} g CO₂`)
),
div({ class: 'carbon-bar-track' },
div({ class: 'carbon-bar-fill carbon-bar-mine', style: `width:${pct}%;` })
),
div({ class: 'carbon-bar-label' },
span(i18n.statsCarbonNetwork || 'Network total'),
span(`${networkCO2} g CO₂`)
),
div({ class: 'carbon-bar-track' },
div({ class: 'carbon-bar-fill carbon-bar-network stats-w-100' })
),
p({ class: 'carbon-bar-note' }, strong(`${pct}%`), ` ${i18n.statsCarbonOfNetwork || 'of network total'}`),
p({ class: 'carbon-bar-formula' }, 'Based on local data storage weight ', strong('(0.0002 kWh/MB × 475 g CO₂/kWh)'))
);
}
if (filter === 'TOMBSTONE') {
const tombCount = stats.tombstoneKPIs?.networkTombstoneCount || 0;
const avgTombBytes = 500;
const tombMB = (tombCount * avgTombBytes) / (1024 * 1024);
const tombCO2 = parseFloat((tombMB * kWhPerMB * gCO2PerKWh).toFixed(4));
const tombPct = networkCO2 > 0 ? Math.min(100, (tombCO2 / networkCO2) * 100).toFixed(1) : '0.0';
return div({ class: 'carbon-chart' },
div({ class: 'carbon-bar-label' },
span(i18n.statsCarbonTombstone || 'Tombstoning footprint'),
span(`${tombCO2} g CO₂`)
),
div({ class: 'carbon-bar-track' },
div({ class: 'carbon-bar-fill carbon-bar-mine', style: `width:${tombPct}%;` })
),
div({ class: 'carbon-bar-label' },
span(i18n.statsCarbonNetwork || 'Network total'),
span(`${networkCO2} g CO₂`)
),
div({ class: 'carbon-bar-track' },
div({ class: 'carbon-bar-fill carbon-bar-network stats-w-100' })
),
p({ class: 'carbon-bar-note' }, strong(`${tombPct}%`), ` ${i18n.statsCarbonOfNetwork || 'of network total'} (${tombCount} tombstones × ~${avgTombBytes} bytes)`),
p({ class: 'carbon-bar-formula' }, 'Based on estimated tombstone message size ', strong('(0.0002 kWh/MB × 475 g CO₂/kWh)'))
);
}
const pct = Math.min(100, (networkCO2 / maxAnnualCO2) * 100).toFixed(1);
return div({ class: 'carbon-chart' },
div({ class: 'carbon-bar-label' },
span(i18n.statsCarbonNetwork || 'Network footprint'),
span(`${networkCO2} g CO₂`)
),
div({ class: 'carbon-bar-track' },
div({ class: 'carbon-bar-fill carbon-bar-network', style: `width:${pct}%;` })
),
div({ class: 'carbon-bar-label' },
span(i18n.statsCarbonMaxAnnual || 'Annual max estimate'),
span(`${maxAnnualCO2} g CO₂`)
),
div({ class: 'carbon-bar-track' },
div({ class: 'carbon-bar-fill carbon-bar-max stats-w-100' })
),
p({ class: 'carbon-bar-note' }, strong(`${pct}%`), ` ${i18n.statsCarbonOfEstMax || 'of estimated max capacity'}`),
p({ class: 'carbon-bar-formula' }, 'Based on local data storage weight ', strong('(0.0002 kWh/MB × 475 g CO₂/kWh)'))
);
})()
),
div({ style: headerStyle },
h3({ class: 'stats-section-h' }, i18n.statsBankingTitle),
ul({ class: 'stats-list-reset' },
li({ class: 'stats-h-row' }, `${i18n.statsEcoWalletLabel}: `, a({ href: '/wallet', class: 'stats-link-break' }, stats?.banking?.myAddress || i18n.statsEcoWalletNotConfigured)),
li({ class: 'stats-h-row' }, `${i18n.statsTotalEcoAddresses}: `, span({ class: 'stats-muted-888' }, String(stats?.banking?.totalAddresses || 0)))
)
),
div({ style: headerStyle },
h3({ class: 'stats-section-h' }, i18n.statsLogsTitle || 'Logs'),
ul({ class: 'stats-list-reset' },
li({ class: 'stats-h-row' }, `${i18n.statsLogsEntries || 'Entries'}: `, span({ class: 'stats-muted-888' }, String(stats?.logsCount || 0)))
)
),
div({ style: headerStyle },
h3({ class: 'stats-section-h' }, i18n.statsAITraining),
ul({ class: 'stats-list-reset' },
li({ class: 'stats-h-row' }, `${i18n.statsAIExchanges}: `, span({ class: 'stats-muted-888' }, String(C(stats, 'aiExchange') || 0)))
)
),
div({ style: headerStyle }, h3(`${i18n.statsPUBs}: ${String(stats.pubsCount || 0)}`)),
filter === 'ALL'
? div({ class: 'stats-container' }, [
div({ style: blockStyle },
h2(i18n.statsActivity7d),
table({ class: 'stats-table' },
tr(th(i18n.day), th(i18n.messages)),
...(Array.isArray(stats.activity?.daily7) ? stats.activity.daily7 : []).map(row => tr(td(row.day), td(String(row.count))))
),
p(`${i18n.statsActivity7dTotal}: ${stats.activity?.daily7Total || 0}`),
p(`${i18n.statsActivity30dTotal}: ${stats.activity?.daily30Total || 0}`)
),
div({ style: blockStyle },
h2(`${i18n.statsDiscoveredTribes}: ${stats.allTribesPublic.length}`),
table({ class: 'stats-table-mt8' },
...stats.allTribesPublic.map(t => tr(td(a({ href: `/tribe/${encodeURIComponent(t.id)}`, class: 'tribe-link' }, t.name))))
)
),
div({ style: blockStyle },
h2(`${i18n.statsPrivateDiscoveredTribes}: ${stats.tribePrivateCount || 0}`)
),
div({ style: blockStyle }, h2(`${i18n.statsUsersTitle}: ${stats.usersKPIs?.totalInhabitants || stats.inhabitants || 0}`)),
div({ style: blockStyle }, h2(`${i18n.statsDiscoveredForum}: ${C(stats, 'forum')}`)),
div({ style: blockStyle }, h2(`${i18n.statsDiscoveredTransfer}: ${C(stats, 'transfer')}`)),
div({ style: blockStyle },
h2(i18n.statsMarketTitle),
ul([
li(`${i18n.statsMarketTotal}: ${stats.marketKPIs?.total || 0}`),
li(`${i18n.statsMarketForSale}: ${stats.marketKPIs?.forSale || 0}`),
li(`${i18n.statsMarketReserved}: ${stats.marketKPIs?.reserved || 0}`),
li(`${i18n.statsMarketClosed}: ${stats.marketKPIs?.closed || 0}`),
li(`${i18n.statsMarketSold}: ${stats.marketKPIs?.sold || 0}`),
li(`${i18n.statsMarketRevenue}: ${((stats.marketKPIs?.revenueECO || 0)).toFixed(6)} ECO`),
li(`${i18n.statsMarketAvgSoldPrice}: ${((stats.marketKPIs?.avgSoldPrice || 0)).toFixed(6)} ECO`)
])
),
div({ style: blockStyle },
h2(i18n.statsProjectsTitle),
ul([
li(`${i18n.statsProjectsTotal}: ${stats.projectsKPIs?.total || 0}`),
li(`${i18n.statsProjectsActive}: ${stats.projectsKPIs?.active || 0}`),
li(`${i18n.statsProjectsCompleted}: ${stats.projectsKPIs?.completed || 0}`),
li(`${i18n.statsProjectsPaused}: ${stats.projectsKPIs?.paused || 0}`),
li(`${i18n.statsProjectsCancelled}: ${stats.projectsKPIs?.cancelled || 0}`),
li(`${i18n.statsProjectsGoalTotal}: ${(stats.projectsKPIs?.ecoGoalTotal || 0)} ECO`),
li(`${i18n.statsProjectsPledgedTotal}: ${(stats.projectsKPIs?.ecoPledgedTotal || 0)} ECO`),
li(`${i18n.statsProjectsSuccessRate}: ${((stats.projectsKPIs?.successRate || 0)).toFixed(1)}%`),
li(`${i18n.statsProjectsAvgProgress}: ${((stats.projectsKPIs?.avgProgress || 0)).toFixed(1)}%`),
li(`${i18n.statsProjectsMedianProgress}: ${((stats.projectsKPIs?.medianProgress || 0)).toFixed(1)}%`),
li(`${i18n.statsProjectsActiveFundingAvg}: ${((stats.projectsKPIs?.activeFundingAvg || 0)).toFixed(1)}%`)
])
),
div({ style: blockStyle },
h2(`${i18n.statsNetworkOpinions}: ${totalOpinions}`),
ul(types.map(t => O(stats, t) > 0 ? li(`${labels[t]}: ${O(stats, t)}`) : null).filter(Boolean))
),
div({ style: blockStyle },
h2(`${i18n.statsNetworkContent}: ${totalContent}`),
ul(
types.filter(t => t !== 'karmaScore' && t !== 'shopProduct' && t !== 'padEntry' && t !== 'chatMessage').map(t => {
if (C(stats, t) <= 0) return null;
if (t === 'shop') return li(
span(`${labels[t]}: ${C(stats, t)}`),
ul([li(`${labels.shopProduct}: ${C(stats, 'shopProduct')}`)])
);
if (t === 'pad') return li(
span(`${labels[t]}: ${C(stats, t)}`),
ul([li(`${labels.padEntry}: ${C(stats, 'padEntry')}`)])
);
if (t === 'chat') return li(
span(`${labels[t]}: ${C(stats, t)}`),
ul([li(`${labels.chatMessage}: ${C(stats, 'chatMessage')}`)])
);
if (t !== 'tribe') return li(`${labels[t]}: ${C(stats, t)}`);
return li(
span(`${labels[t]}: ${C(stats, t)}`),
ul([
li(`${i18n.statsPublic}: ${stats.tribePublicCount || 0}`),
li(`${i18n.statsPrivate}: ${stats.tribePrivateCount || 0}`)
])
);
}).filter(Boolean)
)
)
])
: filter === 'MINE'
? div({ class: 'stats-container' }, [
div({ style: blockStyle },
h2(i18n.statsActivity7d),
table({ class: 'stats-table' },
tr(th(i18n.day), th(i18n.messages)),
...(Array.isArray(stats.activity?.daily7) ? stats.activity.daily7 : []).map(row => tr(td(row.day), td(String(row.count))))
),
p(`${i18n.statsActivity7dTotal}: ${stats.activity?.daily7Total || 0}`),
p(`${i18n.statsActivity30dTotal}: ${stats.activity?.daily30Total || 0}`)
),
div({ style: blockStyle },
h2(`${i18n.statsDiscoveredTribes}: ${stats.memberTribesDetailed.length}`),
table({ class: 'stats-table-mt8' },
...stats.memberTribesDetailed.map(t => tr(td(a({ href: `/tribe/${encodeURIComponent(t.id)}`, class: 'tribe-link' }, t.name))))
)
),
Array.isArray(stats.myPrivateTribesDetailed) && stats.myPrivateTribesDetailed.length
? div({ style: blockStyle },
h2(`${i18n.statsPrivateDiscoveredTribes}: ${stats.myPrivateTribesDetailed.length}`),
table({ class: 'stats-table-mt8' },
...stats.myPrivateTribesDetailed.map(tp => tr(td(a({ href: `/tribe/${encodeURIComponent(tp.id)}`, class: 'tribe-link' }, tp.name))))
)
)
: null,
div({ style: blockStyle }, h2(`${i18n.statsYourForum}: ${C(stats, 'forum')}`)),
div({ style: blockStyle }, h2(`${i18n.statsYourTransfer}: ${C(stats, 'transfer')}`)),
div({ style: blockStyle },
h2(i18n.statsMarketTitle),
ul([
li(`${i18n.statsMarketTotal}: ${stats.marketKPIs?.total || 0}`),
li(`${i18n.statsMarketForSale}: ${stats.marketKPIs?.forSale || 0}`),
li(`${i18n.statsMarketReserved}: ${stats.marketKPIs?.reserved || 0}`),
li(`${i18n.statsMarketClosed}: ${stats.marketKPIs?.closed || 0}`),
li(`${i18n.statsMarketSold}: ${stats.marketKPIs?.sold || 0}`),
li(`${i18n.statsMarketRevenue}: ${((stats.marketKPIs?.revenueECO || 0)).toFixed(6)} ECO`),
li(`${i18n.statsMarketAvgSoldPrice}: ${((stats.marketKPIs?.avgSoldPrice || 0)).toFixed(6)} ECO`)
])
),
div({ style: blockStyle },
h2(i18n.statsProjectsTitle),
ul([
li(`${i18n.statsProjectsTotal}: ${stats.projectsKPIs?.total || 0}`),
li(`${i18n.statsProjectsActive}: ${stats.projectsKPIs?.active || 0}`),
li(`${i18n.statsProjectsCompleted}: ${stats.projectsKPIs?.completed || 0}`),
li(`${i18n.statsProjectsPaused}: ${stats.projectsKPIs?.paused || 0}`),
li(`${i18n.statsProjectsCancelled}: ${stats.projectsKPIs?.cancelled || 0}`),
li(`${i18n.statsProjectsGoalTotal}: ${(stats.projectsKPIs?.ecoGoalTotal || 0)} ECO`),
li(`${i18n.statsProjectsPledgedTotal}: ${(stats.projectsKPIs?.ecoPledgedTotal || 0)} ECO`),
li(`${i18n.statsProjectsSuccessRate}: ${((stats.projectsKPIs?.successRate || 0)).toFixed(1)}%`),
li(`${i18n.statsProjectsAvgProgress}: ${((stats.projectsKPIs?.avgProgress || 0)).toFixed(1)}%`),
li(`${i18n.statsProjectsMedianProgress}: ${((stats.projectsKPIs?.medianProgress || 0)).toFixed(1)}%`),
li(`${i18n.statsProjectsActiveFundingAvg}: ${((stats.projectsKPIs?.activeFundingAvg || 0)).toFixed(1)}%`)
])
),
div({ style: blockStyle },
h2(`${i18n.statsYourOpinions}: ${totalOpinions}`),
ul(types.map(t => O(stats, t) > 0 ? li(`${labels[t]}: ${O(stats, t)}`) : null).filter(Boolean))
),
div({ style: blockStyle },
h2(`${i18n.statsYourContent}: ${totalContent}`),
ul(
types.filter(t => t !== 'karmaScore' && t !== 'shopProduct').map(t => {
if (C(stats, t) <= 0) return null;
if (t === 'shop') return li(
span(`${labels[t]}: ${C(stats, t)}`),
ul([li(`${labels.shopProduct}: ${C(stats, 'shopProduct')}`)])
);
if (t !== 'tribe') return li(`${labels[t]}: ${C(stats, t)}`);
return li(
span(`${labels[t]}: ${C(stats, t)}`),
ul([
li(`${i18n.statsPublic}: ${stats.tribePublicCount || 0}`),
li(`${i18n.statsPrivate}: ${stats.tribePrivateCount || 0}`),
...(Array.isArray(stats.myPrivateTribesDetailed) && stats.myPrivateTribesDetailed.length
? [
li(i18n.statsPrivateDiscoveredTribes),
...stats.myPrivateTribesDetailed.map(tp =>
li(a({ href: `/tribe/${encodeURIComponent(tp.id)}`, class: 'tribe-link' }, tp.name))
)
]
: [])
])
);
}).filter(Boolean)
)
)
])
: div({ class: 'stats-container' }, [
div({ style: blockStyle },
h2(`${i18n.TOMBSTONEButton}: ${stats.userTombstoneCount}`),
h2(`${i18n.statsTombstoneRatio.toUpperCase()}: ${((stats.tombstoneKPIs?.ratio || 0)).toFixed(2)}%`)
)
])
) )
) )
); );
}; };

View file

@ -1,6 +1,6 @@
const { div, h2, p, section, button, form, input, select, option, a, br, textarea, label, span } = require("../server/node_modules/hyperaxe"); const { div, h2, p, section, button, form, input, select, option, a, br, textarea, label, span } = require("../server/node_modules/hyperaxe");
const moment = require("../server/node_modules/moment"); const moment = require("../server/node_modules/moment");
const { template, i18n } = require("./main_views"); const { template, i18n, userLink} = require("./main_views");
const { config } = require("../server/SSB_server.js"); const { config } = require("../server/SSB_server.js");
const { renderUrl } = require("../backend/renderUrl"); const { renderUrl } = require("../backend/renderUrl");
@ -162,10 +162,15 @@ const renderTaskCommentsSection = (taskId, comments = [], currentFilter = "all")
button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton) button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton)
) )
), ),
comments && comments.length (() => {
const visibleComments = (comments || []).filter(c => {
const t = c && c.value && c.value.content && c.value.content.text;
return t && String(t).trim();
});
return visibleComments.length
? div( ? div(
{ class: "comments-list" }, { class: "comments-list" },
comments.map((c) => { visibleComments.map((c) => {
const author = c.value && c.value.author ? c.value.author : ""; const author = c.value && c.value.author ? c.value.author : "";
const ts = c.value && c.value.timestamp ? c.value.timestamp : c.timestamp; const ts = c.value && c.value.timestamp ? c.value.timestamp : c.timestamp;
const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : ""; const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : "";
@ -191,7 +196,8 @@ const renderTaskCommentsSection = (taskId, comments = [], currentFilter = "all")
); );
}) })
) )
: p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet) : p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet);
})()
); );
}; };
@ -221,7 +227,7 @@ const renderTaskItem = (task, filter) => {
span( span(
{ class: "card-value" }, { class: "card-value" },
assignees.length assignees.length
? assignees.map((id, i) => [i > 0 ? ", " : "", a({ class: "user-link", href: `/author/${encodeURIComponent(id)}` }, id)]).flat() ? assignees.map((id, i) => [i > 0 ? ", " : "", userLink(id)]).flat()
: i18n.noAssignees : i18n.noAssignees
) )
), ),
@ -242,7 +248,7 @@ const renderTaskItem = (task, filter) => {
p( p(
{ class: "card-footer" }, { class: "card-footer" },
span({ class: "date-link" }, `${moment(task.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `), span({ class: "date-link" }, `${moment(task.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
a({ href: `/author/${encodeURIComponent(task.author)}`, class: "user-link" }, `${task.author}`) userLink(task.author)
) )
); );
}; };
@ -447,7 +453,7 @@ exports.singleTaskView = async (task, filter, comments = []) => {
span( span(
{ class: "card-value" }, { class: "card-value" },
assignees.length assignees.length
? assignees.map((id, i) => [i > 0 ? ", " : "", a({ class: "user-link", href: `/author/${encodeURIComponent(id)}` }, id)]).flat() ? assignees.map((id, i) => [i > 0 ? ", " : "", userLink(id)]).flat()
: i18n.noAssignees : i18n.noAssignees
) )
), ),
@ -455,7 +461,7 @@ exports.singleTaskView = async (task, filter, comments = []) => {
p( p(
{ class: "card-footer" }, { class: "card-footer" },
span({ class: "date-link" }, `${moment(task.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `), span({ class: "date-link" }, `${moment(task.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
a({ href: `/author/${encodeURIComponent(task.author)}`, class: "user-link" }, `${task.author}`) userLink(task.author)
) )
), ),
renderTaskCommentsSection(task.id, comments, currentFilter) renderTaskCommentsSection(task.id, comments, currentFilter)

View file

@ -19,7 +19,7 @@ const {
td td
} = require("../server/node_modules/hyperaxe"); } = require("../server/node_modules/hyperaxe");
const { template, i18n } = require("./main_views"); const { template, i18n, userLink} = require("./main_views");
const moment = require("../server/node_modules/moment"); const moment = require("../server/node_modules/moment");
const { config } = require("../server/SSB_server.js"); const { config } = require("../server/SSB_server.js");
const { renderUrl } = require("../backend/renderUrl"); const { renderUrl } = require("../backend/renderUrl");
@ -180,7 +180,7 @@ const renderTorrentTable = (torrents, filter, params = {}) => {
torrents.map((t) => torrents.map((t) =>
tr( tr(
td(moment(t.createdAt).format("YYYY/MM/DD HH:mm")), td(moment(t.createdAt).format("YYYY/MM/DD HH:mm")),
td(a({ href: `/author/${encodeURIComponent(t.author)}`, class: "user-link" }, t.author)), td(userLink(t.author)),
td(t.title || ""), td(t.title || ""),
td(formatSize(t.size)), td(formatSize(t.size)),
td( td(
@ -375,7 +375,7 @@ exports.singleTorrentView = async (torrentObj, filter = "all", comments = [], pa
return p( return p(
{ class: "card-footer" }, { class: "card-footer" },
span({ class: "date-link" }, `${moment(torrentObj.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `), span({ class: "date-link" }, `${moment(torrentObj.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
a({ href: `/author/${encodeURIComponent(torrentObj.author)}`, class: "user-link" }, `${torrentObj.author}`), userLink(torrentObj.author),
showUpdated showUpdated
? span( ? span(
{ class: "votations-comment-date" }, { class: "votations-comment-date" },

View file

@ -1,5 +1,5 @@
const { div, h2, p, section, button, form, a, input, br, span, label, select, option, progress } = require("../server/node_modules/hyperaxe") const { div, h2, p, section, button, form, a, input, br, span, label, select, option, progress } = require("../server/node_modules/hyperaxe")
const { template, i18n } = require("./main_views") const { template, i18n, userLink} = require("./main_views")
const moment = require("../server/node_modules/moment") const moment = require("../server/node_modules/moment")
const { config } = require("../server/SSB_server.js") const { config } = require("../server/SSB_server.js")
const opinionCategories = require("../backend/opinion_categories") const opinionCategories = require("../backend/opinion_categories")
@ -188,7 +188,7 @@ const generateTransferCard = (transfer, filter, params = {}) => {
p( p(
{ class: "card-footer" }, { class: "card-footer" },
span({ class: "date-link" }, `${moment(transfer.createdAt).format("YYYY-MM-DD HH:mm")} ${i18n.performed} `), span({ class: "date-link" }, `${moment(transfer.createdAt).format("YYYY-MM-DD HH:mm")} ${i18n.performed} `),
a({ href: `/author/${encodeURIComponent(transfer.from)}`, class: "user-link" }, `${transfer.from}`), userLink(transfer.from),
renderUpdatedLabel(transfer.createdAt, transfer.updatedAt) renderUpdatedLabel(transfer.createdAt, transfer.updatedAt)
) )
) )
@ -397,8 +397,8 @@ exports.singleTransferView = async (transfer, filter, params = {}) => {
div( div(
{ class: "card-section transfer" }, { class: "card-section transfer" },
topbar ? topbar : null, topbar ? topbar : null,
renderCardField(`${i18n.transfersFrom}:`, a({ class: "user-link", href: `/author/${encodeURIComponent(transfer.from)}` }, transfer.from)), renderCardField(`${i18n.transfersFrom}:`, userLink(transfer.from)),
renderCardField(`${i18n.transfersTo}:`, a({ class: "user-link", href: `/author/${encodeURIComponent(transfer.to)}` }, transfer.to)), renderCardField(`${i18n.transfersTo}:`, userLink(transfer.to)),
br, br,
div({ class: "transfer-amount-highlight" }, renderCardField(`${i18n.transfersAmount}:`, `${fmtAmount(transfer.amount)} ECO`)), div({ class: "transfer-amount-highlight" }, renderCardField(`${i18n.transfersAmount}:`, `${fmtAmount(transfer.amount)} ECO`)),
renderCardField(`${i18n.transfersConcept}:`, transfer.concept || ""), renderCardField(`${i18n.transfersConcept}:`, transfer.concept || ""),
@ -420,7 +420,7 @@ exports.singleTransferView = async (transfer, filter, params = {}) => {
p( p(
{ class: "card-footer" }, { class: "card-footer" },
span({ class: "date-link" }, `${moment(transfer.createdAt).format("YYYY-MM-DD HH:mm")} ${i18n.performed} `), span({ class: "date-link" }, `${moment(transfer.createdAt).format("YYYY-MM-DD HH:mm")} ${i18n.performed} `),
a({ href: `/author/${encodeURIComponent(transfer.from)}`, class: "user-link" }, `${transfer.from}`), userLink(transfer.from),
renderUpdatedLabel(transfer.createdAt, transfer.updatedAt) renderUpdatedLabel(transfer.createdAt, transfer.updatedAt)
), ),
div( div(

View file

@ -17,7 +17,7 @@ const {
} = require("../server/node_modules/hyperaxe"); } = require("../server/node_modules/hyperaxe");
const moment = require("../server/node_modules/moment"); const moment = require("../server/node_modules/moment");
const { template, i18n } = require("./main_views"); const { template, i18n, userLink} = require("./main_views");
const { config } = require("../server/SSB_server.js"); const { config } = require("../server/SSB_server.js");
const { renderUrl } = require("../backend/renderUrl") const { renderUrl } = require("../backend/renderUrl")
const { renderMapLocationVisitLabel } = require("./maps_view"); const { renderMapLocationVisitLabel } = require("./maps_view");
@ -116,7 +116,10 @@ const renderVideoOwnerActions = (filter, videoObj, params = {}) => {
}; };
const renderVideoCommentsSection = (videoId, comments = [], returnTo = null) => { const renderVideoCommentsSection = (videoId, comments = [], returnTo = null) => {
const list = safeArr(comments); const list = safeArr(comments).filter(c => {
const t = c && c.value && c.value.content && c.value.content.text;
return t && String(t).trim();
});
const commentsCount = list.length; const commentsCount = list.length;
return div( return div(
@ -231,7 +234,7 @@ const renderVideoList = (videos, filter, params = {}) => {
return p( return p(
{ class: "card-footer" }, { class: "card-footer" },
span({ class: "date-link" }, `${moment(videoObj.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `), span({ class: "date-link" }, `${moment(videoObj.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
a({ href: `/author/${encodeURIComponent(videoObj.author)}`, class: "user-link" }, `${videoObj.author}`), userLink(videoObj.author),
showUpdated showUpdated
? span( ? span(
{ class: "votations-comment-date" }, { class: "votations-comment-date" },
@ -423,7 +426,7 @@ exports.singleVideoView = async (videoObj, filter = "all", comments = [], params
return p( return p(
{ class: "card-footer" }, { class: "card-footer" },
span({ class: "date-link" }, `${moment(videoObj.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `), span({ class: "date-link" }, `${moment(videoObj.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
a({ href: `/author/${encodeURIComponent(videoObj.author)}`, class: "user-link" }, `${videoObj.author}`), userLink(videoObj.author),
showUpdated showUpdated
? span( ? span(
{ class: "votations-comment-date" }, { class: "votations-comment-date" },

View file

@ -1,5 +1,5 @@
const { div, h2, p, section, button, form, a, textarea, br, input, table, tr, th, td, label, span } = require("../server/node_modules/hyperaxe"); const { div, h2, p, section, button, form, a, textarea, br, input, table, tr, th, td, label, span } = require("../server/node_modules/hyperaxe");
const { template, i18n } = require("./main_views"); const { template, i18n, userLink} = require("./main_views");
const moment = require("../server/node_modules/moment"); const moment = require("../server/node_modules/moment");
const { config } = require("../server/SSB_server.js"); const { config } = require("../server/SSB_server.js");
const opinionCategories = require("../backend/opinion_categories"); const opinionCategories = require("../backend/opinion_categories");
@ -230,7 +230,7 @@ const renderVoteCard = (v, voteOptions, firstRow, secondRow, mode, activeFilter)
p( p(
{ class: "card-footer" }, { class: "card-footer" },
span({ class: "date-link" }, `${moment(v.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `), span({ class: "date-link" }, `${moment(v.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
a({ href: `/author/${encodeURIComponent(v.createdBy)}`, class: "user-link" }, `${v.createdBy}`) userLink(v.createdBy)
), ),
renderOpinionsBar(v, returnTo) renderOpinionsBar(v, returnTo)
); );