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:
parent
13161b2158
commit
3a3563f2a0
84 changed files with 5842 additions and 1621 deletions
79
AUTOMATIZACION/00_INDICE.txt
Normal file
79
AUTOMATIZACION/00_INDICE.txt
Normal 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.
|
||||
102
AUTOMATIZACION/01_OPCIONES.txt
Normal file
102
AUTOMATIZACION/01_OPCIONES.txt
Normal 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.
|
||||
122
AUTOMATIZACION/02_OPCION_A_cron_simple.txt
Normal file
122
AUTOMATIZACION/02_OPCION_A_cron_simple.txt
Normal 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.
|
||||
145
AUTOMATIZACION/03_OPCION_B_multiagente.txt
Normal file
145
AUTOMATIZACION/03_OPCION_B_multiagente.txt
Normal 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.
|
||||
115
AUTOMATIZACION/04_OPCION_C_github_actions.txt
Normal file
115
AUTOMATIZACION/04_OPCION_C_github_actions.txt
Normal 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
|
||||
136
AUTOMATIZACION/05_OPCION_D_webhook_reactivo.txt
Normal file
136
AUTOMATIZACION/05_OPCION_D_webhook_reactivo.txt
Normal 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.
|
||||
147
AUTOMATIZACION/06_TESTING_APP_seccion.txt
Normal file
147
AUTOMATIZACION/06_TESTING_APP_seccion.txt
Normal 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.
|
||||
162
AUTOMATIZACION/07_HUMAN_IN_THE_LOOP.txt
Normal file
162
AUTOMATIZACION/07_HUMAN_IN_THE_LOOP.txt
Normal 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.
|
||||
168
AUTOMATIZACION/08_PROMPTS_para_agentes.md
Normal file
168
AUTOMATIZACION/08_PROMPTS_para_agentes.md
Normal 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.
|
||||
```
|
||||
295
AUTOMATIZACION/09_SCRIPTS.md
Normal file
295
AUTOMATIZACION/09_SCRIPTS.md
Normal 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"
|
||||
```
|
||||
258
AUTOMATIZACION/10_DEBIAN_setup.txt
Normal file
258
AUTOMATIZACION/10_DEBIAN_setup.txt
Normal 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 ...
|
||||
|
|
@ -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)
|
||||
- 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
|
||||
--------------------------------------------------------------
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -80,7 +80,8 @@ const handleBlobUpload = async function (ctx, fileFieldName) {
|
|||
'.flac': 'audio/flac', '.aac': 'audio/aac', '.opus': 'audio/opus',
|
||||
'.pdf': 'application/pdf', '.png': 'image/png', '.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp',
|
||||
'.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' };
|
||||
|
|
@ -101,7 +102,7 @@ const handleBlobUpload = async function (ctx, fileFieldName) {
|
|||
blob.mime = 'application/octet-stream';
|
||||
}
|
||||
|
||||
if (blob.mime.startsWith('image/')) {
|
||||
if (blob.mime.startsWith('image/') && blob.mime !== 'image/gif') {
|
||||
data = await stripImageMetadata(data);
|
||||
} else if (blob.mime === 'application/pdf') {
|
||||
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("video/")) return `\n[video:${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})`;
|
||||
};
|
||||
|
|
@ -213,8 +215,27 @@ const serveBlob = async function (ctx) {
|
|||
if (ft && ft.mime) mime = ft.mime;
|
||||
} 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.set('Content-Disposition', `inline; filename="${raw}"`);
|
||||
ctx.set('Content-Disposition', `${disposition}; filename="${filename}"`);
|
||||
ctx.set('Cache-Control', 'public, max-age=31536000, immutable');
|
||||
|
||||
const range = ctx.headers.range;
|
||||
|
|
|
|||
20
nodejs-project/nodejs-project/src/backend/nameCache.js
Normal file
20
nodejs-project/nodejs-project/src/backend/nameCache.js
Normal 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 }
|
||||
|
|
@ -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) {
|
||||
if (!text) return ''
|
||||
const i18n = getI18n()
|
||||
return String(text)
|
||||
|
||||
let html = String(text)
|
||||
|
||||
html = html
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
|
||||
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*(&[^)\s]+\.sha256)\s*\)/g, (_, alt, blob) =>
|
||||
`<img src="/blob/${encodeURIComponent(blob.replace(/&/g, '&'))}" alt="${alt}" class="post-image" />`
|
||||
)
|
||||
|
|
@ -28,21 +72,68 @@ function renderTextWithStyles(text) {
|
|||
.replace(/\[pdf:([^\]]*)\]\(\s*(&[^)\s]+\.sha256)\s*\)/g, (_, name, blob) =>
|
||||
`<a class="post-pdf" href="/blob/${encodeURIComponent(blob.replace(/&/g, '&'))}" target="_blank">${name || i18n.pdfFallbackLabel || 'PDF'}</a>`
|
||||
)
|
||||
|
||||
html = html
|
||||
.replace(/\[@([^\]]+)\]\(@?([A-Za-z0-9+/=.\-]+\.ed25519)\)/g, (_, name, id) =>
|
||||
`<a href="/author/${encodeURIComponent('@' + id)}" class="mention" target="_blank">@${name}</a>`
|
||||
)
|
||||
.replace(/@([A-Za-z0-9+/=.\-]+\.ed25519)/g, (_, id) =>
|
||||
`<a href="/author/${encodeURIComponent('@' + id)}" class="mention" target="_blank">@${id}</a>`
|
||||
)
|
||||
|
||||
const escAttr = (s) => String(s).replace(/"/g, '"').replace(/'/g, ''')
|
||||
html = html
|
||||
.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 =>
|
||||
`<a href="${url}" target="_blank" class="styled-link">${url}</a>`
|
||||
.replace(/(https?:\/\/[^\s"'<>]+)/g, url =>
|
||||
`<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 =>
|
||||
`<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
|
||||
}
|
||||
|
||||
module.exports = { renderTextWithStyles }
|
||||
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, renderTextPreview }
|
||||
|
|
|
|||
|
|
@ -1022,30 +1022,37 @@ pre, code {
|
|||
}
|
||||
|
||||
.actpager {
|
||||
margin-top: 0 !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.actpager-frame {
|
||||
gap: 8px !important;
|
||||
padding: 6px 0 !important;
|
||||
gap: 4px !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.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 {
|
||||
margin-top: 4px !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.actpager-arrows {
|
||||
gap: 40px !important;
|
||||
gap: 32px !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.actpager-arrow {
|
||||
width: 32px !important;
|
||||
height: 32px !important;
|
||||
font-size: 1.3rem !important;
|
||||
width: 28px !important;
|
||||
height: 28px !important;
|
||||
font-size: 1.15rem !important;
|
||||
background: transparent !important;
|
||||
border: 1px solid #555 !important;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -240,6 +240,7 @@ nav ul li a:hover {
|
|||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-weight: 600;
|
||||
|
|
@ -255,6 +256,20 @@ nav ul li a:hover {
|
|||
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 {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: #ffd36a;
|
||||
|
|
@ -295,6 +310,7 @@ nav ul li a:hover {
|
|||
.oasis-nav-list li a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.35rem 1.25rem 0.35rem 1.5rem;
|
||||
font-size: 0.85rem;
|
||||
text-decoration: none;
|
||||
|
|
@ -307,7 +323,18 @@ nav ul li a:hover {
|
|||
}
|
||||
|
||||
.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 {
|
||||
|
|
@ -459,6 +486,7 @@ nav ul li a:hover {
|
|||
|
||||
.top-bar-left,
|
||||
.top-bar-mid,
|
||||
.top-bar-center,
|
||||
.top-bar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -474,10 +502,78 @@ nav ul li a:hover {
|
|||
justify-content: center;
|
||||
}
|
||||
|
||||
.top-bar-center {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.top-bar-right {
|
||||
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-mid nav ul,
|
||||
.top-bar-right nav ul {
|
||||
|
|
@ -1038,6 +1134,9 @@ button.create-button:hover {
|
|||
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 {
|
||||
display: flex;
|
||||
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; }
|
||||
|
||||
.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 .card-value{white-space:pre-wrap;word-break:break-word}
|
||||
.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-mine{background:#3498db !important}
|
||||
.carbon-bar-max{background:#555 !important;border:none !important}
|
||||
.carbon-bar-note{font-size:13px;color:#888;margin:6px 0 2px}
|
||||
.carbon-bar-formula{font-size:12px;color:#999;margin:2px 0}
|
||||
.carbon-bar-note{font-size:13px;color:#ffd700;margin:6px 0 2px}
|
||||
.carbon-bar-formula{font-size:12px;color:#ffd700;margin:2px 0 10px}
|
||||
|
||||
/* parliament */
|
||||
.cycle-info {
|
||||
|
|
@ -3726,6 +3827,26 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
|
|||
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 {
|
||||
display: flex;
|
||||
gap: .5em;
|
||||
|
|
@ -4123,50 +4244,6 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
|
|||
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 {
|
||||
width: auto;
|
||||
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-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}
|
||||
.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 {
|
||||
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;
|
||||
}
|
||||
/* === Mobile additions (QR share + mobile menu hide on desktop) === */
|
||||
.qr-share-details {
|
||||
margin: 8px 0;
|
||||
width: 100%;
|
||||
|
|
|
|||
|
|
@ -394,6 +394,17 @@ a.user-link:focus {
|
|||
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 */
|
||||
.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; }
|
||||
|
|
|
|||
|
|
@ -312,6 +312,12 @@ a.user-link:focus {
|
|||
}
|
||||
|
||||
/* Blockexplorer */
|
||||
.stats-kpi-label { color: #ffa300 !important; }
|
||||
.stats-kpi-value { color: #FFD700 !important; }
|
||||
.carbon-bar-note, .carbon-bar-formula { color: #FFD700 !important; }
|
||||
.graphos-node-label { fill: #ddd !important; }
|
||||
.graphos-node-label-me { fill: #ffa500 !important; }
|
||||
.graphos-legend { color: #ddd !important; }
|
||||
.blockchain-view { background-color: #191b20 !important; color: #FFD700 !important; }
|
||||
.block { background: #23242a !important; }
|
||||
.block:hover { box-shadow: 0 8px 32px rgba(35,40,50,0.18) !important; }
|
||||
|
|
|
|||
|
|
@ -405,6 +405,13 @@ a.user-link:focus {
|
|||
color: #00FF00 !important;
|
||||
}
|
||||
|
||||
.stats-kpi-label { color: #00FF00 !important; }
|
||||
.stats-kpi-value { color: #00FF00 !important; }
|
||||
.carbon-bar-note, .carbon-bar-formula { color: #00FF00 !important; }
|
||||
.graphos-node-label { fill: #00FF00 !important; }
|
||||
.graphos-node-label-me { fill: #ffa500 !important; font-weight: bold !important; }
|
||||
.graphos-legend { color: #00FF00 !important; }
|
||||
.graphos-edge { stroke: #003300 !important; }
|
||||
/* Blockexplorer */
|
||||
.blockchain-view { background-color: #000000 !important; color: #00FF00 !important; }
|
||||
.block { background: #1A1A1A !important; border: 1px solid #00FF00 !important; }
|
||||
|
|
|
|||
|
|
@ -440,6 +440,12 @@ a.user-link:focus {
|
|||
color: #9B1C96 !important;
|
||||
}
|
||||
|
||||
.stats-kpi-label { color: #B86ADE !important; }
|
||||
.stats-kpi-value { color: #FFEEDB !important; }
|
||||
.carbon-bar-note, .carbon-bar-formula { color: #FFEEDB !important; }
|
||||
.graphos-node-label { fill: #FFEEDB !important; }
|
||||
.graphos-node-label-me { fill: #ffa500 !important; }
|
||||
.graphos-legend { color: #FFEEDB !important; }
|
||||
/* Blockexplorer */
|
||||
.blockchain-view { background-color: #2D0B47 !important; color: #FFEEDB !important; }
|
||||
.block { background: #3C1360 !important; border: 1px solid #B86ADE !important; }
|
||||
|
|
|
|||
|
|
@ -38,6 +38,11 @@ module.exports = {
|
|||
],
|
||||
profile: "الصورة الرمزية",
|
||||
inhabitants: "السكان",
|
||||
peersReplicatedFeeds: "الموجزات المنسوخة",
|
||||
graphos: "غرافوس",
|
||||
graphosDescription: "خريطة تفاعلية للشبكة من حولك.",
|
||||
graphosYou: "أنت",
|
||||
graphosTotalNodes: "إجمالي العقد",
|
||||
manualMode: "الوضع اليدوي",
|
||||
mentions: "الإشارات",
|
||||
mentionsDescription: [
|
||||
|
|
@ -2148,6 +2153,7 @@ module.exports = {
|
|||
bankAddressInvalid: 'عنوان غير صالح',
|
||||
bankAddressDeleted: 'تم حذف العنوان',
|
||||
bankAddressNotFound: 'لم يتم العثور على العنوان',
|
||||
bankAddressForbidden: 'يمكنك فقط تعيين عنوان الدفع الخاص بك',
|
||||
bankAddressTotal: 'إجمالي العناوين',
|
||||
bankAddressSearch: 'ابحث عن @ساكن أو عنوان',
|
||||
bankAddressActions: 'الإجراءات',
|
||||
|
|
@ -2753,6 +2759,11 @@ module.exports = {
|
|||
fileTooLargeTitle: "الملف كبير جدًا",
|
||||
fileTooLargeMessage: "يتجاوز الملف الحجم الأقصى المسموح به (50 ميغابايت). يرجى اختيار ملف أصغر.",
|
||||
goBack: "رجوع",
|
||||
errorPageTitle: "حدث خطأ",
|
||||
aiNavPlaceholder: "إلى أين تريد الذهاب؟",
|
||||
aiNavDisabled: "تعطيل التنقل بالذكاء الاصطناعي.",
|
||||
modulesAINavLabel: "AINav",
|
||||
modulesAINavDescription: "وحدة لإجراء استعلامات بلغة طبيعية حول محتوى الشبكة.",
|
||||
directConnect: "اتصال مباشر",
|
||||
directConnectDescription: "اتصل مباشرة بقرين عن طريق إدخال عنوان IP والمنفذ والمفتاح العام. سيتم إضافة القرين كاتصال مُتابَع.",
|
||||
peerHost: "IP / اسم المضيف",
|
||||
|
|
@ -2948,6 +2959,8 @@ module.exports = {
|
|||
gamesBackToGames: "العودة إلى الألعاب",
|
||||
modulesGamesLabel: "الألعاب",
|
||||
modulesGamesDescription: "وحدة لاكتشاف وتشغيل بعض الألعاب.",
|
||||
modulesGraphosLabel: "غرافوس",
|
||||
modulesGraphosDescription: "وحدة لاستكشاف الشبكة كخريطة تفاعلية للعقد.",
|
||||
gamesCocolandTitle: "Cocoland",
|
||||
gamesCocolandDesc: "جوزة هند بعيون تقفز فوق أشجار النخيل وتجمع ECOins.",
|
||||
gamesTheFlowTitle: "ECOinflow",
|
||||
|
|
@ -3017,6 +3030,9 @@ module.exports = {
|
|||
calendarCreated: "تم الإنشاء",
|
||||
calendarAuthor: "المؤلف",
|
||||
calendarJoin: "انضمام",
|
||||
calendarGenerateInvite: "إنشاء دعوة",
|
||||
calendarInviteCodePlaceholder: "أدخل رمز الدعوة...",
|
||||
calendarValidateInvite: "التحقق من الرمز",
|
||||
calendarJoined: "منضم",
|
||||
calendarAddDate: "إضافة تاريخ",
|
||||
calendarAddNote: "إضافة ملاحظة",
|
||||
|
|
|
|||
|
|
@ -38,6 +38,11 @@ module.exports = {
|
|||
],
|
||||
profile: "Avatar",
|
||||
inhabitants: "Bewohner",
|
||||
peersReplicatedFeeds: "Replizierte Feeds",
|
||||
graphos: "Graphos",
|
||||
graphosDescription: "Interaktive Karte des Netzwerks um dich herum.",
|
||||
graphosYou: "Du",
|
||||
graphosTotalNodes: "Knoten insgesamt",
|
||||
manualMode: "Manueller Modus",
|
||||
mentions: "Erwähnungen",
|
||||
mentionsDescription: [
|
||||
|
|
@ -2147,6 +2152,7 @@ module.exports = {
|
|||
bankAddressInvalid: 'Ungültige Adresse',
|
||||
bankAddressDeleted: 'Adresse gelöscht',
|
||||
bankAddressNotFound: 'Adresse nicht gefunden',
|
||||
bankAddressForbidden: 'Du kannst nur deine eigene Auszahlungsadresse festlegen',
|
||||
bankAddressTotal: 'Adressen gesamt',
|
||||
bankAddressSearch: 'Nach @Bewohner oder Adresse suchen',
|
||||
bankAddressActions: 'Aktionen',
|
||||
|
|
@ -2752,6 +2758,11 @@ module.exports = {
|
|||
fileTooLargeTitle: "Datei zu groß",
|
||||
fileTooLargeMessage: "Die Datei überschreitet die maximal erlaubte Größe (50 MB). Bitte wähle eine kleinere Datei.",
|
||||
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",
|
||||
directConnectDescription: "Verbinde dich direkt mit einem Peer, indem du IP-Adresse, Port und öffentlichen Schlüssel eingibst.",
|
||||
peerHost: "IP / Hostname",
|
||||
|
|
@ -2891,6 +2902,8 @@ module.exports = {
|
|||
gamesBackToGames: "Zurück zu Spielen",
|
||||
modulesGamesLabel: "Spiele",
|
||||
modulesGamesDescription: "Modul zum Entdecken und Spielen von Spielen.",
|
||||
modulesGraphosLabel: "Graphos",
|
||||
modulesGraphosDescription: "Modul, um das Netzwerk als interaktive Karte der Knoten zu erkunden.",
|
||||
gamesCocolandTitle: "Cocoland",
|
||||
gamesCocolandDesc: "Eine Kokosnuss mit Augen, die über Palmen springt und ECOins sammelt.",
|
||||
gamesTheFlowTitle: "ECOinflow",
|
||||
|
|
@ -3013,6 +3026,9 @@ module.exports = {
|
|||
calendarCreated: "Erstellt",
|
||||
calendarAuthor: "Autor",
|
||||
calendarJoin: "Beitreten",
|
||||
calendarGenerateInvite: "Einladung erstellen",
|
||||
calendarInviteCodePlaceholder: "Einladungscode eingeben...",
|
||||
calendarValidateInvite: "Code prüfen",
|
||||
calendarJoined: "Beigetreten",
|
||||
calendarAddDate: "Datum hinzufügen",
|
||||
calendarAddNote: "Notiz hinzufügen",
|
||||
|
|
|
|||
|
|
@ -38,6 +38,11 @@ module.exports = {
|
|||
],
|
||||
profile: "Avatar",
|
||||
inhabitants: "Inhabitants",
|
||||
peersReplicatedFeeds: "Replicated feeds",
|
||||
graphos: "Graphos",
|
||||
graphosDescription: "Interactive map of the network around you.",
|
||||
graphosYou: "You",
|
||||
graphosTotalNodes: "Total nodes",
|
||||
manualMode: "Manual Mode",
|
||||
mentions: "Mentions",
|
||||
mentionsDescription: [
|
||||
|
|
@ -2153,6 +2158,7 @@ module.exports = {
|
|||
bankAddressInvalid: 'Invalid address',
|
||||
bankAddressDeleted: 'Address deleted',
|
||||
bankAddressNotFound: 'Address not found',
|
||||
bankAddressForbidden: 'You can only set your own payout address',
|
||||
bankAddressTotal: 'Total Addresses',
|
||||
bankAddressSearch: 'Search @inhabitant or address',
|
||||
bankAddressActions: 'Actions',
|
||||
|
|
@ -2758,6 +2764,11 @@ module.exports = {
|
|||
fileTooLargeTitle: "File too large",
|
||||
fileTooLargeMessage: "The file exceeds the maximum allowed size (50 MB). Please select a smaller file.",
|
||||
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",
|
||||
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",
|
||||
|
|
@ -2921,6 +2932,9 @@ module.exports = {
|
|||
calendarCreated: "Created",
|
||||
calendarAuthor: "Author",
|
||||
calendarJoin: "Join Calendar",
|
||||
calendarGenerateInvite: "Generate invite",
|
||||
calendarInviteCodePlaceholder: "Enter invite code...",
|
||||
calendarValidateInvite: "Validate code",
|
||||
calendarJoined: "Joined",
|
||||
calendarAddDate: "Add Date",
|
||||
calendarAddNote: "Add Note",
|
||||
|
|
@ -3024,6 +3038,8 @@ module.exports = {
|
|||
gamesBackToGames: "Back to Games",
|
||||
modulesGamesLabel: "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",
|
||||
gamesCocolandDesc: "A coconut with eyes jumping over palm trees and collecting ECOins. How far can you go?",
|
||||
gamesTheFlowTitle: "ECOinflow",
|
||||
|
|
|
|||
|
|
@ -38,6 +38,11 @@ module.exports = {
|
|||
],
|
||||
profile: "Avatar",
|
||||
inhabitants: "Habitantes",
|
||||
peersReplicatedFeeds: "Feeds replicados",
|
||||
graphos: "Graphos",
|
||||
graphosDescription: "Mapa interactivo de la red alrededor de ti.",
|
||||
graphosYou: "Tú",
|
||||
graphosTotalNodes: "Nodos totales",
|
||||
manualMode: "Modo Manual",
|
||||
mentions: "Menciones",
|
||||
mentionsDescription: [
|
||||
|
|
@ -2151,6 +2156,7 @@ module.exports = {
|
|||
bankAddressInvalid: 'Dirección no válida',
|
||||
bankAddressDeleted: 'Dirección eliminada',
|
||||
bankAddressNotFound: 'Dirección no encontrada',
|
||||
bankAddressForbidden: 'Solo puedes configurar tu propia dirección de cobro',
|
||||
bankAddressTotal: 'Total de Direcciones',
|
||||
bankAddressSearch: 'Buscar @habitante o dirección',
|
||||
bankAddressActions: 'Acciones',
|
||||
|
|
@ -2757,6 +2763,11 @@ module.exports = {
|
|||
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.",
|
||||
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",
|
||||
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",
|
||||
|
|
@ -2922,6 +2933,9 @@ module.exports = {
|
|||
calendarCreated: "Creado",
|
||||
calendarAuthor: "Autor",
|
||||
calendarJoin: "Unirse al Calendario",
|
||||
calendarGenerateInvite: "Generar invitación",
|
||||
calendarInviteCodePlaceholder: "Introduce código...",
|
||||
calendarValidateInvite: "Validar código",
|
||||
calendarJoined: "Unido",
|
||||
calendarAddDate: "Añadir Fecha",
|
||||
calendarAddNote: "Añadir Nota",
|
||||
|
|
@ -3025,6 +3039,8 @@ module.exports = {
|
|||
gamesBackToGames: "Volver a Juegos",
|
||||
modulesGamesLabel: "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",
|
||||
gamesCocolandDesc: "Un coco con ojos saltando palmeras y coleccionando ECOins. ¿Hasta dónde puedes llegar?",
|
||||
gamesTheFlowTitle: "ECOinflow",
|
||||
|
|
|
|||
|
|
@ -38,6 +38,11 @@ module.exports = {
|
|||
],
|
||||
profile: "Abatarra",
|
||||
inhabitants: "Bizilagunak",
|
||||
peersReplicatedFeeds: "Errepikatutako jarioak",
|
||||
graphos: "Graphos",
|
||||
graphosDescription: "Sarearen mapa interaktiboa zure inguruan.",
|
||||
graphosYou: "Zu",
|
||||
graphosTotalNodes: "Nodo guztiak",
|
||||
manualMode: "Eskuzko modua",
|
||||
mentions: "Aipamenak",
|
||||
mentionsDescription: [
|
||||
|
|
@ -2118,6 +2123,7 @@ module.exports = {
|
|||
bankAddressInvalid: 'Helbide baliogabea',
|
||||
bankAddressDeleted: 'Helbidea ezabatuta',
|
||||
bankAddressNotFound: 'Helbiderik ez da aurkitu',
|
||||
bankAddressForbidden: 'Zure ordainketa-helbidea soilik konfigura dezakezu',
|
||||
bankAddressTotal: 'Guztira',
|
||||
bankAddressSearch: 'Erabiltzailea edo helbidea bilatu',
|
||||
bankAddressActions: 'Ekintzak',
|
||||
|
|
@ -2724,6 +2730,11 @@ module.exports = {
|
|||
fileTooLargeTitle: "Fitxategia handiegia",
|
||||
fileTooLargeMessage: "Fitxategiak onartutako gehienezko tamaina gainditzen du (50 MB). Mesedez, hautatu fitxategi txikiago bat.",
|
||||
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",
|
||||
directConnectDescription: "Konektatu zuzenean parekide batera bere IP helbidea, portua eta gako publikoa sartuz. Parekidea jarraipen-konexio gisa gehituko da.",
|
||||
peerHost: "IP / Ostalari-izena",
|
||||
|
|
@ -2918,6 +2929,8 @@ module.exports = {
|
|||
gamesBackToGames: "Jokoetara itzuli",
|
||||
modulesGamesLabel: "Jokoak",
|
||||
modulesGamesDescription: "Jokoak aurkitzeko eta jolasteko modulua.",
|
||||
modulesGraphosLabel: "Graphos",
|
||||
modulesGraphosDescription: "Sarea nodoen mapa interaktibo gisa esploratzeko modulua.",
|
||||
gamesCocolandTitle: "Cocoland",
|
||||
gamesCocolandDesc: "Begiak dituen koko bat palmondoen gainetik jauzika ECOins biltzen.",
|
||||
gamesTheFlowTitle: "ECOinflow",
|
||||
|
|
@ -2987,6 +3000,9 @@ module.exports = {
|
|||
calendarCreated: "Sortua",
|
||||
calendarAuthor: "Egilea",
|
||||
calendarJoin: "Batu",
|
||||
calendarGenerateInvite: "Sortu gonbidapena",
|
||||
calendarInviteCodePlaceholder: "Sartu gonbidapen-kodea...",
|
||||
calendarValidateInvite: "Egiaztatu kodea",
|
||||
calendarJoined: "Batuta",
|
||||
calendarAddDate: "Data gehitu",
|
||||
calendarAddNote: "Oharra gehitu",
|
||||
|
|
|
|||
|
|
@ -38,6 +38,11 @@ module.exports = {
|
|||
],
|
||||
profile: "Avatar",
|
||||
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",
|
||||
mentions: "Mentions",
|
||||
mentionsDescription: [
|
||||
|
|
@ -2143,6 +2148,7 @@ module.exports = {
|
|||
bankAddressInvalid: 'Adresse non valide',
|
||||
bankAddressDeleted: 'Adresse supprimée',
|
||||
bankAddressNotFound: 'Adresse introuvable',
|
||||
bankAddressForbidden: 'Vous ne pouvez configurer que votre propre adresse de paiement',
|
||||
bankAddressTotal: 'Total des adresses',
|
||||
bankAddressSearch: 'Rechercher @habitant ou adresse',
|
||||
bankAddressActions: 'Actions',
|
||||
|
|
@ -2749,6 +2755,11 @@ module.exports = {
|
|||
fileTooLargeTitle: "Fichier trop volumineux",
|
||||
fileTooLargeMessage: "Le fichier dépasse la taille maximale autorisée (50 Mo). Veuillez sélectionner un fichier plus petit.",
|
||||
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",
|
||||
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",
|
||||
|
|
@ -2946,6 +2957,8 @@ module.exports = {
|
|||
gamesBackToGames: "Retour aux Jeux",
|
||||
modulesGamesLabel: "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",
|
||||
gamesCocolandDesc: "Une noix de coco avec des yeux qui saute par-dessus des palmiers en collectant des ECOins.",
|
||||
gamesTheFlowTitle: "ECOinflow",
|
||||
|
|
@ -3015,6 +3028,9 @@ module.exports = {
|
|||
calendarCreated: "Créé",
|
||||
calendarAuthor: "Auteur",
|
||||
calendarJoin: "Rejoindre",
|
||||
calendarGenerateInvite: "Générer une invitation",
|
||||
calendarInviteCodePlaceholder: "Entrez le code d'invitation...",
|
||||
calendarValidateInvite: "Valider le code",
|
||||
calendarJoined: "Rejoint",
|
||||
calendarAddDate: "Ajouter une date",
|
||||
calendarAddNote: "Ajouter une note",
|
||||
|
|
|
|||
|
|
@ -38,6 +38,11 @@ module.exports = {
|
|||
],
|
||||
profile: "अवतार",
|
||||
inhabitants: "निवासी",
|
||||
peersReplicatedFeeds: "प्रतिकृत फ़ीड",
|
||||
graphos: "ग्राफोस",
|
||||
graphosDescription: "आपके आसपास के नेटवर्क का इंटरैक्टिव मानचित्र।",
|
||||
graphosYou: "आप",
|
||||
graphosTotalNodes: "कुल नोड्स",
|
||||
manualMode: "मैनुअल मोड",
|
||||
mentions: "उल्लेख",
|
||||
mentionsDescription: [
|
||||
|
|
@ -2148,6 +2153,7 @@ module.exports = {
|
|||
bankAddressInvalid: 'अमान्य पता',
|
||||
bankAddressDeleted: 'पता हटाया गया',
|
||||
bankAddressNotFound: 'पता नहीं मिला',
|
||||
bankAddressForbidden: 'आप केवल अपना भुगतान पता सेट कर सकते हैं',
|
||||
bankAddressTotal: 'कुल पते',
|
||||
bankAddressSearch: '@निवासी या पता खोजें',
|
||||
bankAddressActions: 'कार्रवाई',
|
||||
|
|
@ -2753,6 +2759,11 @@ module.exports = {
|
|||
fileTooLargeTitle: "फ़ाइल बहुत बड़ी",
|
||||
fileTooLargeMessage: "फ़ाइल अधिकतम अनुमत आकार (50 MB) से अधिक है। कृपया एक छोटी फ़ाइल चुनें।",
|
||||
goBack: "वापस जाएँ",
|
||||
errorPageTitle: "कुछ गलत हो गया",
|
||||
aiNavPlaceholder: "आप कहाँ जाना चाहते हैं?",
|
||||
aiNavDisabled: "AI नेविगेशन अक्षम है।",
|
||||
modulesAINavLabel: "AINav",
|
||||
modulesAINavDescription: "नेटवर्क की सामग्री पर प्राकृतिक भाषा में प्रश्न करने का मॉड्यूल।",
|
||||
directConnect: "सीधा कनेक्शन",
|
||||
directConnectDescription: "किसी पीयर से सीधे कनेक्ट करने के लिए उनका IP पता, पोर्ट और सार्वजनिक कुंजी दर्ज करें। पीयर को एक अनुसरित कनेक्शन के रूप में जोड़ा जाएगा।",
|
||||
peerHost: "IP / होस्टनाम",
|
||||
|
|
@ -2948,6 +2959,8 @@ module.exports = {
|
|||
gamesBackToGames: "खेलों पर वापस जाएं",
|
||||
modulesGamesLabel: "खेल",
|
||||
modulesGamesDescription: "कुछ खेल खोजने और खेलने का मॉड्यूल।",
|
||||
modulesGraphosLabel: "ग्राफोस",
|
||||
modulesGraphosDescription: "नोड्स के इंटरैक्टिव मानचित्र के रूप में नेटवर्क का अन्वेषण करने का मॉड्यूल।",
|
||||
gamesCocolandTitle: "Cocoland",
|
||||
gamesCocolandDesc: "आंखों वाला एक नारियल ताड़ के पेड़ों के ऊपर कूदता है और ECOins इकट्ठा करता है।",
|
||||
gamesTheFlowTitle: "ECOinflow",
|
||||
|
|
@ -3017,6 +3030,9 @@ module.exports = {
|
|||
calendarCreated: "बनाया गया",
|
||||
calendarAuthor: "लेखक",
|
||||
calendarJoin: "जुड़ें",
|
||||
calendarGenerateInvite: "आमंत्रण बनाएं",
|
||||
calendarInviteCodePlaceholder: "आमंत्रण कोड दर्ज करें...",
|
||||
calendarValidateInvite: "कोड सत्यापित करें",
|
||||
calendarJoined: "जुड़े हुए",
|
||||
calendarAddDate: "तारीख जोड़ें",
|
||||
calendarAddNote: "नोट जोड़ें",
|
||||
|
|
|
|||
|
|
@ -38,6 +38,11 @@ module.exports = {
|
|||
],
|
||||
profile: "Avatar",
|
||||
inhabitants: "Abitanti",
|
||||
peersReplicatedFeeds: "Feed replicati",
|
||||
graphos: "Graphos",
|
||||
graphosDescription: "Mappa interattiva della rete attorno a te.",
|
||||
graphosYou: "Tu",
|
||||
graphosTotalNodes: "Nodi totali",
|
||||
manualMode: "Modalità manuale",
|
||||
mentions: "Menzioni",
|
||||
mentionsDescription: [
|
||||
|
|
@ -2148,6 +2153,7 @@ module.exports = {
|
|||
bankAddressInvalid: 'Indirizzo non valido',
|
||||
bankAddressDeleted: 'Indirizzo eliminato',
|
||||
bankAddressNotFound: 'Indirizzo non trovato',
|
||||
bankAddressForbidden: 'Puoi impostare solo il tuo indirizzo di pagamento',
|
||||
bankAddressTotal: 'Indirizzi totali',
|
||||
bankAddressSearch: 'Cerca @abitante o indirizzo',
|
||||
bankAddressActions: 'Azioni',
|
||||
|
|
@ -2753,6 +2759,11 @@ module.exports = {
|
|||
fileTooLargeTitle: "File troppo grande",
|
||||
fileTooLargeMessage: "Il file supera la dimensione massima consentita (50 MB). Seleziona un file più piccolo.",
|
||||
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",
|
||||
directConnectDescription: "Connettiti direttamente a un peer inserendo indirizzo IP, porta e chiave pubblica.",
|
||||
peerHost: "IP / Nome host",
|
||||
|
|
@ -2949,6 +2960,8 @@ module.exports = {
|
|||
gamesBackToGames: "Torna ai Giochi",
|
||||
modulesGamesLabel: "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",
|
||||
gamesCocolandDesc: "Una noce di cocco con occhi che salta palme e raccoglie ECOins.",
|
||||
gamesTheFlowTitle: "ECOinflow",
|
||||
|
|
@ -3018,6 +3031,9 @@ module.exports = {
|
|||
calendarCreated: "Creato",
|
||||
calendarAuthor: "Autore",
|
||||
calendarJoin: "Partecipa",
|
||||
calendarGenerateInvite: "Genera invito",
|
||||
calendarInviteCodePlaceholder: "Inserisci il codice di invito...",
|
||||
calendarValidateInvite: "Convalida codice",
|
||||
calendarJoined: "Iscritto",
|
||||
calendarAddDate: "Aggiungi data",
|
||||
calendarAddNote: "Aggiungi nota",
|
||||
|
|
|
|||
|
|
@ -38,6 +38,11 @@ module.exports = {
|
|||
],
|
||||
profile: "Avatar",
|
||||
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",
|
||||
mentions: "Menções",
|
||||
mentionsDescription: [
|
||||
|
|
@ -2148,6 +2153,7 @@ module.exports = {
|
|||
bankAddressInvalid: 'Invalid address',
|
||||
bankAddressDeleted: 'Address deleted',
|
||||
bankAddressNotFound: 'Address not found',
|
||||
bankAddressForbidden: 'Só podes configurar o teu próprio endereço de cobrança',
|
||||
bankAddressTotal: 'Total Addresses',
|
||||
bankAddressSearch: 'Search @inhabitant or address',
|
||||
bankAddressActions: 'Actions',
|
||||
|
|
@ -2753,6 +2759,11 @@ module.exports = {
|
|||
fileTooLargeTitle: "Ficheiro demasiado grande",
|
||||
fileTooLargeMessage: "O ficheiro excede o tamanho máximo permitido (50 MB). Por favor, seleciona um ficheiro mais pequeno.",
|
||||
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",
|
||||
directConnectDescription: "Conecta-te diretamente a um par inserindo o endereço IP, porta e chave pública.",
|
||||
peerHost: "IP / Nome do anfitrião",
|
||||
|
|
@ -2949,6 +2960,8 @@ module.exports = {
|
|||
gamesBackToGames: "Voltar aos Jogos",
|
||||
modulesGamesLabel: "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",
|
||||
gamesCocolandDesc: "Um coco com olhos a saltar palmeiras e a colecionar ECOins.",
|
||||
gamesTheFlowTitle: "ECOinflow",
|
||||
|
|
@ -3018,6 +3031,9 @@ module.exports = {
|
|||
calendarCreated: "Criado",
|
||||
calendarAuthor: "Autor",
|
||||
calendarJoin: "Participar",
|
||||
calendarGenerateInvite: "Gerar convite",
|
||||
calendarInviteCodePlaceholder: "Digite o código de convite...",
|
||||
calendarValidateInvite: "Validar código",
|
||||
calendarJoined: "Inscrito",
|
||||
calendarAddDate: "Adicionar data",
|
||||
calendarAddNote: "Adicionar nota",
|
||||
|
|
|
|||
|
|
@ -38,6 +38,11 @@ module.exports = {
|
|||
],
|
||||
profile: "Аватар",
|
||||
inhabitants: "Жители",
|
||||
peersReplicatedFeeds: "Реплицированные ленты",
|
||||
graphos: "Графос",
|
||||
graphosDescription: "Интерактивная карта сети вокруг вас.",
|
||||
graphosYou: "Вы",
|
||||
graphosTotalNodes: "Всего узлов",
|
||||
manualMode: "Ручной режим",
|
||||
mentions: "Упоминания",
|
||||
mentionsDescription: [
|
||||
|
|
@ -2113,6 +2118,7 @@ module.exports = {
|
|||
bankAddressInvalid: "Недействительный адрес",
|
||||
bankAddressDeleted: "Адрес удалён",
|
||||
bankAddressNotFound: "Адрес не найден",
|
||||
bankAddressForbidden: "Вы можете задать только свой собственный адрес для выплат",
|
||||
bankAddressTotal: "Всего адресов",
|
||||
bankAddressSearch: "Поиск @жителя или адреса",
|
||||
bankAddressActions: "Действия",
|
||||
|
|
@ -2707,6 +2713,11 @@ module.exports = {
|
|||
fileTooLargeTitle: "Файл слишком большой",
|
||||
fileTooLargeMessage: "Файл превышает максимально допустимый размер (50 МБ). Пожалуйста, выберите файл меньшего размера.",
|
||||
goBack: "Назад",
|
||||
errorPageTitle: "Что-то пошло не так",
|
||||
aiNavPlaceholder: "Куда вы хотите перейти?",
|
||||
aiNavDisabled: "Навигация с ИИ отключена.",
|
||||
modulesAINavLabel: "AINav",
|
||||
modulesAINavDescription: "Модуль для запросов на естественном языке к содержимому сети.",
|
||||
directConnect: "Прямое подключение",
|
||||
directConnectDescription: "Подключитесь напрямую к узлу, введя его IP-адрес, порт и публичный ключ. Узел будет добавлен как отслеживаемое соединение.",
|
||||
peerHost: "IP / Имя хоста",
|
||||
|
|
@ -2911,6 +2922,8 @@ module.exports = {
|
|||
gamesBackToGames: "Назад к играм",
|
||||
modulesGamesLabel: "Игры",
|
||||
modulesGamesDescription: "Модуль для открытия и игры в игры.",
|
||||
modulesGraphosLabel: "Графос",
|
||||
modulesGraphosDescription: "Модуль для исследования сети в виде интерактивной карты узлов.",
|
||||
gamesCocolandTitle: "Cocoland",
|
||||
gamesCocolandDesc: "Кокос с глазами прыгает через пальмы и собирает ECOins.",
|
||||
gamesTheFlowTitle: "ECOinflow",
|
||||
|
|
@ -2980,6 +2993,9 @@ module.exports = {
|
|||
calendarCreated: "Создан",
|
||||
calendarAuthor: "Автор",
|
||||
calendarJoin: "Присоединиться",
|
||||
calendarGenerateInvite: "Создать приглашение",
|
||||
calendarInviteCodePlaceholder: "Введите код приглашения...",
|
||||
calendarValidateInvite: "Проверить код",
|
||||
calendarJoined: "Участник",
|
||||
calendarAddDate: "Добавить дату",
|
||||
calendarAddNote: "Добавить заметку",
|
||||
|
|
|
|||
|
|
@ -38,6 +38,11 @@ module.exports = {
|
|||
],
|
||||
profile: "头像",
|
||||
inhabitants: "居民",
|
||||
peersReplicatedFeeds: "复制的 Feed",
|
||||
graphos: "Graphos",
|
||||
graphosDescription: "你周围网络的互动地图。",
|
||||
graphosYou: "你",
|
||||
graphosTotalNodes: "节点总数",
|
||||
manualMode: "手动模式",
|
||||
mentions: "提及",
|
||||
mentionsDescription: [
|
||||
|
|
@ -2149,6 +2154,7 @@ module.exports = {
|
|||
bankAddressInvalid: '无效地址',
|
||||
bankAddressDeleted: '地址已删除',
|
||||
bankAddressNotFound: '未找到地址',
|
||||
bankAddressForbidden: '您只能设置自己的收款地址',
|
||||
bankAddressTotal: '地址总数',
|
||||
bankAddressSearch: '搜索 @居民或地址',
|
||||
bankAddressActions: '操作',
|
||||
|
|
@ -2754,6 +2760,11 @@ module.exports = {
|
|||
fileTooLargeTitle: "文件过大",
|
||||
fileTooLargeMessage: "文件超过了允许的最大大小(50 MB)。请选择较小的文件。",
|
||||
goBack: "返回",
|
||||
errorPageTitle: "出现了问题",
|
||||
aiNavPlaceholder: "你想去哪里?",
|
||||
aiNavDisabled: "AI 导航已停用。",
|
||||
modulesAINavLabel: "AINav",
|
||||
modulesAINavDescription: "用于以自然语言查询网络内容的模块。",
|
||||
directConnect: "直接连接",
|
||||
directConnectDescription: "通过输入对方的 IP 地址、端口和公钥直接连接到节点。该节点将被添加为已关注的连接。",
|
||||
peerHost: "IP / 主机名",
|
||||
|
|
@ -2949,6 +2960,8 @@ module.exports = {
|
|||
gamesBackToGames: "返回游戏",
|
||||
modulesGamesLabel: "游戏",
|
||||
modulesGamesDescription: "用于发现和玩游戏的模块。",
|
||||
modulesGraphosLabel: "Graphos",
|
||||
modulesGraphosDescription: "将网络作为节点交互式地图浏览的模块。",
|
||||
gamesCocolandTitle: "Cocoland",
|
||||
gamesCocolandDesc: "一颗有眼睛的椰子跳过棕榈树,收集ECOins。",
|
||||
gamesTheFlowTitle: "ECOinflow",
|
||||
|
|
@ -3018,6 +3031,9 @@ module.exports = {
|
|||
calendarCreated: "已创建",
|
||||
calendarAuthor: "作者",
|
||||
calendarJoin: "加入",
|
||||
calendarGenerateInvite: "生成邀请",
|
||||
calendarInviteCodePlaceholder: "输入邀请码...",
|
||||
calendarValidateInvite: "验证邀请码",
|
||||
calendarJoined: "已加入",
|
||||
calendarAddDate: "添加日期",
|
||||
calendarAddNote: "添加笔记",
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ const cli = (presets, defaultConfigFile) =>
|
|||
})
|
||||
.options("offline", {
|
||||
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),
|
||||
type: "boolean",
|
||||
})
|
||||
|
|
@ -54,31 +54,6 @@ const cli = (presets, defaultConfigFile) =>
|
|||
default: _.get(presets, "debug", false),
|
||||
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;
|
||||
|
||||
module.exports = { cli };
|
||||
|
|
|
|||
|
|
@ -6,18 +6,18 @@ const configFilePath = path.join(__dirname, 'oasis-config.json');
|
|||
if (!fs.existsSync(configFilePath)) {
|
||||
const defaultConfig = {
|
||||
"themes": {
|
||||
"current": "OasisMobile"
|
||||
"current": "Dark-SNH"
|
||||
},
|
||||
"modules": {
|
||||
"popularMod": "on",
|
||||
"topicsMod": "off",
|
||||
"summariesMod": "off",
|
||||
"topicsMod": "on",
|
||||
"summariesMod": "on",
|
||||
"latestMod": "on",
|
||||
"threadsMod": "on",
|
||||
"multiverseMod": "on",
|
||||
"invitesMod": "on",
|
||||
"walletMod": "off",
|
||||
"legacyMod": "off",
|
||||
"walletMod": "on",
|
||||
"legacyMod": "on",
|
||||
"cipherMod": "on",
|
||||
"bookmarksMod": "on",
|
||||
"videosMod": "on",
|
||||
|
|
@ -27,32 +27,34 @@ if (!fs.existsSync(configFilePath)) {
|
|||
"imagesMod": "on",
|
||||
"trendingMod": "on",
|
||||
"eventsMod": "on",
|
||||
"tasksMod": "off",
|
||||
"tasksMod": "on",
|
||||
"marketMod": "on",
|
||||
"votesMod": "on",
|
||||
"tribesMod": "on",
|
||||
"reportsMod": "off",
|
||||
"reportsMod": "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",
|
||||
"calendarsMod": "on",
|
||||
"transfersMod": "on",
|
||||
"feedMod": "on",
|
||||
"pixeliaMod": "on",
|
||||
"agendaMod": "on",
|
||||
"aiMod": "on",
|
||||
"aiNavMod": "on",
|
||||
"forumMod": "on",
|
||||
"gamesMod": "on",
|
||||
"jobsMod": "on",
|
||||
"shopsMod": "on",
|
||||
"projectsMod": "on",
|
||||
"bankingMod": "on",
|
||||
"parliamentMod": "on",
|
||||
"courtsMod": "on",
|
||||
"favoritesMod": "on",
|
||||
"logsMod": "on",
|
||||
"mapsMod": "on",
|
||||
"chatsMod": "on",
|
||||
"torrentsMod": "on"
|
||||
"torrentsMod": "on",
|
||||
"graphosMod": "on"
|
||||
},
|
||||
"wallet": {
|
||||
"url": "http://localhost:7474",
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
{
|
||||
"themes": {
|
||||
"current": "OasisMobile"
|
||||
"current": "Dark-SNH"
|
||||
},
|
||||
"modules": {
|
||||
"popularMod": "on",
|
||||
"topicsMod": "off",
|
||||
"summariesMod": "off",
|
||||
"topicsMod": "on",
|
||||
"summariesMod": "on",
|
||||
"latestMod": "on",
|
||||
"threadsMod": "on",
|
||||
"multiverseMod": "on",
|
||||
"invitesMod": "on",
|
||||
"walletMod": "off",
|
||||
"legacyMod": "off",
|
||||
"walletMod": "on",
|
||||
"legacyMod": "on",
|
||||
"cipherMod": "on",
|
||||
"bookmarksMod": "on",
|
||||
"videosMod": "on",
|
||||
|
|
@ -21,32 +21,34 @@
|
|||
"imagesMod": "on",
|
||||
"trendingMod": "on",
|
||||
"eventsMod": "on",
|
||||
"tasksMod": "off",
|
||||
"tasksMod": "on",
|
||||
"marketMod": "on",
|
||||
"votesMod": "on",
|
||||
"tribesMod": "on",
|
||||
"reportsMod": "off",
|
||||
"reportsMod": "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",
|
||||
"calendarsMod": "on",
|
||||
"transfersMod": "on",
|
||||
"feedMod": "on",
|
||||
"pixeliaMod": "on",
|
||||
"agendaMod": "on",
|
||||
"aiMod": "on",
|
||||
"aiNavMod": "on",
|
||||
"forumMod": "on",
|
||||
"gamesMod": "on",
|
||||
"jobsMod": "on",
|
||||
"shopsMod": "on",
|
||||
"projectsMod": "on",
|
||||
"bankingMod": "on",
|
||||
"parliamentMod": "on",
|
||||
"courtsMod": "on",
|
||||
"favoritesMod": "on",
|
||||
"logsMod": "on",
|
||||
"mapsMod": "on",
|
||||
"chatsMod": "on",
|
||||
"torrentsMod": "on"
|
||||
"torrentsMod": "on",
|
||||
"graphosMod": "on"
|
||||
},
|
||||
"wallet": {
|
||||
"url": "http://localhost:7474",
|
||||
|
|
@ -55,9 +57,7 @@
|
|||
"fee": "5"
|
||||
},
|
||||
"walletPub": {
|
||||
"url": "",
|
||||
"user": "",
|
||||
"pass": ""
|
||||
"pubId": ""
|
||||
},
|
||||
"ai": {
|
||||
"prompt": "Provide an informative and precise response."
|
||||
|
|
|
|||
|
|
@ -1,7 +1,30 @@
|
|||
const pull = require('../server/node_modules/pull-stream');
|
||||
const ssbRef = require('../server/node_modules/ssb-ref');
|
||||
const { getConfig } = require('../configs/config-manager.js');
|
||||
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 ORDER_MARKET = ['FOR_SALE','OPEN','RESERVED','CLOSED','SOLD'];
|
||||
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_public_pref') return 'courtsPublicPref';
|
||||
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') {
|
||||
const br = Array.isArray(c.branch) ? c.branch : [];
|
||||
if (br.includes(c.vote.link) && Number(c.vote.value) === 1) return 'spread';
|
||||
|
|
@ -70,8 +97,10 @@ module.exports = ({ cooler }) => {
|
|||
const c = v?.content;
|
||||
if (!c?.type) 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;
|
||||
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);
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -429,12 +316,13 @@ module.exports = ({ cooler }) => {
|
|||
a.content.rootTitle = rootAction?.content?.title || a.content.rootTitle || '';
|
||||
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)));
|
||||
|
||||
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 byKey = new Map();
|
||||
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 });
|
||||
|
||||
const tribeInternalTypes = new Set(['tribeLeave', 'tribeFeedPost', 'tribeFeedRefeed', 'tribe-content']);
|
||||
const isAllowedTribeActivity = (a) => !tribeInternalTypes.has(a.type);
|
||||
const tribeInternalTypes = new Set(['tribe-content', 'tribeParliamentCandidature', 'tribeParliamentTerm', 'tribeParliamentProposal', 'tribeParliamentRule', 'tribeParliamentLaw', 'tribeParliamentRevocation']);
|
||||
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;
|
||||
if (filter === 'mine') out = deduped.filter(a => a.author === userId && isAllowedTribeActivity(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 === 'all') out = deduped.filter(isAllowedTribeActivity);
|
||||
else if (filter === 'banking') out = deduped.filter(a => a.type === 'bankWallet' || a.type === 'bankClaim');
|
||||
if (filter === 'mine') out = deduped.filter(a => a.author === userId && isAllowedTribeActivity(a) && isVisible(a));
|
||||
else if (filter === 'recent') { const cutoff = Date.now() - 24 * 60 * 60 * 1000; out = deduped.filter(a => (a.ts || 0) >= cutoff && isAllowedTribeActivity(a) && isVisible(a)) }
|
||||
else if (filter === 'all') out = deduped.filter(a => isAllowedTribeActivity(a) && isVisible(a));
|
||||
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 === 'tribe') out = deduped.filter(a => a.type === 'tribe' || String(a.type || '').startsWith('tribe'));
|
||||
else if (filter === 'spread') out = deduped.filter(a => a.type === 'spread');
|
||||
|
|
@ -510,6 +418,10 @@ module.exports = ({ cooler }) => {
|
|||
});
|
||||
else if (filter === 'task')
|
||||
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);
|
||||
|
||||
out.sort((a, b) => (b.ts || 0) - (a.ts || 0));
|
||||
|
|
|
|||
|
|
@ -713,35 +713,12 @@ async function getLastPublishedTimestamp(userId) {
|
|||
if (!pubId) throw new Error("no_pub_configured");
|
||||
const alreadyClaimed = await hasClaimedThisMonth(uid);
|
||||
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();
|
||||
if (!ssb || !ssb.publish) throw new Error("ssb_unavailable");
|
||||
const now = new Date().toISOString();
|
||||
const transferContent = {
|
||||
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 };
|
||||
const claimContent = { type: "ubiClaim", pubId, epochId, claimedAt: now };
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
const pull = require("../server/node_modules/pull-stream")
|
||||
const crypto = require("crypto")
|
||||
const { getConfig } = require("../configs/config-manager.js")
|
||||
const logLimit = getConfig().ssbLogStream?.limit || 1000
|
||||
const INVITE_CODE_BYTES = 16
|
||||
|
||||
const safeText = (v) => String(v || "").trim()
|
||||
const normalizeTags = (raw) => {
|
||||
|
|
@ -43,7 +45,46 @@ module.exports = ({ cooler, pmModel, tribeCrypto, tribesModel }) => {
|
|||
const encryptIfTribe = tribeHelpers ? tribeHelpers.encryptIfTribe : async (c) => c
|
||||
const decryptIfTribe = tribeHelpers ? tribeHelpers.decryptIfTribe : async (c) => c
|
||||
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 tomb = new Set()
|
||||
|
|
@ -95,6 +136,7 @@ module.exports = ({ cooler, pmModel, tribeCrypto, tribesModel }) => {
|
|||
tags: Array.isArray(c.tags) ? c.tags : [],
|
||||
author: c.author || node.author,
|
||||
participants: Array.isArray(c.participants) ? c.participants : [],
|
||||
invites: Array.isArray(c.invites) ? c.invites : [],
|
||||
createdAt: c.createdAt || new Date(node.ts).toISOString(),
|
||||
updatedAt: c.updatedAt || 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 (!firstDate || new Date(firstDate).getTime() <= Date.now()) throw new Error("First date must be in the future")
|
||||
|
||||
let content = {
|
||||
let plainContent = {
|
||||
type: "calendar",
|
||||
title: safeText(title),
|
||||
status: validStatus,
|
||||
|
|
@ -151,39 +193,83 @@ module.exports = ({ cooler, pmModel, tribeCrypto, tribesModel }) => {
|
|||
tags: normalizeTags(tags),
|
||||
author: userId,
|
||||
participants: [userId],
|
||||
invites: [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
...(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) => {
|
||||
ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg))
|
||||
})
|
||||
|
||||
const calendarId = calMsg.key
|
||||
const dates = expandRecurrence(firstDate, deadline, intervalWeekly, intervalMonthly, intervalYearly)
|
||||
|
||||
const allDateMsgs = []
|
||||
for (const d of dates) {
|
||||
if (calKey && tribeCrypto) {
|
||||
tribeCrypto.setKey(calendarId, calKey, 1)
|
||||
try {
|
||||
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: d.toISOString(),
|
||||
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 } : {})
|
||||
}
|
||||
dateContent = await encryptIfTribe(dateContent)
|
||||
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))
|
||||
})
|
||||
allDateMsgs.push(dateMsg)
|
||||
}
|
||||
|
||||
if (firstNote && safeText(firstNote) && allDateMsgs.length > 0) {
|
||||
for (const dateMsg of allDateMsgs) {
|
||||
if (firstNote && safeText(firstNote)) {
|
||||
let noteContent = {
|
||||
type: "calendarNote",
|
||||
calendarId,
|
||||
|
|
@ -193,25 +279,28 @@ module.exports = ({ cooler, pmModel, tribeCrypto, tribesModel }) => {
|
|||
createdAt: new Date().toISOString(),
|
||||
...(tribeId ? { tribeId } : {})
|
||||
}
|
||||
noteContent = await encryptIfTribe(noteContent)
|
||||
if (tribeId) noteContent = await encryptIfTribe(noteContent)
|
||||
else if (calKey) noteContent = tribeCrypto.encryptContent(noteContent, [calKey], true)
|
||||
await new Promise((resolve, reject) => {
|
||||
ssbClient.publish(noteContent, (err, msg) => err ? reject(err) : resolve(msg))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return calMsg
|
||||
},
|
||||
|
||||
async updateCalendarById(id, data) {
|
||||
const tipId = await this.resolveCurrentId(id)
|
||||
const rootId = await this.resolveRootId(id)
|
||||
const ssbClient = await openSsb()
|
||||
const userId = ssbClient.id
|
||||
const item = await new Promise((resolve, reject) => {
|
||||
ssbClient.get(tipId, (err, it) => err ? reject(err) : resolve(it))
|
||||
})
|
||||
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")
|
||||
if ((oldDec.author || item.content.author) !== userId) throw new Error("Not the author")
|
||||
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 : []),
|
||||
author: oldDec.author || userId,
|
||||
participants: oldDec.participants || [userId],
|
||||
invites: Array.isArray(oldDec.invites) ? oldDec.invites : [],
|
||||
...(item.content.tribeId ? { tribeId: item.content.tribeId } : {}),
|
||||
createdAt: oldDec.createdAt,
|
||||
updatedAt: new Date().toISOString(),
|
||||
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 tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
|
||||
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")
|
||||
const dec = await decryptIfTribe(item.content)
|
||||
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 }
|
||||
return new Promise((resolve, reject) => ssbClient.publish(tombstone, e => e ? reject(e) : resolve()))
|
||||
},
|
||||
|
||||
async joinCalendar(calendarId) {
|
||||
const tipId = await this.resolveCurrentId(calendarId)
|
||||
const rootId = await this.resolveRootId(calendarId)
|
||||
const ssbClient = await openSsb()
|
||||
const userId = ssbClient.id
|
||||
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")
|
||||
const dec = await decryptIfTribe(item.content)
|
||||
const dec = item.content.tribeId
|
||||
? await decryptIfTribe(item.content)
|
||||
: decryptCalendarRoot(item.content, rootId)
|
||||
assertReadable(dec, "Calendar")
|
||||
const participants = Array.isArray(dec.participants) ? dec.participants : []
|
||||
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 = {
|
||||
type: "calendar",
|
||||
title: dec.title || "",
|
||||
|
|
@ -265,12 +364,14 @@ module.exports = ({ cooler, pmModel, tribeCrypto, tribesModel }) => {
|
|||
tags: Array.isArray(dec.tags) ? dec.tags : [],
|
||||
author: dec.author,
|
||||
participants: [...participants, userId],
|
||||
invites: Array.isArray(dec.invites) ? dec.invites : [],
|
||||
...(item.content.tribeId ? { tribeId: item.content.tribeId } : {}),
|
||||
createdAt: dec.createdAt,
|
||||
updatedAt: new Date().toISOString(),
|
||||
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 tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
|
||||
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) {
|
||||
const tipId = await this.resolveCurrentId(calendarId)
|
||||
const rootId = await this.resolveRootId(calendarId)
|
||||
const ssbClient = await openSsb()
|
||||
const userId = ssbClient.id
|
||||
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")
|
||||
const dec = await decryptIfTribe(item.content)
|
||||
const dec = item.content.tribeId
|
||||
? await decryptIfTribe(item.content)
|
||||
: decryptCalendarRoot(item.content, rootId)
|
||||
assertReadable(dec, "Calendar")
|
||||
if ((dec.author || item.content.author) === userId) throw new Error("Author cannot leave")
|
||||
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 : [],
|
||||
author: dec.author,
|
||||
participants: participants.filter(p => p !== userId),
|
||||
invites: Array.isArray(dec.invites) ? dec.invites : [],
|
||||
...(item.content.tribeId ? { tribeId: item.content.tribeId } : {}),
|
||||
createdAt: dec.createdAt,
|
||||
updatedAt: new Date().toISOString(),
|
||||
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 tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
|
||||
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 (!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 dates = expandRecurrence(date, deadlineForExpansion, intervalWeekly, intervalMonthly, intervalYearly)
|
||||
const allMsgs = []
|
||||
for (const d of dates) {
|
||||
const hasInterval = hasAnyInterval(intervalWeekly, intervalMonthly, intervalYearly)
|
||||
const ruleDeadline = hasInterval ? (intervalDeadline || cal.deadline || "") : ""
|
||||
let dateContent = {
|
||||
type: "calendarDate",
|
||||
calendarId: rootId,
|
||||
date: d.toISOString(),
|
||||
date: new Date(date).toISOString(),
|
||||
label: safeText(label),
|
||||
author: userId,
|
||||
createdAt: new Date().toISOString(),
|
||||
...(intervalWeekly ? { intervalWeekly: true } : {}),
|
||||
...(intervalMonthly ? { intervalMonthly: true } : {}),
|
||||
...(intervalYearly ? { intervalYearly: true } : {}),
|
||||
...(ruleDeadline ? { intervalDeadline: ruleDeadline } : {}),
|
||||
...(cal.tribeId ? { tribeId: cal.tribeId } : {})
|
||||
}
|
||||
dateContent = await encryptIfTribe(dateContent)
|
||||
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))
|
||||
})
|
||||
allMsgs.push(msg)
|
||||
}
|
||||
return allMsgs
|
||||
return [msg]
|
||||
},
|
||||
|
||||
async getDatesForCalendar(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 messages = await readAll(ssbClient)
|
||||
const authorByKey = new Map()
|
||||
|
|
@ -411,14 +520,23 @@ module.exports = ({ cooler, pmModel, tribeCrypto, tribesModel }) => {
|
|||
dec = r && !r._undecryptable ? r : c
|
||||
if (r && r._undecryptable) continue
|
||||
}
|
||||
dates.push({
|
||||
const baseEntry = {
|
||||
key: m.key,
|
||||
calendarId: dec.calendarId || c.calendarId,
|
||||
date: dec.date,
|
||||
label: dec.label || "",
|
||||
author: dec.author || v.author,
|
||||
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))
|
||||
return dates
|
||||
|
|
@ -487,7 +605,8 @@ module.exports = ({ cooler, pmModel, tribeCrypto, tribesModel }) => {
|
|||
createdAt: new Date().toISOString(),
|
||||
...(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) => {
|
||||
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) {
|
||||
const c = (m.value || {}).content
|
||||
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()
|
||||
|
|
@ -569,6 +689,7 @@ module.exports = ({ cooler, pmModel, tribeCrypto, tribesModel }) => {
|
|||
}
|
||||
}
|
||||
|
||||
const calendarDeadlines = new Map()
|
||||
const dueByCalendar = new Map()
|
||||
for (const m of messages) {
|
||||
if (tombstoned.has(m.key)) continue
|
||||
|
|
@ -581,21 +702,42 @@ module.exports = ({ cooler, pmModel, tribeCrypto, tribesModel }) => {
|
|||
if (!r || r._undecryptable) continue
|
||||
dec = r
|
||||
}
|
||||
if (!dec.date || new Date(dec.date).getTime() > now) continue
|
||||
if (sentMarkers.has(`${c.calendarId}::${m.key}`)) continue
|
||||
const entry = { key: m.key, calendarId: c.calendarId, date: dec.date, label: dec.label || "" }
|
||||
const list = dueByCalendar.get(c.calendarId) || []
|
||||
if (!dec.date) continue
|
||||
const calId = c.calendarId
|
||||
let calDeadline = calendarDeadlines.get(calId)
|
||||
if (calDeadline === undefined) {
|
||||
try {
|
||||
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(c.calendarId, list)
|
||||
dueByCalendar.set(calId, list)
|
||||
}
|
||||
}
|
||||
|
||||
const publishMarker = (calendarId, dateId) => new Promise((resolve, reject) => {
|
||||
ssbClient.publish({
|
||||
const publishMarker = (calendarId, dateId, occurrence) => new Promise((resolve, reject) => {
|
||||
const payload = {
|
||||
type: "calendarReminderSent",
|
||||
calendarId,
|
||||
dateId,
|
||||
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()) {
|
||||
|
|
@ -623,10 +765,134 @@ module.exports = ({ cooler, pmModel, tribeCrypto, tribesModel }) => {
|
|||
}
|
||||
}
|
||||
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 (_) {}
|
||||
}
|
||||
},
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -192,18 +192,47 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
|
|||
...(tribeId ? { tribeId } : {})
|
||||
}
|
||||
|
||||
if (tribeCrypto && !tribeId) {
|
||||
if (!tribeCrypto) {
|
||||
return new Promise((resolve, reject) => {
|
||||
ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg))
|
||||
})
|
||||
}
|
||||
|
||||
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))
|
||||
})
|
||||
tribeCrypto.setKey(result.key, chatKey, 1)
|
||||
return result
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
ssbClient.publish(content, (err, msg) => err ? reject(err) : resolve(msg))
|
||||
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 } = {}) {
|
||||
|
|
@ -211,21 +240,22 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
|
|||
const ssbClient = await openSsb()
|
||||
const userId = ssbClient.id
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
ssbClient.get(tipId, (err, item) => {
|
||||
if (err || !item?.content) return reject(new Error("Chat not found"))
|
||||
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) return reject(new Error("Not the author"))
|
||||
if (!skipAuthorCheck && rawAuthor && rawAuthor !== userId) throw new Error("Not the author")
|
||||
|
||||
const rootId = tipId
|
||||
const messages = []
|
||||
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) return reject(new Error("Invalid chat"))
|
||||
if (!chat) throw new Error("Invalid chat")
|
||||
|
||||
const updated = {
|
||||
let updated = {
|
||||
type: "chat",
|
||||
replaces: tipId,
|
||||
title: data.title !== undefined ? safeText(data.title) : chat.title,
|
||||
|
|
@ -238,15 +268,34 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
|
|||
invites: data.invites !== undefined ? safeArr(data.invites) : chat.invites,
|
||||
author: chat.author,
|
||||
createdAt: chat.createdAt,
|
||||
updatedAt: new Date().toISOString()
|
||||
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) => {
|
||||
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))
|
||||
})
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
async deleteChatById(id) {
|
||||
|
|
@ -372,12 +421,17 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
|
|||
let invite = code
|
||||
|
||||
if (tribeCrypto) {
|
||||
const ekChain = tribeCrypto.encryptChainForInvite([chat.rootId], code)
|
||||
if (ekChain) {
|
||||
invite = { code, ekChain, 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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const invites = [...chat.invites, invite]
|
||||
await this.updateChatById(chatId, { invites, members: chat.members, status: chat.status, title: chat.title, description: chat.description, image: chat.image, category: chat.category, tags: chat.tags })
|
||||
|
|
@ -414,18 +468,51 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
|
|||
if (!matchedChat) throw new Error("Invalid or expired invite code")
|
||||
if (matchedChat.members.includes(userId)) throw new Error("Already a participant")
|
||||
|
||||
if (tribeCrypto && typeof matchedInvite === "object" && matchedInvite.ek) {
|
||||
const chatKey = tribeCrypto.decryptFromInvite(matchedInvite.ek, code)
|
||||
let chatKey = 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)
|
||||
}
|
||||
}
|
||||
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 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
|
||||
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 })
|
||||
|
||||
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
|
||||
},
|
||||
|
||||
|
|
@ -437,17 +524,12 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
|
|||
if (chat.status === "CLOSED") throw new Error("Chat is closed")
|
||||
if (chat.members.includes(userId)) return chat.key
|
||||
|
||||
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 (pub) return await this.joinByInvite(pub.code)
|
||||
}
|
||||
|
||||
const members = [...chat.members, userId]
|
||||
|
||||
if (tribeCrypto) {
|
||||
const chatKey = tribeCrypto.getKey(chat.rootId)
|
||||
if (chatKey && ssbClient.keys) {
|
||||
try {
|
||||
tribeCrypto.boxKeyForMember(chatKey, userId, ssbClient.keys)
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
},
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ const fs = require('fs/promises');
|
|||
const os = require('os');
|
||||
|
||||
const ssbRef = require("../server/node_modules/ssb-ref");
|
||||
const nameCache = require('../backend/nameCache');
|
||||
|
||||
const { getConfig } = require('../configs/config-manager.js');
|
||||
const logLimit = getConfig().ssbLogStream?.limit || 1000;
|
||||
|
|
@ -63,7 +64,7 @@ const publicOnlyFilter = pull.filter(isNotPrivate);
|
|||
const configure = (...customOptions) =>
|
||||
Object.assign({}, defaultOptions, ...customOptions);
|
||||
|
||||
// peers
|
||||
// PEERS
|
||||
const ebtDir = path.join(os.homedir(), '.ssb', 'ebt');
|
||||
const unfollowedPath = path.join(os.homedir(), '.ssb', 'gossip_unfollowed.json');
|
||||
|
||||
|
|
@ -117,13 +118,10 @@ const canonicalizePubId = (s) => {
|
|||
};
|
||||
|
||||
const parseRemote = (remote) => {
|
||||
// net: format (TCP)
|
||||
let m = /^net:([^:]+):\d+~shs:([^=]+)=/.exec(remote);
|
||||
if (m) return { host: m[1], pubId: canonicalizePubId(m[2]) };
|
||||
// ws/wss format (WebSocket)
|
||||
m = /^wss?:\/\/([^:/]+)(?::\d+)?.*~shs:([^=]+)=/.exec(remote);
|
||||
if (m) return { host: m[1], pubId: canonicalizePubId(m[2]) };
|
||||
// Generic: extract ~shs: part from any format
|
||||
m = /~shs:([^=]+)=/.exec(remote);
|
||||
if (m) return { host: null, pubId: canonicalizePubId(m[1]) };
|
||||
return { host: null, pubId: null };
|
||||
|
|
@ -161,7 +159,7 @@ function toLegacyInvite(s) {
|
|||
return `${m[1]}:${m[2]}:@${key}~${m[4]}`;
|
||||
}
|
||||
|
||||
// core modules
|
||||
// CORE MODEL
|
||||
module.exports = ({ cooler, isPublic }) => {
|
||||
const models = {};
|
||||
const getAbout = async ({ key, feedId }) => {
|
||||
|
|
@ -303,12 +301,16 @@ models.about = {
|
|||
if (isPublic && (await models.about.publicWebHosting(feedId)) === false) {
|
||||
return "Redacted";
|
||||
}
|
||||
return (
|
||||
(await getAbout({
|
||||
key: "name",
|
||||
feedId,
|
||||
})) || feedId.slice(1, 1 + 8)
|
||||
);
|
||||
const resolved = await getAbout({ key: "name", feedId });
|
||||
if (resolved) nameCache.set(feedId, resolved, Date.now());
|
||||
return resolved || 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) => {
|
||||
let found = [];
|
||||
|
|
@ -397,9 +399,11 @@ models.about = {
|
|||
if (typeof currentEntry == "undefined") {
|
||||
dirty = true;
|
||||
feeds_to_name[feed] = newEntry;
|
||||
nameCache.set(feed, name, ts);
|
||||
} else if (currentEntry.ts < ts) {
|
||||
dirty = true;
|
||||
feeds_to_name[feed] = newEntry;
|
||||
nameCache.set(feed, name, ts);
|
||||
}
|
||||
}, (err) => {
|
||||
console.error(err);
|
||||
|
|
@ -601,14 +605,97 @@ models.meta = {
|
|||
pull.take(1),
|
||||
pull.collect((err, [entries]) => {
|
||||
if (err) return reject(err);
|
||||
resolve(entries);
|
||||
resolve(entries || []);
|
||||
})
|
||||
);
|
||||
});
|
||||
},
|
||||
connectedPeers: async () => {
|
||||
const peers = await models.meta.peers();
|
||||
return peers.filter(([_, data]) => data.state === "connected");
|
||||
const ssb = await cooler.open();
|
||||
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 () => {
|
||||
const entries = await models.meta.connectedPeers();
|
||||
|
|
@ -617,7 +704,6 @@ models.meta = {
|
|||
discovered: async () => {
|
||||
const ssb = await cooler.open();
|
||||
const snapshot = await ssb.conn.dbPeers();
|
||||
// Read gossip.json to merge announcers data
|
||||
const gossipPath = path.join(os.homedir(), '.ssb', 'gossip.json');
|
||||
let gossipMap = new Map();
|
||||
try {
|
||||
|
|
@ -629,7 +715,6 @@ models.meta = {
|
|||
}
|
||||
} catch {}
|
||||
const allDbPeers = await enrichEntries(snapshot);
|
||||
// Merge announcers from gossip.json into enriched peers
|
||||
for (const [, peerData] of allDbPeers) {
|
||||
if ((!peerData.announcers || peerData.announcers === 0) && gossipMap.has(peerData.key)) {
|
||||
const gossipEntry = gossipMap.get(peerData.key);
|
||||
|
|
@ -637,15 +722,13 @@ models.meta = {
|
|||
}
|
||||
}
|
||||
const connectedEntries = await models.meta.connectedPeers();
|
||||
const onlineKeys = new Set(connectedEntries.map(([remote]) => {
|
||||
const m = /~shs:([^=]+)=/.exec(remote);
|
||||
if (!m) return null;
|
||||
let core = m[1].replace(/-/g, '+').replace(/_/g, '/');
|
||||
if (!core.endsWith('=')) core += '=';
|
||||
return `@${core}.ed25519`;
|
||||
}).filter(Boolean));
|
||||
const discoveredPeers = allDbPeers.filter(([, d]) => !onlineKeys.has(d.key));
|
||||
const discoveredIds = new Set(allDbPeers.map(([, d]) => d.key));
|
||||
const onlineKeys = new Set(
|
||||
connectedEntries
|
||||
.map(([, d]) => d && d.key ? canonicalizePubId(d.key) : null)
|
||||
.filter(Boolean)
|
||||
);
|
||||
const discoveredPeers = allDbPeers.filter(([, d]) => !onlineKeys.has(canonicalizePubId(d.key)));
|
||||
const discoveredIds = new Set(allDbPeers.map(([, d]) => canonicalizePubId(d.key)));
|
||||
const ebtList = await loadPeersFromEbt();
|
||||
const ebtMap = new Map(ebtList.map(e => [e.pub, e.users]));
|
||||
const unknownPeers = [];
|
||||
|
|
@ -723,9 +806,7 @@ models.meta = {
|
|||
const ssb = await cooler.open();
|
||||
const code = toLegacyInvite(String(invite || ''));
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
ssb.invite.accept(code, (err, res) => {
|
||||
err ? reject(err) : resolve(res);
|
||||
});
|
||||
ssb.invite.accept(code, (err, res) => err ? reject(err) : resolve(res));
|
||||
});
|
||||
const { host, pubId } = parseRemote(code);
|
||||
await new Promise((resolve) => {
|
||||
|
|
@ -734,7 +815,7 @@ models.meta = {
|
|||
pubHost: host || null,
|
||||
pubKey: pubId || null,
|
||||
acceptedAt: new Date().toISOString(),
|
||||
}, (err) => resolve());
|
||||
}, (_err) => resolve());
|
||||
});
|
||||
return result;
|
||||
},
|
||||
|
|
@ -1954,7 +2035,5 @@ models.vote = {
|
|||
});
|
||||
},
|
||||
};
|
||||
|
||||
//return models
|
||||
return models;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
const pull = require("../server/node_modules/pull-stream");
|
||||
const crypto = require("crypto");
|
||||
const { getConfig } = require("../configs/config-manager.js");
|
||||
|
||||
const logLimit = getConfig().ssbLogStream?.limit || 1000;
|
||||
const INVITE_CODE_BYTES = 16;
|
||||
|
||||
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 decryptIfTribe = tribeHelpers ? tribeHelpers.decryptIfTribe : async (c) => c;
|
||||
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) =>
|
||||
new Promise((resolve, reject) => {
|
||||
|
|
@ -157,6 +199,8 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
|
|||
mapType: ALLOWED_MAP_TYPES.has(c.mapType) ? c.mapType : "SINGLE",
|
||||
tags: safeArr(c.tags),
|
||||
author: c.author,
|
||||
members: Array.isArray(c.members) ? c.members : [],
|
||||
invites: Array.isArray(c.invites) ? c.invites : [],
|
||||
tribeId: c.tribeId || null,
|
||||
encrypted: !!undec,
|
||||
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) {
|
||||
const ssbClient = await openSsb();
|
||||
const userId = ssbClient.id;
|
||||
const tags = normalizeTags(tagsRaw) || [];
|
||||
const now = new Date().toISOString();
|
||||
const mType = ALLOWED_MAP_TYPES.has(mapType) ? mapType : "SINGLE";
|
||||
|
||||
let content = {
|
||||
let plainContent = {
|
||||
type: "map",
|
||||
title: title || "",
|
||||
lat: parseFloat(lat) || 0,
|
||||
|
|
@ -207,7 +252,9 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
|
|||
description: description || "",
|
||||
markerLabel: markerLabel || "",
|
||||
mapType: mType,
|
||||
author: ssbClient.id,
|
||||
author: userId,
|
||||
members: [userId],
|
||||
invites: [],
|
||||
tags,
|
||||
...(tribeId ? { tribeId } : {}),
|
||||
...(image ? { image } : {}),
|
||||
|
|
@ -215,21 +262,71 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
|
|||
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)));
|
||||
});
|
||||
|
||||
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) {
|
||||
const ssbClient = await openSsb();
|
||||
const userId = ssbClient.id;
|
||||
const tipId = await this.resolveCurrentId(id);
|
||||
const rootId = await this.resolveRootId(id);
|
||||
const oldMsg = await getMsg(ssbClient, tipId);
|
||||
|
||||
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");
|
||||
if ((oldDecrypted.author || oldMsg.content.author) !== userId) throw new Error("Not the author");
|
||||
|
||||
|
|
@ -248,13 +345,19 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
|
|||
mapType: mType,
|
||||
tags,
|
||||
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 } : {}),
|
||||
...(image ? { image } : (oldDecrypted.image ? { image: oldDecrypted.image } : {})),
|
||||
createdAt: oldDecrypted.createdAt,
|
||||
updatedAt: now
|
||||
};
|
||||
|
||||
if (oldMsg.content.tribeId) {
|
||||
updated = await encryptIfTribe(updated);
|
||||
} else if (mType !== "SINGLE") {
|
||||
updated = encryptStandalone(updated, rootId);
|
||||
}
|
||||
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
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");
|
||||
|
||||
const now = new Date().toISOString();
|
||||
let rootId = tipId;
|
||||
while (idx.backward && idx.backward.has(rootId)) rootId = idx.backward.get(rootId);
|
||||
let content = {
|
||||
type: "mapMarker",
|
||||
mapId: tipId,
|
||||
mapId: rootId,
|
||||
lat: parseFloat(lat) || 0,
|
||||
lng: parseFloat(lng) || 0,
|
||||
label: label || "",
|
||||
|
|
@ -315,7 +420,12 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
|
|||
};
|
||||
if (image) content.image = image;
|
||||
|
||||
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) => {
|
||||
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)));
|
||||
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");
|
||||
}
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -20,16 +20,41 @@ module.exports = ({ cooler, cipherModel, tribeCrypto, tribesModel }) => {
|
|||
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb }
|
||||
|
||||
let keyringPath = null
|
||||
const getKeyring = () => {
|
||||
let migratedToTribeCrypto = false
|
||||
const getLegacyKeyringPath = () => {
|
||||
if (!keyringPath) {
|
||||
const ssbConfig = require("../server/node_modules/ssb-config/inject")()
|
||||
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 key = Buffer.from(keyHex, "hex")
|
||||
|
|
@ -225,6 +250,13 @@ module.exports = ({ cooler, cipherModel, tribeCrypto, tribesModel }) => {
|
|||
if (!keyHex) keyHex = crypto.randomBytes(32).toString("hex")
|
||||
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 = {
|
||||
type: "pad",
|
||||
title: enc(safeText(title)),
|
||||
|
|
@ -233,17 +265,28 @@ module.exports = ({ cooler, cipherModel, tribeCrypto, tribesModel }) => {
|
|||
tags: enc(normalizeTags(tagsRaw).join(",")),
|
||||
author: ssbClient.id,
|
||||
members: [ssbClient.id],
|
||||
invites: [],
|
||||
invites: initialInvites,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
encrypted: true,
|
||||
...(tribeId ? { tribeId } : {})
|
||||
}
|
||||
|
||||
const userId = ssbClient.id
|
||||
return new Promise((resolve, reject) => {
|
||||
ssbClient.publish(content, (err, msg) => {
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
|
@ -452,13 +495,31 @@ module.exports = ({ cooler, cipherModel, tribeCrypto, tribesModel }) => {
|
|||
}
|
||||
if (!matchedPad) throw new Error("Invalid or expired invite code")
|
||||
if (matchedPad.members.includes(userId)) throw new Error("Already a member")
|
||||
let padKey = null
|
||||
let resolvedRootId = null
|
||||
if (typeof matchedInvite === "object" && matchedInvite.ek) {
|
||||
const padKey = decryptFromInvite(matchedInvite.ek, code)
|
||||
const rootId = await this.resolveRootId(matchedPad.rootId)
|
||||
setPadKey(rootId, padKey)
|
||||
padKey = decryptFromInvite(matchedInvite.ek, code)
|
||||
resolvedRootId = await this.resolveRootId(matchedPad.rootId)
|
||||
setPadKey(resolvedRootId, padKey)
|
||||
}
|
||||
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
|
||||
return inv.code !== code
|
||||
})
|
||||
|
|
|
|||
|
|
@ -3,13 +3,38 @@ const moment = require('../server/node_modules/moment');
|
|||
const { getConfig } = require('../configs/config-manager.js');
|
||||
const logLimit = getConfig().ssbLogStream?.limit || 1000;
|
||||
|
||||
module.exports = ({ cooler, padsModel }) => {
|
||||
module.exports = ({ cooler, padsModel, tribeCrypto, tribesModel }) => {
|
||||
let ssb;
|
||||
const openSsb = async () => {
|
||||
if (!ssb) ssb = await cooler.open();
|
||||
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 = [
|
||||
'post', 'about', 'curriculum', 'tribe', 'transfer', 'feed',
|
||||
'votes', 'report', 'task', 'event', 'bookmark', 'document',
|
||||
|
|
@ -250,6 +275,7 @@ module.exports = ({ cooler, padsModel }) => {
|
|||
|
||||
const search = async ({ query, types = [], resultsPerPage = "10" }) => {
|
||||
const ssbClient = await openSsb();
|
||||
const viewerId = ssbClient.id;
|
||||
const queryLower = String(query || '').toLowerCase();
|
||||
|
||||
const messages = await new Promise((res, rej) => {
|
||||
|
|
@ -277,6 +303,39 @@ module.exports = ({ cooler, padsModel }) => {
|
|||
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) {
|
||||
for (const msg of latestByKey.values()) {
|
||||
const c = msg?.value?.content;
|
||||
|
|
|
|||
|
|
@ -319,17 +319,17 @@ module.exports = ({ cooler }) => {
|
|||
|
||||
const allTribesPublic = tribeDedupNodes
|
||||
.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 memberTribesDetailed = tribeDedupNodes
|
||||
.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
|
||||
.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 opinions = {};
|
||||
|
|
@ -446,6 +446,40 @@ module.exports = ({ cooler }) => {
|
|||
.slice(0, 5)
|
||||
.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 myAddress = addrMap[userId] || null;
|
||||
const banking = {
|
||||
|
|
@ -507,8 +541,19 @@ module.exports = ({ cooler }) => {
|
|||
},
|
||||
tombstoneKPIs: {
|
||||
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
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -130,23 +130,23 @@ module.exports = ({ cooler, padsModel, tribesModel }) => {
|
|||
|
||||
for (const oldId of replacesMap.keys()) latestByKey.delete(oldId);
|
||||
|
||||
const anonTribeIds = new Set();
|
||||
const viewerId = ssbClient.id;
|
||||
const viewerVisibleTribeIds = new Set();
|
||||
if (tribesModel) {
|
||||
const allTribes = await tribesModel.listAll().catch(() => []);
|
||||
for (const tribe of allTribes) {
|
||||
if (tribe.isAnonymous === true) anonTribeIds.add(tribe.id);
|
||||
}
|
||||
const visibleTribes = await tribesModel.listTribesForViewer(viewerId).catch(() => []);
|
||||
for (const tribe of visibleTribes) viewerVisibleTribeIds.add(tribe.id);
|
||||
}
|
||||
|
||||
let filtered = Array.from(latestByKey.values()).filter(msg => {
|
||||
const c = msg?.value?.content;
|
||||
if (!c || c.type === 'tombstone') 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 (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 === '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;
|
||||
return true;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -13,10 +13,12 @@ const ENVELOPE_PRESERVE = new Set([
|
|||
'type', 'tribeId', 'contentType', 'replaces', 'target', 'author',
|
||||
'createdAt', 'updatedAt', 'encryptedPayload',
|
||||
'mapId', 'calendarId', 'dateId', 'padId', 'roomId', 'parentId',
|
||||
'members', 'invites', 'participants',
|
||||
'_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) => {
|
||||
const keyringPath = path.join(configPath, 'tribe-keys.json');
|
||||
|
|
@ -25,6 +27,7 @@ module.exports = (configPath) => {
|
|||
const loadKeyring = () => {
|
||||
try {
|
||||
keyring = JSON.parse(fs.readFileSync(keyringPath, 'utf8'));
|
||||
try { fs.chmodSync(keyringPath, 0o600); } catch (_) {}
|
||||
} catch (e) {
|
||||
if (e.code !== 'ENOENT') throw e;
|
||||
keyring = {};
|
||||
|
|
@ -34,8 +37,9 @@ module.exports = (configPath) => {
|
|||
|
||||
const saveKeyring = () => {
|
||||
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);
|
||||
try { fs.chmodSync(keyringPath, 0o600); } catch (_) {}
|
||||
};
|
||||
|
||||
const generateTribeKey = () => crypto.randomBytes(32).toString('hex');
|
||||
|
|
@ -60,8 +64,30 @@ module.exports = (configPath) => {
|
|||
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 entry = keyring[tribeRootId] || { keys: [], gen: 0 };
|
||||
if (entry.keys.includes(newKeyHex)) return entry.gen;
|
||||
entry.keys.unshift(newKeyHex);
|
||||
entry.gen = (entry.gen || 0) + 1;
|
||||
keyring[tribeRootId] = entry;
|
||||
|
|
@ -69,65 +95,110 @@ module.exports = (configPath) => {
|
|||
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 iv = crypto.randomBytes(12);
|
||||
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 authTag = cipher.getAuthTag();
|
||||
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 iv = Buffer.from(encrypted.slice(0, 24), 'hex');
|
||||
const authTag = Buffer.from(encrypted.slice(24, 56), 'hex');
|
||||
const ciphertext = Buffer.from(encrypted.slice(56), 'hex');
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
|
||||
if (aad) decipher.setAAD(aad);
|
||||
decipher.setAuthTag(authTag);
|
||||
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8');
|
||||
};
|
||||
|
||||
const encryptForInvite = (tribeKeyHex, inviteCode) => {
|
||||
const derived = crypto.scryptSync(inviteCode, INVITE_SALT, 32);
|
||||
const generateInviteSalt = () => crypto.randomBytes(16).toString('hex');
|
||||
|
||||
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'));
|
||||
};
|
||||
|
||||
const decryptFromInvite = (encryptedKey, inviteCode) => {
|
||||
const derived = crypto.scryptSync(inviteCode, INVITE_SALT, 32);
|
||||
const decryptFromInvite = (encryptedKey, inviteCode, salt) => {
|
||||
const derived = deriveInviteKey(inviteCode, salt);
|
||||
return decryptWithKey(encryptedKey, derived.toString('hex'));
|
||||
};
|
||||
|
||||
const encryptChainForInvite = (ancestryRootIds, inviteCode) => {
|
||||
const chain = ancestryRootIds.map(rootId => ({ rootId, key: getKey(rootId), gen: getGen(rootId) }));
|
||||
if (chain.some(e => !e.key)) return null;
|
||||
const derived = crypto.scryptSync(inviteCode, INVITE_SALT, 32);
|
||||
const encryptChainForInvite = (ancestryRootIds, inviteCode, salt) => {
|
||||
const chain = ancestryRootIds.map(rootId => ({
|
||||
rootId,
|
||||
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'));
|
||||
};
|
||||
|
||||
const decryptChainFromInvite = (encryptedPayload, inviteCode) => {
|
||||
const derived = crypto.scryptSync(inviteCode, INVITE_SALT, 32);
|
||||
const decryptChainFromInvite = (encryptedPayload, inviteCode, salt) => {
|
||||
const derived = deriveInviteKey(inviteCode, salt);
|
||||
try {
|
||||
const json = decryptWithKey(encryptedPayload, derived.toString('hex'));
|
||||
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 (_) {}
|
||||
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;
|
||||
for (const keyHex of keyChain) {
|
||||
data = encryptWithKey(data, keyHex);
|
||||
const last = keyChain.length - 1;
|
||||
for (let i = 0; i < keyChain.length; i++) {
|
||||
data = encryptWithKey(data, keyChain[i], i === last ? aad : undefined);
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
const decryptChain = (encrypted, keyChain) => {
|
||||
const decryptChain = (encrypted, keyChain, aad) => {
|
||||
const reversed = [...keyChain].reverse();
|
||||
let data = encrypted;
|
||||
for (const keyHex of reversed) {
|
||||
data = decryptWithKey(data, keyHex);
|
||||
for (let i = 0; i < reversed.length; i++) {
|
||||
data = decryptWithKey(data, reversed[i], i === 0 ? aad : undefined);
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
|
@ -145,20 +216,32 @@ module.exports = (configPath) => {
|
|||
}
|
||||
}
|
||||
const plaintext = JSON.stringify(payload);
|
||||
const encryptedPayload = encryptChain(plaintext, keyChain);
|
||||
const result = {};
|
||||
for (const [k, v] of Object.entries(content)) {
|
||||
if (customFields ? ENVELOPE_PRESERVE.has(k) : !SENSITIVE_FIELDS.includes(k)) {
|
||||
result[k] = v;
|
||||
}
|
||||
}
|
||||
const aad = canonicalAad(result);
|
||||
const encryptedPayload = encryptChain(plaintext, keyChain, aad);
|
||||
result.encryptedPayload = encryptedPayload;
|
||||
return result;
|
||||
};
|
||||
|
||||
const decryptContent = (content, keyChainSets) => {
|
||||
if (!content.encryptedPayload) return content;
|
||||
const envelope = { ...content };
|
||||
delete envelope.encryptedPayload;
|
||||
const aad = canonicalAad(envelope);
|
||||
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 {
|
||||
const plaintext = decryptChain(content.encryptedPayload, keyChain);
|
||||
const payload = JSON.parse(plaintext);
|
||||
|
|
@ -229,10 +312,31 @@ module.exports = (configPath) => {
|
|||
const decryptFromTribe = async (content, tribesModel) => {
|
||||
if (!content || !content.encryptedPayload) return content;
|
||||
const tid = content.tribeId;
|
||||
if (!tid) return content;
|
||||
const sets = await resolveKeyChainSets(tid, tribesModel);
|
||||
if (!sets || !sets.length) return { ...content, _undecryptable: true };
|
||||
return decryptContent(content, sets);
|
||||
if (tid) {
|
||||
let sets = null;
|
||||
try { sets = await resolveKeyChainSets(tid, tribesModel); } catch (_) {}
|
||||
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) => ({
|
||||
|
|
@ -267,10 +371,11 @@ module.exports = (configPath) => {
|
|||
SENSITIVE_FIELDS,
|
||||
ENVELOPE_PRESERVE,
|
||||
loadKeyring, saveKeyring,
|
||||
generateTribeKey, getKey, getKeys, getGen, setKey, addNewKey,
|
||||
generateTribeKey, getKey, getKeys, getGen, setKey, setKeys, mergeKeys, addNewKey,
|
||||
encryptWithKey, decryptWithKey,
|
||||
encryptForInvite, decryptFromInvite,
|
||||
encryptChainForInvite, decryptChainFromInvite,
|
||||
generateInviteSalt, hashInviteCode, inviteMatchesCode,
|
||||
encryptChain, decryptChain,
|
||||
encryptContent, decryptContent,
|
||||
boxKeyForMember, unboxKeyFromMember,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
const pull = require('../server/node_modules/pull-stream');
|
||||
const { getConfig } = require('../configs/config-manager.js');
|
||||
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 categories = require('../backend/opinion_categories');
|
||||
|
|
@ -30,7 +31,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
|
|||
const ssbClient = await openSsb();
|
||||
return new Promise((resolve, reject) =>
|
||||
pull(
|
||||
ssbClient.createLogStream({ limit: logLimit }),
|
||||
ssbClient.createLogStream({ limit: tribeLogLimit }),
|
||||
pull.collect((err, msgs) => err ? reject(err) : resolve(msgs))
|
||||
)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ const pull = require('../server/node_modules/pull-stream');
|
|||
const crypto = require('crypto');
|
||||
const { getConfig } = require('../configs/config-manager.js');
|
||||
const logLimit = getConfig().ssbLogStream?.limit || 1000;
|
||||
const tribeLogLimit = Math.max(logLimit, 100000);
|
||||
|
||||
const INVITE_CODE_BYTES = 16;
|
||||
const VALID_INVITE_MODES = ['strict', 'open'];
|
||||
|
|
@ -57,7 +58,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
|
|||
const client = await openSsb();
|
||||
return new Promise((resolve, reject) => {
|
||||
pull(
|
||||
client.createLogStream({ limit: logLimit }),
|
||||
client.createLogStream({ limit: tribeLogLimit }),
|
||||
pull.collect((err, msgs) => {
|
||||
if (err) return reject(err);
|
||||
const tombstones = new Map();
|
||||
|
|
@ -88,6 +89,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
|
|||
let progress = true;
|
||||
while (progress) {
|
||||
progress = false;
|
||||
const candidatesByReplaces = new Map();
|
||||
for (const [k, entry] of tribeMsgs.entries()) {
|
||||
if (tribes.has(k)) continue;
|
||||
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 (!structuralFieldsEqual(parentEntry.content, entry.content)) continue;
|
||||
}
|
||||
parent.set(k, replaces);
|
||||
child.set(replaces, k);
|
||||
tribes.set(k, entry);
|
||||
rootByTip.set(k, root);
|
||||
if (!candidatesByReplaces.has(replaces)) candidatesByReplaces.set(replaces, []);
|
||||
candidatesByReplaces.get(replaces).push({ k, entry, isRootAuthor, 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -129,7 +146,25 @@ module.exports = ({ cooler, tribeCrypto }) => {
|
|||
const tip = tipOf(root);
|
||||
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();
|
||||
resolve(tribeIndex);
|
||||
})
|
||||
|
|
@ -196,8 +231,16 @@ module.exports = ({ cooler, tribeCrypto }) => {
|
|||
if (tribeCrypto) {
|
||||
const ancestryIds = await this.getAncestryChain(tribeId).catch(() => null);
|
||||
if (Array.isArray(ancestryIds) && ancestryIds.length) {
|
||||
const ekChain = tribeCrypto.encryptChainForInvite(ancestryIds, code);
|
||||
if (ekChain) invite = { code, ekChain, gen: tribeCrypto.getGen(ancestryIds[0]) };
|
||||
const salt = tribeCrypto.generateInviteSalt();
|
||||
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];
|
||||
|
|
@ -236,10 +279,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
|
|||
for (const t of tribes) {
|
||||
if (!t.invites) continue;
|
||||
for (const inv of t.invites) {
|
||||
if (typeof inv === 'string' && inv === code) {
|
||||
matchedTribe = t; matchedInvite = inv; break;
|
||||
}
|
||||
if (typeof inv === 'object' && inv.code === code) {
|
||||
if (tribeCrypto ? tribeCrypto.inviteMatchesCode(inv, code) : (inv === code || (inv && inv.code === code))) {
|
||||
matchedTribe = t; matchedInvite = inv; break;
|
||||
}
|
||||
}
|
||||
|
|
@ -253,16 +293,23 @@ module.exports = ({ cooler, tribeCrypto }) => {
|
|||
let storedGen = 1;
|
||||
let storedRootId = null;
|
||||
if (tribeCrypto && typeof matchedInvite === 'object') {
|
||||
const salt = matchedInvite.salt;
|
||||
if (matchedInvite.ekChain) {
|
||||
const chain = tribeCrypto.decryptChainFromInvite(matchedInvite.ekChain, code);
|
||||
const chain = tribeCrypto.decryptChainFromInvite(matchedInvite.ekChain, code, salt);
|
||||
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;
|
||||
storedTribeKey = chain[0].key;
|
||||
storedGen = chain[0].gen || 1;
|
||||
}
|
||||
} else if (matchedInvite.ek) {
|
||||
storedTribeKey = tribeCrypto.decryptFromInvite(matchedInvite.ek, code);
|
||||
storedTribeKey = tribeCrypto.decryptFromInvite(matchedInvite.ek, code, salt);
|
||||
storedRootId = await this.getRootId(matchedTribe.id);
|
||||
storedGen = matchedInvite.gen || 1;
|
||||
tribeCrypto.setKey(storedRootId, storedTribeKey, storedGen);
|
||||
|
|
@ -270,8 +317,9 @@ module.exports = ({ cooler, tribeCrypto }) => {
|
|||
}
|
||||
const members = [...matchedTribe.members, userId];
|
||||
const invites = matchedTribe.invites.filter(inv => {
|
||||
if (tribeCrypto) return !tribeCrypto.inviteMatchesCode(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 =>
|
||||
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 currentKey = tribeCrypto.getKey(rootId);
|
||||
if (!currentKey) return;
|
||||
const allKeys = tribeCrypto.getKeys(rootId);
|
||||
const gen = tribeCrypto.getGen(rootId);
|
||||
const payload = JSON.stringify({ keys: allKeys, gen });
|
||||
const memberKeys = {};
|
||||
const memberKeysFull = {};
|
||||
for (const memberId of toMembers) {
|
||||
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) => {
|
||||
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(() => {});
|
||||
},
|
||||
|
|
@ -341,7 +393,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
|
|||
if (!currentKey) return;
|
||||
const gen = tribeCrypto.getGen(rootId);
|
||||
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();
|
||||
for (const m of msgs) {
|
||||
|
|
@ -386,10 +438,10 @@ module.exports = ({ cooler, tribeCrypto }) => {
|
|||
},
|
||||
|
||||
async getTribeById(tribeId) {
|
||||
const { tribes, tombstoned, child } = await buildTribeIndex();
|
||||
const { tribes, tombstoned, effectivelyTombstoned, child } = await buildTribeIndex();
|
||||
let latestId = tribeId;
|
||||
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);
|
||||
if (!tribe) throw new Error('Tribe not found');
|
||||
return {
|
||||
|
|
@ -403,6 +455,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
|
|||
isAnonymous: tribe.content.isAnonymous,
|
||||
members: Array.isArray(tribe.content.members) ? tribe.content.members : [],
|
||||
invites: Array.isArray(tribe.content.invites) ? tribe.content.invites : [],
|
||||
inviteLog: Array.isArray(tribe.content.inviteLog) ? tribe.content.inviteLog : [],
|
||||
inviteMode: tribe.content.inviteMode || 'strict',
|
||||
status: tribe.content.status || 'OPEN',
|
||||
parentTribeId: tribe.content.parentTribeId || null,
|
||||
|
|
@ -410,12 +463,11 @@ module.exports = ({ cooler, tribeCrypto }) => {
|
|||
createdAt: tribe.content.createdAt,
|
||||
updatedAt: tribe.content.updatedAt,
|
||||
author: tribe.content.author,
|
||||
inviteLog: Array.isArray(tribe.content.inviteLog) ? tribe.content.inviteLog : [],
|
||||
};
|
||||
},
|
||||
|
||||
async listAll() {
|
||||
const { tribes, tombstoned, tipByRoot, rootByTip } = await buildTribeIndex();
|
||||
const { tribes, tombstoned, effectivelyTombstoned, tipByRoot, rootByTip } = await buildTribeIndex();
|
||||
const resolveParent = (pid) => {
|
||||
if (!pid) return null;
|
||||
const root = rootByTip.get(pid) || pid;
|
||||
|
|
@ -424,6 +476,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
|
|||
const items = [];
|
||||
for (const [root, tip] of tipByRoot) {
|
||||
if (tombstoned.has(root) || tombstoned.has(tip)) continue;
|
||||
if (effectivelyTombstoned.has(root) || effectivelyTombstoned.has(tip)) continue;
|
||||
const entry = tribes.get(tip);
|
||||
if (!entry) continue;
|
||||
const c = entry.content;
|
||||
|
|
@ -438,6 +491,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
|
|||
isAnonymous: c.isAnonymous !== false,
|
||||
members: Array.isArray(c.members) ? c.members : [],
|
||||
invites: Array.isArray(c.invites) ? c.invites : [],
|
||||
inviteLog: Array.isArray(c.inviteLog) ? c.inviteLog : [],
|
||||
inviteMode: c.inviteMode || 'strict',
|
||||
status: c.status || 'OPEN',
|
||||
parentTribeId: resolveParent(c.parentTribeId),
|
||||
|
|
@ -445,7 +499,6 @@ module.exports = ({ cooler, tribeCrypto }) => {
|
|||
createdAt: c.createdAt,
|
||||
updatedAt: c.updatedAt,
|
||||
author: c.author,
|
||||
inviteLog: Array.isArray(c.inviteLog) ? c.inviteLog : [],
|
||||
_ts: entry._ts
|
||||
});
|
||||
}
|
||||
|
|
@ -495,31 +548,38 @@ module.exports = ({ cooler, tribeCrypto }) => {
|
|||
if (!oldKey) return;
|
||||
const newKey = tribeCrypto.generateTribeKey();
|
||||
const newGen = tribeCrypto.addNewKey(rootId, newKey);
|
||||
const allKeys = tribeCrypto.getKeys(rootId);
|
||||
const fullPayload = JSON.stringify({ keys: allKeys, gen: newGen });
|
||||
const memberKeys = {};
|
||||
const memberKeysFull = {};
|
||||
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 BATCH_SIZE = 20;
|
||||
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) => {
|
||||
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));
|
||||
});
|
||||
}
|
||||
const tribe = await this.getTribeById(tribeId);
|
||||
if (Array.isArray(tribe.invites) && tribe.invites.length > 0) {
|
||||
const ancestryIds = await this.getAncestryChain(tribeId).catch(() => [rootId]);
|
||||
const updatedInvites = tribe.invites.map(inv => {
|
||||
if (typeof inv === 'object' && inv.code) {
|
||||
const ekChain = tribeCrypto.encryptChainForInvite(ancestryIds, inv.code);
|
||||
if (ekChain) return { code: inv.code, ekChain, gen: newGen };
|
||||
return { code: inv.code, ek: tribeCrypto.encryptForInvite(newKey, inv.code), gen: newGen };
|
||||
}
|
||||
return inv;
|
||||
const survivingInvites = tribe.invites.map(inv => {
|
||||
if (typeof inv === 'string') return inv;
|
||||
if (!inv || typeof inv !== 'object') return inv;
|
||||
const next = { ...inv, gen: newGen };
|
||||
delete next.ekChain;
|
||||
delete next.ek;
|
||||
return next;
|
||||
});
|
||||
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 msgs = await new Promise((resolve, reject) => {
|
||||
pull(
|
||||
ssb.createLogStream({ limit: logLimit }),
|
||||
ssb.createLogStream({ limit: tribeLogLimit }),
|
||||
pull.collect((err, msgs) => err ? reject(err) : resolve(msgs))
|
||||
);
|
||||
});
|
||||
const byTribe = new Map();
|
||||
for (const m of msgs) {
|
||||
const c = m.value?.content;
|
||||
if (!c || c.type !== 'tribe-keys') continue;
|
||||
const myEntry = c.memberKeys && c.memberKeys[ssb.id];
|
||||
if (!myEntry) continue;
|
||||
const currentGen = tribeCrypto.getGen(c.tribeId);
|
||||
if (c.generation <= currentGen) continue;
|
||||
const newKey = tribeCrypto.unboxKeyFromMember(myEntry, config.keys, ssbKeys);
|
||||
if (newKey) {
|
||||
tribeCrypto.addNewKey(c.tribeId, newKey);
|
||||
if (!c || c.type !== 'tribe-keys' || !c.tribeId) continue;
|
||||
const fullEntry = c.memberKeysFull && c.memberKeysFull[ssb.id];
|
||||
const singleEntry = c.memberKeys && c.memberKeys[ssb.id];
|
||||
if (!fullEntry && !singleEntry) continue;
|
||||
const list = byTribe.get(c.tribeId) || [];
|
||||
list.push({ generation: c.generation || 0, fullEntry, singleEntry });
|
||||
byTribe.set(c.tribeId, list);
|
||||
}
|
||||
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();
|
||||
await new Promise((resolve, reject) => {
|
||||
pull(
|
||||
ssb.createLogStream({ limit: logLimit }),
|
||||
ssb.createLogStream({ limit: tribeLogLimit }),
|
||||
pull.collect((err, msgs) => {
|
||||
if (err) return reject(err);
|
||||
for (const m of msgs) {
|
||||
|
|
@ -636,12 +714,19 @@ module.exports = ({ cooler, tribeCrypto }) => {
|
|||
tribeIndex = null;
|
||||
},
|
||||
|
||||
async listSubTribes(parentId) {
|
||||
async listSubTribes(parentId, userId) {
|
||||
const idx = await buildTribeIndex();
|
||||
const rootOf = (id) => { let cur = id; while (idx.parent.has(cur)) cur = idx.parent.get(cur); return cur; };
|
||||
const parentRoot = rootOf(parentId);
|
||||
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) {
|
||||
|
|
@ -661,6 +746,10 @@ module.exports = ({ cooler, tribeCrypto }) => {
|
|||
try {
|
||||
const tribe = await this.getTribeById(tribeId);
|
||||
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 (Array.isArray(tribe.members) && tribe.members.includes(userId)) return true;
|
||||
const effective = await this.getEffectiveStatus(tribeId);
|
||||
|
|
|
|||
|
|
@ -48,8 +48,31 @@ const manifestFile = path.join(config.path, 'manifest.json');
|
|||
let server;
|
||||
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') {
|
||||
try {
|
||||
server = Server(config);
|
||||
} catch (err) {
|
||||
handleFatal(err);
|
||||
}
|
||||
fs.writeFileSync(manifestFile, JSON.stringify(server.getManifest(), null, 2));
|
||||
|
||||
const { cmdAliases } = require('../client/cli-cmd-aliases');
|
||||
|
|
@ -91,7 +114,14 @@ if (argv[0] === 'start') {
|
|||
}
|
||||
|
||||
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);
|
||||
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@krakenslab/oasis",
|
||||
"version": "0.7.5",
|
||||
"version": "0.7.6",
|
||||
"description": "Oasis Social Networking Project Utopia",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ const {
|
|||
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 { config } = require("../server/SSB_server.js");
|
||||
const { renderUrl } = require("../backend/renderUrl")
|
||||
|
|
@ -100,7 +100,10 @@ const renderAudioOwnerActions = (filter, audioObj, params = {}) => {
|
|||
};
|
||||
|
||||
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;
|
||||
|
||||
return div(
|
||||
|
|
@ -221,7 +224,7 @@ const renderAudioList = (audios, filter, params = {}) => {
|
|||
return p(
|
||||
{ class: "card-footer" },
|
||||
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
|
||||
? span(
|
||||
{ class: "votations-comment-date" },
|
||||
|
|
@ -416,7 +419,7 @@ exports.singleAudioView = async (audioObj, filter = "all", comments = [], params
|
|||
return p(
|
||||
{ class: "card-footer" },
|
||||
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
|
||||
? span(
|
||||
{ class: "votations-comment-date" },
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
const { form, button, div, h2, p, section, input, label, textarea, br, a, span, 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 { config } = require("../server/SSB_server.js");
|
||||
const { renderUrl } = require("../backend/renderUrl");
|
||||
|
|
@ -59,7 +59,10 @@ const renderBookmarkActions = (filter, bookmark, params = {}) => {
|
|||
};
|
||||
|
||||
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;
|
||||
|
||||
return div(
|
||||
|
|
@ -220,7 +223,7 @@ const renderBookmarkList = (filteredBookmarks, filter, params = {}) => {
|
|||
return p(
|
||||
{ class: "card-footer" },
|
||||
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
|
||||
? span(
|
||||
{ class: "votations-comment-date" },
|
||||
|
|
@ -453,7 +456,7 @@ exports.singleBookmarkView = async (bookmark, filter = "all", comments = [], par
|
|||
return p(
|
||||
{ class: "card-footer" },
|
||||
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
|
||||
? span(
|
||||
{ class: "votations-comment-date" },
|
||||
|
|
|
|||
|
|
@ -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 { template, i18n } = require("./main_views")
|
||||
const { template, i18n, userLink} = require("./main_views")
|
||||
const moment = require("../server/node_modules/moment")
|
||||
const { config } = require("../server/SSB_server.js")
|
||||
const { renderUrl } = require("../backend/renderUrl")
|
||||
|
|
@ -131,9 +131,7 @@ const renderMessage = (msg, chatAuthor) => {
|
|||
const isSelf = String(msg.author) === String(userId)
|
||||
const dateStr = moment(msg.createdAt).format("YYYY/MM/DD HH:mm")
|
||||
const shortId = msg.author ? "@" + msg.author.slice(1, 9) + "\u2026" : "?"
|
||||
const authorLink = msg.author
|
||||
? a({ href: `/author/${encodeURIComponent(msg.author)}`, class: "user-link" }, shortId)
|
||||
: span("?")
|
||||
const authorLink = msg.author ? userLink(msg.author) : span("?")
|
||||
|
||||
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(
|
||||
td({ class: "tribe-info-value", colspan: "4" },
|
||||
a({ href: `/author/${encodeURIComponent(chat.author)}`, class: "user-link" }, chat.author)
|
||||
userLink(chat.author)
|
||||
)
|
||||
),
|
||||
tr(
|
||||
|
|
@ -328,9 +326,12 @@ exports.singleChatView = async (chat, filter, messages = [], params = {}) => {
|
|||
)
|
||||
: null,
|
||||
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)
|
||||
return visible.length
|
||||
? visible.map(msg => renderMessage(msg, chat.author))
|
||||
: p({ class: "chat-no-messages" }, i18n.chatNoMessages)
|
||||
})()
|
||||
)
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 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');
|
||||
|
||||
|
|
@ -341,21 +341,8 @@ const shortId = (id) => {
|
|||
return `${s.slice(0, 6)}…${s.slice(-4)}`;
|
||||
};
|
||||
|
||||
const UserLinkCompact = (id) =>
|
||||
id
|
||||
? 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 UserLinkCompact = (id) => id ? userLink(id) : span('');
|
||||
const UserLinkFull = (id) => id ? userLink(id) : span('');
|
||||
|
||||
const renderRichTextNodes = (raw) => {
|
||||
const text = String(raw || '');
|
||||
|
|
@ -438,20 +425,14 @@ const CaseCard = (c) => {
|
|||
const mediatorLinksAccuser = mediatorsAccuser.map((mId, idx) =>
|
||||
span(
|
||||
{ class: 'mediator' },
|
||||
a(
|
||||
{ class: 'user-link', href: `/author/${encodeURIComponent(mId)}` },
|
||||
shortId(mId)
|
||||
),
|
||||
userLink(mId),
|
||||
idx < mediatorsAccuser.length - 1 ? span(', ') : null
|
||||
)
|
||||
);
|
||||
const mediatorLinksRespondent = mediatorsRespondent.map((mId, idx) =>
|
||||
span(
|
||||
{ class: 'mediator' },
|
||||
a(
|
||||
{ class: 'user-link', href: `/author/${encodeURIComponent(mId)}` },
|
||||
shortId(mId)
|
||||
),
|
||||
userLink(mId),
|
||||
idx < mediatorsRespondent.length - 1 ? span(', ') : null
|
||||
)
|
||||
);
|
||||
|
|
@ -637,10 +618,7 @@ const MyCaseCard = (c) => {
|
|||
const mediatorLinksAccuser = mediatorsAccuser.map((mId, idx) =>
|
||||
span(
|
||||
{},
|
||||
a(
|
||||
{ class: 'user-link', href: `/author/${encodeURIComponent(mId)}` },
|
||||
mId
|
||||
),
|
||||
userLink(mId),
|
||||
idx < mediatorsAccuser.length - 1 ? span(', ') : null
|
||||
)
|
||||
);
|
||||
|
|
@ -648,10 +626,7 @@ const MyCaseCard = (c) => {
|
|||
const mediatorLinksRespondent = mediatorsRespondent.map((mId, idx) =>
|
||||
span(
|
||||
{},
|
||||
a(
|
||||
{ class: 'user-link', href: `/author/${encodeURIComponent(mId)}` },
|
||||
mId
|
||||
),
|
||||
userLink(mId),
|
||||
idx < mediatorsRespondent.length - 1 ? span(', ') : null
|
||||
)
|
||||
);
|
||||
|
|
@ -945,12 +920,7 @@ const NominationsTable = (nominations = [], currentUserId = '') => {
|
|||
currentUserId &&
|
||||
String(n.judgeId || '') === String(currentUserId || '');
|
||||
return tr(
|
||||
td(
|
||||
a(
|
||||
{ class: 'user-link', href: `/author/${encodeURIComponent(n.judgeId)}` },
|
||||
n.judgeId
|
||||
)
|
||||
),
|
||||
td(userLink(n.judgeId)),
|
||||
td(String(n.supports || 0)),
|
||||
td(fmt(n.createdAt)),
|
||||
td(
|
||||
|
|
@ -1081,20 +1051,14 @@ const CaseDetailsBlock = (c) => {
|
|||
const mediatorLinksAccuser = mediatorsAccuser.map((mId, idx) =>
|
||||
span(
|
||||
{ class: 'mediator' },
|
||||
a(
|
||||
{ class: 'user-link', href: `/author/${encodeURIComponent(mId)}` },
|
||||
shortId(mId)
|
||||
),
|
||||
userLink(mId),
|
||||
idx < mediatorsAccuser.length - 1 ? span(', ') : null
|
||||
)
|
||||
);
|
||||
const mediatorLinksRespondent = mediatorsRespondent.map((mId, idx) =>
|
||||
span(
|
||||
{ class: 'mediator' },
|
||||
a(
|
||||
{ class: 'user-link', href: `/author/${encodeURIComponent(mId)}` },
|
||||
shortId(mId)
|
||||
),
|
||||
userLink(mId),
|
||||
idx < mediatorsRespondent.length - 1 ? span(', ') : null
|
||||
)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 { template, i18n } = require('./main_views');
|
||||
const { template, i18n, userLink} = require('./main_views');
|
||||
const { renderUrl } = require('../backend/renderUrl');
|
||||
|
||||
const generateCVBox = (label, content, className) => {
|
||||
|
|
@ -156,7 +156,7 @@ exports.cvView = async (cv) => {
|
|||
})
|
||||
: 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.personalSkills && cv.personalSkills.length)
|
||||
? div(
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ const { form, button, div, h2, p, section, input, label, br, a, span, textarea,
|
|||
require("../server/node_modules/hyperaxe");
|
||||
|
||||
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 { renderUrl } = require("../backend/renderUrl");
|
||||
const opinionCategories = require("../backend/opinion_categories");
|
||||
|
|
@ -74,7 +74,10 @@ const renderDocumentActions = (filter, doc, params = {}) => {
|
|||
};
|
||||
|
||||
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;
|
||||
|
||||
return div(
|
||||
|
|
@ -203,7 +206,7 @@ const renderDocumentList = (documents, filter, params = {}) => {
|
|||
return p(
|
||||
{ class: "card-footer" },
|
||||
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
|
||||
? span(
|
||||
{ class: "votations-comment-date" },
|
||||
|
|
@ -408,7 +411,7 @@ exports.singleDocumentView = async (doc, filter = "all", comments = [], params =
|
|||
return p(
|
||||
{ class: "card-footer" },
|
||||
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
|
||||
? span(
|
||||
{ class: "votations-comment-date" },
|
||||
|
|
|
|||
|
|
@ -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 { template, i18n } = require("./main_views");
|
||||
const { template, i18n, userLink} = require("./main_views");
|
||||
const moment = require("../server/node_modules/moment");
|
||||
const { config } = require("../server/SSB_server.js");
|
||||
const { renderUrl } = require("../backend/renderUrl");
|
||||
|
|
@ -163,10 +163,15 @@ const renderEventCommentsSection = (eventId, comments = [], currentFilter = "all
|
|||
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(
|
||||
{ class: "comments-list" },
|
||||
comments.map((c) => {
|
||||
visibleComments.map((c) => {
|
||||
const author = c.value && c.value.author ? c.value.author : "";
|
||||
const ts = c.value && c.value.timestamp ? c.value.timestamp : c.timestamp;
|
||||
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(
|
||||
{ class: "card-footer" },
|
||||
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
|
||||
.filter(Boolean)
|
||||
.map((id, i) => [i > 0 ? ", " : "", a({ class: "user-link", href: `/author/${encodeURIComponent(id)}` }, id)])
|
||||
.map((id, i) => [i > 0 ? ", " : "", userLink(id)])
|
||||
.flat()
|
||||
: i18n.noAttendees
|
||||
)
|
||||
|
|
@ -481,7 +487,7 @@ exports.singleEventView = async (event, filter, comments = [], params = {}) => {
|
|||
p(
|
||||
{ class: "card-footer" },
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
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 { renderUrl } = require("../backend/renderUrl");
|
||||
|
||||
|
|
@ -90,7 +90,7 @@ const renderFavoriteCard = (item, filter) => {
|
|||
p(
|
||||
{ class: "card-footer" },
|
||||
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) : ""
|
||||
)
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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 { renderTextWithStyles } = require("../backend/renderTextWithStyles");
|
||||
const opinionCategories = require("../backend/opinion_categories");
|
||||
|
|
@ -193,7 +193,7 @@ const renderFeedCard = (feed) => {
|
|||
p(
|
||||
{ class: "card-footer" },
|
||||
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
|
||||
)
|
||||
)
|
||||
|
|
@ -361,7 +361,7 @@ exports.singleFeedView = (feed, comments = []) => {
|
|||
p(
|
||||
{ class: "card-footer" },
|
||||
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
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ const {
|
|||
input, label, br, select, option, h2, textarea
|
||||
} = require("../server/node_modules/hyperaxe");
|
||||
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 { renderUrl } = require('../backend/renderUrl');
|
||||
const { renderTextWithStyles } = require('../backend/renderTextWithStyles');
|
||||
|
|
@ -100,6 +100,7 @@ const renderForumForm = () =>
|
|||
const renderThread = (nodes, level = 0, forumId) => {
|
||||
if (!Array.isArray(nodes)) return [];
|
||||
return [...nodes]
|
||||
.filter(m => (m && m.text && String(m.text).trim()) || (m && Array.isArray(m.children) && m.children.length))
|
||||
.sort((a, b) =>
|
||||
wilsonScore(b.positiveVotes, b.negativeVotes)
|
||||
- wilsonScore(a.positiveVotes, a.negativeVotes)
|
||||
|
|
@ -117,11 +118,7 @@ const renderThread = (nodes, level = 0, forumId) => {
|
|||
div({ class: 'comment-header' },
|
||||
span({ class: 'date-link' },
|
||||
`${moment(m.timestamp).format('YYYY/MM/DD HH:mm:ss')} ${i18n.performed}`),
|
||||
a({
|
||||
href: `/author/${encodeURIComponent(m.author)}`,
|
||||
class: 'user-link',
|
||||
style: 'margin-left:12px;'
|
||||
}, m.author),
|
||||
userLink(m.author),
|
||||
div({ class: 'comment-votes' },
|
||||
span({ class: 'votes-count' }, `▲: ${m.positiveVotes || 0}`),
|
||||
span({ class: 'votes-count', style: 'margin-left:12px;' },
|
||||
|
|
@ -164,10 +161,13 @@ const renderThread = (nodes, level = 0, forumId) => {
|
|||
});
|
||||
};
|
||||
|
||||
const renderForumList = (forums, currentFilter) =>
|
||||
div({ class: 'forum-list' },
|
||||
Array.isArray(forums) && forums.length
|
||||
? forums.map(f =>
|
||||
const renderForumList = (forums, currentFilter) => {
|
||||
const visibleForums = (Array.isArray(forums) ? forums : []).filter(f =>
|
||||
(f && f.title && String(f.title).trim()) || (f && f.text && String(f.text).trim())
|
||||
)
|
||||
return div({ class: 'forum-list' },
|
||||
visibleForums.length
|
||||
? visibleForums.map(f =>
|
||||
div({ class: 'forum-card' },
|
||||
div({ class: 'forum-score-col' },
|
||||
renderVotes(f.key, f.score, f.key)
|
||||
|
|
@ -203,11 +203,7 @@ const renderForumList = (forums, currentFilter) =>
|
|||
div({ class: 'forum-footer' },
|
||||
span({ class: 'date-link' },
|
||||
`${moment(f.createdAt).format('YYYY/MM/DD HH:mm:ss')} ${i18n.performed}`),
|
||||
a({
|
||||
href: `/author/${encodeURIComponent(f.author)}`,
|
||||
class: 'user-link',
|
||||
style: 'margin-left:12px;'
|
||||
}, f.author)
|
||||
userLink(f.author)
|
||||
),
|
||||
currentFilter === 'mine' && f.author === userId
|
||||
? div({ class: 'forum-owner-actions' },
|
||||
|
|
@ -226,6 +222,7 @@ const renderForumList = (forums, currentFilter) =>
|
|||
)
|
||||
: p(i18n.noForums)
|
||||
);
|
||||
}
|
||||
|
||||
exports.forumView = async (forums, currentFilter) => {
|
||||
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' },
|
||||
span({ class: 'date-link' },
|
||||
`${moment(forum.createdAt).format('YYYY/MM/DD HH:mm:ss')} ${i18n.performed}`),
|
||||
a({
|
||||
href: `/author/${encodeURIComponent(forum.author)}`,
|
||||
class: 'user-link',
|
||||
style: 'margin-left:12px;'
|
||||
}, forum.author)
|
||||
userLink(forum.author)
|
||||
),
|
||||
div({
|
||||
class: 'forum-body',
|
||||
|
|
|
|||
|
|
@ -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 { template, i18n } = require('./main_views');
|
||||
const { template, i18n, userLink} = require('./main_views');
|
||||
const moment = require("../server/node_modules/moment");
|
||||
|
||||
const getGames = () => [
|
||||
|
|
@ -44,7 +44,7 @@ const renderHallOfFame = (hall) => {
|
|||
...hall[game.id].map((entry, idx) =>
|
||||
tr(
|
||||
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(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()),
|
||||
topScore
|
||||
? 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') + ': '),
|
||||
String(topScore.score)
|
||||
)
|
||||
|
|
|
|||
129
nodejs-project/nodejs-project/src/views/graphos_view.js
Normal file
129
nodejs-project/nodejs-project/src/views/graphos_view.js
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
const escText = (s) => String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
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
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
|
@ -2,7 +2,7 @@ const { form, button, div, h2, p, section, input, label, br, a, img, span, texta
|
|||
require("../server/node_modules/hyperaxe");
|
||||
|
||||
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 { renderUrl } = require("../backend/renderUrl")
|
||||
const { renderMapLocationVisitLabel } = require("./maps_view");
|
||||
|
|
@ -160,7 +160,7 @@ const renderImageList = (images, filter, params = {}) => {
|
|||
return p(
|
||||
{ class: "card-footer" },
|
||||
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
|
||||
? span(
|
||||
{ class: "votations-comment-date" },
|
||||
|
|
@ -255,7 +255,10 @@ const renderLightbox = (images) =>
|
|||
});
|
||||
|
||||
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;
|
||||
|
||||
return div(
|
||||
|
|
@ -475,7 +478,7 @@ exports.singleImageView = async (imageObj, filter = "all", comments = [], params
|
|||
return p(
|
||||
{ class: "card-footer" },
|
||||
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
|
||||
? span(
|
||||
{ class: "votations-comment-date" },
|
||||
|
|
|
|||
|
|
@ -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 { template, i18n } = require("./main_views")
|
||||
const { template, i18n, userLink} = require("./main_views")
|
||||
const moment = require("../server/node_modules/moment")
|
||||
const { config } = require("../server/SSB_server.js")
|
||||
const { renderUrl } = require("../backend/renderUrl")
|
||||
|
|
@ -268,7 +268,7 @@ const renderJobList = (jobs, filter, params = {}) => {
|
|||
p(
|
||||
{ class: "card-footer" },
|
||||
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)
|
||||
)
|
||||
)
|
||||
|
|
@ -388,7 +388,7 @@ const renderCVList = (inhabitants) =>
|
|||
div(
|
||||
{ class: "inhabitant-details" },
|
||||
user.description ? p(...renderUrl(user.description)) : null,
|
||||
p(a({ class: "user-link", href: `/author/${encodeURIComponent(user.id)}` }, user.id)),
|
||||
p(userLink(user.id)),
|
||||
div(
|
||||
{ class: "cv-actions" },
|
||||
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 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
|
||||
|
||||
return div(
|
||||
|
|
@ -582,7 +585,7 @@ exports.singleJobsView = async (job, filter = "ALL", comments = [], params = {})
|
|||
p(
|
||||
{ class: "card-footer" },
|
||||
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)
|
||||
)
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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 markdown = require("./markdown");
|
||||
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");
|
||||
let selectedLanguage = "en";
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ const { form, button, div, h2, h3, p, section, input, label, br, a, span, textar
|
|||
require("../server/node_modules/hyperaxe");
|
||||
|
||||
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 { renderMapWithPins, renderZoomedMapWithPins, getViewportBounds, latLngToPx, pxToLatLng, MAP_W, MAP_H, getMaxTileZoom } = require("../maps/map_renderer");
|
||||
const { sanitizeHtml } = require('../backend/sanitizeHtml');
|
||||
|
|
@ -313,7 +313,7 @@ const renderMarkersList = (markers, mapObj) => {
|
|||
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-meta" },
|
||||
a({ href: `/author/${encodeURIComponent(mk.author)}`, class: "user-link" }, mk.author),
|
||||
userLink(mk.author),
|
||||
` · ${moment(mk.createdAt).fromNow()}`))
|
||||
])));
|
||||
};
|
||||
|
|
@ -351,7 +351,7 @@ const renderMapCard = (mapObj, filter, params = {}) => {
|
|||
p({ class: "card-footer" },
|
||||
span({ class: "date-link" }, moment(mapObj.createdAt).fromNow()),
|
||||
span(" · "),
|
||||
a({ href: `/author/${encodeURIComponent(mapObj.author)}`, class: "user-link" }, mapObj.author))));
|
||||
userLink(mapObj.author))));
|
||||
};
|
||||
|
||||
const renderMapList = (maps, filter, params = {}) =>
|
||||
|
|
@ -433,7 +433,7 @@ exports.singleMapView = async (mapObj, filter = "all", params = {}) => {
|
|||
br(),
|
||||
p({ class: "card-footer" },
|
||||
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
|
||||
? span({ class: "votations-comment-date" }, ` · ${i18n.mapUpdatedAt}: ${moment(mapObj.updatedAt).format("YYYY/MM/DD HH:mm:ss")}`)
|
||||
: null),
|
||||
|
|
|
|||
|
|
@ -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 { template, i18n } = require("./main_views")
|
||||
const { template, i18n, userLink} = require("./main_views")
|
||||
const moment = require("../server/node_modules/moment")
|
||||
const { config } = require("../server/SSB_server.js")
|
||||
const { renderUrl } = require("../backend/renderUrl")
|
||||
|
|
@ -138,10 +138,15 @@ const renderMarketCommentsSection = (itemId, returnTo, comments = []) => {
|
|||
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(
|
||||
{ class: "comments-list" },
|
||||
comments.map((c) => {
|
||||
visibleComments.map((c) => {
|
||||
const author = c.value && c.value.author ? c.value.author : ""
|
||||
const ts = c.value && c.value.timestamp ? c.value.timestamp : c.timestamp
|
||||
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)
|
||||
})()
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -678,7 +684,7 @@ exports.singleMarketView = async (item, filter, comments = [], params = {}) => {
|
|||
renderCardField(`${i18n.marketItemIncludesShipping}:`, `${item.includesShipping ? i18n.YESLabel : i18n.NOLabel}`),
|
||||
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,
|
||||
renderCardFieldRich(`${i18n.marketItemSeller}:`, [a({ class: "user-link", href: `/author/${encodeURIComponent(item.seller)}` }, item.seller)])
|
||||
renderCardFieldRich(`${i18n.marketItemSeller}:`, [userLink(item.seller)])
|
||||
),
|
||||
item.item_type === "auction"
|
||||
? div(
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ const modulesView = () => {
|
|||
const modules = [
|
||||
{ name: 'agenda', label: i18n.modulesAgendaLabel, description: i18n.modulesAgendaDescription },
|
||||
{ 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: 'banking', label: i18n.modulesBankingLabel, description: i18n.modulesBankingDescription },
|
||||
{ name: 'bookmarks', label: i18n.modulesBookmarksLabel, description: i18n.modulesBookmarksDescription },
|
||||
|
|
@ -20,6 +21,7 @@ const modulesView = () => {
|
|||
{ name: 'feed', label: i18n.modulesFeedLabel, description: i18n.modulesFeedDescription },
|
||||
{ name: 'forum', label: i18n.modulesForumLabel, description: i18n.modulesForumDescription },
|
||||
{ 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: 'invites', label: i18n.modulesInvitesLabel, description: i18n.modulesInvitesDescription },
|
||||
{ name: 'jobs', label: i18n.modulesJobsLabel, description: i18n.modulesJobsDescription },
|
||||
|
|
@ -86,7 +88,7 @@ const modulesView = () => {
|
|||
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]) => {
|
||||
const presetLabel = (i18n[`modulesPreset_${key}`] || key).toUpperCase();
|
||||
const isActive = modules.every(m => mods.includes(m.name) === (moduleStates[`${m.name}Mod`] === 'on'));
|
||||
|
|
|
|||
|
|
@ -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 { template, i18n } = require('./main_views');
|
||||
const { template, i18n, userLink} = require('./main_views');
|
||||
const { config } = require('../server/SSB_server.js');
|
||||
const { renderTextWithStyles } = require('../backend/renderTextWithStyles');
|
||||
const { renderUrl } = require('../backend/renderUrl');
|
||||
|
|
@ -215,11 +215,11 @@ const renderContentHtml = (content, key) => {
|
|||
),
|
||||
div({ class: 'card-field' },
|
||||
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' },
|
||||
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' },
|
||||
span({ class: 'card-label' }, `${i18n.transfersConfirmations}: `),
|
||||
|
|
@ -273,7 +273,7 @@ exports.opinionsView = (items, filter) => {
|
|||
contentHtml,
|
||||
p({ class: 'card-footer' },
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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 { template, i18n } = require("./main_views")
|
||||
const { template, i18n, userLink} = require("./main_views")
|
||||
const moment = require("../server/node_modules/moment")
|
||||
const { config } = require("../server/SSB_server.js")
|
||||
|
||||
|
|
@ -226,7 +226,7 @@ exports.singlePadView = async (pad, entries, params) => {
|
|||
),
|
||||
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"))),
|
||||
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))),
|
||||
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.")
|
||||
|
||||
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" },
|
||||
h4(i18n.padVersionHistory || "Version History"),
|
||||
...entries.slice().reverse().map((e, idx) =>
|
||||
...visibleEntries.slice().reverse().map((e, idx) =>
|
||||
div({ class: "pad-version-item" },
|
||||
span({ class: "pad-version-date" }, moment(e.createdAt).format("YYYY-MM-DD HH:mm")),
|
||||
span({ class: "pad-version-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")
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 moment = require("../server/node_modules/moment");
|
||||
const { template, i18n } = require('./main_views');
|
||||
const { template, i18n, userLink} = require('./main_views');
|
||||
|
||||
const TERM_DAYS = 60;
|
||||
|
||||
|
|
@ -131,7 +131,7 @@ const GovernmentCard = (g, meta) => {
|
|||
const actorLink =
|
||||
g.powerType === 'tribe'
|
||||
? 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 memberIds = Array.isArray(g.membersList) ? g.membersList : (Array.isArray(g.members) ? g.members : []);
|
||||
const membersRow =
|
||||
|
|
@ -143,7 +143,7 @@ const GovernmentCard = (g, meta) => {
|
|||
div(
|
||||
span({ class: 'card-label' }, (i18n.parliamentMembers + ': ').toUpperCase()),
|
||||
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))
|
||||
)
|
||||
)
|
||||
|
|
@ -247,7 +247,7 @@ const CandidatureStats = (cands, govCard, leaderMeta) => {
|
|||
const winLbl = (i18n.parliamentWinningCandidature || i18n.parliamentCurrentLeader || 'WINNING CANDIDATURE').toUpperCase();
|
||||
const idLink = leader
|
||||
? (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))
|
||||
: null;
|
||||
return div(
|
||||
|
|
@ -287,7 +287,7 @@ const CandidaturesTable = (candidatures) => {
|
|||
const rows = (candidatures || []).map(c => {
|
||||
const idLink =
|
||||
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));
|
||||
return tr(
|
||||
td(idLink),
|
||||
|
|
@ -349,7 +349,7 @@ const ProposalsList = (proposals) => {
|
|||
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))
|
||||
span({ class: 'card-value' }, userLink(pItem.proposer))
|
||||
),
|
||||
div(
|
||||
{ class: 'card-field' },
|
||||
|
|
@ -419,7 +419,7 @@ const FutureLawsList = (rows) => {
|
|||
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.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 || ''),
|
||||
p(pItem.description || '')
|
||||
)
|
||||
|
|
@ -480,7 +480,7 @@ const RevocationsList = (revocations) => {
|
|||
span({ class: 'card-label' }, i18n.parliamentLawProposer.toUpperCase() + ': '),
|
||||
span(
|
||||
{ class: 'card-value' },
|
||||
a({ class: 'user-link', href: `/author/${encodeURIComponent(pItem.proposer)}` }, pItem.proposer)
|
||||
userLink(pItem.proposer)
|
||||
)
|
||||
),
|
||||
div(
|
||||
|
|
@ -551,7 +551,7 @@ const FutureRevocationsList = (rows) => {
|
|||
br(),
|
||||
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.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 || ''),
|
||||
p(pItem.reasons || '')
|
||||
)
|
||||
|
|
@ -605,7 +605,7 @@ const LawsList = (laws) => {
|
|||
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.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 || ''),
|
||||
p(l.description || ''),
|
||||
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' },
|
||||
g.powerType === 'tribe'
|
||||
? 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,
|
||||
(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 link = l.powerType === 'tribe'
|
||||
? 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);
|
||||
return tr(
|
||||
td(leaderCell),
|
||||
|
|
|
|||
|
|
@ -23,10 +23,10 @@ const peersView = async ({ onlinePeers, discoveredPeers, unknownPeers }) => {
|
|||
const { name, users, key } = peer;
|
||||
const peerUrl = `/author/${encodeURIComponent(key)}`;
|
||||
const filteredUsers = (users || []).filter(u => u.id !== key);
|
||||
const userCount = filteredUsers.length || peer.announcers || 0;
|
||||
const userCount = filteredUsers.length;
|
||||
return tr(
|
||||
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))
|
||||
);
|
||||
};
|
||||
|
|
@ -35,19 +35,9 @@ const peersView = async ({ onlinePeers, discoveredPeers, unknownPeers }) => {
|
|||
const dedupDiscovered = deduplicatePeers(discoveredPeers);
|
||||
const dedupUnknown = deduplicatePeers(unknownPeers);
|
||||
|
||||
const countPeers = (list) => {
|
||||
let usersTotal = 0;
|
||||
for (const item of list) {
|
||||
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 onlineCount = dedupOnline.length;
|
||||
const discoveredCount = dedupDiscovered.length;
|
||||
const unknownCount = dedupUnknown.length;
|
||||
|
||||
const renderPeerTable = (peers) => {
|
||||
if (peers.length === 0) return p(i18n.noConnections || i18n.noDiscovered);
|
||||
|
|
@ -55,7 +45,7 @@ const peersView = async ({ onlinePeers, discoveredPeers, unknownPeers }) => {
|
|||
tr(
|
||||
td({ class: 'card-label' }, i18n.peerHost || 'Pub'),
|
||||
td({ class: 'card-label' }, 'Key'),
|
||||
td({ class: 'card-label' }, i18n.inhabitants || 'Inhabitants')
|
||||
td({ class: 'card-label' }, i18n.peersReplicatedFeeds || 'Replicated feeds')
|
||||
),
|
||||
...peers.map(renderPeerRow)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 { template, i18n } = require('./main_views');
|
||||
const { template, i18n, userLink} = require('./main_views');
|
||||
|
||||
exports.pixeliaView = (pixelArt, errorMessage) => {
|
||||
const title = i18n.pixeliaTitle;
|
||||
|
|
@ -81,7 +81,7 @@ exports.pixeliaView = (pixelArt, errorMessage) => {
|
|||
h2(i18n.contributorsTitle),
|
||||
ul(
|
||||
...contributors.map(author =>
|
||||
li(a({ class: 'user-link', href: `/author/${encodeURIComponent(author)}` }, author))
|
||||
li(userLink(author))
|
||||
)
|
||||
)
|
||||
) : null
|
||||
|
|
|
|||
|
|
@ -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 { template, i18n } = require("./main_views")
|
||||
const { template, i18n, userLink} = require("./main_views")
|
||||
const moment = require("../server/node_modules/moment")
|
||||
const { config } = require("../server/SSB_server.js")
|
||||
const { renderUrl } = require("../backend/renderUrl")
|
||||
|
|
@ -142,7 +142,7 @@ const renderFollowers = (project) => {
|
|||
return div(
|
||||
{ class: "followers-block" },
|
||||
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
|
||||
)
|
||||
}
|
||||
|
|
@ -171,7 +171,7 @@ const renderBackers = (project, filter) => {
|
|||
...backers.slice(0, 8).map((b) =>
|
||||
tr(
|
||||
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`)
|
||||
)
|
||||
)
|
||||
|
|
@ -285,7 +285,7 @@ const renderMilestonesAndBounties = (project, filter, editable) => {
|
|||
),
|
||||
safeText(b.description) ? p(...renderUrl(b.description)) : null,
|
||||
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
|
||||
? form(
|
||||
{ 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,
|
||||
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
|
||||
? form(
|
||||
{ method: "POST", action: `/projects/bounties/claim/${encodeURIComponent(project.id)}/${globalIndex}` },
|
||||
|
|
@ -588,7 +588,7 @@ const renderProjectList = (projects, filter) => {
|
|||
div(
|
||||
{ class: "card-footer" },
|
||||
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(
|
||||
{ class: "card-footer" },
|
||||
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(
|
||||
|
|
@ -760,23 +760,29 @@ exports.singleProjectView = async (project, filter, comments, params = {}) => {
|
|||
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(
|
||||
{ class: "comments-list" },
|
||||
comments.map((c) => {
|
||||
visibleComments.map((c) => {
|
||||
const author = c?.value?.author || ""
|
||||
const ts = c?.value?.timestamp || c?.timestamp
|
||||
const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : ""
|
||||
const relDate = ts ? moment(ts).fromNow() : ""
|
||||
return div(
|
||||
{ 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-body" }, ...renderUrl(c?.value?.content?.text || ""))
|
||||
)
|
||||
})
|
||||
)
|
||||
: p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet)
|
||||
})()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 { template, i18n } = require("./main_views");
|
||||
const { template, i18n, userLink} = require("./main_views");
|
||||
const { config } = require("../server/SSB_server.js");
|
||||
const moment = require("../server/node_modules/moment");
|
||||
const { renderUrl } = require("../backend/renderUrl");
|
||||
|
|
@ -215,10 +215,15 @@ const renderReportCommentsSection = (reportId, comments = []) => {
|
|||
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(
|
||||
{ class: "comments-list" },
|
||||
comments.map((c) => {
|
||||
visibleComments.map((c) => {
|
||||
const author = c.value && c.value.author ? c.value.author : "";
|
||||
const ts = c.value && c.value.timestamp ? c.value.timestamp : c.timestamp;
|
||||
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(
|
||||
{ class: "card-footer" },
|
||||
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(
|
||||
{ class: "card-footer" },
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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 { template, i18n } = require('./main_views');
|
||||
const { template, i18n, userLink} = require('./main_views');
|
||||
const moment = require("../server/node_modules/moment");
|
||||
const { renderTextWithStyles } = require('../backend/renderTextWithStyles');
|
||||
const { renderUrl } = require('../backend/renderUrl');
|
||||
|
|
@ -120,7 +120,7 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
|
|||
case '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.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
|
||||
);
|
||||
case 'feed': {
|
||||
|
|
@ -190,23 +190,15 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
|
|||
);
|
||||
case '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(/&/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 ? content.description : null,
|
||||
br(),br(),
|
||||
div({ style: 'display:flex; gap:.6em; flex-wrap:wrap;' },
|
||||
content.location ? p({ style: 'color:#9aa3b2;' }, `${i18n.tribeLocationLabel.toUpperCase()}: `, ...renderUrl(content.location)) : null,
|
||||
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}`)
|
||||
),
|
||||
content.description ? div({ class: 'card-field card-field-stacked' }, span({ class: 'card-label' }, i18n.searchDescription + ':'), span({ class: 'card-value' }, ...renderUrl(content.description))) : null,
|
||||
content.location ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.tribeLocationLabel + ':'), span({ class: 'card-value' }, ...renderUrl(content.location))) : null,
|
||||
div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.tribeIsAnonymousLabel + ':'), span({ class: 'card-value' }, content.isAnonymous ? i18n.tribePrivate : i18n.tribePublic)),
|
||||
content.inviteMode ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.tribeModeLabel + ':'), span({ class: 'card-value' }, String(content.inviteMode).toUpperCase())) : null,
|
||||
div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.tribeLARPLabel + ':'), span({ class: 'card-value' }, content.isLARP ? i18n.tribeYes : i18n.tribeNo)),
|
||||
Array.isArray(content.members)
|
||||
? div({},
|
||||
div({ class: 'card-field' },
|
||||
h2(`${i18n.tribeMembersCount}: ${content.members.length}`),
|
||||
)
|
||||
)
|
||||
? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.tribeMembersCount + ':'), span({ class: 'card-value' }, String(content.members.length)))
|
||||
: null,
|
||||
content.tags && content.tags.length
|
||||
? div({ class: 'card-tags' }, content.tags.map(tag =>
|
||||
|
|
@ -274,8 +266,9 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
|
|||
);
|
||||
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.description ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.torrentDescriptionLabel || 'Description') + ':'), span({ class: 'card-value' }, content.description)) : 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 || 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
|
||||
? div({ class: 'card-tags' }, content.tags.map(tag =>
|
||||
a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`)
|
||||
|
|
@ -292,7 +285,7 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
|
|||
br(),
|
||||
blobImg(content.image),
|
||||
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.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,
|
||||
|
|
@ -325,8 +318,8 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
|
|||
);
|
||||
case '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,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.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.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
|
||||
|
|
@ -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.amount ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.transfersAmount + ':'), span({ class: 'card-value' }, content.amount)) : null,
|
||||
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.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.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' }, userLink(content.to))) : null,
|
||||
br(),
|
||||
content.confirmedBy && 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':
|
||||
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.text ? div({ class: 'card-field' }, span({ class: 'card-value' }, content.text)) : null
|
||||
content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.title + ':'), span({ class: 'card-value' }, content.title)) : 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':
|
||||
return div({ class: 'search-vote-link' },
|
||||
|
|
@ -527,16 +521,6 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
|
|||
)
|
||||
) : 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':
|
||||
return div({ class: 'search-map' },
|
||||
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
|
||||
? p({ class: 'card-footer' },
|
||||
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,
|
||||
]);
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 { template, i18n } = require("./main_views")
|
||||
const { template, i18n, userLink} = require("./main_views")
|
||||
const moment = require("../server/node_modules/moment")
|
||||
const { config } = require("../server/SSB_server.js")
|
||||
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())
|
||||
),
|
||||
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(
|
||||
td({ class: "tribe-info-label" }, i18n.shopLocation),
|
||||
|
|
@ -458,7 +458,7 @@ exports.singleProductView = async (product, shop, comments = [], params = {}) =>
|
|||
p({ class: "card-footer" },
|
||||
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)
|
||||
? div({ class: "voting-buttons transfer-voting-buttons" },
|
||||
|
|
|
|||
|
|
@ -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 { template, i18n } = require('./main_views');
|
||||
const { template, i18n, userLink } = require('./main_views');
|
||||
|
||||
Object.assign(i18n, {
|
||||
statsChat: "Chats",
|
||||
|
|
@ -26,6 +26,11 @@ Object.assign(i18n, {
|
|||
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 wClass = (pct) => {
|
||||
const n = Math.max(0, Math.min(100, Math.round((pct || 0) / 5) * 5));
|
||||
return `stats-w-${n}`;
|
||||
};
|
||||
|
||||
exports.statsView = (stats, filter) => {
|
||||
const title = i18n.statsTitle;
|
||||
const description = i18n.statsDescription;
|
||||
|
|
@ -86,41 +91,53 @@ exports.statsView = (stats, filter) => {
|
|||
};
|
||||
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 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);';
|
||||
return template(
|
||||
title,
|
||||
section(
|
||||
div({ class: 'tags-header' },
|
||||
h2(title),
|
||||
p(description)
|
||||
),
|
||||
div({ class: 'mode-buttons stats-grid' },
|
||||
modes.map(m =>
|
||||
form({ method: 'GET', action: '/stats' },
|
||||
input({ type: 'hidden', name: 'filter', value: m }),
|
||||
button({ type: 'submit', class: filter === m ? 'filter-btn active' : 'filter-btn' }, i18n[m + 'Button'])
|
||||
)
|
||||
|
||||
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)}` })
|
||||
),
|
||||
section(
|
||||
div({ style: headerStyle },
|
||||
h3({ class: 'stats-h-row' }, `${i18n.statsCreatedAt}: `, span({ class: 'stats-muted-888' }, stats.createdAt)),
|
||||
h3({ class: 'stats-section-h' },
|
||||
a({ class: "user-link", href: `/author/${encodeURIComponent(stats.id)}`, class: 'stats-link' }, stats.id)
|
||||
),
|
||||
div({ class: 'stats-mb-16' },
|
||||
ul({ class: 'stats-list-reset' },
|
||||
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'),
|
||||
(() => {
|
||||
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);
|
||||
|
|
@ -143,14 +160,14 @@ exports.statsView = (stats, filter) => {
|
|||
const maxAnnualCO2 = 500;
|
||||
|
||||
if (filter === 'MINE') {
|
||||
const pct = networkCO2 > 0 ? Math.min(100, (userCO2 / networkCO2) * 100).toFixed(1) : '0.0';
|
||||
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', style: `width:${pct}%;` })
|
||||
div({ class: `carbon-bar-fill carbon-bar-mine ${wClass(pct)}` })
|
||||
),
|
||||
div({ class: 'carbon-bar-label' },
|
||||
span(i18n.statsCarbonNetwork || 'Network total'),
|
||||
|
|
@ -159,7 +176,7 @@ exports.statsView = (stats, filter) => {
|
|||
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-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)'))
|
||||
);
|
||||
}
|
||||
|
|
@ -168,14 +185,14 @@ exports.statsView = (stats, filter) => {
|
|||
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';
|
||||
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', style: `width:${tombPct}%;` })
|
||||
div({ class: `carbon-bar-fill carbon-bar-mine ${wClass(tombPct)}` })
|
||||
),
|
||||
div({ class: 'carbon-bar-label' },
|
||||
span(i18n.statsCarbonNetwork || 'Network total'),
|
||||
|
|
@ -184,18 +201,18 @@ exports.statsView = (stats, filter) => {
|
|||
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-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).toFixed(1);
|
||||
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', style: `width:${pct}%;` })
|
||||
div({ class: `carbon-bar-fill carbon-bar-network ${wClass(pct)}` })
|
||||
),
|
||||
div({ class: 'carbon-bar-label' },
|
||||
span(i18n.statsCarbonMaxAnnual || 'Annual max estimate'),
|
||||
|
|
@ -204,211 +221,263 @@ exports.statsView = (stats, filter) => {
|
|||
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-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)'))
|
||||
);
|
||||
})()
|
||||
),
|
||||
div({ style: headerStyle },
|
||||
})();
|
||||
|
||||
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),
|
||||
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)))
|
||||
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)))
|
||||
)
|
||||
),
|
||||
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))))
|
||||
)
|
||||
)
|
||||
|
||||
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,
|
||||
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')}`)])
|
||||
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)}%`)
|
||||
)
|
||||
);
|
||||
if (t !== 'tribe') return li(`${labels[t]}: ${C(stats, t)}`);
|
||||
|
||||
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(`${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))
|
||||
)
|
||||
]
|
||||
: [])
|
||||
])
|
||||
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))
|
||||
);
|
||||
}).filter(Boolean)
|
||||
})
|
||||
)
|
||||
: 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)}%`)
|
||||
)
|
||||
)
|
||||
])
|
||||
: div({ class: 'stats-container' }, [
|
||||
div({ style: blockStyle },
|
||||
h2(`${i18n.TOMBSTONEButton}: ${stats.userTombstoneCount}`),
|
||||
h2(`${i18n.statsTombstoneRatio.toUpperCase()}: ${((stats.tombstoneKPIs?.ratio || 0)).toFixed(2)}%`)
|
||||
: null;
|
||||
|
||||
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: '/stats' },
|
||||
input({ type: 'hidden', name: 'filter', value: m }),
|
||||
button({ type: 'submit', class: filter === m ? 'filter-btn active' : 'filter-btn' }, i18n[m + 'Button'])
|
||||
)
|
||||
])
|
||||
)
|
||||
),
|
||||
section(
|
||||
topStrip,
|
||||
headerCard,
|
||||
bankingCard,
|
||||
carbonCard,
|
||||
allMode,
|
||||
mineMode,
|
||||
tombMode
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 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 { renderUrl } = require("../backend/renderUrl");
|
||||
|
||||
|
|
@ -162,10 +162,15 @@ const renderTaskCommentsSection = (taskId, comments = [], currentFilter = "all")
|
|||
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(
|
||||
{ class: "comments-list" },
|
||||
comments.map((c) => {
|
||||
visibleComments.map((c) => {
|
||||
const author = c.value && c.value.author ? c.value.author : "";
|
||||
const ts = c.value && c.value.timestamp ? c.value.timestamp : c.timestamp;
|
||||
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(
|
||||
{ class: "card-value" },
|
||||
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
|
||||
)
|
||||
),
|
||||
|
|
@ -242,7 +248,7 @@ const renderTaskItem = (task, filter) => {
|
|||
p(
|
||||
{ class: "card-footer" },
|
||||
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(
|
||||
{ class: "card-value" },
|
||||
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
|
||||
)
|
||||
),
|
||||
|
|
@ -455,7 +461,7 @@ exports.singleTaskView = async (task, filter, comments = []) => {
|
|||
p(
|
||||
{ class: "card-footer" },
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ const {
|
|||
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 { config } = require("../server/SSB_server.js");
|
||||
const { renderUrl } = require("../backend/renderUrl");
|
||||
|
|
@ -180,7 +180,7 @@ const renderTorrentTable = (torrents, filter, params = {}) => {
|
|||
torrents.map((t) =>
|
||||
tr(
|
||||
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(formatSize(t.size)),
|
||||
td(
|
||||
|
|
@ -375,7 +375,7 @@ exports.singleTorrentView = async (torrentObj, filter = "all", comments = [], pa
|
|||
return p(
|
||||
{ class: "card-footer" },
|
||||
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
|
||||
? span(
|
||||
{ class: "votations-comment-date" },
|
||||
|
|
|
|||
|
|
@ -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 { template, i18n } = require("./main_views")
|
||||
const { template, i18n, userLink} = require("./main_views")
|
||||
const moment = require("../server/node_modules/moment")
|
||||
const { config } = require("../server/SSB_server.js")
|
||||
const opinionCategories = require("../backend/opinion_categories")
|
||||
|
|
@ -188,7 +188,7 @@ const generateTransferCard = (transfer, filter, params = {}) => {
|
|||
p(
|
||||
{ class: "card-footer" },
|
||||
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)
|
||||
)
|
||||
)
|
||||
|
|
@ -397,8 +397,8 @@ exports.singleTransferView = async (transfer, filter, params = {}) => {
|
|||
div(
|
||||
{ class: "card-section transfer" },
|
||||
topbar ? topbar : null,
|
||||
renderCardField(`${i18n.transfersFrom}:`, a({ class: "user-link", href: `/author/${encodeURIComponent(transfer.from)}` }, transfer.from)),
|
||||
renderCardField(`${i18n.transfersTo}:`, a({ class: "user-link", href: `/author/${encodeURIComponent(transfer.to)}` }, transfer.to)),
|
||||
renderCardField(`${i18n.transfersFrom}:`, userLink(transfer.from)),
|
||||
renderCardField(`${i18n.transfersTo}:`, userLink(transfer.to)),
|
||||
br,
|
||||
div({ class: "transfer-amount-highlight" }, renderCardField(`${i18n.transfersAmount}:`, `${fmtAmount(transfer.amount)} ECO`)),
|
||||
renderCardField(`${i18n.transfersConcept}:`, transfer.concept || ""),
|
||||
|
|
@ -420,7 +420,7 @@ exports.singleTransferView = async (transfer, filter, params = {}) => {
|
|||
p(
|
||||
{ class: "card-footer" },
|
||||
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)
|
||||
),
|
||||
div(
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ const {
|
|||
} = require("../server/node_modules/hyperaxe");
|
||||
|
||||
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 { renderUrl } = require("../backend/renderUrl")
|
||||
const { renderMapLocationVisitLabel } = require("./maps_view");
|
||||
|
|
@ -116,7 +116,10 @@ const renderVideoOwnerActions = (filter, videoObj, params = {}) => {
|
|||
};
|
||||
|
||||
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;
|
||||
|
||||
return div(
|
||||
|
|
@ -231,7 +234,7 @@ const renderVideoList = (videos, filter, params = {}) => {
|
|||
return p(
|
||||
{ class: "card-footer" },
|
||||
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
|
||||
? span(
|
||||
{ class: "votations-comment-date" },
|
||||
|
|
@ -423,7 +426,7 @@ exports.singleVideoView = async (videoObj, filter = "all", comments = [], params
|
|||
return p(
|
||||
{ class: "card-footer" },
|
||||
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
|
||||
? span(
|
||||
{ class: "votations-comment-date" },
|
||||
|
|
|
|||
|
|
@ -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 { template, i18n } = require("./main_views");
|
||||
const { template, i18n, userLink} = require("./main_views");
|
||||
const moment = require("../server/node_modules/moment");
|
||||
const { config } = require("../server/SSB_server.js");
|
||||
const opinionCategories = require("../backend/opinion_categories");
|
||||
|
|
@ -230,7 +230,7 @@ const renderVoteCard = (v, voteOptions, firstRow, secondRow, mode, activeFilter)
|
|||
p(
|
||||
{ class: "card-footer" },
|
||||
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)
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue