psy преди 1 ден
родител
ревизия
477a99333b
променени са 91 файла, в които са добавени 4349 реда и са изтрити 1266 реда
  1. 5 1
      .gitignore
  2. 2 0
      README.md
  3. 38 0
      docs/CHANGELOG.md
  4. 142 132
      docs/PUB/deploy.md
  5. 26 0
      docs/PUB/oasis-pub.service
  6. 77 9
      install.sh
  7. 48 7
      oasis.sh
  8. 72 0
      src/AI/embedder.js
  9. 114 0
      src/AI/routes_index.js
  10. 326 92
      src/backend/backend.js
  11. 9 0
      src/backend/blobHandler.js
  12. 20 0
      src/backend/nameCache.js
  13. 5 4
      src/backend/renderTextWithStyles.js
  14. 168 10
      src/client/assets/styles/style.css
  15. 11 0
      src/client/assets/themes/Clear-SNH.css
  16. 6 0
      src/client/assets/themes/Dark-SNH.css
  17. 7 0
      src/client/assets/themes/Matrix-SNH.css
  18. 25 0
      src/client/assets/themes/OasisMobile.css
  19. 6 0
      src/client/assets/themes/Purple-SNH.css
  20. 16 0
      src/client/assets/translations/oasis_ar.js
  21. 17 1
      src/client/assets/translations/oasis_de.js
  22. 17 1
      src/client/assets/translations/oasis_en.js
  23. 16 0
      src/client/assets/translations/oasis_es.js
  24. 17 1
      src/client/assets/translations/oasis_eu.js
  25. 16 0
      src/client/assets/translations/oasis_fr.js
  26. 16 0
      src/client/assets/translations/oasis_hi.js
  27. 17 1
      src/client/assets/translations/oasis_it.js
  28. 17 1
      src/client/assets/translations/oasis_pt.js
  29. 16 0
      src/client/assets/translations/oasis_ru.js
  30. 16 0
      src/client/assets/translations/oasis_zh.js
  31. 1 26
      src/client/oasis_client.js
  32. 3 1
      src/configs/config-manager.js
  33. 3 1
      src/configs/oasis-config.json
  34. 26 1
      src/models/activity_model.js
  35. 2 25
      src/models/banking_model.js
  36. 339 73
      src/models/calendars_model.js
  37. 138 56
      src/models/chats_model.js
  38. 106 18
      src/models/main_models.js
  39. 263 9
      src/models/maps_model.js
  40. 72 11
      src/models/pads_model.js
  41. 60 1
      src/models/search_model.js
  42. 49 4
      src/models/stats_model.js
  43. 7 7
      src/models/tags_model.js
  44. 132 27
      src/models/tribe_crypto.js
  45. 2 1
      src/models/tribes_content_model.js
  46. 135 46
      src/models/tribes_model.js
  47. 24 1
      src/server/SSB_server.js
  48. 763 28
      src/server/package-lock.json
  49. 2 1
      src/server/package.json
  50. 26 25
      src/views/activity_view.js
  51. 6 6
      src/views/agenda_view.js
  52. 7 4
      src/views/audio_view.js
  53. 6 5
      src/views/banking_views.js
  54. 7 4
      src/views/bookmark_view.js
  55. 32 7
      src/views/calendars_view.js
  56. 9 8
      src/views/chats_view.js
  57. 10 46
      src/views/courts_view.js
  58. 2 2
      src/views/cv_view.js
  59. 7 4
      src/views/document_view.js
  60. 13 7
      src/views/event_view.js
  61. 2 2
      src/views/favorites_view.js
  62. 3 3
      src/views/feed_view.js
  63. 13 20
      src/views/forum_view.js
  64. 3 3
      src/views/games_view.js
  65. 129 0
      src/views/graphos_view.js
  66. 7 4
      src/views/image_view.js
  67. 4 21
      src/views/inhabitants_view.js
  68. 8 4
      src/views/invites_view.js
  69. 8 5
      src/views/jobs_view.js
  70. 97 29
      src/views/main_views.js
  71. 4 4
      src/views/maps_view.js
  72. 10 4
      src/views/market_view.js
  73. 2 0
      src/views/modules_view.js
  74. 4 4
      src/views/opinions_view.js
  75. 6 5
      src/views/pads_view.js
  76. 12 12
      src/views/parliament_view.js
  77. 6 16
      src/views/peers_view.js
  78. 2 2
      src/views/pixelia_view.js
  79. 16 10
      src/views/projects_view.js
  80. 12 6
      src/views/report_view.js
  81. 23 37
      src/views/search_view.js
  82. 3 3
      src/views/shops_view.js
  83. 377 308
      src/views/stats_view.js
  84. 14 8
      src/views/task_view.js
  85. 3 3
      src/views/torrents_view.js
  86. 5 5
      src/views/transfer_view.js
  87. 2 2
      src/views/trending_view.js
  88. 22 22
      src/views/tribes_view.js
  89. 7 4
      src/views/video_view.js
  90. 2 2
      src/views/vote_view.js
  91. 3 3
      src/views/wallet_view.js

+ 5 - 1
.gitignore

@@ -1,5 +1,9 @@
 node_modules/
+packages/
 *.gguf
+*.onnx
+*.tar.gz
+src/AI/embeddings/
+src/AI/.cache/
 .update_required
-packages/
 cache/

+ 2 - 0
README.md

@@ -65,6 +65,7 @@ Oasis is TRULY MODULAR. Here's a list of what comes deployed with the "core".
 
  + Agenda: Module to manage all your assigned items.
  + AI: Module to talk with a LLM called '42'.
+ + AINav: Module for natural-language queries about the network's content.
  + Audios: Module to discover and manage audios.
  + Banking: Module to determine the real value of ECOIN and distribute a UBI using the common treasury.
  + BlockExplorer: Module to navigate the blockchain.
@@ -80,6 +81,7 @@ Oasis is TRULY MODULAR. Here's a list of what comes deployed with the "core".
  + Forums: Module to discover and manage forums.
  + Games: Module to play and share your scores in various mini-games.	
  + Governance: Module to discover and manage votes.	
+ + Graphos: Module to explore the network as an interactive map of peers.
  + Images: Module to discover and manage images.
  + Invites: Module to manage and apply invite codes.
  + Jobs: Module to discover and manage jobs.	

+ 38 - 0
docs/CHANGELOG.md

@@ -13,6 +13,44 @@ All notable changes to this project will be documented in this file.
 ### Security
 -->
 
+## v0.7.6 - 2026-05-09
+
+### Added
+
+- Graphos: interactive network map (Graphos plugin).
+- PUB systemd service unit (Documentation).
+- NameAuthor resolver (Core plugin).
+- Error page handlering (Core plugin).
+- Smart navigation prompt (AI plugin).
+
+### Changed
+
+- Advanced statistics dashboard (Core plugin).
+- Online peer detection (Core plugin).
+- Peers table: clickable keys and clearer columns (Core plugin).
+- Invites table layout (Invites plugin).
+- Menu reorganization: invites and peers moved to Tools (Core plugin).
+- CLI launcher: gui/server/help modes and argument forwarding (Core plugin).
+- PUB deployment guide rewritten for `sh oasis.sh server` (Documentation).
+- Tribe creator auto-follows new members at startup (Tribes plugin).
+- Blockexplorer decrypts encrypted tribe content transparently (Core plugin).
+- Standalone chat content is encrypted end-to-end (Chats plugin).
+- Standalone calendars (and their dates and notes) are encrypted end-to-end (Calendars plugin).
+- Calendar invite UI: author can generate codes; non-participants can validate a code (Calendars plugin).
+- Standalone maps (OPEN and CLOSED) are encrypted end-to-end (Maps plugin).
+- Blockexplorer attempts transparent decryption for any encrypted block (Core plugin).
+- Oasis flume LOCK stacktrace (Core plugin).
+
+### Fixed
+
+- Calendar chain removing (Calendars plugin).
+- Hide stack traces in unreachable pub errors (Invites plugin).
+- Tribe deletion now cascades to sub-tribes and their content (Tribes plugin).
+- Sub-tribe access requires parent membership (Tribes plugin).
+- Concurrent tribe updates now resolve deterministically (creator wins, then oldest member) (Tribes plugin).
+- Standalone chats, pads, maps and calendars require membership for direct URL access (Core plugin).
+- Maps CLOSED enforcement now applies to standalone maps (Maps plugin).
+
 ## v0.7.5 - 2026-05-01
 
 ### Added

+ 142 - 132
docs/PUB/deploy.md

@@ -1,82 +1,75 @@
 # Oasis PUB Deployment Guide
 
-This guide will walk you through the process of deploying an **Oasis PUB** on your server. 
+This guide walks you through deploying an **Oasis PUB** on a VPS using the Oasis launcher (`sh oasis.sh server`). A PUB needs a static, publicly-reachable IP address and an open TCP port (default `8008`).
 
 ---
 
-A PUB server needs a static, publicly-reachable IP address.
+## 1) Prepare the server
 
-By default it uses port 8008, so make sure to expose that port (or whatever port you configure) to the internet.
+Install the basics:
 
-## 1) Install NodeJS (LTS v18.20.8 for SSB-Server compatibility)
+```
+sudo apt-get update
+sudo apt-get install -y git curl build-essential
+```
 
-   sudo apt-get install git curl
-   curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
-   source ~/.bashrc
-   nvm install 18
-   nvm use 18
-   nvm alias default 18
+Install Node.js (Oasis is tested on Node 22; older LTS versions also work for server-only mode):
 
-## 2) Create a `~/.ssb/config` file and the `oasis-pub-server.sh` launch script
+```
+curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
+source ~/.bashrc
+nvm install 22
+nvm use 22
+nvm alias default 22
+```
 
-Before running the server, create a config file that enables needed plugins and network options.
+## 2) Clone Oasis
 
-   mkdir -p ~/.ssb
-   nano ~/.ssb/config
+```
+cd ~
+git clone https://code.03c8.net/krakenslab/oasis oasis
+cd oasis
+```
 
-Paste this:
+## 3) Install dependencies
 
+The `install.sh` script installs Node deps and applies the bundled patches. **You can skip the AI model download** — a PUB does not need it.
+
+```
+bash install.sh
+```
+
+If the AI model download fails or you skipped it, that's fine. The PUB will run without it.
+
+## 4) Configure the PUB
+
+Edit `src/configs/server-config.json`:
+
+```json
 {
-  "logging": {
-    "level": "info"
-  },
-  "caps": {
-    "shs": "zTmidAb7t+tKi7W93FIHbOvlbd936x6G/vm8e8Td//A="
-  },
+  "logging": { "level": "info" },
+  "caps": { "shs": "zTmidAb7t+tKi7W93FIHbOvlbd936x6G/vm8e8Td//A=" },
   "pub": true,
   "local": false,
-  "friends": {
-    "dunbar": 150,
-    "hops": 3
-  },
+  "friends": { "dunbar": 150, "hops": 3 },
   "gossip": {
     "connections": 50,
-    "seed": false,
-    "global": false
+    "friends": true,
+    "seed": true,
+    "global": true
   },
   "connections": {
     "incoming": {
       "net": [
-        {
-          "port": 8008,
-          "scope": "public",
-          "transform": "shs",
-          "external": "{your-hostname}"
-        },
-        {
-          "port": 8008,
-          "host": "localhost",
-          "scope": "device",
-          "transform": "shs"
-        }
+        { "port": 8008, "scope": "public", "transform": "shs", "external": "{your-hostname}" },
+        { "port": 8008, "host": "localhost", "scope": "device", "transform": "shs" }
       ],
       "unix": [
-        {
-          "scope": [
-            "device",
-            "local",
-            "private"
-          ],
-          "transform": "noauth"
-        }
+        { "scope": ["device", "local", "private"], "transform": "noauth" }
       ]
     },
     "outgoing": {
-      "net": [
-        {
-          "transform": "shs"
-        }
-      ]
+      "net": [{ "transform": "shs" }]
     }
   },
   "replicationScheduler": {
@@ -90,124 +83,141 @@ Paste this:
     ]
   }
 }
+```
+
+Replace `{your-hostname}` with your VPS public hostname or IP.
+
+## 5) Launch the PUB (server-only)
+
+In server-only mode Oasis runs **only the SSB sbot**, not the web GUI or AI service.
+
+Using `tmux` (simple):
+
+```
+tmux new -s oasis-pub
+cd ~/oasis
+sh oasis.sh server
+# detach: Ctrl-b, then d
+```
 
-Be sure to replace {your-hostname} with your server’s domain or IP.
+Using `systemd` (recommended for production). The repo ships a ready-to-use unit file at `docs/PUB/oasis-pub.service`. Copy it, edit the `YOUR_USER` placeholders (and the `node` path if you installed Node via `nvm`), then enable it:
 
-## 3) Install ssb-server and plugins locally
+```
+sudo cp ~/oasis/docs/PUB/oasis-pub.service /etc/systemd/system/oasis-pub.service
+sudo nano /etc/systemd/system/oasis-pub.service     # replace YOUR_USER, adjust PATH/ExecStart for nvm if needed
+sudo systemctl daemon-reload
+sudo systemctl enable --now oasis-pub
+sudo journalctl -u oasis-pub -f
+```
 
-   npm -g install ssb-server
+Tip — if you used `nvm` to install Node:
 
-   cd ~/.ssb
-   npm init -y
+```
+which node                                          # e.g. /home/YOUR_USER/.nvm/versions/node/v22.0.0/bin/node
+```
 
-   npm install ssb-ebt ssb-conn ssb-replication-scheduler ssb-blobs ssb-friends ssb-logging
-   
-   npm audit fix
-   
-## 4) Create the launch script and some patches
+Then in the unit file uncomment the `Environment=PATH=...` line that points at that nvm bin dir, or replace `ExecStart` with the absolute node path on `src/server/SSB_server.js start`.
 
-Save the following script at: ~/oasis-pub/patch-ssb-ref.js 
-   
-   const fs = require('fs');
-   const path = require('path');
+Data is written to `~/.ssb/`.
 
-   const ssbRefPath = path.resolve(__dirname, 'node_modules/ssb-ref/index.js'); // Adjust as needed
+## 6) Get your PUB ID
 
-   if (fs.existsSync(ssbRefPath)) {
-     const data = fs.readFileSync(ssbRefPath, 'utf8');
-     const patchedData = data.replace(
-       'exports.parseAddress = deprecate(\'ssb-ref.parseAddress\', parseAddress)',
-       'exports.parseAddress = parseAddress'
-     );
+For administrative actions (creating invites, publishing the PUB profile, announcing) you need the SSB CLI. Install `ssb-server` globally on the same machine:
 
-     if (data !== patchedData) {
-       fs.writeFileSync(ssbRefPath, patchedData);
-       console.log('[OASIS] [PATCH] Patched ssb-ref to remove deprecated usage of parseAddress');
-     }
-   }
+```
+npm install -g ssb-server
+```
 
-And make it executable:
+Then, from the PUB user's shell:
 
-   chmod +x ~/oasis-pub/patch-ssb-ref.js 
+```
+ssb-server whoami
+```
 
-Finally, save the following script at: ~/oasis-pub/oasis-pub-server.sh.
-    
-  #!/bin/bash
-  export NODE_OPTIONS="--no-deprecation"
+Example response:
 
-  cd ~/oasis-pub
-  node patch-ssb-ref.js
-  ssb-server start
-   
-And make it executable:
+```
+{ "id": "@mGrevRCSX4E5dLgmflWBc50Qkn/1RXUAtDaGHOJ8xB4=.ed25519" }
+```
 
-   chmod +x ~/oasis-pub/oasis-pub-server.sh
+> The CLI talks to the running sbot via the unix socket at `~/.ssb/socket`, so this works as long as `oasis.sh server` is running.
 
-## 5) Run the server script
+## 7) Set the PUB profile name
 
-Use a session-manager such as screen or tmux to create a detachable session. Start the session and run the script:
+Give your PUB a human-readable name:
 
-   ~/oasis-pub/oasis-pub-server.sh
+```
+ssb-server publish --type about --about {pub-id} --name "{pub-name}"
+```
 
-Then, detach the session.
+## 8) Create invite codes
 
-## 6) Create the Pub's profile
+```
+ssb-server invite.create 1
+```
 
-It's a good idea to give your PUB a name, by publishing one on its feed. 
+The number is how many times the code can be redeemed. For an open PUB you might use a large number:
 
-To do this, first get the PUB's ID, with: 
+```
+ssb-server invite.create 500
+```
 
-   cd ~/oasis-pub 
-   ssb-server whoami
-   
-   {
-     "id": "@mGrevRCSX4E5dLgmflWBc50Qkn/1RXUAtDaGHOJ8xB4=.ed25519"
-   }
+Distribute these codes to people who should join the PUB.
 
-Then, publish a name with the following command:
+## 9) Announce the PUB
 
-   ssb-server publish --type about --about {pub-id} --name {name}
+So clients can discover the PUB by its hostname, publish a `pub` message:
 
-## 7) Create Invites
+```
+ssb-server publish --type pub --address.key {pub-id} --address.host {your-hostname} --address.port 8008
+```
 
-For a last step, you should create invite codes, which you can send to other inhabitants to let them join the PUB. 
+Example for `solarnethub.com`:
 
-The command to create an invite code is:
+```
+ssb-server publish --type pub \
+  --address.key @mGrevRCSX4E5dLgmflWBc50Qkn/1RXUAtDaGHOJ8xB4=.ed25519 \
+  --address.host solarnethub.com \
+  --address.port 8008
+```
 
-   cd ~/oasis-pub 
-   ssb-server invite.create 1
+## 10) Follow another PUB (federation)
 
-This may now be given out to friends, to command your PUB to follow them. 
+Federate with other PUBs in the same Oasis network so they replicate each other:
 
-If you want to let a single code be used more than once, you can provide a number larger than 1.
+```
+ssb-server publish --type contact --contact {other-pub-id} --following
+```
 
-   cd ~/oasis-pub 
-   ssb-server invite.create 500
+Example, federating with `solarnethub.com`:
 
-## 8) Announce your PUB
+```
+ssb-server publish --type contact \
+  --contact "@mGrevRCSX4E5dLgmflWBc50Qkn/1RXUAtDaGHOJ8xB4=.ed25519" \
+  --following
+```
 
-To announce your PUB, publish this message:
+## 11) Health checks
 
-   cd ~/oasis-pub 
-   ssb-server publish --type pub --address.key {pub-id} --address.host {name} --address.port {number}
-   
-For example, to announce `solarnethub.com` PUB: "La Plaza":
+While the PUB is running, useful inspection commands:
 
-   ssb-server publish --type pub --address.key @mGrevRCSX4E5dLgmflWBc50Qkn/1RXUAtDaGHOJ8xB4=.ed25519 --address.host solarnethub.com --address.port 8008
-    
-## 9) Following another PUB
+```
+ssb-server status                 # peer / replication overview
+ssb-server gossip.peers           # cold-storage peer list with state
+ls -la ~/.ssb/                    # confirm flume/, blobs/, gossip.json, conn.json exist
+sudo journalctl -u oasis-pub -f   # tail service logs
+```
 
-To follow another PUB's feed, publish this other message:
+## 12) Disabling the AI module (only relevant if also running the GUI)
 
-   cd ~/oasis-pub 
-   ssb-server publish --type contact --contact {pub-id-to-follow} --following
-    
-For example, to follow `solarnethub.com` PUB: "La Plaza":
+`sh oasis.sh server` does **not** load the GUI or AI service, so `oasis-config.json` is ignored in server-only mode. Only `server-config.json` matters.
 
-   cd ~/oasis-pub 
-   ssb-server publish --type contact --contact "@mGrevRCSX4E5dLgmflWBc50Qkn/1RXUAtDaGHOJ8xB4=.ed25519" --following
+If you also run the GUI on the same VPS (`sh oasis.sh` without `server`), set `aiMod` to `off` in `src/configs/oasis-config.json` to skip the AI model:
 
-## 10) Join the Oasis PUB Network
+```
+sed -i 's/"aiMod": *"on"/"aiMod": "off"/' src/configs/oasis-config.json
+```
 
-To help your PUB discover other Oasis-connected peers and speed up replication, we’ve included the Oasis seed PUB at `solarnethub.com` in your config file.
+## 13) Joining the Oasis network
 
+The default seed PUB at `solarnethub.com` is included in `autofollow.suggestions` above. As soon as your PUB connects to it (or to any peer that knows about it), gossip propagates the rest of the network's pub list.

+ 26 - 0
docs/PUB/oasis-pub.service

@@ -0,0 +1,26 @@
+[Unit]
+Description=Oasis PUB
+Documentation=https://wiki.solarnethub.com/socialnet/snh-pub
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+Type=simple
+User=YOUR_USER
+WorkingDirectory=/home/YOUR_USER/oasis
+ExecStart=/bin/sh /home/YOUR_USER/oasis/oasis.sh server
+Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
+Restart=on-failure
+RestartSec=5
+KillSignal=SIGTERM
+TimeoutStopSec=30
+StandardOutput=journal
+StandardError=journal
+SyslogIdentifier=oasis-pub
+NoNewPrivileges=yes
+ProtectSystem=full
+ProtectHome=read-only
+ReadWritePaths=/home/YOUR_USER/.ssb /home/YOUR_USER/oasis
+
+[Install]
+WantedBy=multi-user.target

+ 77 - 9
install.sh

@@ -3,7 +3,7 @@
 cd src/server
 
 printf "==========================\n"
-printf "|| OASIS Installer v0.2 ||\n"
+printf "|| OASIS Installer v0.4 ||\n"
 printf "==========================\n"
 
 sudo apt-get install -y git curl tar
@@ -14,19 +14,87 @@ npm install .
 npm audit fix
 
 MODEL_DIR="../AI"
-MODEL_FILE="oasis-42-1-chat.Q4_K_M.gguf"
-MODEL_TAR="$MODEL_FILE.tar.gz"
-MODEL_URL="https://solarnethub.com/code/models/$MODEL_TAR"
+LLM_FILE="oasis-42-1-chat.Q4_K_M.gguf"
+LLM_TAR="$LLM_FILE.tar.gz"
+LLM_URL="https://solarnethub.com/code/models/$LLM_TAR"
+EMB_DIR="$MODEL_DIR/embeddings"
+EMB_TAR="oasis-embeddings.tar.gz"
+EMB_URL="https://solarnethub.com/code/models/$EMB_TAR"
+EMB_FILE="$EMB_DIR/onnx/model_quantized.onnx"
+CONFIG_PATH="../configs/oasis-config.json"
 
-if [ ! -f "$MODEL_DIR/$MODEL_FILE" ]; then
+CHOICE="${OASIS_AI:-}"
+
+if [ -z "$CHOICE" ] && [ -t 0 ]; then
+    echo ""
+    echo "Do you want to enable AI features in Oasis?"
+    echo ""
+    echo "  [1] Full AI: chat assistant (42) + smart navigation prompt (~3.9 GB)"
+    echo "  [2] Smart navigation only (~150 MB)"
+    echo "  [3] No AI features (no downloads, AI tabs hidden)"
+    echo ""
+    read -p "Choose [1/2/3] (default 3): " ANS
+    case "$ANS" in
+        1) CHOICE="full" ;;
+        2) CHOICE="nav" ;;
+        *) CHOICE="none" ;;
+    esac
+fi
+
+if [ -z "$CHOICE" ]; then
+    CHOICE="none"
+fi
+
+case "$CHOICE" in
+    full)
+        WANT_LLM=1
+        WANT_EMB=1
+        ;;
+    nav)
+        WANT_LLM=0
+        WANT_EMB=1
+        ;;
+    *)
+        WANT_LLM=0
+        WANT_EMB=0
+        ;;
+esac
+
+if [ "$WANT_LLM" = "1" ] && [ ! -f "$MODEL_DIR/$LLM_FILE" ]; then
     echo ""
     echo "downloading AI model [size: 3,8 GiB (4.081.004.224 bytes)] ..."
-    curl -L -o "$MODEL_DIR/$MODEL_TAR" "$MODEL_URL"
+    curl -L -o "$MODEL_DIR/$LLM_TAR" "$LLM_URL"
+    echo ""
+    echo "extracting package: $LLM_TAR..."
+    echo ""
+    tar -xzf "$MODEL_DIR/$LLM_TAR" -C "$MODEL_DIR"
+    rm "$MODEL_DIR/$LLM_TAR"
+fi
+
+if [ "$WANT_EMB" = "1" ] && [ ! -f "$EMB_FILE" ]; then
+    echo ""
+    echo "downloading embeddings model [size: ~60 MiB] ..."
+    curl -L -o "$MODEL_DIR/$EMB_TAR" "$EMB_URL"
     echo ""
-    echo "extracting package: $MODEL_TAR..."
+    echo "extracting package: $EMB_TAR..."
     echo ""
-    tar -xzf "$MODEL_DIR/$MODEL_TAR" -C "$MODEL_DIR"
-    rm "$MODEL_DIR/$MODEL_TAR"
+    tar -xzf "$MODEL_DIR/$EMB_TAR" -C "$MODEL_DIR"
+    rm "$MODEL_DIR/$EMB_TAR"
+fi
+
+if [ -f "$CONFIG_PATH" ]; then
+    AI_VAL="off"; NAV_VAL="off"
+    [ "$WANT_LLM" = "1" ] && AI_VAL="on"
+    [ "$WANT_EMB" = "1" ] && NAV_VAL="on"
+    node -e "
+const fs = require('fs');
+const p = '$CONFIG_PATH';
+const cfg = JSON.parse(fs.readFileSync(p, 'utf8'));
+cfg.modules = cfg.modules || {};
+cfg.modules.aiMod = '$AI_VAL';
+cfg.modules.aiNavMod = '$NAV_VAL';
+fs.writeFileSync(p, JSON.stringify(cfg, null, 2) + '\n');
+"
 fi
 
 printf "==========================\n"

+ 48 - 7
oasis.sh

@@ -5,6 +5,35 @@ MODE=$1
 MODEL_PATH="$CURRENT_DIR/src/AI/oasis-42-1-chat.Q4_K_M.gguf"
 CONFIG_FILE="$CURRENT_DIR/src/configs/oasis-config.json"
 
+show_help() {
+  cat <<'EOF'
+Usage: sh oasis.sh [mode] [-- <option>=<value> ...]
+
+Modes:
+  gui             Launch the Oasis web GUI (default).
+  server          Launch only the Oasis backend in headless / pub mode.
+  help, -h        Show this help message.
+
+GUI options (forwarded to the backend):
+  --host=<ip>           Hostname / IP the web UI listens on (default: localhost).
+                        Use 0.0.0.0 to expose on a VPS.
+  --port=<n>            Port for the web UI (default: 3000).
+  --allow-host=<host>   Extra hostname allowed when behind a reverse proxy.
+  --public              Public-hosting mode: disables POST and redacts content
+                        from people who haven't opted in to public hosting.
+  --offline             Don't try to connect to Oasis peers / pubs.
+  --no-open             Don't auto-open a browser tab on launch (useful on a VPS).
+  --debug               Verbose logging.
+
+Examples:
+  sh oasis.sh
+  sh oasis.sh server
+  sh oasis.sh --host=0.0.0.0 --port=8080 --no-open
+  sh oasis.sh --public --no-open --host=0.0.0.0 --port=8080
+  sh oasis.sh --allow-host=oasis.example.com --no-open
+EOF
+}
+
 if [ -f "$CONFIG_FILE" ]; then
   if [ -f "$MODEL_PATH" ]; then
     sed -i.bak 's/"aiMod": *"off"/"aiMod": "on"/' "$CONFIG_FILE"
@@ -14,10 +43,22 @@ if [ -f "$CONFIG_FILE" ]; then
   rm -f "$CONFIG_FILE.bak"
 fi
 
-if [ "$MODE" = "server" ]; then
-  cd "$CURRENT_DIR/src/server" || exit 1
-  exec node SSB_server.js start
-else
-  cd "$CURRENT_DIR/src/backend" || exit 1
-  exec node backend.js
-fi
+case "$MODE" in
+  help|-h|--help)
+    show_help
+    exit 0
+    ;;
+  server|pub)
+    cd "$CURRENT_DIR/src/server" || exit 1
+    exec node SSB_server.js start
+    ;;
+  gui)
+    shift
+    cd "$CURRENT_DIR/src/backend" || exit 1
+    exec node backend.js "$@"
+    ;;
+  *)
+    cd "$CURRENT_DIR/src/backend" || exit 1
+    exec node backend.js "$@"
+    ;;
+esac

+ 72 - 0
src/AI/embedder.js

@@ -0,0 +1,72 @@
+const path = require('path')
+const fs = require('fs')
+
+const MODEL_DIR = path.join(__dirname, 'embeddings')
+const MODEL_FILE = path.join(MODEL_DIR, 'onnx', 'model_quantized.onnx')
+
+let pipelinePromise = null
+let unavailableReason = null
+
+const isInstalled = () => {
+  try {
+    return fs.existsSync(MODEL_FILE)
+  } catch (_) {
+    return false
+  }
+}
+
+const dot = (a, b) => {
+  let s = 0
+  for (let i = 0; i < a.length; i++) s += a[i] * b[i]
+  return s
+}
+
+const norm = (a) => Math.sqrt(dot(a, a)) || 1
+
+const cosine = (a, b) => dot(a, b) / (norm(a) * norm(b))
+
+const ensurePipeline = async () => {
+  if (pipelinePromise) return pipelinePromise
+  if (!isInstalled()) {
+    unavailableReason = 'model_not_installed'
+    return null
+  }
+  pipelinePromise = (async () => {
+    let mod
+    try {
+      const url = require('url')
+      const transformersPath = path.join(__dirname, '..', 'server', 'node_modules', '@xenova', 'transformers', 'src', 'transformers.js')
+      mod = await import(url.pathToFileURL(transformersPath).href)
+    } catch (e) {
+      unavailableReason = 'transformers_not_installed:' + (e && e.message ? e.message : 'unknown')
+      return null
+    }
+    const { pipeline, env } = mod
+    env.allowRemoteModels = false
+    env.localModelPath = path.join(__dirname)
+    env.cacheDir = path.join(__dirname, '.cache')
+    try {
+      const fe = await pipeline('feature-extraction', 'embeddings', { quantized: true })
+      return fe
+    } catch (e) {
+      unavailableReason = 'load_failed:' + (e && e.message ? e.message : 'unknown')
+      return null
+    }
+  })()
+  return pipelinePromise
+}
+
+const embed = async (text) => {
+  const fe = await ensurePipeline()
+  if (!fe) return null
+  const out = await fe(String(text || '').trim(), { pooling: 'mean', normalize: true })
+  return Array.from(out.data)
+}
+
+const status = () => ({
+  installed: isInstalled(),
+  loaded: !!pipelinePromise,
+  unavailableReason
+})
+
+module.exports = { embed, cosine, isInstalled, status, MODEL_DIR, MODEL_FILE }

+ 114 - 0
src/AI/routes_index.js

@@ -0,0 +1,114 @@
+const path = require('path')
+const fs = require('fs')
+
+const ROUTES = [
+  { path: '/public/latest', mod: null,         description: 'home, latest posts, public timeline, news, recent activity' },
+  { path: '/feed',          mod: 'feedMod',    description: 'feed, microblog, opinions, share thoughts, vote on posts, refeeds' },
+  { path: '/forum',         mod: 'forumMod',   description: 'forum, discussions, threads, debates, conversation by category' },
+  { path: '/inhabitants',   mod: 'inhabitantsMod', description: 'inhabitants, users, people, profiles, contacts, follow, block' },
+  { path: '/tribes',        mod: 'tribeMod',   description: 'tribes, groups, communities, private rooms, sub-tribes, governance' },
+  { path: '/chats',         mod: 'chatMod',    description: 'chats, messaging, encrypted rooms, group conversations' },
+  { path: '/pads',          mod: 'padMod',     description: 'pads, collaborative editor, shared notes, encrypted documents' },
+  { path: '/calendars',     mod: 'calendarMod', description: 'calendar, events by date, schedule, reminders, recurring dates' },
+  { path: '/maps',          mod: 'mapMod',     description: 'maps, locations, markers, geography, places' },
+  { path: '/events',        mod: 'eventMod',   description: 'events, agenda, meetups, gatherings, RSVP' },
+  { path: '/agenda',        mod: 'agendaMod',  description: 'agenda, scheduled items, upcoming, my dates' },
+  { path: '/tasks',         mod: 'taskMod',    description: 'tasks, todo, assignments, work items, priorities' },
+  { path: '/projects',      mod: 'projectMod', description: 'projects, milestones, backers, crowdfunding, bounties' },
+  { path: '/jobs',          mod: 'jobMod',     description: 'jobs, work, hiring, salaries, vacancies, applications' },
+  { path: '/market',        mod: 'marketMod',  description: 'market, marketplace, buy, sell, items, auctions, ECO' },
+  { path: '/shops',         mod: 'shopMod',    description: 'shops, stores, products, ecommerce, vendors' },
+  { path: '/banking',       mod: 'bankingMod', description: 'banking, wallet, ECO balance, send money, transfers, payments, UBI claim' },
+  { path: '/transfers',     mod: 'transferMod', description: 'transfers, payments, money movements, ECO transactions, history' },
+  { path: '/wallet',        mod: 'walletMod',  description: 'wallet, ECOin address, send and receive, QR code, balance' },
+  { path: '/parliament',    mod: 'parliamentMod', description: 'parliament, governance, government, proposals, laws, leaders, voting' },
+  { path: '/courts',        mod: 'courtsMod',  description: 'courts, judges, accusations, mediators, justice, disputes' },
+  { path: '/votations',     mod: 'votationsMod', description: 'votations, polls, surveys, multi-option votes' },
+  { path: '/votes',         mod: 'votesMod',   description: 'votes, ballots, decisions, polling, voting' },
+  { path: '/opinions',      mod: 'opinionsMod', description: 'opinions, reactions, ratings, sentiment, expressing views' },
+  { path: '/trending',      mod: 'trendingMod', description: 'trending, popular, hot, top voted, what is being discussed' },
+  { path: '/reports',       mod: 'reportsMod', description: 'reports, bug reports, abuse, incidents, severity, confirmations' },
+  { path: '/audios',        mod: 'audioMod',   description: 'audios, music, podcasts, voice recordings, sound files' },
+  { path: '/videos',        mod: 'videoMod',   description: 'videos, films, clips, recordings, watch' },
+  { path: '/images',        mod: 'imageMod',   description: 'images, photos, pictures, gallery, memes' },
+  { path: '/documents',     mod: 'documentMod', description: 'documents, PDFs, files, papers, references' },
+  { path: '/bookmarks',     mod: 'bookmarkMod', description: 'bookmarks, links, saved websites, favorites' },
+  { path: '/torrents',      mod: 'torrentMod', description: 'torrents, magnet links, file sharing, downloads' },
+  { path: '/tags',          mod: 'tagsMod',    description: 'tags, hashtags, topics, categories, labels' },
+  { path: '/search',        mod: null,         description: 'search, find, query, lookup' },
+  { path: '/inbox',         mod: null,         description: 'inbox, notifications, mentions, alerts, messages addressed to me' },
+  { path: '/pm',            mod: 'privateMessageMod', description: 'private messages, direct messages, DMs, encrypted PM' },
+  { path: '/publish',       mod: null,         description: 'publish, write, create post, new entry, compose' },
+  { path: '/games',         mod: 'gameMod',    description: 'games, play, mini-games, scoring, fun' },
+  { path: '/pixelia',       mod: 'pixeliaMod', description: 'pixelia, pixel canvas, draw, collaborative pixel art' },
+  { path: '/cv',            mod: 'cvMod',      description: 'cv, curriculum, resume, my profile, skills, experiences' },
+  { path: '/legacy',        mod: 'legacyMod',  description: 'legacy, export data, import, backup, restore identity' },
+  { path: '/cipher',        mod: 'cipherMod',  description: 'cipher, encrypt, decrypt, password, vault' },
+  { path: '/stats',         mod: 'statsMod',   description: 'stats, statistics, KPIs, metrics, dashboard, carbon footprint' },
+  { path: '/blockchain',    mod: 'blockchainMod', description: 'blockchain, blocks, explorer, ledger, chain' },
+  { path: '/peers',         mod: 'peersMod',   description: 'peers, connections, network, nodes, who am I connected to' },
+  { path: '/invites',       mod: 'invitesMod', description: 'invites, pub invitations, join code, follow PUB' },
+  { path: '/graphos',       mod: 'graphosMod', description: 'graphos, network map, visualization, relationship graph' },
+  { path: '/modules',       mod: null,         description: 'modules, features, enable disable plugins, settings' },
+  { path: '/settings',      mod: null,         description: 'settings, preferences, language, theme, configuration' },
+  { path: '/favorites',     mod: 'favoritesMod', description: 'favorites, starred items, saved content' },
+  { path: '/logs',          mod: 'logsMod',    description: 'logs, life log, personal records, journal, experiences' }
+]
+
+const CACHE_FILE = path.join(__dirname, 'embeddings', 'routes_cache.json')
+
+let cache = null
+
+const buildCacheKey = () => ROUTES.map(r => r.path + '|' + r.description).join('\n')
+
+const loadCache = () => {
+  try {
+    if (!fs.existsSync(CACHE_FILE)) return null
+    const data = JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8'))
+    if (data.key === buildCacheKey() && Array.isArray(data.entries)) return data.entries
+    return null
+  } catch (_) {
+    return null
+  }
+}
+
+const saveCache = (entries) => {
+  try {
+    fs.mkdirSync(path.dirname(CACHE_FILE), { recursive: true })
+    fs.writeFileSync(CACHE_FILE, JSON.stringify({ key: buildCacheKey(), entries }, null, 2), 'utf8')
+  } catch (_) {}
+}
+
+const ensureIndex = async ({ embed }) => {
+  if (cache) return cache
+  const cached = loadCache()
+  if (cached) { cache = cached; return cache }
+  const entries = []
+  for (const r of ROUTES) {
+    const vec = await embed(r.description)
+    if (!vec) return null
+    entries.push({ path: r.path, mod: r.mod, vector: vec })
+  }
+  cache = entries
+  saveCache(entries)
+  return cache
+}
+
+const resolveBest = async (queryVector, { isModuleEnabled, threshold = 0.4, embed } = {}) => {
+  const idx = await ensureIndex({ embed })
+  if (!idx) return null
+  let best = null
+  for (const entry of idx) {
+    if (entry.mod && typeof isModuleEnabled === 'function' && !isModuleEnabled(entry.mod)) continue
+    const score = (() => {
+      let s = 0
+      for (let i = 0; i < queryVector.length; i++) s += queryVector[i] * entry.vector[i]
+      return s
+    })()
+    if (!best || score > best.score) best = { path: entry.path, score }
+  }
+  if (!best || best.score < threshold) return null
+  return best
+}
+
+module.exports = { ROUTES, ensureIndex, resolveBest }

+ 326 - 92
src/backend/backend.js

@@ -130,6 +130,37 @@ const sanitizeMsgText = (msg) => {
 const sanitizeMessages = (msgs) => Array.isArray(msgs) ? msgs.map(sanitizeMsgText) : msgs;
 
 const parseBool01 = v => String(Array.isArray(v) ? v[v.length - 1] : v || '') === '1';
+const sendErrorPage = (ctx, message, { title, status } = {}) => {
+  const { errorView } = require('../views/main_views');
+  const ref = ctx.request.header.referer;
+  let backHref = '/';
+  try {
+    if (ref) {
+      const u = new URL(ref);
+      if ((u.protocol === 'http:' || u.protocol === 'https:') && u.host === ctx.host) {
+        backHref = u.pathname + u.search + u.hash;
+      }
+    }
+  } catch (_) {}
+  if (status) ctx.status = status;
+  ctx.type = 'html';
+  ctx.body = errorView({ title, message, backHref });
+};
+
+const safeRefererRedirect = (ctx, fallback = '/') => {
+  const ref = ctx.request.header.referer;
+  if (!ref) { ctx.redirect(fallback); return; }
+  try {
+    const u = new URL(ref);
+    if ((u.protocol !== 'http:' && u.protocol !== 'https:') || u.host !== ctx.host) {
+      ctx.redirect(fallback);
+      return;
+    }
+    ctx.redirect(u.pathname + u.search + u.hash);
+  } catch (_) {
+    ctx.redirect(fallback);
+  }
+};
 const checkMod = (ctx, mod) => {
   const cfg = getConfig();
   const serverValue = cfg.modules?.[mod];
@@ -208,6 +239,11 @@ Alternatively, you can set the default port in ${defaultConfigFile} with:
         }
       });
     });
+  } else if (err && (err.name === 'OpenError' || (typeof err.message === 'string' && /Resource temporarily unavailable/i.test(err.message) && /\.ssb\/.*LOCK/i.test(err.message)))) {
+    console.log("");
+    console.log("Another Oasis instance is already running on this machine. Close the other instance (or kill the process) and try again.");
+    console.log("");
+    process.exit(1);
   } else {
     console.log("");
     console.log("Oasis traceback (share below content with devs to report!):");
@@ -278,7 +314,7 @@ const statsModel = require('../models/stats_model')({ cooler, isPublic: config.p
 const padsModel = require('../models/pads_model')({ cooler, cipherModel, tribeCrypto, tribesModel });
 const tagsModel = require('../models/tags_model')({ cooler, isPublic: config.public, padsModel, tribesModel });
 const tribesContentModel = require('../models/tribes_content_model')({ cooler, isPublic: config.public, tribeCrypto, tribesModel });
-const searchModel = require('../models/search_model')({ cooler, isPublic: config.public, padsModel });
+const searchModel = require('../models/search_model')({ cooler, isPublic: config.public, padsModel, tribeCrypto, tribesModel });
 const activityModel = require('../models/activity_model')({ cooler, isPublic: config.public });
 const pixeliaModel = require('../models/pixelia_model')({ cooler, isPublic: config.public });
 const marketModel = require('../models/market_model')({ cooler, isPublic: config.public, tribeCrypto });
@@ -454,6 +490,7 @@ tribesModel.processIncomingKeys().then(async () => {
     const mine = (await tribesModel.listAll()).filter(t => t.author === viewerId);
     for (const t of mine) {
       await tribesModel.ensureTribeKeyDistribution(t.id).catch(() => {});
+      await tribesModel.ensureFollowTribeMembers(t.id).catch(() => {});
     }
   } catch (_) {}
 }).catch(err => {
@@ -552,6 +589,12 @@ const qp = (ctx, def = 1) => Math.max(1, parseInt(ctx.query.page) || def);
 about._startNameWarmup();
 async function renderBlobMarkdown(text, mentions = {}, myFeedId, myUsername) {
   if (!text) return '';
+  const escHtml = (s) => String(s)
+    .replace(/&/g, '&amp;')
+    .replace(/</g, '&lt;')
+    .replace(/>/g, '&gt;')
+    .replace(/"/g, '&quot;')
+    .replace(/'/g, '&#39;');
   const mentionByFeed = {};
   Object.values(mentions).forEach(arr => {
     arr.forEach(m => {
@@ -559,7 +602,7 @@ async function renderBlobMarkdown(text, mentions = {}, myFeedId, myUsername) {
     });
   });
   text = text.replace(/\[@([^\]]+)\]\(([^)]+)\)/g, (_, name, id) => {
-    return `<a class="mention" href="/author/${encodeURIComponent(id)}">@${name}</a>`;
+    return `<a class="mention" href="/author/${encodeURIComponent(id)}">@${escHtml(name)}</a>`;
   });
   const words = text.split(' ');
   text = (await Promise.all(
@@ -574,7 +617,7 @@ async function renderBlobMarkdown(text, mentions = {}, myFeedId, myUsername) {
         } else {
           try { resolvedName = await about.name(feedWithAt); } catch { resolvedName = feedId.slice(0, 8); }
         }
-        return word.replace(match[0], `<a class="mention" href="/author/${encodeURIComponent(feedWithAt)}">@${resolvedName}</a>`);
+        return word.replace(match[0], `<a class="mention" href="/author/${encodeURIComponent(feedWithAt)}">@${escHtml(resolvedName)}</a>`);
       }
       return word;
     })
@@ -588,7 +631,8 @@ async function renderBlobMarkdown(text, mentions = {}, myFeedId, myUsername) {
       `<video controls class="post-video" src="/blob/${encodeURIComponent(id)}"></video>`)
     .replace(/\[pdf:([^\]]*)\]\(([^)]+)\)/g, (_, name, id) => {
       const { i18n } = require("../views/main_views");
-      return `<a class="post-pdf" href="/blob/${encodeURIComponent(id)}" target="_blank">${name || (i18n && i18n.pdfFallbackLabel) || 'PDF'}</a>`;
+      const label = name || (i18n && i18n.pdfFallbackLabel) || 'PDF';
+      return `<a class="post-pdf" href="/blob/${encodeURIComponent(id)}" target="_blank">${escHtml(label)}</a>`;
     });
   return text;
 }
@@ -616,6 +660,7 @@ async function resolveMentionText(text) {
 
 const preparePreview = async function (ctx) {
   let text = String(ctx.request.body.text || "")
+  if (text.length > 8000) text = text.slice(0, 8000)
   const contentWarning = stripDangerousTags(String(ctx.request.body.contentWarning || ""))
   const ensureAt = (id) => {
     const s = String(id || "")
@@ -846,6 +891,7 @@ const { feedView, feedCreateView, singleFeedView } = require("../views/feed_view
 const { legacyView } = require("../views/legacy_view");
 const { opinionsView } = require("../views/opinions_view");
 const { peersView } = require("../views/peers_view");
+const { graphosView } = require("../views/graphos_view");
 const { searchView } = require("../views/search_view");
 const { transferView, singleTransferView } = require("../views/transfer_view");
 const { cipherView } = require("../views/cipher_view");
@@ -861,7 +907,7 @@ const { jobsView, singleJobsView, renderJobForm } = require("../views/jobs_view"
 const { shopsView, singleShopView, singleProductView, editProductView } = require("../views/shops_view");
 const { chatsView, singleChatView, renderChatInvitePage } = require("../views/chats_view");
 const { padsView, singlePadView, renderPadInvitePage } = require("../views/pads_view");
-const { calendarsView, singleCalendarView } = require("../views/calendars_view");
+const { calendarsView, singleCalendarView, renderCalendarInvitePage } = require("../views/calendars_view");
 const { projectsView, singleProjectView } = require("../views/projects_view")
 const { renderBankingView, renderSingleAllocationView, renderEpochView } = require("../views/banking_views")
 const { favoritesView } = require("../views/favorites_view");
@@ -891,6 +937,38 @@ const tooLong = (ctx, value, max, label) => {
   }
   return false;
 };
+
+const buildEffectivePrivateChainIds = async () => {
+  const ids = new Set();
+  const all = await tribesModel.listAll().catch(() => []);
+  for (const tr of all) {
+    try {
+      const eff = await tribesModel.getEffectiveStatus(tr.id);
+      if (!eff || !eff.isPrivate) continue;
+      const chain = await tribesModel.getChainIds(tr.id).catch(() => [tr.id]);
+      for (const cid of chain) ids.add(cid);
+    } catch (_) {}
+  }
+  return ids;
+};
+
+const isBlockRestricted = (block, effPrivateChainIds) => {
+  if (!block) return false;
+  const c = block.content || {};
+  const t = c.type || block.type || '';
+  const isPrivate = String(c.isPublic || '').toLowerCase() === 'private';
+  const tribeMsgInPrivate = t === 'tribe' && (effPrivateChainIds.has(block.id) || (c.replaces && effPrivateChainIds.has(c.replaces)));
+  const tribeKeysInPrivate = t === 'tribe-keys' && c.tribeId && effPrivateChainIds.has(c.tribeId);
+  const tribeContentInPrivate = !!c.tribeId && effPrivateChainIds.has(c.tribeId);
+  return tribeMsgInPrivate ||
+    tribeKeysInPrivate ||
+    tribeContentInPrivate ||
+    t.startsWith('courts') ||
+    t === 'job' || t === 'job_sub' ||
+    c.status === 'INVITE-ONLY' || c.status === 'PRIVATE' ||
+    isPrivate;
+};
+
 router
   .param("imageSize", (imageSize, ctx, next) => {
     const size = Number(imageSize);
@@ -999,15 +1077,9 @@ router
     let filter = query.filter || 'recent';
     if (searchActive && String(filter).toLowerCase() === 'recent') filter = 'all';
     const blockchainData = await blockchainModel.listBlockchain(filter, userId, search);
-    const allTribesList = await tribesModel.listAll().catch(() => []);
-    const anonTribeSet = new Set(allTribesList.filter(tr => tr.isAnonymous === true).map(tr => tr.id));
+    const effPrivateChainIds = await buildEffectivePrivateChainIds();
     for (const block of blockchainData) {
-      const c = block.content || {};
-      const t = c.type || block.type || '';
-      const isPrivate = String(c.isPublic || '').toLowerCase() === 'private';
-      block.restricted = t === 'tribe' || t.startsWith('courts') || t === 'job' || t === 'job_sub' ||
-        c.status === 'INVITE-ONLY' || c.status === 'PRIVATE' ||
-        (c.tribeId && anonTribeSet.has(c.tribeId)) || isPrivate;
+      block.restricted = isBlockRestricted(block, effPrivateChainIds);
     }
     ctx.body = renderBlockchainView(blockchainData, filter, userId, search);
   })
@@ -1024,21 +1096,21 @@ router
     let filter = query.filter || 'recent';
     if (searchActive && String(filter).toLowerCase() === 'recent') filter = 'all';
     const blockId = ctx.params.id;
-    const block = await blockchainModel.getBlockById(blockId, userId);
+    let block = await blockchainModel.getBlockById(blockId, userId);
     const viewMode = query.view || 'block';
     let restricted = false;
     if (block) {
-      const c = block.value?.content || {};
-      const t = c.type || '';
-      const allTribes = await tribesModel.listAll().catch(() => []);
-      const anonTribeIds = new Set(allTribes.filter(tr => tr.isAnonymous === true).map(tr => tr.id));
-      const isPrivate = String(c.isPublic || '').toLowerCase() === 'private';
-      restricted = t === 'tribe' ||
-        t.startsWith('courts') ||
-        t === 'job' || t === 'job_sub' ||
-        c.status === 'INVITE-ONLY' || c.status === 'PRIVATE' ||
-        (c.tribeId && anonTribeIds.has(c.tribeId)) ||
-        isPrivate;
+      const effPrivateChainIds = await buildEffectivePrivateChainIds();
+      restricted = isBlockRestricted(block, effPrivateChainIds);
+      const c = block.content || {};
+      if (!restricted && c.encryptedPayload && tribeCrypto) {
+        try {
+          const decrypted = await tribeCrypto.decryptFromTribe(c, tribesModel);
+          if (decrypted && !decrypted._undecryptable) {
+            block = { ...block, content: decrypted };
+          }
+        } catch (_) {}
+      }
     }
     ctx.body = renderSingleBlockView(block, filter, userId, search, viewMode, restricted);
   })
@@ -1202,8 +1274,13 @@ router
         if (!parentTribe.members.includes(uid)) { ctx.body = tribeAccessDeniedView(parentTribe); return; }
         tribeMembers = parentTribe.members;
       } catch { ctx.redirect('/tribes'); return; }
+    } else {
+      const members = Array.isArray(mapItem.members) ? mapItem.members : [];
+      const mt = String(mapItem.mapType || '').toUpperCase();
+      const isOpenAccess = mt === 'OPEN' || mt === 'SINGLE';
+      if (!isOpenAccess && mapItem.author !== uid && !members.includes(uid)) { ctx.redirect('/maps?filter=all'); return; }
     }
-    if (String(mapItem.mapType || '').toUpperCase() === 'CLOSED' && mapItem.author !== uid && mapItem.tribeId) {
+    if (String(mapItem.mapType || '').toUpperCase() === 'CLOSED' && mapItem.author !== uid) {
       ctx.body = tribeAccessDeniedView(parentTribe); return;
     }
     ctx.body = await singleMapView({ ...mapItem, isFavorite: fav.has(String(mapItem.rootId || mapItem.key)) }, filter, { q, zoom, mkLat, mkLng, mkMarkerLabel, tribeMembers, returnTo: safeReturnTo(ctx, `/maps?filter=${encodeURIComponent(filter)}`, ['/maps']) });
@@ -1568,7 +1645,8 @@ router
   })
   .get('/tribes', async ctx => {
     if (!checkMod(ctx, 'tribesMod')) { ctx.redirect('/modules'); return; }
-    const filter = qf(ctx), search = ctx.query.search || '', tribes = await tribesModel.listAll();
+    const uid = getViewerId();
+    const filter = qf(ctx), search = ctx.query.search || '', tribes = await tribesModel.listTribesForViewer(uid);
     const filteredTribes = search ? tribes.filter(t => t.title.toLowerCase().includes(search.toLowerCase())) : tribes;
     ctx.body = await tribesView(filteredTribes, filter, null, ctx.query, tribes);
   })
@@ -1592,7 +1670,8 @@ router
       const seen = new Set();
       return results.flat().filter(item => { const k = item.id || item.key; if (seen.has(k)) return false; seen.add(k); return true; });
     };
-    const tribe = await tribesModel.getTribeById(ctx.params.tribeId);
+    const tribe = await tribesModel.getTribeById(ctx.params.tribeId).catch(() => null);
+    if (!tribe) { ctx.redirect('/tribes'); return; }
     const uid = getViewerId();
     const query = { feedFilter: 'TOP', ...ctx.query };
     if (!tribe.members.includes(uid)) {
@@ -1613,14 +1692,14 @@ router
       const replies = await listByTribeAllChain(tribe.id, 'forum-reply');
       sectionData = [...forums, ...replies];
     } else if (section === 'subtribes') {
-      sectionData = await tribesModel.listSubTribes(tribe.id);
+      sectionData = await tribesModel.listSubTribes(tribe.id, uid);
     } else if (mediaSections[section]) {
       sectionData = await listByTribeAllChain(tribe.id, 'media');
     } else if (contentTypeMap[section]) {
       sectionData = await listByTribeAllChain(tribe.id, contentTypeMap[section]);
     } else if (section === 'activity') {
       const allContent = await listByTribeAllChain(tribe.id, null);
-      const subTribes = await tribesModel.listSubTribes(tribe.id);
+      const subTribes = await tribesModel.listSubTribes(tribe.id, uid);
       const subContent = [];
       for (const st of subTribes) {
         const stItems = await listByTribeAllChain(st.id, null).catch(() => []);
@@ -1668,7 +1747,7 @@ router
         calendarsModel.listAll({ filter: 'all', viewerId: uid }).catch(() => []),
         mapsModel.listAll({ filter: 'all', q: '', viewerId: uid }).catch(() => []),
         torrentsModel.listAll({ filter: 'all', viewerId: uid }).catch(() => []),
-        tribesModel.listSubTribes(tribe.id).catch(() => []),
+        tribesModel.listSubTribes(tribe.id, uid).catch(() => []),
         tribesModel.getChainIds(tribe.id).catch(() => [tribe.id])
       ]);
       const tribeChainSetT = new Set(tribeChainT);
@@ -1788,7 +1867,7 @@ router
       const hasElectedCandidate = Array.isArray(candidatures) && candidatures.some(c => (c.status || 'OPEN') === 'OPEN' && Number(c.votes || 0) > 0);
       sectionData = { filter: gf, term, candidatures, rules, leaders, isCreator, isMember, canPublishToGlobal: isMember || isCreator, alreadyPublishedThisGlobalCycle, hasElectedCandidate };
     }
-    const subTribes = await tribesModel.listSubTribes(tribe.id);
+    const subTribes = await tribesModel.listSubTribes(tribe.id, uid);
     tribe.subTribes = subTribes;
     if (tribe.parentTribeId) {
       try { tribe.parentTribe = await tribesModel.getTribeById(tribe.parentTribeId); } catch (_) {}
@@ -1934,6 +2013,56 @@ router
     const { discoveredPeers, unknownPeers } = await meta.discovered();
     ctx.body = await peersView({ onlinePeers: await meta.onlinePeers(), discoveredPeers, unknownPeers });
   })
+  .get("/graphos", async (ctx) => {
+    if (!checkMod(ctx, 'graphosMod')) return ctx.redirect('/modules');
+    const filter = String(ctx.query?.filter || 'ALL').toUpperCase() === 'MINE' ? 'MINE' : 'ALL';
+    const onlinePeers = await meta.onlinePeers();
+    const { discoveredPeers, unknownPeers } = filter === 'MINE'
+      ? { discoveredPeers: [], unknownPeers: [] }
+      : await meta.discovered();
+    const ssb = await require('../client/gui')({ offline: require('../server/ssb_config').offline }).open();
+    const myId = ssb.id;
+    const shortId = (key) => {
+      const core = String(key).replace(/^@/, '').replace(/\.ed25519$/, '');
+      return '@' + core.slice(0, 8) + '…';
+    };
+    const resolveName = async (key) => {
+      try {
+        const n = await about.name(key);
+        if (!n) return shortId(key);
+        if (n === 'Redacted') return shortId(key);
+        if (n === String(key).replace(/^@/, '').slice(0, 8)) return shortId(key);
+        return n;
+      } catch {
+        return shortId(key);
+      }
+    };
+    const seen = new Set([myId]);
+    const collected = [];
+    const collect = (entries, kind) => {
+      for (const [, data] of entries) {
+        if (!data || !data.key || seen.has(data.key)) continue;
+        seen.add(data.key);
+        collected.push({ key: data.key, kind });
+      }
+    };
+    collect(onlinePeers, 'online');
+    collect(discoveredPeers, 'discovered');
+    collect(unknownPeers, 'unknown');
+    const peers = await Promise.all(collected.map(async (p) => ({
+      key: p.key,
+      name: await resolveName(p.key),
+      kind: p.kind
+    })));
+    const me = { key: myId, name: await resolveName(myId) };
+    const kpis = {
+      total: peers.length + 1,
+      online: onlinePeers.length,
+      discovered: discoveredPeers.length,
+      unknown: unknownPeers.length
+    };
+    ctx.body = await graphosView({ filter, me, peers, kpis });
+  })
   .get("/invites", async (ctx) => {
     if (!checkMod(ctx, 'invitesMod')) return ctx.redirect('/modules');
     ctx.body = await invitesView({});
@@ -2025,7 +2154,7 @@ router
   })
   .get('/legacy', async (ctx) => {
     if (!checkMod(ctx, 'legacyMod')) return ctx.redirect('/modules');
-    try { ctx.body = await legacyView(); } catch (error) { ctx.body = { error: error.message }; }
+    try { ctx.body = await legacyView(); } catch (error) { sendErrorPage(ctx, error.message); }
   })
   .get('/bookmarks', async (ctx) => {
     if (!checkMod(ctx, 'bookmarksMod')) return ctx.redirect('/modules');
@@ -2287,6 +2416,10 @@ router
         await tribesModel.processIncomingKeys().catch(() => {});
         chat = await chatsModel.getChatById(ctx.params.chatId);
       } catch { ctx.redirect('/tribes'); return; }
+    } else {
+      const members = Array.isArray(chat.members) ? chat.members : [];
+      const isOpen = String(chat.status || '').toUpperCase() === 'OPEN';
+      if (!isOpen && chat.author !== uid && !members.includes(uid)) { ctx.redirect('/chats?filter=all'); return; }
     }
     const fav = await mediaFavorites.getFavoriteSet('chats');
     const messages = await chatsModel.listMessages(chat.rootId || chat.key);
@@ -2326,6 +2459,10 @@ router
         await tribesModel.processIncomingKeys().catch(() => {});
         pad = await padsModel.getPadById(ctx.params.padId);
       } catch { ctx.redirect('/tribes'); return; }
+    } else {
+      const members = Array.isArray(pad.members) ? pad.members : [];
+      const isOpen = String(pad.status || '').toUpperCase() === 'OPEN';
+      if (!isOpen && pad.author !== uid && !members.includes(uid)) { ctx.redirect('/pads?filter=all'); return; }
     }
     const fav = await mediaFavorites.getFavoriteSet('pads');
     const entries = await padsModel.getEntries(pad.rootId);
@@ -2371,6 +2508,10 @@ router
         parentTribe = await tribesModel.getTribeById(cal.tribeId);
         if (!parentTribe.members.includes(uid)) { ctx.body = tribeAccessDeniedView(parentTribe); return; }
       } catch { ctx.redirect('/tribes'); return; }
+    } else {
+      const participants = Array.isArray(cal.participants) ? cal.participants : (Array.isArray(cal.members) ? cal.members : []);
+      const isOpen = String(cal.status || '').toUpperCase() === 'OPEN';
+      if (!isOpen && cal.author !== uid && !participants.includes(uid)) { ctx.redirect('/calendars?filter=all'); return; }
     }
     if (String(cal.status || '').toUpperCase() === 'CLOSED' && cal.author !== uid) {
       ctx.body = tribeAccessDeniedView(parentTribe); return;
@@ -2546,7 +2687,7 @@ router
     try {
       ctx.body = await cipherView();
     } catch (error) {
-      ctx.body = { error: error.message };
+      sendErrorPage(ctx, error.message);
     }
   })  
   .get("/thread/:message", async (ctx) => {
@@ -2675,8 +2816,7 @@ router
   .post('/ai', koaBody(), async (ctx) => {
     const { input } = ctx.request.body;
     if (!input) {
-      ctx.status = 400;
-      ctx.body = { error: 'No input provided' };
+      sendErrorPage(ctx, 'No input provided', { status: 400 });
       return;
     }
     startAI();
@@ -2802,6 +2942,51 @@ router
     const userPrompt = config.ai?.prompt?.trim() || '';
     ctx.body = aiView([], userPrompt);
   })
+  .post('/ai/ask', koaBody(), async (ctx) => {
+    if (!checkMod(ctx, 'aiNavMod')) {
+      sendErrorPage(ctx, require('../views/main_views').i18n.aiNavDisabled || 'AI navigation is disabled.', { status: 403 });
+      return;
+    }
+    const raw = String(ctx.request.body?.q || ctx.request.body?.prompt || '').trim();
+    if (!raw) { ctx.redirect('/'); return; }
+    const ssbRefLib = require('../server/node_modules/ssb-ref');
+    const hashtagMatch = raw.match(/^#([\p{L}\p{N}_-]+)/u);
+    if (hashtagMatch) {
+      ctx.redirect('/search?query=' + encodeURIComponent('#' + hashtagMatch[1]));
+      return;
+    }
+    const feedMatch = raw.match(/^@?([A-Za-z0-9+/=._-]+\.ed25519)\b/);
+    if (feedMatch) {
+      const id = (feedMatch[0].startsWith('@') ? feedMatch[0] : '@' + feedMatch[1]);
+      if (ssbRefLib.isFeed(id)) { ctx.redirect('/author/' + encodeURIComponent(id)); return; }
+    }
+    if (/^https?:\/\//i.test(raw)) {
+      try {
+        const u = new URL(raw);
+        if (u.host === ctx.host) { ctx.redirect(u.pathname + u.search + u.hash); return; }
+      } catch (_) {}
+    }
+    try {
+      const embedder = require('../AI/embedder');
+      const routesIndex = require('../AI/routes_index');
+      if (!embedder.isInstalled()) {
+        ctx.redirect('/search?query=' + encodeURIComponent(raw));
+        return;
+      }
+      const vec = await embedder.embed(raw);
+      if (!vec) {
+        ctx.redirect('/search?query=' + encodeURIComponent(raw));
+        return;
+      }
+      const isModuleEnabled = (modName) => checkMod(ctx, modName);
+      const best = await routesIndex.resolveBest(vec, { isModuleEnabled, embed: embedder.embed });
+      if (best && best.path) {
+        ctx.redirect(best.path);
+        return;
+      }
+    } catch (_) {}
+    ctx.redirect('/search?query=' + encodeURIComponent(raw));
+  })
   .post('/pixelia/paint', koaBody(), async (ctx) => {
     const x = Number(ctx.request.body.x), y = Number(ctx.request.body.y), color = ctx.request.body.color;
     if (!Number.isFinite(x) || !Number.isFinite(y) || x < 1 || x > 50 || y < 1 || y > 200) {
@@ -2961,29 +3146,38 @@ router
   })
   .post("/follow/:feed", koaBody(), async (ctx) => {
     ctx.body = await friend.follow(ctx.params.feed);
-    ctx.redirect(new URL(ctx.request.header.referer).href);
+    safeRefererRedirect(ctx, '/inhabitants');
   })
   .post("/unfollow/:feed", koaBody(), async (ctx) => {
     ctx.body = await friend.unfollow(ctx.params.feed);
-    ctx.redirect(new URL(ctx.request.header.referer).href);
+    safeRefererRedirect(ctx, '/inhabitants');
   })
   .post("/block/:feed", koaBody(), async (ctx) => {
     ctx.body = await friend.block(ctx.params.feed);
-    ctx.redirect(new URL(ctx.request.header.referer).href);
+    safeRefererRedirect(ctx, '/inhabitants');
   })
   .post("/unblock/:feed", koaBody(), async (ctx) => {
     ctx.body = await friend.unblock(ctx.params.feed);
-    ctx.redirect(new URL(ctx.request.header.referer).href);
+    safeRefererRedirect(ctx, '/inhabitants');
   })
   .post("/like/:message", koaBody(), async (ctx) => {
     const { message } = ctx.params, voteValue = Number(ctx.request.body.voteValue);
-    const referer = new URL(ctx.request.header.referer);
-    referer.hash = `centered-footer-${encodeURIComponent(message)}`;
+    const ref = ctx.request.header.referer;
+    let target = '/public/latest';
+    try {
+      if (ref) {
+        const u = new URL(ref);
+        if ((u.protocol === 'http:' || u.protocol === 'https:') && u.host === ctx.host) {
+          u.hash = `centered-footer-${encodeURIComponent(message)}`;
+          target = u.pathname + u.search + u.hash;
+        }
+      }
+    } catch (_) {}
     const msgData = await post.get(message);
     const isPrivate = msgData.value.meta.private === true;
     const normalized = (isPrivate ? msgData.value.content.recps : []).map(r => typeof r === 'string' ? r : r?.link).filter(Boolean);
     ctx.body = await vote.publish({ messageKey: message, value: voteValue, recps: normalized.length ? normalized : undefined });
-    ctx.redirect(referer.href);
+    ctx.redirect(target);
   }) 
   .post('/forum/create', koaBody(), async ctx => {
     const { category, title, text } = ctx.request.body;
@@ -3003,7 +3197,7 @@ router
   })
   .post('/forum/delete/:id', koaBody(), async ctx => {
     const forum = await forumModel.getForumById(ctx.params.id).catch(() => null);
-    if (!forum || forum.author !== getViewerId()) { ctx.status = 403; ctx.body = 'Forbidden'; return; }
+    if (!forum || forum.author !== getViewerId()) { sendErrorPage(ctx, 'Forbidden', { status: 403 }); return; }
     await forumModel.deleteForumById(ctx.params.id);
     ctx.redirect('/forum');
   })
@@ -3149,6 +3343,30 @@ router
   })
   .post("/maps/favorites/add/:id", koaBody(), async ctx => favAction(ctx, 'maps', 'add'))
   .post("/maps/favorites/remove/:id", koaBody(), async ctx => favAction(ctx, 'maps', 'remove'))
+  .post("/maps/generate-invite/:id", koaBody(), async (ctx) => {
+    if (!checkMod(ctx, 'mapsMod')) { ctx.redirect('/modules'); return; }
+    try {
+      const code = await mapsModel.generateInvite(ctx.params.id);
+      ctx.body = `<html><body><p>Map invite code: <code>${code}</code></p><p><a href="/maps/${encodeURIComponent(ctx.params.id)}">Back</a></p></body></html>`;
+    } catch (e) {
+      ctx.redirect(`/maps/${encodeURIComponent(ctx.params.id)}`);
+    }
+  })
+  .post("/maps/join-code", koaBody(), async (ctx) => {
+    if (!checkMod(ctx, 'mapsMod')) { ctx.redirect('/modules'); return; }
+    const code = String((ctx.request.body || {}).code || "").trim();
+    try {
+      const mapId = await mapsModel.joinByInvite(code);
+      ctx.redirect(`/maps/${encodeURIComponent(mapId)}`);
+    } catch (_) {
+      ctx.redirect('/maps');
+    }
+  })
+  .post("/maps/join/:id", koaBody(), async (ctx) => {
+    if (!checkMod(ctx, 'mapsMod')) { ctx.redirect('/modules'); return; }
+    try { await mapsModel.joinMap(ctx.params.id); } catch (_) {}
+    ctx.redirect(`/maps/${encodeURIComponent(ctx.params.id)}`);
+  })
   .post("/maps/:mapId/marker", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => {
     if (!checkMod(ctx, 'mapsMod')) { ctx.redirect('/modules'); return; }
     const uid = getViewerId();
@@ -3156,8 +3374,8 @@ router
     if (mapItem.tribeId) {
       try {
         const t = await tribesModel.getTribeById(mapItem.tribeId);
-        if (!t.members.includes(uid)) { ctx.status = 403; ctx.body = "Forbidden"; return; }
-      } catch { ctx.status = 403; ctx.body = "Forbidden"; return; }
+        if (!t.members.includes(uid)) { sendErrorPage(ctx, "Forbidden", { status: 403 }); return; }
+      } catch { sendErrorPage(ctx, "Forbidden", { status: 403 }); return; }
     }
     const b = ctx.request.body;
     const imageBlobId = extractBlobId(await handleBlobUpload(ctx, 'image')) || "";
@@ -3487,10 +3705,10 @@ router
     const { exec } = require('child_process');
     try {
       await panicmodeModel.removeSSB();
-      ctx.body = { message: 'Your blockchain has been succesfully deleted!' };
+      sendErrorPage(ctx, 'Your blockchain has been successfully deleted!');
       exec('pkill -f "node SSB_server.js start"');
       setTimeout(() => process.exit(0), 1000);
-    } catch (error) { ctx.body = { error: 'Error deleting your blockchain: ' + error.message }; }
+    } catch (error) { sendErrorPage(ctx, 'Error deleting your blockchain: ' + error.message); }
   })
   .post('/export/create', async (ctx) => {
     try {
@@ -3500,7 +3718,7 @@ router
       ctx.set('Content-Disposition', `attachment; filename=ssb_exported.zip`);
       ctx.body = fs.createReadStream(outputPath);
       ctx.res.on('finish', () => fs.unlinkSync(outputPath));
-    } catch (error) { ctx.body = { error: 'Error exporting your blockchain: ' + error.message }; }
+    } catch (error) { sendErrorPage(ctx, 'Error exporting your blockchain: ' + error.message); }
   })
   .post('/tasks/create', koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async ctx => {
     const b = ctx.request.body;
@@ -3708,7 +3926,7 @@ router
   })
   .post('/parliament/proposals/close/:id', koaBody(), async (ctx) => {
     const canClose = await parliamentModel.canPropose();
-    if (!canClose) { ctx.status = 403; ctx.body = 'Forbidden'; return; }
+    if (!canClose) { sendErrorPage(ctx, 'Forbidden', { status: 403 }); return; }
     await parliamentModel.closeProposal(ctx.params.id).catch(e => ctx.throw(400, String(e?.message || e)));
     ctx.redirect('/parliament?filter=proposals');
   })
@@ -4052,13 +4270,13 @@ router
     const uid = getViewerId();
     const chat = await chatsModel.getChatById(ctx.params.id);
     if (!chat) { ctx.status = 404; ctx.body = "Chat not found"; return; }
-    if (chat.status === "CLOSED") { ctx.status = 403; ctx.body = "Chat is closed"; return; }
-    if (chat.status === "INVITE-ONLY" && !chat.members.includes(uid) && chat.author !== uid) { ctx.status = 403; ctx.body = "Invite-only chat"; return; }
+    if (chat.status === "CLOSED") { sendErrorPage(ctx, "Chat is closed", { status: 403 }); return; }
+    if (chat.status === "INVITE-ONLY" && !chat.members.includes(uid) && chat.author !== uid) { sendErrorPage(ctx, "Invite-only chat", { status: 403 }); return; }
     if (chat.tribeId) {
       try {
         const t = await tribesModel.getTribeById(chat.tribeId);
-        if (!t.members.includes(uid)) { ctx.status = 403; ctx.body = "Forbidden"; return; }
-      } catch { ctx.status = 403; ctx.body = "Forbidden"; return; }
+        if (!t.members.includes(uid)) { sendErrorPage(ctx, "Forbidden", { status: 403 }); return; }
+      } catch { sendErrorPage(ctx, "Forbidden", { status: 403 }); return; }
       ctx.redirect(safeReturnTo(ctx, `/chats/${encodeURIComponent(ctx.params.id)}`, ['/chats']));
       return;
     }
@@ -4083,8 +4301,8 @@ router
     if (chat && chat.tribeId) {
       try {
         const t = await tribesModel.getTribeById(chat.tribeId);
-        if (!t.members.includes(uid)) { ctx.status = 403; ctx.body = "Forbidden"; return; }
-      } catch { ctx.status = 403; ctx.body = "Forbidden"; return; }
+        if (!t.members.includes(uid)) { sendErrorPage(ctx, "Forbidden", { status: 403 }); return; }
+      } catch { sendErrorPage(ctx, "Forbidden", { status: 403 }); return; }
     }
     const text = stripDangerousTags(String(ctx.request.body.text || '').trim());
     const imageBlob = ctx.request.files?.image ? extractBlobId(await handleBlobUpload(ctx, 'image')) : null;
@@ -4151,13 +4369,13 @@ router
     const uid = getViewerId();
     const pad = await padsModel.getPadById(ctx.params.id);
     if (!pad) { ctx.status = 404; ctx.body = "Pad not found"; return; }
-    if (pad.isClosed || pad.status === "CLOSED") { ctx.status = 403; ctx.body = "Pad is closed"; return; }
-    if (pad.status === "INVITE-ONLY" && !pad.members.includes(uid) && pad.author !== uid) { ctx.status = 403; ctx.body = "Invite-only pad"; return; }
+    if (pad.isClosed || pad.status === "CLOSED") { sendErrorPage(ctx, "Pad is closed", { status: 403 }); return; }
+    if (pad.status === "INVITE-ONLY" && !pad.members.includes(uid) && pad.author !== uid) { sendErrorPage(ctx, "Invite-only pad", { status: 403 }); return; }
     if (pad.tribeId) {
       try {
         const t = await tribesModel.getTribeById(pad.tribeId);
-        if (!t.members.includes(uid)) { ctx.status = 403; ctx.body = "Forbidden"; return; }
-      } catch { ctx.status = 403; ctx.body = "Forbidden"; return; }
+        if (!t.members.includes(uid)) { sendErrorPage(ctx, "Forbidden", { status: 403 }); return; }
+      } catch { sendErrorPage(ctx, "Forbidden", { status: 403 }); return; }
       ctx.redirect(`/pads/${encodeURIComponent(ctx.params.id)}`);
       return;
     }
@@ -4171,8 +4389,8 @@ router
     if (pad && pad.tribeId) {
       try {
         const t = await tribesModel.getTribeById(pad.tribeId);
-        if (!t.members.includes(uid)) { ctx.status = 403; ctx.body = "Forbidden"; return; }
-      } catch { ctx.status = 403; ctx.body = "Forbidden"; return; }
+        if (!t.members.includes(uid)) { sendErrorPage(ctx, "Forbidden", { status: 403 }); return; }
+      } catch { sendErrorPage(ctx, "Forbidden", { status: 403 }); return; }
     }
     const b = ctx.request.body || {};
     const text = stripDangerousTags(String(b.text || "").trim());
@@ -4231,12 +4449,13 @@ router
   .post("/calendars/delete/:id", koaBody(), async (ctx) => {
     if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; }
     const target = await calendarsModel.getCalendarById(ctx.params.id).catch(() => null);
-    if (target && target.tribeId) {
-      const t = await tribesModel.getTribeById(target.tribeId).catch(() => null);
+    const tribeId = target && target.tribeId;
+    if (tribeId) {
+      const t = await tribesModel.getTribeById(tribeId).catch(() => null);
       if (!t || !t.members.includes(getViewerId())) { ctx.status = 403; ctx.redirect('/tribes'); return; }
     }
-    try { await calendarsModel.deleteCalendarById(ctx.params.id); } catch (_) {}
-    ctx.redirect('/calendars');
+    await calendarsModel.deleteCalendarById(ctx.params.id);
+    ctx.redirect(tribeId ? `/tribe/${encodeURIComponent(tribeId)}?section=calendars` : '/calendars');
   })
   .post("/calendars/join/:id", koaBody(), async (ctx) => {
     if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; }
@@ -4248,6 +4467,25 @@ router
     try { await calendarsModel.joinCalendar(ctx.params.id); } catch (_) {}
     ctx.redirect(`/calendars/${encodeURIComponent(ctx.params.id)}`);
   })
+  .post("/calendars/generate-invite/:id", koaBody(), async (ctx) => {
+    if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; }
+    try {
+      const code = await calendarsModel.generateInvite(ctx.params.id);
+      ctx.body = renderCalendarInvitePage(code);
+    } catch (e) {
+      ctx.redirect(`/calendars/${encodeURIComponent(ctx.params.id)}`);
+    }
+  })
+  .post("/calendars/join-code", koaBody(), async (ctx) => {
+    if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; }
+    const code = String((ctx.request.body || {}).code || "").trim();
+    try {
+      const calId = await calendarsModel.joinByInvite(code);
+      ctx.redirect(`/calendars/${encodeURIComponent(calId)}`);
+    } catch (_) {
+      ctx.redirect('/calendars');
+    }
+  })
   .post("/calendars/leave/:id", koaBody(), async (ctx) => {
     if (!checkMod(ctx, 'calendarsMod')) { ctx.redirect('/modules'); return; }
     const target = await calendarsModel.getCalendarById(ctx.params.id).catch(() => null);
@@ -4265,8 +4503,8 @@ router
     if (calForGate && calForGate.tribeId) {
       try {
         const t = await tribesModel.getTribeById(calForGate.tribeId);
-        if (!t.members.includes(uid)) { ctx.status = 403; ctx.body = "Forbidden"; return; }
-      } catch { ctx.status = 403; ctx.body = "Forbidden"; return; }
+        if (!t.members.includes(uid)) { sendErrorPage(ctx, "Forbidden", { status: 403 }); return; }
+      } catch { sendErrorPage(ctx, "Forbidden", { status: 403 }); return; }
     }
     const b = ctx.request.body || {};
     const intervalWeekly  = [].concat(b.intervalWeekly).includes("1");
@@ -4294,8 +4532,8 @@ router
     if (calForGate && calForGate.tribeId) {
       try {
         const t = await tribesModel.getTribeById(calForGate.tribeId);
-        if (!t.members.includes(uid)) { ctx.status = 403; ctx.body = "Forbidden"; return; }
-      } catch { ctx.status = 403; ctx.body = "Forbidden"; return; }
+        if (!t.members.includes(uid)) { sendErrorPage(ctx, "Forbidden", { status: 403 }); return; }
+      } catch { sendErrorPage(ctx, "Forbidden", { status: 403 }); return; }
     }
     const b = ctx.request.body || {};
     const text = stripDangerousTags(String(b.text || "").trim());
@@ -4468,8 +4706,8 @@ router
   .post("/banking/claim/:id", koaBody(), async (ctx) => {
     const { i18n: _i18n } = require("../views/main_views");
     const userId = getViewerId(), allocation = await bankingModel.getAllocationById(ctx.params.id);
-    if (!allocation) { ctx.body = { error: _i18n.errorNoAllocation }; return; }
-    if (allocation.to !== userId || (allocation.status !== "UNCLAIMED" && allocation.status !== "UNCONFIRMED")) { ctx.body = { error: _i18n.errorInvalidClaim }; return; }
+    if (!allocation) { sendErrorPage(ctx, _i18n.errorNoAllocation); return; }
+    if (allocation.to !== userId || (allocation.status !== "UNCLAIMED" && allocation.status !== "UNCONFIRMED")) { sendErrorPage(ctx, _i18n.errorInvalidClaim); return; }
     if (!bankingModel.isPubNode()) {
       ctx.redirect("/banking?filter=overview&msg=claimed_pending");
       return;
@@ -4479,17 +4717,24 @@ router
     ctx.redirect(`/banking?claimed=${encodeURIComponent(txid)}`);
   })
   .post("/banking/simulate", koaBody(), async (ctx) => {
-    if (!bankingModel.isPubNode()) { ctx.status = 403; ctx.body = { error: require("../views/main_views").i18n.bankPubOnly }; return; }
+    if (!bankingModel.isPubNode()) { sendErrorPage(ctx, require("../views/main_views").i18n.bankPubOnly, { status: 403 }); return; }
     const { epochId, rules } = ctx.request.body || {};
     ctx.body = await bankingModel.computeEpoch({ epochId, rules });
   })
   .post("/banking/run", koaBody(), async (ctx) => {
-    if (!bankingModel.isPubNode()) { ctx.status = 403; ctx.body = { error: require("../views/main_views").i18n.bankPubOnly }; return; }
+    if (!bankingModel.isPubNode()) { sendErrorPage(ctx, require("../views/main_views").i18n.bankPubOnly, { status: 403 }); return; }
     const { epochId, rules } = ctx.request.body || {};
     ctx.body = await bankingModel.executeEpoch({ epochId, rules });
   })
   .post("/banking/addresses", koaBody(), async (ctx) => {
-    const b = ctx.request.body || {}, res = await bankingModel.addAddress({ userId: (b.userId || "").trim(), address: (b.address || "").trim() });
+    const b = ctx.request.body || {};
+    const viewerId = getViewerId();
+    const submittedId = (b.userId || "").trim();
+    if (submittedId && submittedId !== viewerId) {
+      ctx.redirect(`/banking?filter=addresses&msg=forbidden`);
+      return;
+    }
+    const res = await bankingModel.addAddress({ userId: viewerId, address: (b.address || "").trim() });
     ctx.redirect(`/banking?filter=addresses&msg=${encodeURIComponent(res.status)}`);
   })
   .post("/banking/addresses/delete", koaBody(), async (ctx) => {
@@ -4507,7 +4752,7 @@ router
     console.log("oasis@version: updating Oasis...", stdout, stderr);
     const { stdout: shOut, stderr: shErr } = await exec("sh install.sh");
     console.log("oasis@version: running install.sh...", shOut, shErr);
-    ctx.redirect(new URL(ctx.request.header.referer).href);
+    safeRefererRedirect(ctx, '/settings');
   })  
   .post("/settings/theme", koaBody(), async (ctx) => {
     const theme = String(ctx.request.body.theme || "").trim(), cfg = getConfig();
@@ -4522,7 +4767,7 @@ router
     cfg.language = lang;
     fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2));
     ctx.cookies.set("language", lang, { maxAge: 365 * 24 * 60 * 60 * 1000, httpOnly: true, sameSite: 'strict', secure: ctx.secure });
-    ctx.redirect(new URL(ctx.request.header.referer).href);
+    safeRefererRedirect(ctx, '/settings');
   })
   .post("/settings/conn/start", koaBody(), async ctx => { await meta.connStart(); ctx.redirect("/peers"); })
   .post("/settings/conn/stop", koaBody(), async ctx => { await meta.connStop(); ctx.redirect("/peers"); })
@@ -4648,7 +4893,7 @@ router
     ctx.redirect('/modules');
   })
   .post("/save-modules", koaBody(), async (ctx) => {
-    const modules = ['popular', 'topics', 'summaries', 'latest', 'threads', 'multiverse', 'invites', 'wallet', 'legacy', 'cipher', 'bookmarks', 'calendars', 'chats', 'videos', 'docs', 'audios', 'tags', 'images', 'maps', 'trending', 'events', 'tasks', 'market', 'tribes', 'votes', 'reports', 'opinions', 'pads', 'transfers', 'feed', 'pixelia', 'agenda', 'favorites', 'ai', 'forum', 'games', 'jobs', 'projects', 'shops', 'banking', 'parliament', 'courts', 'logs', 'torrents'];
+    const modules = ['popular', 'topics', 'summaries', 'latest', 'threads', 'multiverse', 'invites', 'wallet', 'legacy', 'cipher', 'bookmarks', 'calendars', 'chats', 'videos', 'docs', 'audios', 'tags', 'images', 'maps', 'trending', 'events', 'tasks', 'market', 'tribes', 'votes', 'reports', 'opinions', 'pads', 'transfers', 'feed', 'pixelia', 'agenda', 'favorites', 'ai', 'forum', 'games', 'graphos', 'jobs', 'projects', 'shops', 'banking', 'parliament', 'courts', 'logs', 'torrents'];
     const cfg = getConfig();
     modules.forEach(mod => cfg.modules[`${mod}Mod`] = ctx.request.body[`${mod}Form`] === 'on' ? 'on' : 'off');
     saveConfig(cfg);
@@ -4731,22 +4976,11 @@ const middleware = [
     const totalCurrent = values.reduce((acc, cur) => acc + cur, 0), totalTarget = status.sync.since * values.length;
     if (totalTarget - totalCurrent > 1024 * 1024) ctx.response.body = indexingView({ percent: Math.floor((totalCurrent / totalTarget) * 1000) / 10 });
     else { try { await next(); } catch (err) {
+      const { i18n } = require('../views/main_views');
       if (err.name === 'FileTooLargeError' || (err.message && err.message.includes('maxFileSize'))) {
-        const { template, i18n } = require('../views/main_views');
-        const referer = ctx.get('referer') || '/';
-        ctx.status = 413;
-        ctx.body = template(
-          i18n.fileTooLargeTitle,
-          section(
-            div({ class: 'tags-header' },
-              h2(i18n.fileTooLargeTitle),
-              p(i18n.fileTooLargeMessage),
-              p(a({ href: referer, class: 'filter-btn', style: 'display:inline-block;text-decoration:none;margin-top:16px;' }, i18n.goBack))
-            )
-          )
-        );
+        sendErrorPage(ctx, i18n.fileTooLargeMessage, { title: i18n.fileTooLargeTitle, status: 413 });
       } else {
-        ctx.status = err.status || 500; ctx.body = { message: err.message || 'Internal Server Error' };
+        sendErrorPage(ctx, err.message || 'Internal Server Error', { status: err.status || 500 });
       }
     } }
   },

+ 9 - 0
src/backend/blobHandler.js

@@ -220,6 +220,15 @@ const serveBlob = async function (ctx) {
     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, '');

+ 20 - 0
src/backend/nameCache.js

@@ -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 }

+ 5 - 4
src/backend/renderTextWithStyles.js

@@ -81,15 +81,16 @@ function renderTextWithStyles(text) {
       `<a href="/author/${encodeURIComponent('@' + id)}" class="mention" target="_blank">@${id}</a>`
     )
 
+  const escAttr = (s) => String(s).replace(/"/g, '&quot;').replace(/'/g, '&#39;')
   html = html
     .replace(/#(\w+)/g, (_, tag) =>
-      `<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')

+ 168 - 10
src/client/assets/styles/style.css

@@ -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 {
@@ -5039,15 +5140,72 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
 .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-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-section-h{font-size:18px;color:#555;margin:8px 0;font-weight:600}
+.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:18px;color:#555;margin:8px 0}
+.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%}

+ 11 - 0
src/client/assets/themes/Clear-SNH.css

@@ -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; }

+ 6 - 0
src/client/assets/themes/Dark-SNH.css

@@ -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; }

+ 7 - 0
src/client/assets/themes/Matrix-SNH.css

@@ -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; }

+ 25 - 0
src/client/assets/themes/OasisMobile.css

@@ -730,3 +730,28 @@ h3 { font-size: 1em !important; }
 .snh-invite-code {
   color: #FFA500 !important;
 }
+
+.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;
+}

+ 6 - 0
src/client/assets/themes/Purple-SNH.css

@@ -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; }

+ 16 - 0
src/client/assets/translations/oasis_ar.js

@@ -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: "إضافة ملاحظة",

+ 17 - 1
src/client/assets/translations/oasis_de.js

@@ -37,7 +37,12 @@ module.exports = {
       " von Bewohnern, die du unterstützt (inkl. Multiversum), nach Aktualität sortiert.",
     ],
     profile: "Avatar",
-    inhabitants: "Bewohner", 
+    inhabitants: "Bewohner",
+    peersReplicatedFeeds: "Replizierte Feeds",
+    graphos: "Graphos",
+    graphosDescription: "Interaktive Karte des Netzwerks um dich herum.",
+    graphosYou: "Du",
+    graphosTotalNodes: "Knoten insgesamt",
     manualMode: "Manueller Modus",
     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",

+ 17 - 1
src/client/assets/translations/oasis_en.js

@@ -37,7 +37,12 @@ module.exports = {
       " from inhabitants you support (included from multiverse), sorted by recency.",
     ],
     profile: "Avatar",
-    inhabitants: "Inhabitants", 
+    inhabitants: "Inhabitants",
+    peersReplicatedFeeds: "Replicated feeds",
+    graphos: "Graphos",
+    graphosDescription: "Interactive map of the network around you.",
+    graphosYou: "You",
+    graphosTotalNodes: "Total nodes",
     manualMode: "Manual Mode",
     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",

+ 16 - 0
src/client/assets/translations/oasis_es.js

@@ -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",

+ 17 - 1
src/client/assets/translations/oasis_eu.js

@@ -37,7 +37,12 @@ module.exports = {
       " eta laguntzen dituzun bizilagunenak, gaurkotasunaren arabera antolatuta. Hautatu edozein bidalketaren ordu-marka hari osoa ikusteko.",
     ],
     profile: "Abatarra",
-    inhabitants: "Bizilagunak", 
+    inhabitants: "Bizilagunak",
+    peersReplicatedFeeds: "Errepikatutako jarioak",
+    graphos: "Graphos",
+    graphosDescription: "Sarearen mapa interaktiboa zure inguruan.",
+    graphosYou: "Zu",
+    graphosTotalNodes: "Nodo guztiak",
     manualMode: "Eskuzko modua",
     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",

+ 16 - 0
src/client/assets/translations/oasis_fr.js

@@ -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",

+ 16 - 0
src/client/assets/translations/oasis_hi.js

@@ -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: "नोट जोड़ें",

+ 17 - 1
src/client/assets/translations/oasis_it.js

@@ -37,7 +37,12 @@ module.exports = {
       " dagli abitanti che supporti (incluso dal multiverso), ordinati per data.",
     ],
     profile: "Avatar",
-    inhabitants: "Abitanti", 
+    inhabitants: "Abitanti",
+    peersReplicatedFeeds: "Feed replicati",
+    graphos: "Graphos",
+    graphosDescription: "Mappa interattiva della rete attorno a te.",
+    graphosYou: "Tu",
+    graphosTotalNodes: "Nodi totali",
     manualMode: "Modalità manuale",
     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",

+ 17 - 1
src/client/assets/translations/oasis_pt.js

@@ -37,7 +37,12 @@ module.exports = {
       " dos habitantes que apoias (incluindo do multiverso), ordenadas por data.",
     ],
     profile: "Avatar",
-    inhabitants: "Habitantes", 
+    inhabitants: "Habitantes",
+    peersReplicatedFeeds: "Feeds replicados",
+    graphos: "Graphos",
+    graphosDescription: "Mapa interativo da rede ao seu redor.",
+    graphosYou: "Você",
+    graphosTotalNodes: "Total de nós",
     manualMode: "Modo manual",
     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",

+ 16 - 0
src/client/assets/translations/oasis_ru.js

@@ -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: "Добавить заметку",

+ 16 - 0
src/client/assets/translations/oasis_zh.js

@@ -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: "添加笔记",

+ 1 - 26
src/client/oasis_client.js

@@ -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 };

+ 3 - 1
src/configs/config-manager.js

@@ -40,6 +40,7 @@ if (!fs.existsSync(configFilePath)) {
       "pixeliaMod": "on",
       "agendaMod": "on",
       "aiMod": "on",
+      "aiNavMod": "on",
       "forumMod": "on",
       "gamesMod": "on",
       "jobsMod": "on",
@@ -52,7 +53,8 @@ if (!fs.existsSync(configFilePath)) {
       "logsMod": "on",
       "mapsMod": "on",
       "chatsMod": "on",
-      "torrentsMod": "on"
+      "torrentsMod": "on",
+      "graphosMod": "on"
     },
     "wallet": {
       "url": "http://localhost:7474",

+ 3 - 1
src/configs/oasis-config.json

@@ -34,6 +34,7 @@
     "pixeliaMod": "on",
     "agendaMod": "on",
     "aiMod": "on",
+    "aiNavMod": "on",
     "forumMod": "on",
     "gamesMod": "on",
     "jobsMod": "on",
@@ -46,7 +47,8 @@
     "logsMod": "on",
     "mapsMod": "on",
     "chatsMod": "on",
-    "torrentsMod": "on"
+    "torrentsMod": "on",
+    "graphosMod": "on"
   },
   "wallet": {
     "url": "http://localhost:7474",

+ 26 - 1
src/models/activity_model.js

@@ -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'];
@@ -74,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);
       }

+ 2 - 25
src/models/banking_model.js

@@ -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) {

+ 339 - 73
src/models/calendars_model.js

@@ -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,53 +193,97 @@ 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) {
-        let dateContent = {
-          type: "calendarDate",
+      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: new Date(firstDate).toISOString(),
+        label: safeText(firstDateLabel),
+        author: userId,
+        createdAt: new Date().toISOString(),
+        ...(intervalWeekly ? { intervalWeekly: true } : {}),
+        ...(intervalMonthly ? { intervalMonthly: true } : {}),
+        ...(intervalYearly ? { intervalYearly: true } : {}),
+        ...(deadline && hasAnyInterval(intervalWeekly, intervalMonthly, intervalYearly) ? { intervalDeadline: deadline } : {}),
+        ...(tribeId ? { tribeId } : {})
+      }
+      if (tribeId) dateContent = await encryptIfTribe(dateContent)
+      else if (calKey) dateContent = tribeCrypto.encryptContent(dateContent, [calKey], true)
+      const dateMsg = await new Promise((resolve, reject) => {
+        ssbClient.publish(dateContent, (err, msg) => err ? reject(err) : resolve(msg))
+      })
+
+      if (firstNote && safeText(firstNote)) {
+        let noteContent = {
+          type: "calendarNote",
           calendarId,
-          date: d.toISOString(),
-          label: safeText(firstDateLabel),
+          dateId: dateMsg.key,
+          text: safeText(firstNote),
           author: userId,
           createdAt: new Date().toISOString(),
           ...(tribeId ? { tribeId } : {})
         }
-        dateContent = await encryptIfTribe(dateContent)
-        const dateMsg = await new Promise((resolve, reject) => {
-          ssbClient.publish(dateContent, (err, msg) => err ? reject(err) : resolve(msg))
+        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))
         })
-        allDateMsgs.push(dateMsg)
-      }
-
-      if (firstNote && safeText(firstNote) && allDateMsgs.length > 0) {
-        for (const dateMsg of allDateMsgs) {
-          let noteContent = {
-            type: "calendarNote",
-            calendarId,
-            dateId: dateMsg.key,
-            text: safeText(firstNote),
-            author: userId,
-            createdAt: new Date().toISOString(),
-            ...(tribeId ? { tribeId } : {})
-          }
-          noteContent = await encryptIfTribe(noteContent)
-          await new Promise((resolve, reject) => {
-            ssbClient.publish(noteContent, (err, msg) => err ? reject(err) : resolve(msg))
-          })
-        }
       }
 
       return calMsg
@@ -205,13 +291,16 @@ module.exports = ({ cooler, pmModel, tribeCrypto, tribesModel }) => {
 
     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) {
-        let dateContent = {
-          type: "calendarDate",
-          calendarId: rootId,
-          date: d.toISOString(),
-          label: safeText(label),
-          author: userId,
-          createdAt: new Date().toISOString(),
-          ...(cal.tribeId ? { tribeId: cal.tribeId } : {})
-        }
-        dateContent = await encryptIfTribe(dateContent)
-        const msg = await new Promise((resolve, reject) => {
-          ssbClient.publish(dateContent, (err, m) => err ? reject(err) : resolve(m))
-        })
-        allMsgs.push(msg)
+      const hasInterval = hasAnyInterval(intervalWeekly, intervalMonthly, intervalYearly)
+      const ruleDeadline = hasInterval ? (intervalDeadline || cal.deadline || "") : ""
+      let dateContent = {
+        type: "calendarDate",
+        calendarId: rootId,
+        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 } : {})
       }
-      return allMsgs
+      if (cal.tribeId) dateContent = await encryptIfTribe(dateContent)
+      else dateContent = encryptStandalone(dateContent, rootId)
+      const msg = await new Promise((resolve, reject) => {
+        ssbClient.publish(dateContent, (err, m) => err ? reject(err) : resolve(m))
+      })
+      return [msg]
     },
 
     async getDatesForCalendar(calendarId) {
       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) || []
-        list.push(entry)
-        dueByCalendar.set(c.calendarId, list)
+        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(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
     }
   }
 }

+ 138 - 56
src/models/chats_model.js

@@ -192,18 +192,47 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
         ...(tribeId ? { tribeId } : {})
       }
 
-      if (tribeCrypto && !tribeId) {
-        const chatKey = tribeCrypto.generateTribeKey()
-        const result = await new Promise((resolve, reject) => {
+      if (!tribeCrypto) {
+        return 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) => {
+      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)
+      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,40 +240,60 @@ 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 c = item.content
-
-          const rawAuthor = c.author || (c.encryptedPayload ? null : undefined)
-          if (!skipAuthorCheck && rawAuthor && rawAuthor !== userId) return reject(new Error("Not the author"))
-
-          const rootId = tipId
-          const messages = []
-          const node = { key: tipId, c, author: item.author, ts: item.timestamp || 0 }
-          const chat = buildChat(node, rootId)
-          if (!chat) return reject(new Error("Invalid chat"))
-
-          const updated = {
-            type: "chat",
-            replaces: tipId,
-            title: data.title !== undefined ? safeText(data.title) : chat.title,
-            description: data.description !== undefined ? safeText(data.description) : chat.description,
-            image: data.image !== undefined ? (data.image ? String(data.image).trim() || null : chat.image) : chat.image,
-            category: data.category !== undefined ? safeText(data.category) : chat.category,
-            status: data.status !== undefined ? (VALID_STATUS.includes(String(data.status).toUpperCase()) ? String(data.status).toUpperCase() : chat.status) : chat.status,
-            tags: data.tags !== undefined ? normalizeTags(data.tags) : chat.tags,
-            members: data.members !== undefined ? safeArr(data.members) : chat.members,
-            invites: data.invites !== undefined ? safeArr(data.invites) : chat.invites,
-            author: chat.author,
-            createdAt: chat.createdAt,
-            updatedAt: new Date().toISOString()
-          }
+      const item = await new Promise((resolve, reject) => {
+        ssbClient.get(tipId, (err, item) => err || !item?.content ? reject(new Error("Chat not found")) : resolve(item))
+      })
+      const c = item.content
+      const rawAuthor = c.author || (c.encryptedPayload ? null : undefined)
+      if (!skipAuthorCheck && rawAuthor && rawAuthor !== userId) throw new Error("Not the author")
+
+      const messages = await readAll(ssbClient)
+      const idx = buildIndex(messages)
+      let rootId = tipId
+      while (idx.parent.has(rootId)) rootId = idx.parent.get(rootId)
+      const node = { key: tipId, c, author: item.author, ts: item.timestamp || 0 }
+      const chat = buildChat(node, rootId)
+      if (!chat) throw new Error("Invalid chat")
 
-          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))
-          })
+      let updated = {
+        type: "chat",
+        replaces: tipId,
+        title: data.title !== undefined ? safeText(data.title) : chat.title,
+        description: data.description !== undefined ? safeText(data.description) : chat.description,
+        image: data.image !== undefined ? (data.image ? String(data.image).trim() || null : chat.image) : chat.image,
+        category: data.category !== undefined ? safeText(data.category) : chat.category,
+        status: data.status !== undefined ? (VALID_STATUS.includes(String(data.status).toUpperCase()) ? String(data.status).toUpperCase() : chat.status) : chat.status,
+        tags: data.tags !== undefined ? normalizeTags(data.tags) : chat.tags,
+        members: data.members !== undefined ? safeArr(data.members) : chat.members,
+        invites: data.invites !== undefined ? safeArr(data.invites) : chat.invites,
+        author: chat.author,
+        createdAt: chat.createdAt,
+        updatedAt: new Date().toISOString(),
+        ...(chat.tribeId ? { tribeId: chat.tribeId } : {})
+      }
+
+      if (tribeCrypto) {
+        if (chat.tribeId) {
+          try {
+            const ancestryIds = await tribesModel.getAncestryChain(chat.tribeId)
+            const chain = []
+            for (const rid of ancestryIds || []) {
+              const k = tribeCrypto.getKey(rid)
+              if (!k) { chain.length = 0; break }
+              chain.push(k)
+            }
+            if (chain.length) updated = tribeCrypto.encryptContent(updated, chain, true)
+          } catch (_) {}
+        } else {
+          const chatKey = tribeCrypto.getKey(rootId)
+          if (chatKey) updated = tribeCrypto.encryptContent(updated, [chatKey], true)
+        }
+      }
+
+      return new Promise((resolve, reject) => {
+        ssbClient.publish({ type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }, (e1) => {
+          if (e1) return reject(e1)
+          ssbClient.publish(updated, (e2, res) => e2 ? reject(e2) : resolve(res))
         })
       })
     },
@@ -372,10 +421,15 @@ module.exports = ({ cooler, tribeCrypto, tribesModel }) => {
       let invite = code
 
       if (tribeCrypto) {
-        const chatKey = tribeCrypto.getKey(chat.rootId)
-        if (chatKey) {
-          const ek = tribeCrypto.encryptForInvite(chatKey, code)
-          invite = { code, ek, gen: tribeCrypto.getGen(chat.rootId) }
+        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) }
+          }
         }
       }
 
@@ -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)
-        tribeCrypto.setKey(matchedChat.rootId, chatKey, matchedInvite.gen || 1)
+      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
 
-      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 (_) {}
-        }
+      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]
       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
     },

+ 106 - 18
src/models/main_models.js

@@ -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;
@@ -300,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 = [];
@@ -394,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);
@@ -598,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();
@@ -632,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 = [];

+ 263 - 9
src/models/maps_model.js

@@ -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
       };
 
-      updated = await encryptIfTribe(updated);
+      if (oldMsg.content.tribeId) {
+        updated = await encryptIfTribe(updated);
+      } else if (mType !== "SINGLE") {
+        updated = encryptStandalone(updated, rootId);
+      }
 
       const result = await new Promise((resolve, reject) => {
         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;
 
-      content = await encryptIfTribe(content);
+      if (node.c.tribeId) {
+        content = await encryptIfTribe(content);
+      } else if (tribeCrypto) {
+        const mapKey = tribeCrypto.getKey(rootId);
+        if (mapKey) content = tribeCrypto.encryptContent(content, [mapKey], true);
+      }
 
       return new Promise((resolve, reject) => {
         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");
     }
   };
 };

+ 72 - 11
src/models/pads_model.js

@@ -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
       })

+ 60 - 1
src/models/search_model.js

@@ -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;

+ 49 - 4
src/models/stats_model.js

@@ -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
     };
 

+ 7 - 7
src/models/tags_model.js

@@ -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;
       });

+ 132 - 27
src/models/tribe_crypto.js

@@ -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,

+ 2 - 1
src/models/tribes_content_model.js

@@ -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))
       )
     );

+ 135 - 46
src/models/tribes_model.js

@@ -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];
@@ -234,10 +277,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;
           }
         }
@@ -251,16 +291,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);
@@ -268,8 +315,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;
       });
       await this.updateTribeById(matchedTribe.id, { members, invites });
       if (tribeCrypto && storedTribeKey && storedRootId) {
@@ -313,14 +361,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(() => {});
     },
@@ -336,7 +388,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) {
@@ -381,10 +433,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 {
@@ -409,7 +461,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
     },
 
     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;
@@ -418,6 +470,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;
@@ -488,31 +541,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);
       }
     },
 
@@ -523,20 +583,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);
         }
       }
     },
@@ -555,7 +633,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) {
@@ -629,12 +707,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) {
@@ -654,6 +739,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);

+ 24 - 1
src/server/SSB_server.js

@@ -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') {
-  server = Server(config);
+  try {
+    server = Server(config);
+  } catch (err) {
+    handleFatal(err);
+  }
   fs.writeFileSync(manifestFile, JSON.stringify(server.getManifest(), null, 2));
 
   const { cmdAliases } = require('../client/cli-cmd-aliases');

Файловите разлики са ограничени, защото са твърде много
+ 763 - 28
src/server/package-lock.json


+ 2 - 1
src/server/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@krakenslab/oasis",
-  "version": "0.7.5",
+  "version": "0.7.6",
   "description": "Oasis - Social Networking Utopia",
   "repository": {
     "type": "git",
@@ -23,6 +23,7 @@
   "dependencies": {
     "@koa/router": "^13.1.0",
     "@open-rpc/client-js": "^1.8.1",
+    "@xenova/transformers": "^2.17.2",
     "abstract-level": "^2.0.1",
     "archiver": "^7.0.1",
     "axios": "^1.10.0",

+ 26 - 25
src/views/activity_view.js

@@ -1,5 +1,5 @@
 const { div, h2, p, section, button, form, a, input, img, textarea, br, span, video: videoHyperaxe, audio: audioHyperaxe, table, tr, td, th } = require("../server/node_modules/hyperaxe");
-const { template, i18n } = require('./main_views');
+const { template, i18n, userLink, userLinkLabel } = require('./main_views');
 const moment = require("../server/node_modules/moment");
 const { renderUrl } = require('../backend/renderUrl');
 const { getConfig } = require("../configs/config-manager.js");
@@ -308,9 +308,6 @@ function renderActionCards(actions, userId, allActions) {
 
   const cards = items.map(action => {
     const date = action.ts ? new Date(action.ts).toLocaleString() : "";
-    const userLink = action.author
-      ? a({ href: `/author/${encodeURIComponent(action.author)}` }, action.author)
-      : 'unknown';
     const type = action.type || 'unknown';
     let skip = false;
     let headerText;
@@ -443,11 +440,11 @@ function renderActionCards(actions, userId, allActions) {
         div({ class: 'card-section banking-ubi' },
           div({ class: 'card-field' },
             span({ class: 'card-label' }, i18n.bankUbiInhabitant + ':'),
-            span({ class: 'card-value' }, a({ href: `/author/${encodeURIComponent(inhabitantId)}`, class: 'user-link' }, inhabitantId))
+            span({ class: 'card-value' }, userLink(inhabitantId))
           ),
           pubId ? div({ class: 'card-field' },
             span({ class: 'card-label' }, i18n.bankUbiPub + ':'),
-            span({ class: 'card-value' }, a({ href: `/author/${encodeURIComponent(pubId)}`, class: 'user-link' }, pubId))
+            span({ class: 'card-value' }, userLink(pubId))
           ) : "",
           div({ class: 'card-field' },
             span({ class: 'card-label' }, i18n.bankUbiClaimedAmount + ':'),
@@ -536,7 +533,7 @@ function renderActionCards(actions, userId, allActions) {
       const { author, name, description, photo, personalSkills, oasisSkills, educationalSkills, languages, professionalSkills, status, preferences, createdAt, updatedAt} = content;
       cardBody.push(
         div({ class: 'card-section curriculum' },
-          h2(a({ href: `/author/${encodeURIComponent(author)}`, class: "user-link" }, `@`, name)),
+          h2(userLink(author, name)),
           div(
             { class: 'card-fields-container' },
             createdAt ?
@@ -706,7 +703,7 @@ function renderActionCards(actions, userId, allActions) {
           typeof isPublic === 'boolean' ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.isPublic || 'Public') + ':'), span({ class: 'card-value' }, isPublic ? 'Yes' : 'No')) : "",
           price ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.price || 'Price') + ':'), span({ class: 'card-value' }, price + " ECO")) : "",
           br(),
-          organizer ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.organizer || 'Organizer') + ': '), a({ class: "user-link", href: `/author/${encodeURIComponent(organizer)}` }, organizer)) : "",
+          organizer ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.organizer || 'Organizer') + ': '), userLink(organizer)) : "",
           Array.isArray(attendees) ? h2({ class: 'card-label' }, (i18n.attendees || 'Attendees') + ': ' + attendees.length) : ""
         )
       );
@@ -730,7 +727,7 @@ function renderActionCards(actions, userId, allActions) {
         const addList = Array.isArray(added) ? added : [];
         const remList = Array.isArray(removed) ? removed : [];
         const renderUserList = (ids) =>
-            ids.map((id, i) => [i > 0 ? ', ' : '', a({ class: 'user-link', href: `/author/${encodeURIComponent(id)}` }, id)]).flat();
+            ids.map((id, i) => [i > 0 ? ', ' : '', userLink(id)]).flat();
         cardBody.push(
             div({ class: 'card-section task' },
                 div({ class: 'card-field' },
@@ -810,7 +807,7 @@ function renderActionCards(actions, userId, allActions) {
                 { class: 'reply-context', style: 'border-left:3px solid #666;padding-left:10px;margin-bottom:8px;opacity:0.85;' },
                 span({ style: 'font-size:0.85em;' },
                   a({ href: ctxHref, class: 'tag-link' }, i18n.inReplyTo || 'IN REPLY TO'),
-                  parentAuthor ? span(' ', a({ href: `/author/${encodeURIComponent(parentAuthor)}`, class: 'user-link', style: 'font-weight:bold;' }, parentName)) : ''
+                  parentAuthor ? span(' ', userLink(parentAuthor, parentName)) : ''
                 ),
                 parentText ? p({ class: 'post-text reply-context-text post-text-pre', style: 'font-size:0.85em;max-height:80px;overflow:hidden;margin-top:4px;' }, ...renderUrlPreserveNewlines(parentText)) : ''
               )
@@ -861,7 +858,7 @@ function renderActionCards(actions, userId, allActions) {
 			),
 			div({ class: 'card-footer thread-reply-footer' },
 			    span({ class: 'date-link' }, rDate),
-			    a({ href: `/author/${encodeURIComponent(r.author)}`, class: 'user-link' }, `${r.author}`),
+			    userLink(r.author),
 			    form({ method: 'GET', action: commentHref, class: 'inline-form' },
 				button({ type: 'submit', class: 'filter-btn' }, i18n.viewDetails)
 			    )
@@ -872,7 +869,7 @@ function renderActionCards(actions, userId, allActions) {
         ),
         p({ class: 'card-footer' },
             span({ class: 'date-link' }, `${action.ts ? new Date(action.ts).toLocaleString() : ''} ${i18n.performed} `),
-            a({ href: `/author/${encodeURIComponent(action.author)}`, class: 'user-link' }, `${action.author}`)
+            userLink(action.author)
         )
         );
     }
@@ -904,7 +901,7 @@ function renderActionCards(actions, userId, allActions) {
               { class: 'reply-context', style: 'border-left:3px solid #666;padding-left:10px;margin-bottom:8px;opacity:0.85;' },
               span({ style: 'font-size:0.85em;' },
                 a({ href: `/forum/${encodeURIComponent(hrefKey)}`, class: 'tag-link' }, i18n.inReplyTo || 'IN REPLY TO'),
-                parentAuthor ? span(' ', a({ href: `/author/${encodeURIComponent(parentAuthor)}`, class: 'user-link', style: 'font-weight:bold;' }, parentName)) : ''
+                parentAuthor ? span(' ', userLink(parentAuthor, parentName)) : ''
               ),
               parentTitle ? p({ class: 'post-text reply-context-text', style: 'font-size:0.85em;max-height:60px;overflow:hidden;margin-top:4px;font-weight:bold;color:#4fc3f7;' }, parentTitle) : ''
             ),
@@ -957,7 +954,7 @@ function renderActionCards(actions, userId, allActions) {
       spreadOriginalAuthor
         ? div({ class: 'card-field' },
             span({ class: 'card-label' }, (i18n.spreadBy || 'By') + ': '),
-            span({ class: 'card-value' }, a({ href: `/author/${encodeURIComponent(spreadOriginalAuthor)}`, class: 'user-link' }, spreadOriginalAuthor))
+            span({ class: 'card-value' }, userLink(spreadOriginalAuthor))
           )
         : '',
       value
@@ -990,7 +987,7 @@ function renderActionCards(actions, userId, allActions) {
       const { about, name, image } = content;
       cardBody.push(
         div({ class: 'card-section about' },
-          h2(a({ href: `/author/${encodeURIComponent(about)}`, class: "user-link" }, `@`, name)),
+          h2(userLink(about, name)),
           image
             ? img({ src: `/blob/${encodeURIComponent(image)}`, alt: name, class: 'activity-avatar' })
             : img({ src: '/assets/images/default-avatar.png', alt: name, class: 'activity-avatar' })
@@ -1001,7 +998,10 @@ function renderActionCards(actions, userId, allActions) {
     if (type === 'contact') {
       const { contact } = content || {};
       const aId = action.author || '';
-      const bId = contact || '';
+      const bId = typeof contact === 'string'
+        ? contact
+        : (contact && typeof contact === 'object' && typeof contact.link === 'string' ? contact.link : '');
+      if (!bId) { skip = true; return null; }
       const pa = getProfile(aId);
       const pb = getProfile(bId);
       const srcA = pa.image ? `/blob/${encodeURIComponent(pa.image)}` : '/assets/images/default-avatar.png';
@@ -1029,7 +1029,7 @@ function renderActionCards(actions, userId, allActions) {
       cardBody.push(
         div({ class: 'card-section pub activity-pub' },
           br(),
-          a({ href: `/author/${encodeURIComponent(pr.id)}`, class: 'user-link' }, pr.name || pr.id),
+          userLink(pr.id, pr.name),
           br(),
           img({ src, alt: pr.name || pr.id, class: 'activity-avatar' })
         )
@@ -1201,8 +1201,9 @@ function renderActionCards(actions, userId, allActions) {
               ? (i18n.unfollowing || 'UNFOLLOWING')
               : 'ACTION';
 
+        const actorId = activity.activityActor || '';
         const msgHtml = tmpl
-          .replace('%OASIS%', `<a class="user-link" href="/author/${encodeURIComponent(activity.activityActor || '')}">${activity.activityActor || ''}</a>`)
+          .replace('%OASIS%', `<a class="user-link" href="/author/${encodeURIComponent(actorId)}">${userLinkLabel(actorId)}</a>`)
           .replace('%PROJECT%', `<a class="user-link" href="/projects/${encodeURIComponent(action.tipId || action.id)}">${title || ''}</a>`)
           .replace('%ACTION%', `<strong>${actionWord}</strong>`);
 
@@ -1218,7 +1219,7 @@ function renderActionCards(actions, userId, allActions) {
           ),
           p({ class: 'card-footer' },
             span({ class: 'date-link' }, `${action.ts ? new Date(action.ts).toLocaleString() : ''} ${i18n.performed} `),
-            a({ href: `/author/${encodeURIComponent(action.author)}`, class: 'user-link' }, `${action.author}`)
+            userLink(action.author)
           )
         );
       }
@@ -1356,7 +1357,7 @@ function renderActionCards(actions, userId, allActions) {
       const { targetType, targetId, targetTitle, method, votes, proposer } = content;
       const link = targetType === 'tribe'
         ? a({ href: `/tribe/${encodeURIComponent(targetId)}`, class: 'user-link' }, targetTitle || targetId)
-        : a({ href: `/author/${encodeURIComponent(targetId)}`, class: 'user-link' }, targetId);
+        : userLink(targetId);
 
       const methodUpper = String(
         i18n['parliamentMethod' + String(method || '').toUpperCase()] || method
@@ -1381,7 +1382,7 @@ function renderActionCards(actions, userId, allActions) {
           ? a({ href: `/tribe/${encodeURIComponent(powerId)}`, class: 'user-link' }, powerTitle || powerId)
           : powerTypeNorm === 'none' || !powerTypeNorm
             ? a({ href: `/parliament?filter=government`, class: 'user-link' }, (i18n.parliamentAnarchy || 'ANARCHY'))
-            : a({ href: `/author/${encodeURIComponent(powerId)}`, class: 'user-link' }, powerTitle || powerId);
+            : userLink(powerId, powerTitle);
 
       const methodUpper = String(
         i18n['parliamentMethod' + String(method || '').toUpperCase()] || method
@@ -1449,7 +1450,7 @@ function renderActionCards(actions, userId, allActions) {
           question ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.parliamentLawQuestion || 'Question') + ':'), span({ class: 'card-value' }, question)) : '',
           description ? p({ style: 'margin:.4rem 0' }, description) : '',
           div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.parliamentLawMethod || 'Method') + ':'), span({ class: 'card-value' }, methodUpper)),
-          proposer ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.parliamentLawProposer || 'Proposer') + ':'), span({ class: 'card-value' }, a({ href: `/author/${encodeURIComponent(proposer)}`, class: 'user-link' }, proposer))) : '',
+          proposer ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.parliamentLawProposer || 'Proposer') + ':'), span({ class: 'card-value' }, userLink(proposer))) : '',
           enactedAt ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.parliamentLawEnacted || 'Enacted at') + ':'), span({ class: 'card-value' }, new Date(enactedAt).toLocaleString())) : '',
           (total || yes) ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.parliamentLawVotes || 'Votes') + ':'), span({ class: 'card-value' }, `${yes}/${total}`)) : ''
         )
@@ -1467,7 +1468,7 @@ function renderActionCards(actions, userId, allActions) {
             answerBy ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.courtsThAnswerBy + ':'), span({ class: 'card-value' }, new Date(answerBy).toLocaleString())) : '',
             evidenceBy ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.courtsThEvidenceBy + ':'), span({ class: 'card-value' }, new Date(evidenceBy).toLocaleString())) : '',
             decisionBy ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.courtsThDecisionBy + ':'), span({ class: 'card-value' }, new Date(decisionBy).toLocaleString())) : '',
-            accuser ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.courtsAccuser + ':'), span({ class: 'card-value' }, a({ href: `/author/${encodeURIComponent(accuser)}`, class: 'user-link' }, accuser))) : '',
+            accuser ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.courtsAccuser + ':'), span({ class: 'card-value' }, userLink(accuser))) : '',
             typeof needed !== 'undefined' ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.courtsVotesNeeded + ':'), span({ class: 'card-value' }, String(needed))) : '',
             (typeof yes !== 'undefined' || typeof total !== 'undefined') ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.courtsVotesSlashTotal + ':'), span({ class: 'card-value' }, `${Number(yes || 0)}/${Number(total || 0)}`)) : '',
             voteId ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.courtsOpenVote + ':'), a({ href: `/votes/${encodeURIComponent(voteId)}`, class: 'tag-link' }, i18n.viewDetails || 'View details')) : ''
@@ -1477,7 +1478,7 @@ function renderActionCards(actions, userId, allActions) {
         const { judgeId } = content;
         cardBody.push(
           div({ class: 'card-section courts' },
-            judgeId ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.courtsJudge + ':'), span({ class: 'card-value' }, a({ href: `/author/${encodeURIComponent(judgeId)}`, class: 'user-link' }, judgeId))) : ''
+            judgeId ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.courtsJudge + ':'), span({ class: 'card-value' }, userLink(judgeId))) : ''
           )
         );
       } else {
@@ -1536,7 +1537,7 @@ function renderActionCards(actions, userId, allActions) {
       div({ class: 'card-body' }, ...cardBody),
       p({ class: 'card-footer' },
         span({ class: 'date-link' }, `${date} ${i18n.performed} `),
-        a({ href: `/author/${encodeURIComponent(action.author)}`, class: 'user-link' }, `${action.author}`)
+        userLink(action.author)
       )
     );
   });

+ 6 - 6
src/views/agenda_view.js

@@ -1,5 +1,5 @@
 const { div, h2, p, section, button, form, img, textarea, a, br, h1, 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');
 
@@ -36,7 +36,7 @@ const renderAgendaItem = (item, userId, filter) => {
   const commonFields = [
     p({ class: 'card-footer' },
       span({ class: 'date-link' }, `${item.createdAt ? moment(item.createdAt).format('YYYY/MM/DD HH:mm:ss') : ''} ${i18n.performed} `),
-      author ? a({ href: `/author/${encodeURIComponent(author)}`, class: 'user-link' }, `${author}`) : ''
+      author ? userLink(author) : ''
     )
   ];
 
@@ -67,7 +67,7 @@ const renderAgendaItem = (item, userId, filter) => {
       const maxBid = bids.length ? Math.max(...bids) : 0;
       details.push(renderCardField(i18n.marketItemHighestBid + ":", `${maxBid} ECO`));
     }
-    const seller = author ? p(a({ class: "user-link", href: `/author/${encodeURIComponent(author)}` }, author)) : '';
+    const seller = author ? p(userLink(author)) : '';
     details.push(br(), div({ class: 'members-list' }, i18n.marketItemSeller + ': ', seller));
   }
 
@@ -80,7 +80,7 @@ const renderAgendaItem = (item, userId, filter) => {
       renderCardField(i18n.agendaMembersCount + ":", Array.isArray(item.members) ? item.members.length : 0),
       br()
     ];
-    const membersList = Array.isArray(item.members) ? item.members.map(member => p(a({ class: "user-link", href: `/author/${encodeURIComponent(member)}` }, member))) : [];
+    const membersList = Array.isArray(item.members) ? item.members.map(member => p(userLink(member))) : [];
     details.push(div({ class: 'members-list' }, `${i18n.agendaMembersLabel}:`, membersList));
   }
 
@@ -128,7 +128,7 @@ const renderAgendaItem = (item, userId, filter) => {
       renderCardField(i18n.agendaTransferDeadline + ":", item.deadline ? fmt(item.deadline) : ''),
       br()
     ];
-    const membersList = item.to ? p(a({ class: "user-link", href: `/author/${encodeURIComponent(item.to)}` }, item.to)) : '';
+    const membersList = item.to ? p(userLink(item.to)) : '';
     details.push(div({ class: 'members-list' }, i18n.to + ': ', membersList));
   }
   
@@ -160,7 +160,7 @@ const renderAgendaItem = (item, userId, filter) => {
               : []));
 
     const subsInterleaved = subs
-      .map((id, i) => [i > 0 ? ', ' : '', a({ class: 'user-link', href: `/author/${encodeURIComponent(id)}` }, id)])
+      .map((id, i) => [i > 0 ? ', ' : '', userLink(id)])
       .flat();
 
     details = [

+ 7 - 4
src/views/audio_view.js

@@ -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" },

+ 6 - 5
src/views/banking_views.js

@@ -1,5 +1,5 @@
 const { div, h2, p, section, button, form, a, input, span, pre, table, thead, tbody, tr, td, th, br } = require("../server/node_modules/hyperaxe");
-const { template, i18n } = require("../views/main_views");
+const { template, i18n, userLink } = require("../views/main_views");
 const moment = require("../server/node_modules/moment");
 
 const FILTER_LABELS = {
@@ -93,7 +93,7 @@ const renderOverviewSummaryTable = (s, rules) => {
       tbody(
         kvRow(i18n.bankUserBalance, `${Number(s.userBalance || 0).toFixed(6)} ECO`),
         kvRow(i18n.bankUbiAvailability, span({ class: availClass }, availLabel)),
-        s.pubId ? kvRow(i18n.pubIdLabel, a({ href: `/author/${encodeURIComponent(s.pubId)}`, class: "user-link" }, s.pubId)) : null,
+        s.pubId ? kvRow(i18n.pubIdLabel, userLink(s.pubId)) : null,
         kvRow(i18n.bankEpoch, String(s.epochId || "-")),
         kvRow(i18n.bankPool, `${pool.toFixed(6)} ECO`),
         kvRow(i18n.bankWeightsSum, String(W.toFixed(6))),
@@ -160,8 +160,8 @@ const allocationsTable = (rows = [], userId) =>
             tr(
               td(new Date(r.createdAt).toLocaleString()),
               td(r.concept || ""),
-              td(a({ href: `/author/${encodeURIComponent(r.from)}`, class: "user-link" }, r.from)),
-              td(a({ href: `/author/${encodeURIComponent(r.to)}`, class: "user-link" }, r.to)),
+              td(userLink(r.from)),
+              td(userLink(r.to)),
               td(String(Number(r.amount || 0).toFixed(6))),
               td(r.status),
               td(
@@ -217,6 +217,7 @@ const flashText = (key) => {
   if (key === "already_claimed") return i18n.bankAlreadyClaimedThisMonth;
   if (key === "no_pub_configured") return i18n.bankNoPubConfigured;
   if (key === "no_funds") return i18n.bankUbiAvailableNo;
+  if (key === "forbidden") return i18n.bankAddressForbidden;
   return "";
 };
 
@@ -291,7 +292,7 @@ const renderAddresses = (data, userId) => {
               tbody(
                 ...rows.map(r =>
                   tr(
-                    td(a({ href: `/author/${encodeURIComponent(r.id)}`, class: "user-link" }, r.id)),
+                    td(userLink(r.id)),
                     td(r.address),
                     td(r.source === "local" ? i18n.bankLocal : i18n.bankFromOasis),
 		td(

+ 7 - 4
src/views/bookmark_view.js

@@ -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" },

+ 32 - 7
src/views/calendars_view.js

@@ -1,5 +1,5 @@
 const { div, h2, h3, h4, p, section, button, form, a, span, br, textarea, input, label, select, option, 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")
 
@@ -165,6 +165,17 @@ const renderMonthGrid = (year, month, datesMap, calendarId) => {
   return div({ class: "calendar-grid" }, ...headerCells, ...cells)
 }
 
+exports.renderCalendarInvitePage = (code) => {
+  const pageContent = div({ class: "invite-page" },
+    h2(i18n.tribeInviteCodeText, code),
+    form({ method: "GET", action: "/calendars" },
+      input({ type: "hidden", name: "filter", value: "all" }),
+      button({ type: "submit", class: "filter-btn" }, i18n.walletBack)
+    )
+  )
+  return template(i18n.calendarGenerateInvite || "Invite", section(pageContent))
+}
+
 exports.calendarsView = async (calendars, filter, calendarToEdit, params) => {
   const q = (params && params.q) || ""
   const showForm = filter === "create" || filter === "edit" || !!calendarToEdit
@@ -241,7 +252,7 @@ exports.singleCalendarView = async (calendar, dates, notesByDate, params) => {
     ),
     table({ class: "tribe-info-table" },
       tr(td({ class: "tribe-info-label" }, i18n.calendarCreated || "Created"), td({ class: "tribe-info-value", colspan: "3" }, moment(calendar.createdAt).format("YYYY-MM-DD"))),
-      tr(td({ class: "tribe-info-value", colspan: "4" }, a({ href: `/author/${encodeURIComponent(calendar.author)}`, class: "user-link" }, calendar.author))),
+      tr(td({ class: "tribe-info-value", colspan: "4" }, userLink(calendar.author))),
       tr(td({ class: "tribe-info-label" }, i18n.calendarStatusLabel || "Status"), td({ class: "tribe-info-value", colspan: "3" }, renderStatus(calendar))),
       calendar.deadline ? tr(td({ class: "tribe-info-label" }, i18n.calendarDeadlineLabel || "Deadline"), td({ class: "tribe-info-value", colspan: "3" }, moment(calendar.deadline).format("YYYY-MM-DD HH:mm"))) : null
     ),
@@ -254,6 +265,11 @@ exports.singleCalendarView = async (calendar, dates, notesByDate, params) => {
             button({ type: "submit", class: "tribe-action-btn" }, i18n.calendarUpdate || "Update")
           )
         : null,
+      isAuthor && calendar.status !== "OPEN"
+        ? form({ method: "POST", action: `/calendars/generate-invite/${encodeURIComponent(calendar.rootId)}` },
+            button({ type: "submit", class: "tribe-action-btn" }, i18n.calendarGenerateInvite || "Generate invite")
+          )
+        : null,
       isAuthor
         ? form({ method: "POST", action: `/calendars/delete/${encodeURIComponent(calendar.rootId)}` },
             button({ type: "submit", class: "tribe-action-btn danger-btn" }, i18n.calendarDelete || "Delete")
@@ -262,11 +278,17 @@ exports.singleCalendarView = async (calendar, dates, notesByDate, params) => {
       !isAuthor
         ? a({ href: `/pm?to=${encodeURIComponent(calendar.author)}`, class: "tribe-action-btn" }, "PM")
         : null,
-      !isAuthor && !isParticipant
+      !isAuthor && !isParticipant && calendar.status === "OPEN"
         ? form({ method: "POST", action: `/calendars/join/${encodeURIComponent(calendar.rootId)}` },
             button({ type: "submit", class: "create-button" }, i18n.calendarJoin || "Join Calendar")
           )
         : null,
+      !isAuthor && !isParticipant && calendar.status !== "OPEN"
+        ? form({ method: "POST", action: "/calendars/join-code" },
+            input({ type: "text", name: "code", placeholder: i18n.calendarInviteCodePlaceholder || "Enter invite code..." }),
+            button({ type: "submit", class: "filter-btn" }, i18n.calendarValidateInvite || "Validate code")
+          )
+        : null,
       !isAuthor && isParticipant
         ? form({ method: "POST", action: `/calendars/leave/${encodeURIComponent(calendar.rootId)}` },
             button({ type: "submit", class: "tribe-action-btn danger-btn" }, i18n.calendarLeave || "Leave Calendar")
@@ -333,9 +355,11 @@ exports.singleCalendarView = async (calendar, dates, notesByDate, params) => {
                 div({ class: "calendar-date-item-header" },
                   `${moment(d.date).format("YYYY-MM-DD HH:mm")}${d.label ? " \u2014 " + d.label : ""}`
                 ),
-                notes.length === 0
-                  ? p({ class: "no-content" }, i18n.calendarNoNotes || "No notes.")
-                  : div(null, ...notes.map(n => {
+                (() => {
+                  const visibleNotes = notes.filter(n => n.text && String(n.text).trim())
+                  return visibleNotes.length === 0
+                    ? p({ class: "no-content" }, i18n.calendarNoNotes || "No notes.")
+                    : div(null, ...visibleNotes.map(n => {
                       const isSelf = String(n.author) === String(userId)
                       const dateStr = moment(n.createdAt).format("YYYY/MM/DD HH:mm")
                       const shortId = n.author ? "@" + n.author.slice(1, 9) + "\u2026" : "?"
@@ -348,13 +372,14 @@ exports.singleCalendarView = async (calendar, dates, notesByDate, params) => {
                           : null,
                         div({ class: "chat-message-meta" },
                           span({ class: "chat-message-sender" },
-                            a({ href: `/author/${encodeURIComponent(n.author)}`, class: "user-link" }, shortId)
+                            userLink(n.author)
                           ),
                           span({ class: "chat-message-date" }, ` [ ${dateStr} ]`)
                         ),
                         span({ class: "chat-message-text" }, ...renderNoteText(n.text || ""))
                       )
                     }))
+                })()
               )
             }))
       )

+ 9 - 8
src/views/chats_view.js

@@ -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))
-        : p({ class: "chat-no-messages" }, i18n.chatNoMessages)
+      (() => {
+        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)
+      })()
     )
   )
 

+ 10 - 46
src/views/courts_view.js

@@ -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
     )
   );

+ 2 - 2
src/views/cv_view.js

@@ -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(

+ 7 - 4
src/views/document_view.js

@@ -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" },

+ 13 - 7
src/views/event_view.js

@@ -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)

+ 2 - 2
src/views/favorites_view.js

@@ -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) : ""
     )
   );
 };

+ 3 - 3
src/views/feed_view.js

@@ -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
             )
           )

+ 13 - 20
src/views/forum_view.js

@@ -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',

+ 3 - 3
src/views/games_view.js

@@ -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 - 0
src/views/graphos_view.js

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

+ 7 - 4
src/views/image_view.js

@@ -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" },

+ 4 - 21
src/views/inhabitants_view.js

@@ -1,5 +1,5 @@
 const { div, h2, p, section, button, form, img, a, textarea, input, br, span, strong } = require("../server/node_modules/hyperaxe");
-const { template, i18n } = require('./main_views');
+const { template, i18n, userLink} = require('./main_views');
 const { renderUrl } = require('../backend/renderUrl');
 const { getConfig } = require('../configs/config-manager');
 
@@ -90,14 +90,7 @@ const renderInhabitantCard = (user, filter, currentUserId) => {
       br(),
       ...lastActivityBadge(user, isMe),
       div({ class: 'inhabitant-karma-ubi' },
-        span({ class: 'karma-line' }, `${i18n.bankingUserEngagementScore}: `, strong(String(typeof user.karmaScore === 'number' ? user.karmaScore : 0))),
-        span({ class: 'ubi-line' }, `${i18n.bankUbiThisMonth}: `, strong(`${Number(user.estimatedUBI || 0).toFixed(6)} ECO`)),
-        span({ class: 'ubi-line' }, `${i18n.bankUbiLastClaimed}: `,
-          user.lastClaimedDate
-            ? a({ href: '/transfers?filter=ubi', class: 'user-link' }, new Date(user.lastClaimedDate).toLocaleDateString())
-            : strong(i18n.bankUbiNeverClaimed)
-        ),
-        span({ class: 'ubi-line' }, `${i18n.bankUbiTotalClaimed}: `, strong(`${Number(user.totalClaimed || 0).toFixed(6)} ECO`))
+        span({ class: 'karma-line' }, `${i18n.bankingUserEngagementScore}: `, strong(String(typeof user.karmaScore === 'number' ? user.karmaScore : 0)))
       )
     ),
     div({ class: 'inhabitant-details' },
@@ -113,7 +106,7 @@ const renderInhabitantCard = (user, filter, currentUserId) => {
         ? p(`${i18n.mutualFollowers}: ${user.mutualCount}`) : null,
       filter === 'blocked' && user.isBlocked
         ? p(i18n.blockedLabel) : null,
-      p(a({ class: 'user-link', href: `/author/${encodeURIComponent(user.id)}` }, user.id)),
+      p(userLink(user.id)),
       user.ecoAddress
         ? div({ class: "eco-wallet" },
             p(`${i18n.bankWalletConnected}: `, strong(user.ecoAddress))
@@ -272,9 +265,6 @@ exports.inhabitantsProfileView = (payload, currentUserId) => {
   const isMe = id && id === currentUserId;
   const title = i18n.inhabitantProfileTitle || i18n.inhabitantviewDetails;
   const karmaScore = typeof safe.karmaScore === 'number' ? safe.karmaScore : 0;
-  const estimatedUBI = Number(safe.estimatedUBI || 0);
-  const lastClaimedDate = safe.lastClaimedDate || null;
-  const totalClaimed = Number(safe.totalClaimed || 0);
 
   const providedBucket = typeof safe.lastActivityBucket === 'string' ? safe.lastActivityBucket : null;
   const dotClass = providedBucket === 'green' ? 'green' : providedBucket === 'orange' ? 'orange' : 'red';
@@ -305,14 +295,7 @@ exports.inhabitantsProfileView = (payload, currentUserId) => {
           h2(name || 'Anonymous'),
           ...lastActivityBadge({ lastActivityBucket: dotClass, deviceSource: safe.deviceSource }, isMe),
           div({ class: 'inhabitant-karma-ubi' },
-            span({ class: 'karma-line' }, `${i18n.bankingUserEngagementScore}: `, strong(String(karmaScore))),
-            span({ class: 'ubi-line' }, `${i18n.bankUbiThisMonth}: `, strong(`${estimatedUBI.toFixed(6)} ECO`)),
-            span({ class: 'ubi-line' }, `${i18n.bankUbiLastClaimed}: `,
-              lastClaimedDate
-                ? a({ href: '/transfers?filter=ubi', class: 'user-link' }, new Date(lastClaimedDate).toLocaleDateString())
-                : strong(i18n.bankUbiNeverClaimed)
-            ),
-            span({ class: 'ubi-line' }, `${i18n.bankUbiTotalClaimed}: `, strong(`${totalClaimed.toFixed(6)} ECO`))
+            span({ class: 'karma-line' }, `${i18n.bankingUserEngagementScore}: `, strong(String(karmaScore)))
           ),
           (!isMe && (id || viewedId))
             ? form(

+ 8 - 4
src/views/invites_view.js

@@ -62,12 +62,17 @@ const invitesView = ({ invitesEnabled }) => {
 
   const hasError = (pubItem) => pubItem && (pubItem.error || (typeof pubItem.failure === 'number' && pubItem.failure > 0));
 
+  const sanitizeError = (err) => {
+    if (!err) return i18n.genericError || 'Unknown error';
+    const firstLine = String(err).split('\n')[0].replace(/^Error:\s*/, '').trim();
+    return firstLine || (i18n.genericError || 'Unknown error');
+  };
+
   const unreachableLabel = i18n.currentlyUnreachable || i18n.currentlyUnrecheable || 'ERROR!';
 
   const pubTableHeader = () => tr(
     td({ class: 'card-label' }, 'PUB'),
     td({ class: 'card-label' }, i18n.invitesPort || 'Port'),
-    td({ class: 'card-label' }, i18n.inhabitants),
     td({ class: 'card-label' }, 'Key'),
     td({ class: 'card-label' }, '')
   );
@@ -75,12 +80,11 @@ const invitesView = ({ invitesEnabled }) => {
   const activePubs = filteredPubs.filter(pubItem => !hasError(pubItem));
   const unreachablePubs = pubs.filter(hasError);
 
-  const renderPubTable = (items, actionFn) => table({ class: 'block-info-table' },
+  const renderPubTable = (items, actionFn) => table({ class: 'block-info-table invites-pubs-table' },
     pubTableHeader(),
     items.map(pubItem => tr(
       td(pubItem.host || '—'),
       td(String(pubItem.port || 8008)),
-      td(String(pubItem.announcers || 0)),
       td(a({ href: encodePubLink(pubItem.key), class: 'user-link' }, pubItem.key)),
       td(actionFn(pubItem))
     ))
@@ -156,7 +160,7 @@ const invitesView = ({ invitesEnabled }) => {
           ? renderPubTable(unreachablePubs, pubItem =>
               div({ class: 'error-box' },
                 p({ class: 'error-title' }, i18n.errorDetails),
-                p({ class: 'error-pre' }, String(pubItem.error || i18n.genericError))
+                p({ class: 'error-pre' }, sanitizeError(pubItem.error))
               )
             )
           : p(i18n.invitesNoUnreachablePubs)

+ 8 - 5
src/views/jobs_view.js

@@ -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)
         )
       ),

+ 97 - 29
src/views/main_views.js

@@ -27,6 +27,40 @@ 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 errorView = ({ title, message, backHref }) => {
+  const heading = title || i18n.errorPageTitle || 'Error';
+  return exports.template(
+    heading,
+    section(
+      div({ class: 'tags-header' },
+        h2(heading),
+        message ? p({ class: 'error-page-message' }, String(message)) : null,
+        div({ class: 'error-page-actions' },
+          a({ href: backHref || '/', class: 'filter-btn' }, i18n.goBack || 'Go back')
+        )
+      )
+    )
+  );
+};
+exports.errorView = errorView;
 
 const i18nBase = require("../client/assets/translations/i18n");
 let selectedLanguage = "en";
@@ -135,8 +169,7 @@ const navLink = ({ href, emoji, text, current, class: extraClass }) =>
           .join(" ")
       },
       span({ class: "emoji" }, emoji),
-      nbsp,
-      text
+      span({ class: "nav-text" }, text)
     )
   );
 
@@ -168,8 +201,7 @@ const navGroup = ({ id, emoji, title, defaultOpen = false }, ...items) => {
     label(
       { for: `oasis-nav-group-${id}`, class: "oasis-nav-header" },
       span({ class: "emoji" }, emoji),
-      nbsp,
-      title,
+      span({ class: "nav-text" }, title),
       span({ class: "oasis-nav-arrow" }, "▾")
     ),
     ul({ class: "oasis-nav-list" }, ...active)
@@ -299,6 +331,21 @@ const renderCipherLink = () => {
   return "";
 };
 
+const renderGraphosLink = () => {
+  const graphosMod = getConfig().modules.graphosMod === "on";
+  if (graphosMod) {
+    return [
+      navLink({
+        href: "/graphos",
+        emoji: "ꔯ",
+        text: i18n.graphos,
+        class: "graphos-link enabled"
+      })
+    ];
+  }
+  return "";
+};
+
 const renderBookmarksLink = () => {
   const bookmarksMod = getConfig().modules.bookmarksMod === "on";
   return bookmarksMod
@@ -832,6 +879,25 @@ const template = (titlePrefix, ...elements) => {
             )
           )
         ),
+        (() => {
+          const aiNavOn = getConfig().modules.aiNavMod === 'on';
+          if (!aiNavOn) return null;
+          return div(
+            { class: "top-bar-center" },
+            form(
+              { method: 'POST', action: '/ai/ask', class: 'ai-ask-form' },
+              input({
+                type: 'text',
+                name: 'q',
+                class: 'ai-ask-input',
+                placeholder: i18n.aiNavPlaceholder || 'Where do you want to go?',
+                autocomplete: 'off',
+                maxlength: '300'
+              }),
+              button({ type: 'submit', class: 'ai-ask-btn' }, '➤')
+            )
+          );
+        })(),
         div(
           { class: "top-bar-right" },
           nav(
@@ -943,18 +1009,25 @@ const template = (titlePrefix, ...elements) => {
                   title: i18n.menuTools
                 },
                 renderAILink(),
-                navLink({
-                  href: "/stats",
-                  emoji: "ꕷ",
-                  text: i18n.statistics
-                }),
                 navLink({
                   href: "/blockexplorer",
                   emoji: "ꖸ",
                   text: i18n.blockchain
                 }),
                 renderCipherLink(),
-                renderLegacyLink()
+                renderGraphosLink(),
+                renderInvitesLink(),
+                renderLegacyLink(),
+                navLink({
+                  href: "/peers",
+                  emoji: "⧖",
+                  text: i18n.peers
+                }),
+                navLink({
+                  href: "/stats",
+                  emoji: "ꕷ",
+                  text: i18n.statistics
+                })
               )
             )
           )
@@ -980,13 +1053,7 @@ const template = (titlePrefix, ...elements) => {
                 renderPadsLink(),
                 renderForumLink(),
                 renderMapsLink(),
-                renderChatsLink(),
-                renderInvitesLink(),
-                navLink({
-                  href: "/peers",
-                  emoji: "⧖",
-                  text: i18n.peers
-                })
+                renderChatsLink()
               ),
               navGroup(
                 {
@@ -1724,7 +1791,7 @@ exports.authorView = ({
         h1({ class: "name" }, name),
       ),
       pre({ class: "md-mention", innerHTML: sanitizeHtml(markdownMention) }),
-      p(a({ class: "user-link", href: `/author/${encodeURIComponent(feedId)}` }, feedId)),
+      p(userLink(feedId, name)),
       div({ class: "profile-metrics" },
         ...lastActivityBadge({ lastActivityBucket: bucket }, true),
         div({ class: "inhabitant-karma-ubi" },
@@ -1991,7 +2058,7 @@ const renderMessage = (msg) => {
       badge,
       ...renderUrl(mentionsText || '[No content]')
     ),
-    p(a({ class: 'user-link', href: `/author/${encodeURIComponent(authorId)}` }, authorName)),
+    p(userLink(authorId, authorName)),
     p(`${i18n.createdAtLabel || 'Created at'}: ${createdAt}`),
     visitUrl
       ? form({ method: 'GET', action: visitUrl },
@@ -2081,8 +2148,7 @@ exports.privateView = async (messagesInput, filter) => {
   const isSent = m => (m?.value?.author === userId) || (m?.value?.content?.from === userId)
   const isToUser = m => Array.isArray(m?.value?.content?.to) && m.value.content.to.includes(userId)
 
-  const linkAuthor = (id) =>
-    a({ class: 'user-link', href: `/author/${encodeURIComponent(id)}` }, id)
+  const linkAuthor = (id) => userLink(id)
 
   const hrefFor = {
     job: (id) => `/jobs/${encodeURIComponent(id)}`,
@@ -2199,7 +2265,7 @@ exports.privateView = async (messagesInput, filter) => {
     }
     flushQuote()
     return parts.join('<br>')
-      .replace(/(@[a-zA-Z0-9/+._=-]+\.ed25519)/g, (match, id) => `<a class="user-link" href="/author/${encodeURIComponent(id)}">${match}</a>`)
+      .replace(/(@[a-zA-Z0-9/+._=-]+\.ed25519)/g, (match, id) => `<a class="user-link" href="/author/${encodeURIComponent(id)}">${userLinkLabel(id)}</a>`)
       .replace(/\/jobs\/([%A-Za-z0-9/+._=-]+\.sha256)/g, (match, id) => `<a class="job-link" href="${hrefFor.job(id)}">${match}</a>`)
       .replace(/\/projects\/([%A-Za-z0-9/+._=-]+\.sha256)/g, (match, id) => `<a class="project-link" href="${hrefFor.project(id)}">${match}</a>`)
       .replace(/\/market\/([%A-Za-z0-9/+._=-]+\.sha256)/g, (match, id) => `<a class="market-link" href="${hrefFor.market(id)}">${match}</a>`)
@@ -2275,7 +2341,7 @@ exports.privateView = async (messagesInput, filter) => {
       h2({ class: 'pm-title' }, `${icon} ${i18n.pmBotProjects} · ${titleH}`),
       p(
         i18n.pmInhabitantWithId, ' ',
-        a({ class: 'user-link', href: `/author/${encodeURIComponent(from)}` }, from),
+        userLink(from),
         ' ',
         isFollow ? (i18n.pmHasFollowedYourProject || 'has followed your project') : (i18n.pmHasUnfollowedYourProject || 'has unfollowed your project'),
         ' ',
@@ -2623,9 +2689,11 @@ const markdownMentionsToHtml = (markdownText) => {
 
   const unescapeBlob = (b) => b.replace(/&amp;/g, '&')
 
+  const escAttr = (s) => String(s).replace(/"/g, '&quot;').replace(/'/g, '&#39;')
+
   const withImages = withBr.replace(
     /!\[([^\]]*)\]\(\s*(&amp;[^)\s]+\.sha256)\s*\)/g,
-    (_m, alt, blob) => `<img src="/blob/${encodeURIComponent(unescapeBlob(blob))}" alt="${alt}" class="post-image">`
+    (_m, alt, blob) => `<img src="/blob/${encodeURIComponent(unescapeBlob(blob))}" alt="${escAttr(alt)}" class="post-image">`
   )
 
   const withVideos = withImages.replace(
@@ -2640,7 +2708,7 @@ const markdownMentionsToHtml = (markdownText) => {
 
   const withPdfs = withAudios.replace(
     /\[pdf:([^\]]*)\]\(\s*(&amp;[^)\s]+\.sha256)\s*\)/g,
-    (_m, name, blob) => `<a class="post-pdf" href="/blob/${encodeURIComponent(unescapeBlob(blob))}" target="_blank">${name || i18n.pdfFallbackLabel || 'PDF'}</a>`
+    (_m, name, blob) => `<a class="post-pdf" href="/blob/${encodeURIComponent(unescapeBlob(blob))}" target="_blank">${escapeHtml(name || i18n.pdfFallbackLabel || 'PDF')}</a>`
   )
 
   const withMentions = withPdfs.replace(
@@ -2648,13 +2716,13 @@ const markdownMentionsToHtml = (markdownText) => {
     (_m, label, feed) => {
       const href = authorHref(feed)
       const shown = `@${String(label || "").replace(/^@+/, "")}`
-      return `<a class="mention" href="${href}">${escapeHtml(shown)}</a>`
+      return `<a class="mention" href="${escAttr(href)}">${escapeHtml(shown)}</a>`
     }
   )
 
   const withLinks = withMentions.replace(
-    /(https?:\/\/[^\s<]+)/g,
-    (u) => `<a href="${u}" target="_blank" rel="noopener noreferrer">${u}</a>`
+    /(https?:\/\/[^\s"'<>]+)/g,
+    (u) => `<a href="${escAttr(u)}" target="_blank" rel="noopener noreferrer">${escAttr(u)}</a>`
   )
 
   return withLinks
@@ -2722,7 +2790,7 @@ const generatePreview = ({ previewData, contentWarning, action }) => {
             { class: "mention-relationship-details" },
             span(
               { class: "mentions-listing" },
-              a({ class: "user-link", href: authorHref(feed) }, `@${stripAt(feed)}`)
+              userLink(feed)
             )
           )
         )

+ 4 - 4
src/views/maps_view.js

@@ -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),

+ 10 - 4
src/views/market_view.js

@@ -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(

+ 2 - 0
src/views/modules_view.js

@@ -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 },

+ 4 - 4
src/views/opinions_view.js

@@ -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);

+ 6 - 5
src/views/pads_view.js

@@ -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")
           )

+ 12 - 12
src/views/parliament_view.js

@@ -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),

+ 6 - 16
src/views/peers_view.js

@@ -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)
     );

+ 2 - 2
src/views/pixelia_view.js

@@ -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 

+ 16 - 10
src/views/projects_view.js

@@ -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)
+      })()
     )
   )
 }

+ 12 - 6
src/views/report_view.js

@@ -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)

+ 23 - 37
src/views/search_view.js

@@ -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(/&amp;/g, '&'); const m = s.match(/!\[[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/); const src = m ? m[1] : s; return src.startsWith('&') ? img({ src: `/blob/${encodeURIComponent(src)}`, class: 'feed-image' }) : img({ src: '/assets/images/default-tribe.png', class: 'feed-image' }); })(),
-        br(),
-        content.description ? 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,
           ]);
         })

+ 3 - 3
src/views/shops_view.js

@@ -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" },

+ 377 - 308
src/views/stats_view.js

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

+ 14 - 8
src/views/task_view.js

@@ -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)

+ 3 - 3
src/views/torrents_view.js

@@ -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" },

+ 5 - 5
src/views/transfer_view.js

@@ -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(

+ 2 - 2
src/views/trending_view.js

@@ -1,5 +1,5 @@
 const { div, h2, p, section, button, form, a, textarea, br, input, table, tr, th, td, img, video: videoHyperaxe, audio: audioHyperaxe, span} = require("../server/node_modules/hyperaxe");
-const { template, i18n } = require('./main_views');
+const { template, i18n, userLink} = require('./main_views');
 const { renderTextWithStyles } = require('../backend/renderTextWithStyles');
 const { config } = require('../server/SSB_server.js');
 const { renderUrl } = require('../backend/renderUrl');
@@ -193,7 +193,7 @@ const renderTrendingCard = (item, votes, categories, seenTitles) => {
     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 ops = c.opinions || {};

+ 22 - 22
src/views/tribes_view.js

@@ -1,6 +1,6 @@
 const { div, h2, h3, p, section, button, form, a, input, img, label, select, option, br, textarea, h1, span, nav, ul, li, video, audio, table, tr, td, thead, tbody, th } = 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 { renderMapLocationUrl, renderMapLocationGrid, renderMapLocationVisitLabel } = require("./maps_view");
@@ -368,7 +368,7 @@ const renderFeedTribeView = async (feedItems, tribe, query = {}, filter) => {
 		    : null
 		),
               div({ class: 'feed-main' },
-                p(`${new Date(m.createdAt).toLocaleString()} — `, a({ class: 'user-link', href: `/author/${encodeURIComponent(m.author)}` }, m.author)),
+                p(`${new Date(m.createdAt).toLocaleString()} — `, userLink(m.author)),
                 br,
                 p(...renderUrl(m.description))
               )
@@ -538,7 +538,7 @@ const renderTribeActivitySection = (tribe, sectionData) => {
         ),
         p({ class: 'card-footer' },
           span({ class: 'date-link' }, `${date} ${i18n.performed || ''} `),
-          a({ href: `/author/${encodeURIComponent(item.author)}`, class: 'user-link' }, item.authorName || item.author)
+          userLink(item.author, item.authorName)
         )
       );
     })
@@ -569,7 +569,7 @@ const renderTribeTrendingSection = (tribe, sectionData, query) => {
           item.refeeds ? span(`${i18n.tribeActivityRefeed}: ${item.refeeds}`) : null,
           Array.isArray(item.attendees) && item.attendees.length ? span(`${i18n.tribeEventAttendees}: ${item.attendees.length}`) : null
         ),
-        p({ class: 'tribe-meta-label' }, a({ class: 'user-link', href: `/author/${encodeURIComponent(item.author)}` }, item.author))
+        p({ class: 'tribe-meta-label' }, userLink(item.author))
       ); })
   );
 };
@@ -612,7 +612,7 @@ const renderTribeTagsSection = (tribe, sectionData, query) => {
           ),
           p({ class: 'card-footer' },
             span({ class: 'date-link' }, new Date(item.createdAt).toLocaleString()),
-            a({ class: 'user-link', href: `/author/${encodeURIComponent(item.author)}` }, item.author)
+            userLink(item.author)
           )
         ))
     ) : null
@@ -647,7 +647,7 @@ const renderTribeSearchSection = (tribe, sectionData, query) => {
           ),
           p({ class: 'card-footer' },
             span({ class: 'date-link' }, new Date(item.createdAt).toLocaleDateString()),
-            a({ class: 'user-link', href: `/author/${encodeURIComponent(item.author)}` }, item.author)
+            userLink(item.author)
           )
         ))
     ) : null
@@ -668,7 +668,7 @@ const renderOverviewSection = (tribe, query, sectionData) => {
       recentFeed.length === 0 ? p(i18n.tribeFeedEmpty) :
         recentFeed.map(m =>
           div({ class: 'feed-item' },
-            p(`${new Date(m.createdAt).toLocaleString()} — `, a({ class: 'user-link', href: `/author/${encodeURIComponent(m.author)}` }, m.author)),
+            p(`${new Date(m.createdAt).toLocaleString()} — `, userLink(m.author)),
             p(...renderUrl(m.description))
           )
         )
@@ -704,7 +704,7 @@ const renderOverviewSection = (tribe, query, sectionData) => {
       h2(i18n.tribeSectionInhabitants),
       p(`${i18n.tribeMembersCount}: ${tribe.members.length}`),
       tribe.members.slice(0, 6).map(m =>
-        a({ class: 'user-link', href: `/author/${encodeURIComponent(m)}` }, m),
+        userLink(m),
       )
     )
   );
@@ -1003,7 +1003,7 @@ const renderForumSection = (tribe, items, query) => {
           ),
           div({ class: 'forum-footer' },
             span({ class: 'date-link' }, `${new Date(thread.createdAt).toLocaleString()} ${i18n.performed || ''}`),
-            a({ href: `/author/${encodeURIComponent(thread.author)}`, class: 'user-link' }, thread.author)
+            userLink(thread.author)
           ),
           div({ class: 'forum-body' }, ...renderUrl(thread.description || '')),
           div({ class: 'forum-meta' },
@@ -1024,7 +1024,7 @@ const renderForumSection = (tribe, items, query) => {
             div({ class: `forum-comment${idx === 0 ? ' highlighted-reply' : ''}` },
               div({ class: 'comment-header' },
                 span({ class: 'date-link' }, `${new Date(r.createdAt).toLocaleString()} ${i18n.performed || ''}`),
-                a({ href: `/author/${encodeURIComponent(r.author)}`, class: 'user-link' }, r.author),
+                userLink(r.author),
                 div({ class: 'comment-votes' },
                   span({ class: 'forum-positive-votes' }, `▲: ${r.refeeds || 0}`)
                 )
@@ -1100,7 +1100,7 @@ const renderForumSection = (tribe, items, query) => {
               ),
               div({ class: 'forum-footer' },
                 span({ class: 'date-link' }, `${new Date(t.createdAt).toLocaleString()} ${i18n.performed || ''}`),
-                a({ href: `/author/${encodeURIComponent(t.author)}`, class: 'user-link' }, t.author)
+                userLink(t.author)
               ),
               t.author === userId ? div({ class: 'forum-owner-actions' },
                 form({ method: 'POST', action: `${tribeUrl}/content/delete/${encodeURIComponent(t.id)}`, class: 'forum-delete-form' },
@@ -1171,7 +1171,7 @@ const renderTribeMediaTypeSection = (tribe, items, query, mediaType) => {
 
   const mediaFooter = (m) => [
     p({ class: 'tribe-media-date' }, span({ class: 'date-link' }, new Date(m.createdAt).toLocaleString())),
-    p({ class: 'tribe-media-author' }, a({ href: `/author/${encodeURIComponent(m.author)}`, class: 'user-link' }, m.author)),
+    p({ class: 'tribe-media-author' }, userLink(m.author)),
     m.author === userId ? form({ method: 'POST', action: `${tribeUrl}/content/delete/${encodeURIComponent(m.id)}` },
       button({ type: 'submit', class: 'tribe-action-btn' }, i18n.tribeContentDelete)
     ) : null
@@ -1330,7 +1330,7 @@ const renderTribeMapsSection = (tribe, maps) => {
         ),
         p({ class: 'card-footer' },
           span({ class: 'date-link' }, new Date(m.createdAt).toLocaleString()),
-          a({ class: 'user-link', href: `/author/${encodeURIComponent(m.author)}` }, m.author)
+          userLink(m.author)
         )
       )
     )
@@ -1369,7 +1369,7 @@ const renderTribeTorrentsSection = (tribe, torrents) => {
         ),
         p({ class: 'card-footer' },
           span({ class: 'date-link' }, new Date(m.createdAt).toLocaleString()),
-          a({ class: 'user-link', href: `/author/${encodeURIComponent(m.author)}` }, m.author)
+          userLink(m.author)
         )
       );
     })
@@ -1400,7 +1400,7 @@ const renderTribePadsSection = (tribe, pads) => {
         ),
         p({ class: 'card-footer' },
           span({ class: 'date-link' }, new Date(m.createdAt).toLocaleString()),
-          a({ class: 'user-link', href: `/author/${encodeURIComponent(m.author)}` }, m.author)
+          userLink(m.author)
         )
       )
     )
@@ -1432,7 +1432,7 @@ const renderTribeChatsSection = (tribe, chats) => {
         ),
         p({ class: 'card-footer' },
           span({ class: 'date-link' }, new Date(m.createdAt).toLocaleString()),
-          a({ class: 'user-link', href: `/author/${encodeURIComponent(m.author)}` }, m.author)
+          userLink(m.author)
         )
       )
     )
@@ -1467,7 +1467,7 @@ const renderTribeCalendarsSection = (tribe, calendars) => {
         ),
         p({ class: 'card-footer' },
           span({ class: 'date-link' }, new Date(m.createdAt).toLocaleString()),
-          a({ class: 'user-link', href: `/author/${encodeURIComponent(m.author)}` }, m.author)
+          userLink(m.author)
         )
       )
     )
@@ -1545,7 +1545,7 @@ exports.tribeView = async (tribe, userIdParam, query, section, sectionData) => {
           td({ class: 'tribe-info-value', colspan: '3' }, new Date(tribe.createdAt).toLocaleString())
         ),
         tr(
-          td({ class: 'tribe-info-value', colspan: '4' }, a({ class: 'user-link', href: `/author/${encodeURIComponent(tribe.author)}` }, tribe.author))
+          td({ class: 'tribe-info-value', colspan: '4' }, userLink(tribe.author))
         ),
         tribe.location ? tr(
           td({ class: 'tribe-info-label' }, i18n.tribeLocationLabel || 'LOCATION'),
@@ -1553,7 +1553,7 @@ exports.tribeView = async (tribe, userIdParam, query, section, sectionData) => {
         ) : null,
         tr(
           td({ class: 'tribe-info-label' }, i18n.tribeStatusLabel || 'STATUS'),
-          td({ class: 'tribe-info-value', colspan: '3' }, String(statusI18n()[tribe.status] || i18n.tribeStatusOpen).toUpperCase())
+          td({ class: 'tribe-info-value', colspan: '3' }, String(tribe.isAnonymous ? i18n.tribePrivate : i18n.tribePublic).toUpperCase())
         ),
         tr(
           td({ class: 'tribe-info-label' }, i18n.tribeModeLabel || 'MODE'),
@@ -1729,7 +1729,7 @@ const governmentCard = (tribe, term, leaders) => {
     !isAnarchy && leaderId
       ? div({ class: 'tribe-leader-block' },
           h3(i18n.tribeGovLeader || 'LEADER'),
-          a({ href: `/author/${encodeURIComponent(leaderId)}`, class: 'user-link' }, leaderId)
+          userLink(leaderId)
         )
       : null
   );
@@ -1759,7 +1759,7 @@ const candidaturesBlock = (tribe, candidatures, alreadyPublishedThisGlobalCycle)
       : ul({}, list.map(c =>
           li({},
             c.candidateId
-              ? a({ href: `/author/${encodeURIComponent(c.candidateId)}`, class: 'user-link' }, c.candidateId)
+              ? userLink(c.candidateId)
               : '?',
             ` — ${c.method || 'DEMOCRACY'} — ${c.votes || 0} ${i18n.votes || 'votes'}`,
             ' ',
@@ -1817,7 +1817,7 @@ const renderGovernance = (tribe, data) => {
     h3(i18n.tribeGovLeader || 'LEADERS'),
     (!Array.isArray(leaders) || leaders.length === 0)
       ? p(i18n.tribeGovernanceNoLeaders || 'No leaders elected yet.')
-      : ul({}, leaders.map(l => li({}, a({ href: `/author/${encodeURIComponent(l)}`, class: 'user-link' }, l))))
+      : ul({}, leaders.map(l => li({}, userLink(l))))
   );
   else body = div({ class: 'card' },
     h3(i18n[`tribeGovFilter${f.charAt(0).toUpperCase()}${f.slice(1)}`] || i18n[`tribeGov${f.charAt(0).toUpperCase()}${f.slice(1)}`] || f.toUpperCase()),

+ 7 - 4
src/views/video_view.js

@@ -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" },

+ 2 - 2
src/views/vote_view.js

@@ -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)
   );

+ 3 - 3
src/views/wallet_view.js

@@ -1,4 +1,4 @@
-const { form, button, div, h2, p, section, input, span, table, thead, tbody, tr, td, th, ul, li, a, br, label } = require("../server/node_modules/hyperaxe");
+const { form, button, div, h2, p, section, input, span, table, thead, tbody, tr, td, th, ul, li, a, br, label, img } = require("../server/node_modules/hyperaxe");
 const QRCode = require('../server/node_modules/qrcode');
 const { template, i18n } = require('./main_views');
 
@@ -70,12 +70,12 @@ exports.walletHistoryView = async (balance, transactions, address) => {
 };
 
 exports.walletReceiveView = async (balance, address) => {
-    const qrImage = await QRCode.toString(address || '', { type: 'svg' });
+    const qrDataUrl = await QRCode.toDataURL(address || '', { type: 'image/png', width: 320, margin: 1 });
     return walletViewRender(
         balance,
         address,
         h2(i18n.walletReceiveTitle),
-        div({ class: 'div-center qr-code', innerHTML: qrImage })
+        div({ class: 'div-center qr-code' }, img({ src: qrDataUrl, alt: 'QR', class: 'wallet-qr-img' }))
     );
 };